Add platform status switch
This commit is contained in:
parent
682a491d27
commit
a76ab941bc
@ -182,10 +182,15 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
input.Provider = strings.TrimSpace(input.Provider)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.InternalName = strings.TrimSpace(input.InternalName)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Provider == "" || input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "provider and name are required")
|
||||
return
|
||||
}
|
||||
if input.Status != "" && input.Status != "enabled" && input.Status != "disabled" {
|
||||
writeError(w, http.StatusBadRequest, "status must be enabled or disabled")
|
||||
return
|
||||
}
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
@ -213,10 +218,15 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
|
||||
input.Provider = strings.TrimSpace(input.Provider)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.InternalName = strings.TrimSpace(input.InternalName)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Provider == "" || input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "provider and name are required")
|
||||
return
|
||||
}
|
||||
if input.Status != "" && input.Status != "enabled" && input.Status != "disabled" {
|
||||
writeError(w, http.StatusBadRequest, "status must be enabled or disabled")
|
||||
return
|
||||
}
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ type CreatePlatformInput struct {
|
||||
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateAPIKeyInput struct {
|
||||
@ -591,11 +592,11 @@ func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) (
|
||||
INSERT INTO integration_platforms (
|
||||
provider, platform_key, name, internal_name, base_url, auth_type, credentials, config,
|
||||
default_pricing_mode, default_discount_factor, pricing_rule_set_id,
|
||||
priority, retry_policy, rate_limit_policy
|
||||
priority, retry_policy, rate_limit_policy, status
|
||||
)
|
||||
VALUES (
|
||||
$1, COALESCE(NULLIF($2, ''), 'platform_' || replace(gen_random_uuid()::text, '-', '')), $3, NULLIF($4, ''), $5, $6, $7, $8,
|
||||
$9, $10, NULLIF($11, '')::uuid, $12, $13, $14
|
||||
$9, $10, NULLIF($11, '')::uuid, $12, $13, $14, COALESCE(NULLIF($15, ''), 'enabled')
|
||||
)
|
||||
RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status,
|
||||
priority, dynamic_priority, COALESCE(dynamic_priority, priority),
|
||||
@ -606,6 +607,7 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C
|
||||
input.Provider, input.PlatformKey, input.Name, strings.TrimSpace(input.InternalName), input.BaseURL, input.AuthType, credentials, config,
|
||||
input.DefaultPricingMode, input.DefaultDiscountFactor, input.PricingRuleSetID, input.Priority,
|
||||
string(retryPolicy), string(rateLimitPolicy),
|
||||
input.Status,
|
||||
).Scan(
|
||||
&platform.ID,
|
||||
&platform.Provider,
|
||||
@ -684,6 +686,7 @@ SET provider = $2,
|
||||
priority = $13,
|
||||
retry_policy = $14,
|
||||
rate_limit_policy = $15,
|
||||
status = COALESCE(NULLIF($16, ''), status),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status,
|
||||
@ -707,6 +710,7 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C
|
||||
input.Priority,
|
||||
string(retryPolicy),
|
||||
string(rateLimitPolicy),
|
||||
input.Status,
|
||||
).Scan(
|
||||
&platform.ID,
|
||||
&platform.Provider,
|
||||
|
||||
@ -560,6 +560,24 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlatformStatus(platform: IntegrationPlatform, status: 'enabled' | 'disabled') {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const input = platformStatusUpdatePayload(platform, status);
|
||||
const updated = await updatePlatform(token, platform.id, input);
|
||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '切换平台状态失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlatformDynamicPriority(platformId: string, input: PlatformDynamicPriorityUpdateRequest) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
@ -1029,6 +1047,7 @@ export function App() {
|
||||
onResetBaseModel={resetBaseModelToDefault}
|
||||
onSavePlatform={savePlatformWithModels}
|
||||
onSavePlatformDynamicPriority={savePlatformDynamicPriority}
|
||||
onTogglePlatformStatus={savePlatformStatus}
|
||||
onSaveProvider={saveProvider}
|
||||
onSavePricingRuleSet={savePricingRuleSet}
|
||||
onSaveRunnerPolicy={saveRunnerPolicy}
|
||||
@ -1105,6 +1124,25 @@ function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, curre
|
||||
};
|
||||
}
|
||||
|
||||
function platformStatusUpdatePayload(platform: IntegrationPlatform, status: 'enabled' | 'disabled'): PlatformCreateInput {
|
||||
return {
|
||||
provider: platform.provider,
|
||||
platformKey: platform.platformKey,
|
||||
name: platform.name,
|
||||
internalName: platform.internalName,
|
||||
baseUrl: platform.baseUrl,
|
||||
authType: platform.authType,
|
||||
config: platform.config ?? {},
|
||||
retryPolicy: platform.retryPolicy ?? {},
|
||||
rateLimitPolicy: platform.rateLimitPolicy ?? {},
|
||||
defaultPricingMode: platform.defaultPricingMode,
|
||||
defaultDiscountFactor: platform.defaultDiscountFactor,
|
||||
pricingRuleSetId: platform.pricingRuleSetId,
|
||||
priority: platform.priority,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function withCredentialPreviewFallback(
|
||||
platform: IntegrationPlatform,
|
||||
input: PlatformCreateInput,
|
||||
|
||||
@ -13,6 +13,7 @@ export * from './message';
|
||||
export * from './popover';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './switch';
|
||||
export * from './table';
|
||||
export * from './tabs';
|
||||
export * from './textarea';
|
||||
|
||||
32
apps/web/src/components/ui/switch.tsx
Normal file
32
apps/web/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
|
||||
checked: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ checked, className, disabled, onCheckedChange, ...props }, ref) => (
|
||||
<button
|
||||
aria-checked={checked}
|
||||
className={cn('shSwitch', className)}
|
||||
data-state={checked ? 'checked' : 'unchecked'}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
role="switch"
|
||||
{...props}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event);
|
||||
if (!event.defaultPrevented && !disabled) {
|
||||
onCheckedChange?.(!checked);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="shSwitchThumb" />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayRunnerPolicyUpsertRequest,
|
||||
GatewayUserUpsertRequest,
|
||||
IntegrationPlatform,
|
||||
PlatformDynamicPriorityUpdateRequest,
|
||||
PricingRuleSetUpsertRequest,
|
||||
RuntimePolicySetUpsertRequest,
|
||||
@ -65,6 +66,7 @@ export function AdminPage(props: {
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
|
||||
onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise<void>;
|
||||
onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise<void>;
|
||||
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
|
||||
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
|
||||
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
|
||||
@ -149,6 +151,7 @@ export function AdminPage(props: {
|
||||
state={props.state}
|
||||
onDeletePlatform={props.onDeletePlatform}
|
||||
onSavePlatform={props.onSavePlatform}
|
||||
onTogglePlatformStatus={props.onTogglePlatformStatus}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'realtimeLoad' && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
||||
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
import type { LoadState, PlatformWithModelsInput } from '../../types';
|
||||
import {
|
||||
authTypes,
|
||||
@ -31,6 +31,7 @@ export function PlatformManagementPanel(props: {
|
||||
state: LoadState;
|
||||
onDeletePlatform: (platformId: string) => Promise<void>;
|
||||
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
|
||||
onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise<void>;
|
||||
}) {
|
||||
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@ -42,6 +43,7 @@ export function PlatformManagementPanel(props: {
|
||||
const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false);
|
||||
const [editingPlatform, setEditingPlatform] = useState<IntegrationPlatform | null>(null);
|
||||
const [pendingDeletePlatform, setPendingDeletePlatform] = useState<IntegrationPlatform | null>(null);
|
||||
const [togglingPlatformId, setTogglingPlatformId] = useState('');
|
||||
const providerMap = useMemo(() => new Map(props.providers.map((item) => [item.providerKey, item])), [props.providers]);
|
||||
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
|
||||
const [form, setForm] = useState<PlatformWizardForm>(() => createEmptyPlatformForm(defaultProvider, providerDefaults(providerMap.get(defaultProvider))));
|
||||
@ -157,6 +159,18 @@ export function PlatformManagementPanel(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlatformStatus(platform: IntegrationPlatform, status: 'enabled' | 'disabled') {
|
||||
setTogglingPlatformId(platform.id);
|
||||
setValidationMessage('');
|
||||
try {
|
||||
await props.onTogglePlatformStatus(platform, status);
|
||||
} catch (err) {
|
||||
setValidationMessage(err instanceof Error ? err.message : '切换平台状态失败');
|
||||
} finally {
|
||||
setTogglingPlatformId('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pageStack">
|
||||
<ScreenMessage message={validationMessage} variant="error" onClose={() => setValidationMessage('')} />
|
||||
@ -183,9 +197,11 @@ export function PlatformManagementPanel(props: {
|
||||
platforms={props.platforms}
|
||||
providerMap={providerMap}
|
||||
pricingRuleSets={props.pricingRuleSets}
|
||||
togglingPlatformId={togglingPlatformId}
|
||||
onDelete={setPendingDeletePlatform}
|
||||
onCreate={openCreateDialog}
|
||||
onEdit={openEditDialog}
|
||||
onToggleStatus={togglePlatformStatus}
|
||||
/>
|
||||
) : (
|
||||
<PlatformModelTable
|
||||
@ -404,9 +420,11 @@ function PlatformTable(props: {
|
||||
platforms: IntegrationPlatform[];
|
||||
providerMap: Map<string, CatalogProvider>;
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
togglingPlatformId: string;
|
||||
onCreate: () => void;
|
||||
onDelete: (platform: IntegrationPlatform) => void;
|
||||
onEdit: (platform: IntegrationPlatform) => void;
|
||||
onToggleStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => void;
|
||||
}) {
|
||||
if (!props.platforms.length) {
|
||||
return (
|
||||
@ -438,6 +456,8 @@ function PlatformTable(props: {
|
||||
const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy);
|
||||
const runtime = platformRuntimeSummary(platform);
|
||||
const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now);
|
||||
const isEnabled = platform.status === 'enabled';
|
||||
const isToggling = props.togglingPlatformId === platform.id;
|
||||
return (
|
||||
<TableRow key={platform.id}>
|
||||
<TableCell>
|
||||
@ -467,12 +487,21 @@ function PlatformTable(props: {
|
||||
</TableCell>
|
||||
<TableCell>{props.platformModelCount.get(platform.id) ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<span className="platformStatusCell">
|
||||
<span className="platformStatusToggle">
|
||||
<Switch
|
||||
aria-label={`${platformDisplayName(platform)} ${isEnabled ? '禁用' : '启用'}平台`}
|
||||
checked={isEnabled}
|
||||
disabled={isToggling}
|
||||
onCheckedChange={(checked) => props.onToggleStatus(platform, checked ? 'enabled' : 'disabled')}
|
||||
/>
|
||||
<span>{isEnabled ? '启用' : '禁用'}</span>
|
||||
</span>
|
||||
<strong>
|
||||
{platformCooldownMs > 0 ? (
|
||||
<Badge variant="warning">冷却中</Badge>
|
||||
) : (
|
||||
<Badge variant={platform.status === 'enabled' ? 'success' : 'secondary'}>{platform.status}</Badge>
|
||||
<Badge variant={isEnabled ? 'success' : 'destructive'}>{isEnabled ? '已启用' : '已禁用'}</Badge>
|
||||
)}
|
||||
</strong>
|
||||
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>
|
||||
|
||||
@ -948,6 +948,33 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.platformStatusCell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.platformStatusCell strong,
|
||||
.platformStatusCell small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platformStatusCell small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.platformStatusToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.tableActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -152,6 +152,51 @@
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.shSwitch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: #d1d5db;
|
||||
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.12);
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.shSwitch[data-state="checked"] {
|
||||
background: #059669;
|
||||
box-shadow: inset 0 1px 2px rgba(6, 95, 70, 0.2), 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.shSwitch:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shSwitch:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.shSwitchThumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.24);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.shSwitch[data-state="checked"] .shSwitchThumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.screenMessageViewport {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
|
||||
@ -97,6 +97,7 @@ export interface PlatformCreateInput {
|
||||
defaultDiscountFactor?: number;
|
||||
pricingRuleSetId?: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PlatformModelBindingInput {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user