From 91752f0c2a5e47a882c413329d82b9c55a59bfb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E6=98=8E=E5=86=8D?= Date: Tue, 18 Aug 2026 23:59:20 +0800 Subject: [PATCH 1/4] feat(ui): add Router Agent and cluster management --- .../src/components/auth/permission-guard.tsx | 11 + frontend/src/components/layout/sidebar.tsx | 4 +- .../components/pages/cluster-information.tsx | 377 +++++++++------ .../token-router/advanced-routing-editor.tsx | 33 +- .../token-router/backend-model-select.tsx | 132 +++++ .../components/pages/token-router/index.tsx | 450 ++++++++++-------- .../token-router/router-detail-drawer.tsx | 89 +++- .../pages/token-router/router-form-dialog.tsx | 192 ++++++-- .../token-router/router-node-section.tsx | 231 +++++++++ .../router-runtime-detail-drawer.tsx | 189 ++++++++ .../token-router/router-status-badge.tsx | 13 + .../tokenizer-asset-binding-status.ts | 48 ++ .../token-router/tokenizer-asset-section.tsx | 260 ++++++++++ frontend/src/i18n/locales/en.ts | 185 ++++++- frontend/src/i18n/locales/ja.ts | 182 ++++++- frontend/src/i18n/locales/ko.ts | 183 ++++++- frontend/src/i18n/locales/zh.ts | 177 ++++++- frontend/src/types/services.ts | 212 ++++++++- 18 files changed, 2584 insertions(+), 384 deletions(-) create mode 100644 frontend/src/components/pages/token-router/backend-model-select.tsx create mode 100644 frontend/src/components/pages/token-router/router-node-section.tsx create mode 100644 frontend/src/components/pages/token-router/router-runtime-detail-drawer.tsx create mode 100644 frontend/src/components/pages/token-router/tokenizer-asset-binding-status.ts create mode 100644 frontend/src/components/pages/token-router/tokenizer-asset-section.tsx diff --git a/frontend/src/components/auth/permission-guard.tsx b/frontend/src/components/auth/permission-guard.tsx index dfb233da96..10ce659774 100644 --- a/frontend/src/components/auth/permission-guard.tsx +++ b/frontend/src/components/auth/permission-guard.tsx @@ -34,6 +34,17 @@ export function PermissionGuard({ scope, children }: PermissionGuardProps) { ); } + const isRouterScope = scope.startsWith('routers:'); + if (isRouterScope && clusterUIConfig?.token_router_enabled === false) { + return ( +
+ +

{t('tokenRouter.featureDisabled')}

+

{t('tokenRouter.featureDisabledDescription')}

+
+ ); + } + // When auth_advanced is disabled, all pages are accessible if (!clusterUIConfig?.auth_advanced) { return <>{children}; diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 9ca3203e6a..90462485ba 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -211,7 +211,9 @@ export function Sidebar() { name: t('menu.tokenRouter'), Icon: Route, Extra: ChevronRight, - show: !clusterUIConfig?.auth_advanced || canAccessRouterPage, + show: + clusterUIConfig?.token_router_enabled !== false && + (!clusterUIConfig?.auth_advanced || canAccessRouterPage), }, { path: '/register-model', diff --git a/frontend/src/components/pages/cluster-information.tsx b/frontend/src/components/pages/cluster-information.tsx index fb1e7808b2..62a22efa28 100644 --- a/frontend/src/components/pages/cluster-information.tsx +++ b/frontend/src/components/pages/cluster-information.tsx @@ -1,10 +1,7 @@ 'use client'; -import { useEffect, useRef, useState, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { format } from 'date-fns'; -import request from '@/lib/request'; -import { formatFileSize } from '@/lib/utils'; -import { useI18n } from '@/contexts/i18n-context'; import PageContainer from '@/components/ui/page-container'; import { Table, @@ -14,44 +11,40 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import type { ClusterInfo } from '@/types/services'; import { useGlobal } from '@/contexts/global-context'; +import { useI18n } from '@/contexts/i18n-context'; +import request from '@/lib/request'; +import { formatFileSize } from '@/lib/utils'; +import type { ClusterInfo, ClusterInformationItem, RouterNodeClusterInfo } from '@/types/services'; -export default function ClusterInfo() { - const [{ supervisors, workers }, setData] = useState<{ +export default function ClusterInfoPage() { + const [{ supervisors, workers, routers }, setData] = useState<{ supervisors: ClusterInfo[]; workers: ClusterInfo[]; - }>({ - supervisors: [], - workers: [], - }); + routers: RouterNodeClusterInfo[]; + }>({ supervisors: [], workers: [], routers: [] }); const [lastUpdateTime, setLastUpdateTime] = useState('-'); const { t } = useI18n(); - const { clusterVersion } = useGlobal(); + const { clusterVersion, clusterUIConfig, globalReady } = useGlobal(); + const tokenRouterEnabled = globalReady && clusterUIConfig?.token_router_enabled !== false; const timerRef = useRef(null); const supervisorSummary = useMemo(() => { - const address: string[] = []; + const addresses: string[] = []; let cpuUsage = 0; let cpuTotal = 0; let memUsage = 0; let memTotal = 0; supervisors.forEach((item) => { - address.push(item.ip_address); + addresses.push(item.ip_address); cpuUsage += (item.cpu_count || 0) - (item.cpu_available || 0); cpuTotal += item.cpu_count || 0; memUsage += item.mem_used || 0; memTotal += item.mem_total || 0; }); return [ - { - label: t('clusterInfo.count'), - value: supervisors.length, - }, - { - label: t('clusterInfo.address'), - value: address.join('、'), - }, + { label: t('clusterInfo.count'), value: supervisors.length }, + { label: t('clusterInfo.address'), value: addresses.join('、') || '-' }, { label: t('clusterInfo.cpuInfo'), value: `${t('clusterInfo.usage')}${cpuUsage.toFixed(2)}`, @@ -68,7 +61,8 @@ export default function ClusterInfo() { total: `${t('clusterInfo.commit')}${clusterVersion['full-revisionid'] || '-'}`, }, ]; - }, [supervisors, clusterVersion]); + }, [clusterVersion, supervisors, t]); + const workersSummary = useMemo(() => { let cpuUsage = 0; let cpuTotal = 0; @@ -78,8 +72,7 @@ export default function ClusterInfo() { let gpuUtilization = 0; let gpuMemoryUsage = 0; let gpuMemoryTotal = 0; - - const nodesWithGpuLoad = workers.filter((obj) => obj['gpu_utilization'] != null).length; + const nodesWithGpuLoad = workers.filter((item) => item.gpu_utilization != null).length; workers.forEach((item) => { cpuUsage += (item.cpu_count || 0) - (item.cpu_available || 0); cpuTotal += item.cpu_count || 0; @@ -91,10 +84,7 @@ export default function ClusterInfo() { gpuMemoryTotal += item.gpu_vram_total || 0; }); return [ - { - label: t('clusterInfo.count'), - value: workers.length, - }, + { label: t('clusterInfo.count'), value: workers.length }, { label: t('clusterInfo.cpuInfo'), value: `${t('clusterInfo.usage')}${cpuUsage.toFixed(2)}`, @@ -107,16 +97,10 @@ export default function ClusterInfo() { }, { label: t('clusterInfo.gpuInfo'), - ...(nodesWithGpuLoad - ? { - value: `${t('clusterInfo.gpuLoad')}: ${(gpuUtilization / nodesWithGpuLoad).toFixed( - 2 - )}%`, - total: `${t('clusterInfo.total')}${gpuCount}`, - } - : { - value: `${t('clusterInfo.total')}${gpuCount}`, - }), + value: nodesWithGpuLoad + ? `${t('clusterInfo.gpuLoad')}: ${(gpuUtilization / nodesWithGpuLoad).toFixed(2)}%` + : `${t('clusterInfo.total')}${gpuCount}`, + total: nodesWithGpuLoad ? `${t('clusterInfo.total')}${gpuCount}` : undefined, }, { label: t('clusterInfo.gpuMemoryInfo'), @@ -129,122 +113,172 @@ export default function ClusterInfo() { total: `${t('clusterInfo.commit')}${clusterVersion['full-revisionid'] || '-'}`, }, ]; - }, [workers, clusterVersion]); - const workerDetails = useMemo(() => { - return workers.map((item) => ({ - ...item, - cpuUsage: ((item.cpu_count || 0) - (item.cpu_available || 0)).toFixed(2), - cpuMemUsage: formatFileSize(item.mem_used || 0), - cpuMemTotal: formatFileSize(item.mem_total || 0), - gpuLoad: - typeof item.gpu_utilization === 'number' ? `${item.gpu_utilization.toFixed(2)}%` : '-', - gpuMemoryUsage: formatFileSize((item.gpu_vram_total || 0) - (item.gpu_vram_available || 0)), - gpuMemoryTotal: formatFileSize(item.gpu_vram_total || 0), - })); - }, [workers]); - const fetchClusterInfo = async () => { + }, [clusterVersion, t, workers]); + + const workerDetails = useMemo( + () => + workers.map((item) => ({ + ...item, + cpuUsage: ((item.cpu_count || 0) - (item.cpu_available || 0)).toFixed(2), + cpuMemUsage: formatFileSize(item.mem_used || 0), + cpuMemTotal: formatFileSize(item.mem_total || 0), + gpuLoad: + typeof item.gpu_utilization === 'number' ? `${item.gpu_utilization.toFixed(2)}%` : '-', + gpuMemoryUsage: formatFileSize((item.gpu_vram_total || 0) - (item.gpu_vram_available || 0)), + gpuMemoryTotal: formatFileSize(item.gpu_vram_total || 0), + })), + [workers] + ); + + const routerSummary = useMemo(() => { + const cpuUsage = routers.reduce( + (total, item) => + total + + (typeof item.cpu_count === 'number' && typeof item.cpu_available === 'number' + ? item.cpu_count - item.cpu_available + : 0), + 0 + ); + const cpuTotal = routers.reduce((total, item) => total + (item.cpu_count || 0), 0); + const memoryUsage = routers.reduce((total, item) => total + (item.mem_used || 0), 0); + const memoryTotal = routers.reduce((total, item) => total + (item.mem_total || 0), 0); + const versionPairs = new Set( + routers.map((item) => `${item.software_version || '-'}@${item.software_revision || '-'}`) + ); + const softwareVersions = [ + ...new Set(routers.map((item) => item.software_version).filter(Boolean)), + ]; + const revisions = [...new Set(routers.map((item) => item.software_revision).filter(Boolean))]; + const versionsConsistent = versionPairs.size <= 1; + const versionDetails = routers + .map( + (item) => + `${item.node_id}: ${item.software_version || '-'}@${item.software_revision || '-'}` + ) + .join('\n'); + const release = + routers.length === 0 + ? '-' + : versionsConsistent + ? softwareVersions[0] || '-' + : t('clusterInfo.routerMultipleVersions'); + const revision = + routers.length === 0 + ? '-' + : versionsConsistent + ? revisions[0] || '-' + : t('clusterInfo.routerMultipleVersions'); + + return [ + { label: t('clusterInfo.count'), value: routers.length }, + { + label: t('clusterInfo.cpuInfo'), + value: `${t('clusterInfo.usage')}${cpuUsage.toFixed(2)}`, + total: `${t('clusterInfo.total')}${cpuTotal.toFixed(2)}`, + }, + { + label: t('clusterInfo.cpuMemoryInfo'), + value: `${t('clusterInfo.usage')}${formatFileSize(memoryUsage)}`, + total: `${t('clusterInfo.total')}${formatFileSize(memoryTotal)}`, + }, + { + label: t('clusterInfo.version'), + value: `${t('clusterInfo.release')}${release}`, + total: `${t('clusterInfo.commit')}${revision}`, + title: versionDetails, + }, + ]; + }, [routers, t]); + + const fetchClusterInfo = useCallback(async () => { try { - const res = await request.get('/v1/cluster/info', { - params: { detailed: true }, + const response = await request.get('/v1/cluster/info', { + params: { detailed: true, include_routers: tokenRouterEnabled }, }); setLastUpdateTime(format(new Date(), 'yyyy-MM-dd HH:mm:ss')); setData({ - supervisors: res.filter((item) => item.node_type === 'Supervisor'), - workers: res.filter((item) => item.node_type === 'Worker'), + supervisors: response.filter( + (item): item is ClusterInfo => item.node_type === 'Supervisor' + ), + workers: response.filter((item): item is ClusterInfo => item.node_type === 'Worker'), + routers: tokenRouterEnabled + ? response.filter( + (item): item is RouterNodeClusterInfo => + item.node_type === 'Router' && item.online && item.connectivity_status === 'online' + ) + : [], }); + } catch (error) { + console.error(error); + } finally { timerRef.current = setTimeout(fetchClusterInfo, 5000); - } catch (err) { - console.log(err) - if(timerRef.current){ - clearTimeout(timerRef.current); - } } - }; + }, [tokenRouterEnabled]); useEffect(() => { - fetchClusterInfo(); + void fetchClusterInfo(); return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } + if (timerRef.current) clearTimeout(timerRef.current); }; - }, []); + }, [fetchClusterInfo]); + + const renderSummary = ( + rows: Array<{ label: string; value: string | number; total?: string; title?: string }> + ) => ( +
+ + + + {t('clusterInfo.item')} + {t('clusterInfo.value')} + + + + + {rows.map((row) => ( + + {row.label} + + {row.value} + + {row.total && {row.total}} + + ))} + +
+
+ ); + return ( - {t('clusterInfo.pageDescription')} - {t('common.lastUpdateTime')}: {lastUpdateTime} + {t('clusterInfo.pageDescription')} {t('common.lastUpdateTime')}: {lastUpdateTime} } >
-
-
{t('clusterInfo.supervisor')}
-
- - - - {t('clusterInfo.item')} - {t('clusterInfo.value')} - - - - - {supervisorSummary.map((row) => ( - - {row.label} - {row.total ? ( - <> - {row.value} - {row.total} - - ) : ( - {row.value} - )} - - ))} - -
-
-
- -
-
{t('clusterInfo.workers')}
-
- - - - {t('clusterInfo.item')} - {t('clusterInfo.value')} - - - - - {workersSummary.map((row) => ( - - {row.label} - {row.total ? ( - <> - {row.value} - {row.total} - - ) : ( - {row.value} - )} - - ))} - -
-
-
+
+

{t('clusterInfo.supervisor')}

+ {renderSummary(supervisorSummary)} +
-
-
- {t('clusterInfo.workerDetails')} -
-
+
+

{t('clusterInfo.workers')}

+ {renderSummary(workersSummary)} +
+ + {tokenRouterEnabled && ( +
+

{t('clusterInfo.routers')}

+ {renderSummary(routerSummary)} +
+ )} + +
+

{t('clusterInfo.workerDetails')}

+
@@ -261,12 +295,12 @@ export default function ClusterInfo() { - {workerDetails.map((row, index) => ( - + {workerDetails.map((row) => ( + {t('clusterInfo.worker')} {row.ip_address} {row.cpuUsage} - {row.cpu_count} + {row.cpu_count ?? '-'} {row.cpuMemUsage} {row.cpuMemTotal} {row.gpu_count} @@ -278,7 +312,74 @@ export default function ClusterInfo() {
-
+ + + {tokenRouterEnabled && ( +
+

+ {t('clusterInfo.routerNodeDetails')} +

+
+ + + + {t('clusterInfo.nodeType')} + {t('clusterInfo.address')} + {t('clusterInfo.cpuUsage')} + {t('clusterInfo.cpuTotal')} + {t('clusterInfo.memUsage')} + {t('clusterInfo.memTotal')} + + + + {routers.length === 0 ? ( + + + {t('clusterInfo.noRouterInstances')} + + + ) : ( + routers.map((router) => { + const cpuUsage = + typeof router.cpu_count === 'number' && + typeof router.cpu_available === 'number' + ? (router.cpu_count - router.cpu_available).toFixed(2) + : '-'; + const addressTitle = + router.node_id && router.node_id !== router.ip_address + ? `${router.ip_address}\n${router.node_id}` + : router.ip_address; + return ( + + {t('clusterInfo.routerNode')} + + {router.ip_address || '-'} + + {cpuUsage} + + {typeof router.cpu_count === 'number' + ? router.cpu_count.toFixed(2) + : '-'} + + + {typeof router.mem_used === 'number' + ? formatFileSize(router.mem_used) + : '-'} + + + {typeof router.mem_total === 'number' + ? formatFileSize(router.mem_total) + : '-'} + + + ); + }) + )} + +
+
+
+ )}
); diff --git a/frontend/src/components/pages/token-router/advanced-routing-editor.tsx b/frontend/src/components/pages/token-router/advanced-routing-editor.tsx index 45364c7677..d907bc4231 100644 --- a/frontend/src/components/pages/token-router/advanced-routing-editor.tsx +++ b/frontend/src/components/pages/token-router/advanced-routing-editor.tsx @@ -15,11 +15,16 @@ import type { TokenRouterRoutingRule, } from '@/types/services'; +import { BackendModelSelect } from './backend-model-select'; import type { TypedRouterDraft } from './router-config-normalizer'; interface Props { value: TypedRouterDraft; candidates: TokenRouterBackendCandidate[]; + candidatesLoading?: boolean; + candidatesLoadFailed?: boolean; + candidateErrors?: string[]; + tokenizerCompatibilityVerified?: boolean; onChange: (value: TypedRouterDraft) => void; } @@ -34,17 +39,17 @@ const conditionOptions = (anyLabel: string, trueLabel: string, falseLabel: strin { value: 'false', label: falseLabel }, ]; -export function AdvancedRoutingEditor({ value, candidates, onChange }: Props) { +export function AdvancedRoutingEditor({ + value, + candidates, + candidatesLoading = false, + candidatesLoadFailed = false, + candidateErrors = [], + tokenizerCompatibilityVerified = true, + onChange, +}: Props) { const { t } = useI18n(); const backendIds = value.backends.map((backend) => backend.id).filter(Boolean); - const candidateOptions = candidates.map((candidate) => ({ - value: candidate.model_uid, - label: `${candidate.model_uid}${candidate.model_engine ? ` · ${candidate.model_engine}` : ''}`, - description: candidate.eligible - ? candidate.compatibility_reason - : candidate.ineligible_reasons.join('; '), - disabled: !candidate.eligible, - })); const updateBackend = (index: number, patch: Partial) => { const backends = value.backends.map((backend, itemIndex) => @@ -176,11 +181,13 @@ export function AdvancedRoutingEditor({ value, candidates, onChange }: Props) { /> - + + setSimpleBackendUids((current) => ({ + ...current, + short: String(modelUid || ''), + })) + } + /> - + + setSimpleBackendUids((current) => ({ + ...current, + long: String(modelUid || ''), + })) + } + /> {numberField('short_max_context', t('tokenRouter.shortContext'))} {numberField('long_max_context', t('tokenRouter.longContext'), false, [ @@ -829,11 +901,69 @@ export function RouterFormDialog({ open, router, onOpenChange, onSaved }: Props) )} + + + + allowClear={false} + options={[ + { value: 'external', label: t('tokenRouter.managementModes.external') }, + { value: 'managed', label: t('tokenRouter.managementModes.managed') }, + ]} + /> + + {numberField('desired_replicas', t('tokenRouter.desiredReplicas'), true)} + + + allowClear={false} + disabled={managementMode !== 'managed'} + options={[ + { value: 'auto', label: t('tokenRouter.placementModes.auto') }, + { value: 'node', label: t('tokenRouter.placementModes.node') }, + ]} + /> + + {placementMode === 'node' && ( + + + + )} + + + allowClear={false} + options={[ + { value: 'disabled', label: t('tokenRouter.failoverModes.disabled') }, + { value: 'enabled', label: t('tokenRouter.failoverModes.enabled') }, + ]} + /> + + {numberField('drain_timeout_seconds', t('tokenRouter.drainTimeout'), false)} + + {numberField('tokenization_workers', t('tokenRouter.tokenWorkers'))} {numberField('tokenization_max_active', t('tokenRouter.tokenActive'), false, [ diff --git a/frontend/src/components/pages/token-router/router-node-section.tsx b/frontend/src/components/pages/token-router/router-node-section.tsx new file mode 100644 index 0000000000..857e8bb957 --- /dev/null +++ b/frontend/src/components/pages/token-router/router-node-section.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, ServerCog } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useI18n } from '@/contexts/i18n-context'; +import request from '@/lib/request'; +import type { TokenRouterNode } from '@/types/services'; +import { getBindingStatusLabel, getBindingStatusTitle } from './tokenizer-asset-binding-status'; + +interface Props { + nodes: TokenRouterNode[]; + initialLoading: boolean; + canOperate: boolean; + onChanged: () => Promise; +} + +type NodeState = TokenRouterNode['desired_state']; + +export function RouterNodeSection({ nodes, initialLoading, canOperate, onChanged }: Props) { + const { t } = useI18n(); + const [busyNodeId, setBusyNodeId] = useState(null); + + const setState = async (node: TokenRouterNode, desiredState: NodeState) => { + setBusyNodeId(node.node_id); + try { + await request.put(`/v1/token_router_nodes/${encodeURIComponent(node.node_id)}/state`, { + desired_state: desiredState, + }); + toast.success(t('tokenRouter.nodeStateUpdated')); + await onChanged(); + } finally { + setBusyNodeId(null); + } + }; + + return ( +
+
+

+ + {t('tokenRouter.routerNodes')} +

+

+ {t('tokenRouter.routerNodesDescription')} +

+
+ +
+ + + + + + + + + + + + + + {t('tokenRouter.nodeId')} + {t('tokenRouter.status')} + {t('tokenRouter.advertiseHost')} + {t('tokenRouter.portPool')} + {t('tokenRouter.nodeCapacity')} + {t('tokenRouter.tokenizerAssets')} + {t('tokenRouter.lastHeartbeat')} + {t('common.actions')} + + + + {initialLoading ? ( + + + + {t('tokenRouter.loading')} + + + ) : nodes.length === 0 ? ( + + + {t('tokenRouter.noRouterNodes')} + + + ) : ( + nodes.map((node) => { + const bindings = node.tokenizer_asset_bindings || []; + const busy = busyNodeId === node.node_id; + const connectivity = + node.connectivity_status || (node.online ? 'online' : 'offline'); + const heartbeatAge = Number.isFinite(node.heartbeat_age_seconds) + ? Math.round(node.heartbeat_age_seconds) + : null; + return ( + + +
{node.node_id}
+
+ +
+ + {t( + connectivity === 'online' + ? 'tokenRouter.nodeOnline' + : connectivity === 'suspected' + ? 'tokenRouter.nodeSuspected' + : 'tokenRouter.nodeOffline' + )} + + + {t(`tokenRouter.nodeStates.${node.desired_state}`)} + +
+
+ {node.advertise_host} + +
+ {node.port_range_start}-{node.port_range_end} +
+
+ {t('tokenRouter.usedPorts')}: {node.used_ports.join(', ') || '—'} +
+
+ +
+ {node.assignments}/{node.max_instances} +
+
+ {t('tokenRouter.availableSlots')}: {node.available_slots} +
+
+ +
+ {bindings.length === 0 + ? '—' + : bindings.map((binding) => ( +
+ {binding.asset_id}:{' '} + {getBindingStatusLabel( + t, + binding.desired_state, + binding.observed_state + )} +
+ ))} +
+
+ +
+ {node.last_seen_at ? new Date(node.last_seen_at).toLocaleString() : '—'} +
+
+ {heartbeatAge === null + ? '—' + : t('tokenRouter.heartbeatAge', { seconds: heartbeatAge })} +
+
+ + {canOperate ? ( +
+ {node.desired_state === 'active' ? ( + + ) : ( + + )} + + +
+ ) : ( + '—' + )} +
+
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/pages/token-router/router-runtime-detail-drawer.tsx b/frontend/src/components/pages/token-router/router-runtime-detail-drawer.tsx new file mode 100644 index 0000000000..6a9b24f8d6 --- /dev/null +++ b/frontend/src/components/pages/token-router/router-runtime-detail-drawer.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { format } from 'date-fns'; +import type { ReactNode } from 'react'; + +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { useI18n } from '@/contexts/i18n-context'; +import { formatFileSize } from '@/lib/utils'; +import type { RouterClusterInfo } from '@/types/services'; +import { RouterStatusBadge } from './router-status-badge'; + +interface Props { + router: RouterClusterInfo | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function formatTimestamp(value?: number | null) { + return typeof value === 'number' ? format(new Date(value * 1000), 'yyyy-MM-dd HH:mm:ss') : '-'; +} + +function formatDuration(value?: number) { + if (typeof value !== 'number') return '-'; + const totalSeconds = Math.max(0, Math.floor(value)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return [ + days ? `${days}d` : '', + hours ? `${hours}h` : '', + minutes ? `${minutes}m` : '', + `${seconds}s`, + ] + .filter(Boolean) + .join(' '); +} + +function formatBytes(value?: number) { + return typeof value === 'number' ? formatFileSize(value) : '-'; +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function DetailItem({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value ?? '-'}
+
+ ); +} + +export function RouterRuntimeDetailDrawer({ router, open, onOpenChange }: Props) { + const { t } = useI18n(); + const resources = router?.process?.resources; + const workerPids = router?.process?.tokenization?.worker_pids; + + return ( + + + + {t('clusterInfo.routerRuntimeDetails')} + + {router && ( +
+ + + + + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/pages/token-router/router-status-badge.tsx b/frontend/src/components/pages/token-router/router-status-badge.tsx index 8da0bcca57..6896848732 100644 --- a/frontend/src/components/pages/token-router/router-status-badge.tsx +++ b/frontend/src/components/pages/token-router/router-status-badge.tsx @@ -3,11 +3,24 @@ import { useI18n } from '@/contexts/i18n-context'; import { cn } from '@/lib/utils'; const styles: Record = { + pending: 'border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300', + assigned: 'border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300', + starting: 'border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300', + unavailable: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + draining: 'border-amber-500/30 bg-amber-500/15 text-amber-700 dark:text-amber-300', ready: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', syncing: 'border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300', degraded: 'border-amber-500/30 bg-amber-500/15 text-amber-700 dark:text-amber-300', + config_error: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + heartbeat_timeout: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + not_running: 'border-slate-500/30 bg-slate-500/15 text-slate-700 dark:text-slate-300', offline: 'border-slate-500/30 bg-slate-500/15 text-slate-700 dark:text-slate-300', error: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + failed: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + crash_loop: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + port_conflict: 'border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300', + stopped: 'border-slate-500/30 bg-slate-500/15 text-slate-700 dark:text-slate-300', + stale: 'border-slate-500/30 bg-slate-500/15 text-slate-700 dark:text-slate-300', disabled: 'border-zinc-500/30 bg-zinc-500/15 text-zinc-700 dark:text-zinc-300', draft: 'border-purple-500/30 bg-purple-500/15 text-purple-700 dark:text-purple-300', }; diff --git a/frontend/src/components/pages/token-router/tokenizer-asset-binding-status.ts b/frontend/src/components/pages/token-router/tokenizer-asset-binding-status.ts new file mode 100644 index 0000000000..24f994a50c --- /dev/null +++ b/frontend/src/components/pages/token-router/tokenizer-asset-binding-status.ts @@ -0,0 +1,48 @@ +import type { TFunc } from '@/contexts/i18n-context'; +import type { TokenizerAssetBinding } from '@/types/services'; + +type BindingDesiredState = TokenizerAssetBinding['desired_state']; +type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +function translateBindingState(t: TFunc, prefix: string, state: string): string { + const key = `${prefix}.${state}`; + const translated = t(key); + return translated === key ? state : translated; +} + +export function getBindingDesiredStateLabel(t: TFunc, state: BindingDesiredState): string { + return translateBindingState(t, 'tokenRouter.bindingDesiredStates', state); +} + +export function getBindingObservedStateLabel(t: TFunc, state: string): string { + return translateBindingState(t, 'tokenRouter.bindingObservedStates', state); +} + +export function getBindingStatusLabel( + t: TFunc, + desiredState: BindingDesiredState, + observedState: string +): string { + return `${getBindingDesiredStateLabel(t, desiredState)} / ${getBindingObservedStateLabel( + t, + observedState + )}`; +} + +export function getBindingStatusTitle( + t: TFunc, + desiredState: BindingDesiredState, + observedState: string +): string { + return `${t('tokenRouter.bindingDesiredState')}: ${getBindingDesiredStateLabel( + t, + desiredState + )}\n${t('tokenRouter.bindingObservedState')}: ${getBindingObservedStateLabel(t, observedState)}`; +} + +export function getBindingObservedStateBadgeVariant(state: string): BadgeVariant { + if (state === 'ready') return 'default'; + if (state === 'error') return 'destructive'; + if (state === 'stale' || state === 'unavailable') return 'outline'; + return 'secondary'; +} diff --git a/frontend/src/components/pages/token-router/tokenizer-asset-section.tsx b/frontend/src/components/pages/token-router/tokenizer-asset-section.tsx new file mode 100644 index 0000000000..5bd5fd2101 --- /dev/null +++ b/frontend/src/components/pages/token-router/tokenizer-asset-section.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Database, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useI18n } from '@/contexts/i18n-context'; +import request from '@/lib/request'; +import type { TokenizerAssetBinding, TokenizerAssetItem, TokenRouterNode } from '@/types/services'; +import { + getBindingObservedStateBadgeVariant, + getBindingStatusLabel, + getBindingStatusTitle, +} from './tokenizer-asset-binding-status'; + +interface Props { + assets: TokenizerAssetItem[]; + nodes: TokenRouterNode[]; + initialLoading: boolean; + canOperate: boolean; + onChanged: () => Promise; +} + +export function TokenizerAssetSection({ + assets, + nodes, + initialLoading, + canOperate, + onChanged, +}: Props) { + const { t } = useI18n(); + const [selectedNodes, setSelectedNodes] = useState>({}); + const [busy, setBusy] = useState(''); + + const bindings = useMemo( + () => + new Map( + nodes.flatMap((node) => + (node.tokenizer_asset_bindings || []).map((binding) => [ + `${binding.asset_id}:${binding.node_id}`, + binding, + ]) + ) + ), + [nodes] + ); + + const bind = async (asset: TokenizerAssetItem) => { + const nodeId = selectedNodes[asset.asset_id]; + if (!nodeId) return; + setBusy(`${asset.asset_id}:${nodeId}:bind`); + try { + await request.post(`/v1/tokenizer_assets/${encodeURIComponent(asset.asset_id)}/bindings`, { + node_ids: [nodeId], + desired_state: 'present', + binding_mode: 'manual', + }); + toast.success(t('tokenRouter.assetBindingUpdated')); + setSelectedNodes((current) => ({ ...current, [asset.asset_id]: '' })); + await onChanged(); + } finally { + setBusy(''); + } + }; + + const updateBinding = async ( + binding: TokenizerAssetBinding, + action: 'revalidate' | 'absent' | 'delete' + ) => { + const key = `${binding.asset_id}:${binding.node_id}:${action}`; + setBusy(key); + const base = `/v1/tokenizer_assets/${encodeURIComponent(binding.asset_id)}/bindings/${encodeURIComponent(binding.node_id)}`; + try { + if (action === 'revalidate') await request.post(`${base}/revalidate`); + else if (action === 'absent') await request.patch(base, { desired_state: 'absent' }); + else await request.delete(base); + toast.success(t('tokenRouter.assetBindingUpdated')); + await onChanged(); + } finally { + setBusy(''); + } + }; + + return ( +
+
+

+ + {t('tokenRouter.assetCatalog')} +

+

+ {t('tokenRouter.assetCatalogDescription')} +

+
+
+ + + + {t('tokenRouter.tokenizerAsset')} + {t('tokenRouter.tokenizerAssetOrigin')} + {t('tokenRouter.tokenizerAssetRevision')} + {t('tokenRouter.tokenizerAssetFingerprint')} + {t('tokenRouter.assetBindings')} + {t('common.actions')} + + + + {initialLoading ? ( + + + + {t('tokenRouter.loading')} + + + ) : assets.length === 0 ? ( + + + {t('tokenRouter.noTokenizerAssets')} + + + ) : ( + assets.map((asset) => { + const assetBindings = nodes + .flatMap((node) => node.tokenizer_asset_bindings || []) + .filter((item) => item.asset_id === asset.asset_id); + return ( + + +
{asset.asset_id}
+ + {asset.enabled + ? t('tokenRouter.assetEnabled') + : t('tokenRouter.assetDisabled')} + +
+ {asset.origin} + {asset.revision} + + {asset.fingerprint.slice(0, 20)}… + + +
+ {assetBindings.length === 0 ? ( + + ) : ( + assetBindings.map((binding) => ( +
+
+ {binding.node_id} + + {getBindingStatusLabel( + t, + binding.desired_state, + binding.observed_state + )} + +
+ {binding.last_error && ( +
{binding.last_error}
+ )} + {canOperate && ( +
+ + + +
+ )} +
+ )) + )} +
+
+ + {canOperate && ( +
+ + +
+ )} +
+
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index bd7e5bffeb..b4f8baeb92 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -22,6 +22,9 @@ const en = { }, tokenRouter: { title: 'Token Router', + featureDisabled: 'Token Router is disabled', + featureDisabledDescription: + 'Token Router has been disabled for this cluster by the global configuration.', description: 'Manage token-budget routing between short- and long-context model backends.', create: 'Create Router', edit: 'Edit Router', @@ -106,8 +109,95 @@ const en = { details: 'Router Details', configuration: 'Configuration Summary', revision: 'Revision', + deployment: 'Deployment', + managementMode: 'Management Mode', + placementMode: 'Node Selection', + placementModes: { + auto: 'Automatic Scheduling', + node: 'Specific Node', + }, + placementNodeId: 'Router Node ID', + placementLabels: 'Required Node Labels', + placementLabelsHint: + 'Enter comma-separated key=value labels. Leave empty to allow any matching node.', + desiredReplicas: 'Desired Replicas', + autoFailover: 'Automatic Failover', + drainTimeout: 'Drain Timeout (seconds)', + replicaSummary: '{{ready}}/{{desired}} ready · {{pending}} pending', + managementModes: { external: 'External', managed: 'Agent managed' }, + failoverModes: { enabled: 'Enabled', disabled: 'Disabled' }, runtimeInstances: 'Runtime Instances', noInstances: 'No runtime instances are registered.', + assignments: 'Assignments', + assignmentCount: 'Assignments', + noAssignments: 'No assignments are scheduled.', + assignmentId: 'Assignment ID', + assignmentGeneration: 'Assignment Generation', + configRevision: 'Config Revision', + desiredState: 'Desired State', + processId: 'Process ID', + listenPort: 'Listen Port', + lastError: 'Last Error', + assetCatalog: 'Tokenizer Asset Catalog', + assetCatalogDescription: + 'Manage persistent Asset-to-Agent desired bindings and observed validation state.', + assetBindings: 'Agent Bindings', + bindAsset: 'Bind', + revalidateAsset: 'Revalidate', + removeAsset: 'Remove', + selectRouterNode: 'Select Router Agent', + assetBindingUpdated: 'Tokenizer Asset binding updated.', + assetEnabled: 'Enabled', + assetDisabled: 'Disabled', + bindingDesiredState: 'Desired State', + bindingObservedState: 'Observed State', + bindingDesiredStates: { + present: 'Should Be Present', + absent: 'Should Be Removed', + }, + bindingObservedStates: { + pending: 'Pending', + preparing: 'Preparing', + validating: 'Validating', + ready: 'Ready', + unavailable: 'Unavailable', + error: 'Error', + removing: 'Removing', + absent: 'Removed', + stale: 'State Stale', + }, + routingPolicies: 'Routing Policies', + routingPoliciesDescription: + 'Manage backend selection, routing rules, deployment replicas, and current service status for Token Routers.', + reportedLabels: 'Reported labels', + managedLabels: 'Managed labels', + routerNodes: 'Router Nodes', + routerNodesDescription: + 'Monitor Agent nodes, scheduling capacity, port allocation, and lifecycle state.', + noRouterNodes: 'No Router Agent nodes are registered.', + nodeId: 'Node ID', + advertiseHost: 'Advertise Host', + portPool: 'Port Pool', + usedPorts: 'Used Ports', + nodeCapacity: 'Assignments / Capacity', + availableSlots: 'Available Slots', + nodeSchedulingMetadata: 'Scheduling Metadata', + tokenizerAssets: 'Tokenizer Assets', + nodeOnline: 'Online', + nodeSuspected: 'Suspected', + nodeOffline: 'Offline', + lastHeartbeat: 'Last Heartbeat', + nodeStates: { + active: 'Active', + cordoned: 'Cordoned', + draining: 'Draining', + disabled: 'Disabled', + }, + cordon: 'Cordon', + uncordon: 'Uncordon', + drain: 'Drain', + disableNode: 'Disable', + nodeStateUpdated: 'Router node state updated.', metrics: 'Metrics Summary', noMetrics: 'No metrics are available.', endpoint: 'Endpoint', @@ -196,6 +286,21 @@ const en = { maxActive: 'Maximum Active', maxQueue: 'Maximum Queue', selectBackendCandidate: 'Select a compatible backend candidate.', + searchBackendModel: 'Search by model UID, name, format, or ability', + backendCandidatesLoading: 'Loading backend model candidates...', + backendCandidatesLoadFailed: + 'Backend model candidates could not be loaded. Refresh and try again.', + noRunningBackendModels: 'No running backend models are currently available.', + noCompatibleBackendModels: + 'No backend model currently satisfies the Router and Tokenizer requirements.', + tokenizerCompatibilityUnverified: + 'A custom Tokenizer path is in use, so model and Tokenizer compatibility is unverified.', + historicalBackendUnavailable: + 'This historical model UID is offline or is no longer present in the candidate list.', + backendAlreadySelected: 'This model UID is already selected by the other simple-mode backend.', + backendCandidateEligible: 'Eligible', + backendUnavailable: 'Unavailable', + backendContextValue: 'Context: {{tokens}} tokens', orderedRules: 'Ordered Routing Rules', firstMatchHint: 'Rules run from highest to lowest priority and stop at the first match.', addRule: 'Add Rule', @@ -231,9 +336,22 @@ const en = { strategy: 'Routing Strategy', }, statuses: { + pending: 'Pending', + assigned: 'Assigned', + failed: 'Failed', + crash_loop: 'Crash Loop', + port_conflict: 'Port Conflict', + stopped: 'Stopped', + stale: 'Stale', + starting: 'Starting', + unavailable: 'Unavailable', + draining: 'Draining', ready: 'Ready', syncing: 'Syncing', degraded: 'Degraded', + config_error: 'Configuration error', + heartbeat_timeout: 'Heartbeat timeout', + not_running: 'Not running', offline: 'Offline', error: 'Error', disabled: 'Disabled', @@ -252,6 +370,7 @@ const en = { reject: 'Reject', }, sections: { + deployment: 'Deployment', basic: 'Basic Configuration', backends: 'Backend Models', routing: 'Routing Policy', @@ -270,6 +389,7 @@ const en = { 'The short context limit cannot exceed the long context limit.', tokenActiveBelowWorkers: 'Tokenization max active must be at least the worker count.', invalidAdvancedConfig: 'The advanced backend and routing rule configuration is invalid.', + invalidPlacementLabels: 'Enter labels as comma-separated key=value pairs.', }, }, model: { @@ -692,8 +812,8 @@ const en = { }, clusterInfo: { pageDescription: - 'View the current state of your XInference cluster — supervisors, workers, and their hardware resources.', - supervisor: 'Supervisor', + 'View the current state of your XInference cluster—including Supervisor, Worker, and Router nodes and their hardware resources.', + supervisor: 'Supervisor Node', workers: 'Workers', workerDetails: 'Worker Details', count: 'Count', @@ -719,6 +839,66 @@ const en = { gpuMemUsage: 'GPU Mem Usage', gpuMemTotal: 'GPU Mem Total', worker: 'Worker', + routers: 'Router Nodes', + routerDescription: + 'Token Routers are independent routing runtime instances; Supervisor host resources are not duplicated here.', + routerInstanceDetails: 'Token Router Instance Details', + routerNodeDetails: 'Router Node Details', + routerNode: 'Router Node', + routerMultipleVersions: 'Multiple / inconsistent versions', + routerConfiguredCount: 'Configured Routers', + routerEnabledCount: 'Enabled Routers', + routerRuntimeCount: 'Runtime Instances', + routerReadyCount: 'Ready Instances', + routerUnavailableCount: 'Unavailable Routers', + routerActiveRequests: 'Active Requests', + routerWaitingRequests: 'Waiting Requests', + routerUid: 'Router UID', + status: 'Status', + endpoint: 'Endpoint', + configVersion: 'Config Revision / ACK', + heartbeat: 'Heartbeat', + requests: 'Active / Waiting', + tokenizerStatus: 'Tokenization Status', + noRouters: 'No Token Router configurations or runtime instances.', + routerOnlineCount: 'Online Instances', + routerProcessCpu: 'Process-tree CPU', + routerProcessRss: 'Process-tree RSS', + routerSoftwareVersion: 'Software Version', + routerSoftwareRevision: 'Commit', + routerVersionConsistency: 'Version Consistency', + routerVersionsConsistent: 'Consistent', + routerVersionsInconsistent: 'Inconsistent', + routerInstanceHost: 'Instance / Host', + routerUptime: 'Uptime', + routerRuntimeDetails: 'Token Router Runtime Details', + routerBasicInfo: 'Basic Information', + routerProcessInfo: 'Process Information', + routerPerformanceInfo: 'Performance Information', + routerVersionInfo: 'Version Information', + routerInstanceId: 'Instance ID', + routerHostname: 'Hostname', + routerOnlineStatus: 'Online Status', + routerOnline: 'Online', + routerOffline: 'Offline', + routerRegisteredAt: 'Registered At', + routerLastHeartbeat: 'Last Heartbeat', + routerMainPid: 'Main Process PID', + routerTokenizerWorkerPids: 'Tokenization Worker PIDs', + routerChildProcessCount: 'Child Processes', + routerThreadCount: 'Threads', + routerStartedAt: 'Started At', + routerCpuCores: 'Equivalent CPU Cores', + routerMainRss: 'Main Process RSS', + routerChildRss: 'Child Process RSS', + routerSampledAt: 'Resource Sampled At', + routerProtocolVersion: 'Control-plane Protocol Version', + routerPythonVersion: 'Python Version', + routerPlatform: 'Platform', + noRouterInstances: 'No online Router nodes.', + secondsAgo: '{{seconds}}s ago', + activeWaiting: '{{active}} / {{waiting}}', + tokenizerActiveWaiting: 'Active {{active}} / Waiting {{waiting}}', }, userManagement: { pageDescription: 'Manage users who can access the Xinference cluster.', @@ -1020,6 +1200,7 @@ const en = { delete: 'Delete', edit: 'Edit', modify: 'Modify', + actions: 'Actions', from: 'From', to: 'To', clear: 'Clear', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 9a11bdba42..e7d62aaa85 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -22,6 +22,9 @@ const ja = { }, tokenRouter: { title: 'Token Router', + featureDisabled: 'Token Router は無効です', + featureDisabledDescription: + 'グローバル設定により、このクラスターの Token Router 機能は無効になっています。', description: '短いコンテキストと長いコンテキストのモデル間のトークン予算ルーティングを管理します。', create: 'Router を作成', @@ -107,8 +110,94 @@ const ja = { details: 'Router 詳細', configuration: '設定概要', revision: 'リビジョン', + deployment: 'デプロイ', + managementMode: '管理モード', + placementMode: 'ノード選択', + placementModes: { + auto: '自動スケジュール', + node: '特定ノード', + }, + placementNodeId: 'Router ノード ID', + placementLabels: '必須ノードラベル', + placementLabelsHint: + 'key=value 形式のラベルをカンマ区切りで入力します。空欄の場合はラベルを制限しません。', + desiredReplicas: '希望レプリカ数', + autoFailover: '自動フェイルオーバー', + drainTimeout: 'ドレインタイムアウト(秒)', + replicaSummary: '準備完了 {{ready}}/{{desired}} · 保留 {{pending}}', + managementModes: { external: '外部管理', managed: 'Agent 管理' }, + failoverModes: { enabled: '有効', disabled: '無効' }, runtimeInstances: 'ランタイムインスタンス', noInstances: '登録済みランタイムインスタンスはありません。', + assignments: '割り当て', + assignmentCount: '割り当て数', + noAssignments: 'スケジュール済みの割り当てはありません。', + assignmentId: '割り当て ID', + assignmentGeneration: '割り当て世代', + configRevision: '設定リビジョン', + desiredState: '期待状態', + processId: 'プロセス ID', + listenPort: '待受ポート', + lastError: '最新のエラー', + assetCatalog: 'Tokenizer Asset カタログ', + assetCatalogDescription: '永続化された Asset-Agent バインドと検証状態を管理します。', + assetBindings: 'Agent バインド', + bindAsset: 'バインド', + revalidateAsset: '再検証', + removeAsset: '削除準備', + selectRouterNode: 'Router Agent を選択', + assetBindingUpdated: 'Tokenizer Asset バインドを更新しました。', + assetEnabled: '有効', + assetDisabled: '無効', + bindingDesiredState: '期待状態', + bindingObservedState: '実際の状態', + bindingDesiredStates: { + present: '配置対象', + absent: '削除対象', + }, + bindingObservedStates: { + pending: '保留中', + preparing: '準備中', + validating: '検証中', + ready: '準備完了', + unavailable: '利用不可', + error: 'エラー', + removing: '削除中', + absent: '削除済み', + stale: '状態失効', + }, + routingPolicies: 'ルーティングポリシー', + routingPoliciesDescription: + 'Token Router のバックエンド選択、ルーティングルール、デプロイレプリカ、現在のサービス状態を管理します。', + reportedLabels: '報告ラベル', + managedLabels: '管理ラベル', + routerNodes: 'Router ノード', + routerNodesDescription: + 'Agent ノード、スケジュール容量、ポート割り当て、ライフサイクル状態を監視します。', + noRouterNodes: '登録済みの Router Agent ノードはありません。', + nodeId: 'ノード ID', + advertiseHost: '公開ホスト', + portPool: 'ポートプール', + usedPorts: '使用中ポート', + nodeCapacity: '割り当て数 / 容量', + availableSlots: '空きスロット', + nodeSchedulingMetadata: 'スケジュールメタデータ', + tokenizerAssets: 'Tokenizer アセット', + nodeOnline: 'オンライン', + nodeSuspected: '通信断の疑い', + nodeOffline: 'オフライン', + lastHeartbeat: '最終ハートビート', + nodeStates: { + active: 'アクティブ', + cordoned: 'スケジュール停止', + draining: 'ドレイン中', + disabled: '無効', + }, + cordon: 'スケジュール停止', + uncordon: 'スケジュール再開', + drain: 'ドレイン', + disableNode: '無効化', + nodeStateUpdated: 'Router ノードの状態を更新しました。', metrics: 'メトリクス概要', noMetrics: '利用可能なメトリクスはありません。', endpoint: 'エンドポイント', @@ -196,6 +285,19 @@ const ja = { maxActive: '最大アクティブ数', maxQueue: '最大キュー数', selectBackendCandidate: '互換性のあるバックエンド候補を選択してください。', + searchBackendModel: 'モデル UID、名前、形式、能力で検索', + backendCandidatesLoading: 'バックエンドモデル候補を読み込んでいます...', + backendCandidatesLoadFailed: + 'バックエンドモデル候補を読み込めませんでした。更新して再試行してください。', + noRunningBackendModels: '現在利用可能な実行中バックエンドモデルはありません。', + noCompatibleBackendModels: 'Router と Tokenizer の要件を満たすバックエンドモデルはありません。', + tokenizerCompatibilityUnverified: + 'カスタム Tokenizer パスを使用しているため、モデルとの互換性は未確認です。', + historicalBackendUnavailable: 'この履歴モデル UID はオフライン、または候補一覧に存在しません。', + backendAlreadySelected: 'このモデル UID は簡易モードの別のバックエンドで選択済みです。', + backendCandidateEligible: '選択可能', + backendUnavailable: '利用不可', + backendContextValue: 'コンテキスト:{{tokens}} トークン', orderedRules: '順序付きルーティングルール', firstMatchHint: '優先度の高い順に評価し、最初に一致したルールで停止します。', addRule: 'ルールを追加', @@ -220,9 +322,22 @@ const ja = { strategy: 'ルーティング戦略', }, statuses: { + pending: '保留中', + assigned: '割り当て済み', + failed: '失敗', + crash_loop: 'クラッシュループ', + port_conflict: 'ポート競合', + stopped: '停止済み', + stale: '期限切れ', + starting: '起動中', + unavailable: '利用不可', + draining: 'ドレイン中', ready: '準備完了', syncing: '同期中', degraded: '縮退', + config_error: '設定エラー', + heartbeat_timeout: 'ハートビートタイムアウト', + not_running: '未実行', offline: 'オフライン', error: 'エラー', disabled: '無効', @@ -241,6 +356,7 @@ const ja = { reject: '拒否', }, sections: { + deployment: 'デプロイ', basic: '基本設定', backends: 'バックエンドモデル', routing: 'ルーティングポリシー', @@ -259,6 +375,7 @@ const ja = { '短文コンテキスト上限は長文コンテキスト上限を超えられません。', tokenActiveBelowWorkers: 'Tokenization 最大アクティブ数はワーカー数以上にしてください。', invalidAdvancedConfig: '高度なバックエンドおよびルーティングルールの設定が無効です。', + invalidPlacementLabels: 'ラベルをカンマ区切りの key=value 形式で入力してください。', }, }, model: { @@ -681,8 +798,8 @@ const ja = { }, clusterInfo: { pageDescription: - 'XInference クラスターの現在の状態を確認します。スーパーバイザーノード、ワーカーノード、およびそれらのハードウェアリソースを含みます。', - supervisor: 'スーパーバイザー', + 'Supervisor、Worker、Router ノードとそのハードウェアリソースを含む XInference クラスターの現在の状態を確認します。', + supervisor: 'Supervisor ノード', workers: 'ワーカー', workerDetails: 'ワーカー詳細', count: '数', @@ -708,6 +825,66 @@ const ja = { gpuMemUsage: 'GPUメモリ使用量', gpuMemTotal: 'GPUメモリ合計', worker: 'ワーカー', + routers: 'Router ノード', + routerDescription: + 'Token Router は独立したルーティング実行インスタンスです。Supervisor のホストリソースは重複表示しません。', + routerInstanceDetails: 'Token Router インスタンス詳細', + routerNodeDetails: 'Router ノード詳細', + routerNode: 'Router ノード', + routerMultipleVersions: '複数 / 不一致バージョン', + routerConfiguredCount: '設定済み Router 数', + routerEnabledCount: '有効な Router 数', + routerRuntimeCount: '実行インスタンス数', + routerReadyCount: '準備完了インスタンス数', + routerUnavailableCount: '利用不可 Router 数', + routerActiveRequests: '処理中リクエスト数', + routerWaitingRequests: '待機リクエスト数', + routerUid: 'Router UID', + status: 'ステータス', + endpoint: 'エンドポイント', + configVersion: '設定リビジョン / ACK', + heartbeat: 'ハートビート', + requests: '処理中 / 待機', + tokenizerStatus: 'Tokenization ステータス', + noRouters: 'Token Router の設定または実行インスタンスがありません。', + routerOnlineCount: 'オンラインインスタンス数', + routerProcessCpu: 'プロセスツリー CPU', + routerProcessRss: 'プロセスツリー RSS', + routerSoftwareVersion: 'ソフトウェアバージョン', + routerSoftwareRevision: 'コミット', + routerVersionConsistency: 'バージョン整合性', + routerVersionsConsistent: '一致', + routerVersionsInconsistent: '不一致', + routerInstanceHost: 'インスタンス / ホスト', + routerUptime: '稼働時間', + routerRuntimeDetails: 'Token Router ランタイム詳細', + routerBasicInfo: '基本情報', + routerProcessInfo: 'プロセス情報', + routerPerformanceInfo: 'パフォーマンス情報', + routerVersionInfo: 'バージョン情報', + routerInstanceId: 'インスタンス ID', + routerHostname: 'ホスト名', + routerOnlineStatus: 'オンライン状態', + routerOnline: 'オンライン', + routerOffline: 'オフライン', + routerRegisteredAt: '登録日時', + routerLastHeartbeat: '最終ハートビート', + routerMainPid: 'メインプロセス PID', + routerTokenizerWorkerPids: 'Tokenization Worker PID', + routerChildProcessCount: '子プロセス数', + routerThreadCount: 'スレッド数', + routerStartedAt: '開始日時', + routerCpuCores: '相当 CPU コア数', + routerMainRss: 'メインプロセス RSS', + routerChildRss: '子プロセス RSS', + routerSampledAt: 'リソース取得日時', + routerProtocolVersion: '制御プレーンプロトコルバージョン', + routerPythonVersion: 'Python バージョン', + routerPlatform: 'プラットフォーム', + noRouterInstances: 'オンラインの Router ノードがありません。', + secondsAgo: '{{seconds}} 秒前', + activeWaiting: '{{active}} / {{waiting}}', + tokenizerActiveWaiting: '処理中 {{active}} / 待機 {{waiting}}', }, userManagement: { pageDescription: 'Xinference クラスターにアクセスできるユーザーを管理します。', @@ -1009,6 +1186,7 @@ const ja = { delete: '削除', edit: '編集', modify: '変更', + actions: '操作', from: '開始', to: '終了', clear: 'クリア', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 03d1e02b90..7c0ef4c308 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -22,6 +22,8 @@ const ko = { }, tokenRouter: { title: 'Token Router', + featureDisabled: 'Token Router가 비활성화되었습니다', + featureDisabledDescription: '전역 설정에서 이 클러스터의 Token Router 기능을 비활성화했습니다.', description: '짧은 컨텍스트와 긴 컨텍스트 모델 백엔드 간의 토큰 예산 라우팅을 관리합니다.', create: 'Router 생성', edit: 'Router 편집', @@ -106,8 +108,94 @@ const ko = { details: 'Router 세부 정보', configuration: '구성 요약', revision: '리비전', + deployment: '배포', + managementMode: '관리 모드', + placementMode: '노드 선택', + placementModes: { + auto: '자동 스케줄링', + node: '특정 노드', + }, + placementNodeId: 'Router 노드 ID', + placementLabels: '필수 노드 레이블', + placementLabelsHint: + 'key=value 레이블을 쉼표로 구분하여 입력하세요. 비워두면 노드 레이블을 제한하지 않습니다.', + desiredReplicas: '희망 복제본 수', + autoFailover: '자동 장애 조치', + drainTimeout: '드레인 시간 제한(초)', + replicaSummary: '준비 {{ready}}/{{desired}} · 대기 {{pending}}', + managementModes: { external: '외부 관리', managed: 'Agent 관리' }, + failoverModes: { enabled: '사용', disabled: '사용 안 함' }, runtimeInstances: '런타임 인스턴스', noInstances: '등록된 런타임 인스턴스가 없습니다.', + assignments: '할당', + assignmentCount: '할당 수', + noAssignments: '예약된 할당이 없습니다.', + assignmentId: '할당 ID', + assignmentGeneration: '할당 세대', + configRevision: '구성 리비전', + desiredState: '희망 상태', + processId: '프로세스 ID', + listenPort: '수신 포트', + lastError: '최근 오류', + assetCatalog: 'Tokenizer Asset 카탈로그', + assetCatalogDescription: '영구 Asset-Agent 바인딩과 검증 상태를 관리합니다.', + assetBindings: 'Agent 바인딩', + bindAsset: '바인딩', + revalidateAsset: '재검증', + removeAsset: '제거', + selectRouterNode: 'Router Agent 선택', + assetBindingUpdated: 'Tokenizer Asset 바인딩이 업데이트되었습니다.', + assetEnabled: '활성화', + assetDisabled: '비활성화', + bindingDesiredState: '희망 상태', + bindingObservedState: '실제 상태', + bindingDesiredStates: { + present: '존재해야 함', + absent: '제거해야 함', + }, + bindingObservedStates: { + pending: '대기 중', + preparing: '준비 중', + validating: '검증 중', + ready: '준비됨', + unavailable: '사용 불가', + error: '오류', + removing: '제거 중', + absent: '제거됨', + stale: '상태 만료됨', + }, + routingPolicies: '라우팅 정책', + routingPoliciesDescription: + 'Token Router의 백엔드 선택, 라우팅 규칙, 배포 복제본 및 현재 서비스 상태를 관리합니다.', + reportedLabels: '보고 라벨', + managedLabels: '관리 라벨', + routerNodes: 'Router 노드', + routerNodesDescription: + 'Agent 노드, 스케줄링 용량, 포트 할당 및 수명 주기 상태를 모니터링합니다.', + noRouterNodes: '등록된 Router Agent 노드가 없습니다.', + nodeId: '노드 ID', + advertiseHost: '공개 호스트', + portPool: '포트 풀', + usedPorts: '사용 중인 포트', + nodeCapacity: '할당 수 / 용량', + availableSlots: '사용 가능한 슬롯', + nodeSchedulingMetadata: '스케줄링 메타데이터', + tokenizerAssets: 'Tokenizer 자산', + nodeOnline: '온라인', + nodeSuspected: '연결 끊김 의심', + nodeOffline: '오프라인', + lastHeartbeat: '마지막 하트비트', + nodeStates: { + active: '활성', + cordoned: '스케줄링 중지', + draining: '드레인 중', + disabled: '비활성화됨', + }, + cordon: '스케줄링 중지', + uncordon: '스케줄링 재개', + drain: '드레인', + disableNode: '비활성화', + nodeStateUpdated: 'Router 노드 상태를 업데이트했습니다.', metrics: '메트릭 요약', noMetrics: '사용 가능한 메트릭이 없습니다.', endpoint: '엔드포인트', @@ -196,6 +284,21 @@ const ko = { maxActive: '최대 활성 수', maxQueue: '최대 대기열 수', selectBackendCandidate: '호환 가능한 백엔드 후보를 선택하세요.', + searchBackendModel: '모델 UID, 이름, 형식 또는 기능으로 검색', + backendCandidatesLoading: '백엔드 모델 후보를 불러오는 중...', + backendCandidatesLoadFailed: + '백엔드 모델 후보를 불러오지 못했습니다. 새로 고친 후 다시 시도하세요.', + noRunningBackendModels: '현재 사용 가능한 실행 중 백엔드 모델이 없습니다.', + noCompatibleBackendModels: + '현재 Router 및 Tokenizer 요구사항을 충족하는 백엔드 모델이 없습니다.', + tokenizerCompatibilityUnverified: + '사용자 지정 Tokenizer 경로를 사용 중이므로 모델과 Tokenizer 호환성이 확인되지 않았습니다.', + historicalBackendUnavailable: + '이 기존 모델 UID는 오프라인이거나 후보 목록에 더 이상 존재하지 않습니다.', + backendAlreadySelected: '이 모델 UID는 간단 모드의 다른 백엔드에서 이미 선택했습니다.', + backendCandidateEligible: '선택 가능', + backendUnavailable: '사용 불가', + backendContextValue: '컨텍스트: {{tokens}} 토큰', orderedRules: '순서가 있는 라우팅 규칙', firstMatchHint: '우선순위가 높은 순서로 평가하고 첫 번째 일치 규칙에서 중지합니다.', addRule: '규칙 추가', @@ -220,9 +323,22 @@ const ko = { strategy: '라우팅 전략', }, statuses: { + pending: '대기 중', + assigned: '할당됨', + failed: '실패', + crash_loop: '충돌 반복', + port_conflict: '포트 충돌', + stopped: '중지됨', + stale: '만료됨', + starting: '시작 중', + unavailable: '사용 불가', + draining: '드레인 중', ready: '준비됨', syncing: '동기화 중', degraded: '성능 저하', + config_error: '구성 오류', + heartbeat_timeout: '하트비트 시간 초과', + not_running: '실행되지 않음', offline: '오프라인', error: '오류', disabled: '비활성화됨', @@ -241,6 +357,7 @@ const ko = { reject: '거부', }, sections: { + deployment: '배포', basic: '기본 구성', backends: '백엔드 모델', routing: '라우팅 정책', @@ -258,6 +375,7 @@ const ko = { shortContextExceedsLongContext: '짧은 컨텍스트 한도는 긴 컨텍스트 한도를 초과할 수 없습니다.', tokenActiveBelowWorkers: 'Tokenization 최대 활성 수는 워커 수 이상이어야 합니다.', invalidAdvancedConfig: '고급 백엔드 및 라우팅 규칙 구성이 올바르지 않습니다.', + invalidPlacementLabels: '레이블을 쉼표로 구분된 key=value 형식으로 입력하세요.', }, }, model: { @@ -675,8 +793,8 @@ const ko = { }, clusterInfo: { pageDescription: - 'XInference 클러스터의 현재 상태를 확인합니다. 여기에는 슈퍼바이저 노드, 워커 노드 및 해당 하드웨어 리소스가 포함됩니다.', - supervisor: '슈퍼바이저', + 'Supervisor, Worker, Router 노드와 해당 하드웨어 리소스를 포함한 XInference 클러스터의 현재 상태를 확인합니다.', + supervisor: '슈퍼바이저 노드', workers: '워커', workerDetails: '워커 세부 정보', count: '수', @@ -702,6 +820,66 @@ const ko = { gpuMemUsage: 'GPU 메모리 사용량', gpuMemTotal: 'GPU 메모리 총계', worker: '워커', + routers: 'Router 노드', + routerDescription: + 'Token Router는 독립적인 라우팅 런타임 인스턴스이며 Supervisor 호스트 리소스는 중복 표시하지 않습니다.', + routerInstanceDetails: 'Token Router 인스턴스 세부 정보', + routerNodeDetails: 'Router 노드 세부 정보', + routerNode: 'Router 노드', + routerMultipleVersions: '다중 / 불일치 버전', + routerConfiguredCount: '구성된 Router 수', + routerEnabledCount: '활성화된 Router 수', + routerRuntimeCount: '런타임 인스턴스 수', + routerReadyCount: '준비된 인스턴스 수', + routerUnavailableCount: '사용 불가 Router 수', + routerActiveRequests: '활성 요청 수', + routerWaitingRequests: '대기 요청 수', + routerUid: 'Router UID', + status: '상태', + endpoint: 'Endpoint', + configVersion: '구성 리비전 / ACK', + heartbeat: '하트비트', + requests: '활성 / 대기', + tokenizerStatus: 'Tokenization 상태', + noRouters: 'Token Router 구성 또는 런타임 인스턴스가 없습니다.', + routerOnlineCount: '온라인 인스턴스 수', + routerProcessCpu: '프로세스 트리 CPU', + routerProcessRss: '프로세스 트리 RSS', + routerSoftwareVersion: '소프트웨어 버전', + routerSoftwareRevision: '커밋', + routerVersionConsistency: '버전 일관성', + routerVersionsConsistent: '일치', + routerVersionsInconsistent: '불일치', + routerInstanceHost: '인스턴스 / 호스트', + routerUptime: '가동 시간', + routerRuntimeDetails: 'Token Router 런타임 세부 정보', + routerBasicInfo: '기본 정보', + routerProcessInfo: '프로세스 정보', + routerPerformanceInfo: '성능 정보', + routerVersionInfo: '버전 정보', + routerInstanceId: '인스턴스 ID', + routerHostname: '호스트 이름', + routerOnlineStatus: '온라인 상태', + routerOnline: '온라인', + routerOffline: '오프라인', + routerRegisteredAt: '등록 시간', + routerLastHeartbeat: '최근 하트비트', + routerMainPid: '메인 프로세스 PID', + routerTokenizerWorkerPids: 'Tokenization Worker PID', + routerChildProcessCount: '자식 프로세스 수', + routerThreadCount: '스레드 수', + routerStartedAt: '시작 시간', + routerCpuCores: '등가 CPU 코어', + routerMainRss: '메인 프로세스 RSS', + routerChildRss: '자식 프로세스 RSS', + routerSampledAt: '리소스 샘플 시간', + routerProtocolVersion: '제어 플레인 프로토콜 버전', + routerPythonVersion: 'Python 버전', + routerPlatform: '플랫폼', + noRouterInstances: '온라인 Router 노드가 없습니다.', + secondsAgo: '{{seconds}}초 전', + activeWaiting: '{{active}} / {{waiting}}', + tokenizerActiveWaiting: '활성 {{active}} / 대기 {{waiting}}', }, userManagement: { pageDescription: 'Xinference 클러스터에 접근할 수 있는 사용자를 관리합니다.', @@ -999,6 +1177,7 @@ const ko = { delete: '삭제', edit: '수정', modify: '수정', + actions: '작업', from: '시작', to: '종료', clear: '지우기', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8ead0ca59a..f99a6d6ae3 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -22,6 +22,8 @@ const zh = { }, tokenRouter: { title: 'Token Router', + featureDisabled: 'Token Router 功能未启用', + featureDisabledDescription: '当前集群已通过全局配置关闭 Token Router 功能。', description: '管理短上下文与长上下文模型后端之间的 Token 预算路由。', create: '创建 Router', edit: '编辑 Router', @@ -102,8 +104,91 @@ const zh = { details: 'Router 详情', configuration: '配置摘要', revision: '配置版本', + deployment: '部署', + managementMode: '管理模式', + placementMode: '节点选择', + placementModes: { + auto: '自动调度', + node: '指定节点', + }, + placementNodeId: 'Router 节点 ID', + placementLabels: '节点标签约束', + placementLabelsHint: '使用英文逗号分隔 key=value 标签;留空表示不限制节点标签。', + desiredReplicas: '期望副本数', + autoFailover: '自动故障迁移', + drainTimeout: '优雅停止超时(秒)', + replicaSummary: '已就绪 {{ready}}/{{desired}} · 等待 {{pending}}', + managementModes: { external: '外部管理', managed: 'Agent 自动管理' }, + failoverModes: { enabled: '开启', disabled: '关闭' }, runtimeInstances: '运行实例', noInstances: '暂无已注册的运行实例。', + assignments: '调度任务', + assignmentCount: '调度任务数', + noAssignments: '暂无已调度的任务。', + assignmentId: '调度任务 ID', + assignmentGeneration: '调度任务世代', + configRevision: '配置修订号', + desiredState: '期望状态', + processId: '进程 ID', + listenPort: '监听端口', + lastError: '最近错误', + assetCatalog: 'Tokenizer 资产目录', + assetCatalogDescription: '管理持久化的资产-Agent 期望绑定及 Agent 实际校验状态。', + assetBindings: 'Agent 绑定', + bindAsset: '绑定', + revalidateAsset: '重新校验', + removeAsset: '移除', + selectRouterNode: '选择 Router Agent', + assetBindingUpdated: 'Tokenizer 资产绑定已更新。', + assetEnabled: '已启用', + assetDisabled: '已禁用', + bindingDesiredState: '期望状态', + bindingObservedState: '实际状态', + bindingDesiredStates: { + present: '应存在', + absent: '应移除', + }, + bindingObservedStates: { + pending: '待处理', + preparing: '准备中', + validating: '校验中', + ready: '已就绪', + unavailable: '不可用', + error: '异常', + removing: '移除中', + absent: '已移除', + stale: '状态已失效', + }, + routingPolicies: '路由策略', + routingPoliciesDescription: '管理 Token Router 的后端选择、路由规则、部署副本和当前服务状态。', + reportedLabels: '上报标签', + managedLabels: '管理标签', + routerNodes: 'Router 节点', + routerNodesDescription: '监控 Agent 节点、调度容量、端口分配和生命周期状态。', + noRouterNodes: '暂无已注册的 Router Agent 节点。', + nodeId: '节点 ID', + advertiseHost: '通告地址', + portPool: '端口池', + usedPorts: '已用端口', + nodeCapacity: '任务数 / 容量', + availableSlots: '可用槽位', + nodeSchedulingMetadata: '调度元数据', + tokenizerAssets: 'Tokenizer 资产', + nodeOnline: '在线', + nodeSuspected: '疑似失联', + nodeOffline: '离线', + lastHeartbeat: '最后心跳', + nodeStates: { + active: '活跃', + cordoned: '已封锁调度', + draining: '迁移中', + disabled: '已禁用', + }, + cordon: '封锁调度', + uncordon: '恢复调度', + drain: '迁移任务', + disableNode: '禁用', + nodeStateUpdated: 'Router 节点状态已更新。', metrics: '指标摘要', noMetrics: '暂无指标数据。', endpoint: '访问地址', @@ -188,6 +273,18 @@ const zh = { maxActive: '最大活跃数', maxQueue: '最大排队数', selectBackendCandidate: '请选择兼容的后端候选模型。', + searchBackendModel: '按模型 UID、名称、格式或能力搜索', + backendCandidatesLoading: '正在加载后端模型候选...', + backendCandidatesLoadFailed: '后端模型候选加载失败,请刷新后重试。', + noRunningBackendModels: '当前没有可用的运行中后端模型。', + noCompatibleBackendModels: '当前没有符合 Router 与 Tokenizer 要求的后端模型。', + tokenizerCompatibilityUnverified: + '当前使用自定义 Tokenizer 路径,模型与 Tokenizer 的兼容性尚未验证。', + historicalBackendUnavailable: '该历史模型 UID 当前已离线或不在候选列表中。', + backendAlreadySelected: '该模型 UID 已被简单模式的另一个后端选择。', + backendCandidateEligible: '可选择', + backendUnavailable: '不可用', + backendContextValue: '上下文:{{tokens}} Tokens', orderedRules: '有序路由规则', firstMatchHint: '按优先级从高到低执行,命中第一条规则后停止。', addRule: '添加规则', @@ -223,9 +320,22 @@ const zh = { strategy: '路由策略', }, statuses: { + pending: '等待中', + assigned: '已分配', + failed: '失败', + crash_loop: '反复崩溃', + port_conflict: '端口冲突', + stopped: '已停止', + stale: '已过期', + starting: '启动中', + unavailable: '不可用', + draining: '排空中', ready: '已就绪', syncing: '同步中', degraded: '降级', + config_error: '配置异常', + heartbeat_timeout: '心跳超时', + not_running: '未运行', offline: '离线', error: '异常', disabled: '已停用', @@ -244,6 +354,7 @@ const zh = { reject: '拒绝', }, sections: { + deployment: '部署配置', basic: '基础配置', backends: '后端模型', routing: '路由策略', @@ -261,6 +372,7 @@ const zh = { shortContextExceedsLongContext: '短请求最大上下文不能超过长请求最大上下文。', tokenActiveBelowWorkers: 'Tokenization 最大活跃数不能小于进程数。', invalidAdvancedConfig: '高级后端和路由规则配置无效。', + invalidPlacementLabels: '请输入以英文逗号分隔的 key=value 标签。', }, }, model: { @@ -671,8 +783,9 @@ const zh = { }, }, clusterInfo: { - pageDescription: '查看您的XInference集群的当前状态——包括主管节点、工作节点及其硬件资源。', - supervisor: '主管', + pageDescription: + '查看您的 XInference 集群的当前状态——包括主管节点、工作节点、路由节点及其硬件资源。', + supervisor: '主管节点', workers: '工作节点', workerDetails: '工作节点详情', count: '数量', @@ -698,6 +811,65 @@ const zh = { gpuMemUsage: 'GPU 内存用量', gpuMemTotal: 'GPU 内存总量', worker: '工作节点', + routers: '路由节点', + routerDescription: 'Token Router 是独立的路由运行实例,此处不重复展示 Supervisor 主机资源。', + routerInstanceDetails: 'Token Router 实例详情', + routerNodeDetails: '路由节点详情', + routerNode: '路由节点', + routerMultipleVersions: '多版本 / 不一致', + routerConfiguredCount: '已配置 Router 数', + routerEnabledCount: '已启用 Router 数', + routerRuntimeCount: '运行实例数', + routerReadyCount: '就绪实例数', + routerUnavailableCount: '不可用 Router 数', + routerActiveRequests: '活动请求数', + routerWaitingRequests: '排队请求数', + routerUid: 'Router UID', + status: '状态', + endpoint: 'Endpoint', + configVersion: '配置版本 / ACK 版本', + heartbeat: '心跳', + requests: '活动 / 排队请求', + tokenizerStatus: 'Tokenization 状态', + noRouters: '当前没有 Token Router 配置或运行实例。', + routerOnlineCount: '在线实例数', + routerProcessCpu: '进程树 CPU', + routerProcessRss: '进程树 RSS', + routerSoftwareVersion: '软件版本', + routerSoftwareRevision: 'Commit', + routerVersionConsistency: '版本一致性', + routerVersionsConsistent: '一致', + routerVersionsInconsistent: '不一致', + routerInstanceHost: '实例 / 主机', + routerUptime: '运行时长', + routerRuntimeDetails: 'Token Router 运行详情', + routerBasicInfo: '基本信息', + routerProcessInfo: '进程信息', + routerPerformanceInfo: '性能信息', + routerVersionInfo: '版本信息', + routerInstanceId: 'Instance ID', + routerHostname: '主机名', + routerOnlineStatus: '在线状态', + routerOnline: '在线', + routerOffline: '离线', + routerRegisteredAt: '注册时间', + routerLastHeartbeat: '最近心跳', + routerMainPid: '主进程 PID', + routerTokenizerWorkerPids: 'Tokenization Worker PID', + routerChildProcessCount: '子进程数量', + routerThreadCount: '线程数量', + routerStartedAt: '启动时间', + routerCpuCores: '等效 CPU Core', + routerMainRss: '主进程 RSS', + routerChildRss: '子进程 RSS', + routerSampledAt: '资源采样时间', + routerProtocolVersion: '控制面协议版本', + routerPythonVersion: 'Python 版本', + routerPlatform: '操作系统与架构', + noRouterInstances: '当前没有在线路由节点。', + secondsAgo: '{{seconds}} 秒前', + activeWaiting: '{{active}} / {{waiting}}', + tokenizerActiveWaiting: '活动 {{active}} / 排队 {{waiting}}', }, login: { welcome: '欢迎回来', @@ -990,6 +1162,7 @@ const zh = { delete: '删除', edit: '编辑', modify: '修改', + actions: '操作', from: '开始', to: '结束', clear: '清除', diff --git a/frontend/src/types/services.ts b/frontend/src/types/services.ts index 342841ccaa..1febc25c23 100644 --- a/frontend/src/types/services.ts +++ b/frontend/src/types/services.ts @@ -20,6 +20,7 @@ export interface ClusterUIConfig { es_enabled: boolean; auth_advanced: boolean; oidc_enabled: boolean; + token_router_enabled?: boolean; } export interface ClusterInfo { node_type: 'Supervisor' | 'Worker'; @@ -284,14 +285,14 @@ export interface UserItem { export interface TokenizerAssetItem { asset_id: string; - origin: 'builtin' | 'external'; + origin: 'builtin' | 'artifact' | 'shared_fs' | 'local' | 'external'; display_name: string; model_family: string; model_name: string; revision: string; encoding_type: string; compatible_models: string[]; - capabilities: Record; + capabilities: Record; enabled: boolean; status: 'available' | 'invalid' | 'disabled' | 'missing' | 'validating'; valid: boolean; @@ -299,6 +300,13 @@ export interface TokenizerAssetItem { errors: string[]; checks?: Record; validated_at?: string; + source?: Record; + metadata?: Record; + bindings?: number; + ready_bindings?: number; + binding_states?: Record; + router_references?: string[]; + binding_items?: TokenizerAssetBinding[]; } export interface TokenizerAssetListResponse { @@ -351,6 +359,103 @@ export interface TokenRouterRoutingRule { action: TokenRouterRoutingAction; } +export interface TokenRouterDeployment { + router_uid: string; + management_mode: 'external' | 'managed'; + desired_replicas: number; + desired_state: 'running' | 'stopped'; + placement: Record; + rollout: { + auto_failover?: boolean; + drain_timeout_seconds?: number; + [key: string]: unknown; + }; + deployment_generation: number; + observed_ready_assignments: number; + effective_ready_runtimes: number; + controllable_ready_runtimes: number; + ready_replicas: number; + pending_replicas: number; + assignments: number; + failure_reason?: string; + recovery_state?: 'stable' | 'degraded' | 'recovering' | 'blocked'; +} + +export interface TokenRouterAssignment { + assignment_id: string; + router_uid: string; + replica_index: number; + node_id: string; + listen_host: string; + listen_port: number; + public_endpoint: string; + desired_state: 'running' | 'stopped'; + observed_state: string; + assignment_generation: number; + config_revision: number; + pid?: number | null; + instance_id?: string | null; + last_error?: string; + management_state?: 'manageable' | 'node_suspected' | 'node_lost'; + failure_reason?: string; + observed?: Record; + created_at?: string; + updated_at?: string; +} + +export interface TokenizerAssetBinding { + asset_id: string; + node_id: string; + desired_state: 'present' | 'absent'; + observed_state: string; + desired_revision: string; + desired_fingerprint: string; + observed_revision: string; + observed_fingerprint: string; + local_path: string; + binding_mode: 'manual' | 'on_demand' | 'legacy'; + owner_type: string; + owner_id: string; + generation: number; + last_error_code: string; + last_error: string; + last_seen_at?: string | null; +} + +export interface TokenRouterNode { + node_id: string; + advertise_host: string; + port_range_start: number; + port_range_end: number; + max_instances: number; + labels: Record; + reported_labels: Record; + managed_labels: Record; + tokenizer_asset_bindings: TokenizerAssetBinding[]; + capabilities: { + tokenizer_assets?: string[]; + [key: string]: unknown; + }; + software_version: string; + software_revision?: string | null; + desired_state: 'active' | 'cordoned' | 'draining' | 'disabled'; + connectivity_status: 'online' | 'suspected' | 'offline'; + management_state: 'active' | 'cordoned' | 'draining' | 'disabled'; + suspected_at?: string | null; + offline_at?: string | null; + observed: Record; + last_seen_at?: string | null; + heartbeat_age_seconds: number; + can_schedule: boolean; + failure_reason?: string; + online: boolean; + assignments: number; + used_ports: number[]; + available_slots: number; + created_at: string; + updated_at: string; +} + interface TokenRouterItemBase { router_uid: string; virtual_model_uid: string; @@ -376,9 +481,21 @@ interface TokenRouterItemBase { }; enabled: boolean; revision: number; - status: 'draft' | 'disabled' | 'syncing' | 'ready' | 'degraded' | 'offline' | 'error'; + status: + | 'draft' + | 'disabled' + | 'pending' + | 'starting' + | 'syncing' + | 'ready' + | 'degraded' + | 'offline' + | 'unavailable' + | 'draining' + | 'error'; runtime_instances: number; online_instances: number; + deployment: TokenRouterDeployment; created_at: string; updated_at: string; } @@ -449,8 +566,97 @@ export function isTypedTokenRouter(router: TokenRouterItem): router is TokenRout return router.config_version === 2 || Array.isArray(router.backends); } +export type RouterClusterStatus = + | 'ready' + | 'syncing' + | 'degraded' + | 'config_error' + | 'heartbeat_timeout' + | 'not_running' + | 'disabled'; + +export interface RouterRuntimeMetadata { + assignment_id?: string; + assignment_generation?: number; + node_id?: string; + hostname?: string; + python_version?: string; + platform?: string; + started_at?: number; +} + +export interface RouterProcessResources { + cpu_percent?: number; + cpu_cores?: number; + cpu_count?: number; + rss_bytes?: number; + memory_total_bytes?: number; + main_process_rss_bytes?: number; + child_process_rss_bytes?: number; + child_process_count?: number; + thread_count?: number; + started_at?: number; + uptime_seconds?: number; + sampled_at?: number; +} + +export interface RouterClusterInfo { + node_type: 'Router'; + router_uid: string; + router_status: RouterClusterStatus; + instance_id: string; + endpoint: string; + online: boolean; + instance_status?: string | null; + acked_revision?: number | null; + config_revision?: number | null; + registered_at?: number | null; + last_heartbeat?: number | null; + heartbeat_age_seconds?: number | null; + version?: string; + protocol_version?: string; + software_version?: string; + software_revision?: string | null; + metadata?: RouterRuntimeMetadata; + config_error?: string; + metrics?: Record; + backend_health?: Record; + process?: { + pid?: number; + revision?: number; + resources?: RouterProcessResources; + tokenization?: { + worker_pids?: number[]; + [key: string]: unknown; + }; + [key: string]: unknown; + }; +} + +export interface RouterNodeClusterInfo { + node_type: 'Router'; + node_id: string; + ip_address: string; + online: boolean; + connectivity_status: 'online'; + gpu_count: number; + gpu_vram_total: number; + cpu_available: number | null; + cpu_count: number | null; + mem_used: number | null; + mem_available: number | null; + mem_total: number | null; + software_version?: string; + software_revision?: string | null; +} + +export type ClusterInformationItem = ClusterInfo | RouterNodeClusterInfo; + export interface TokenRouterRuntimeInstance { router_uid: string; + assignment_id?: string; + assignment_generation?: number; + node_id?: string; instance_id: string; endpoint: string; status?: string; From 3d0d7cf97f9b8b70f1db4cba7604c74aa9f4152c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E6=98=8E=E5=86=8D?= Date: Wed, 19 Aug 2026 13:01:17 +0800 Subject: [PATCH 2/4] fix(ui): prevent duplicate cluster polling --- .../components/pages/cluster-information.tsx | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/pages/cluster-information.tsx b/frontend/src/components/pages/cluster-information.tsx index 62a22efa28..3cdb427305 100644 --- a/frontend/src/components/pages/cluster-information.tsx +++ b/frontend/src/components/pages/cluster-information.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { format } from 'date-fns'; import PageContainer from '@/components/ui/page-container'; import { @@ -27,7 +27,6 @@ export default function ClusterInfoPage() { const { t } = useI18n(); const { clusterVersion, clusterUIConfig, globalReady } = useGlobal(); const tokenRouterEnabled = globalReady && clusterUIConfig?.token_router_enabled !== false; - const timerRef = useRef(null); const supervisorSummary = useMemo(() => { const addresses: string[] = []; @@ -190,37 +189,48 @@ export default function ClusterInfoPage() { ]; }, [routers, t]); - const fetchClusterInfo = useCallback(async () => { - try { - const response = await request.get('/v1/cluster/info', { - params: { detailed: true, include_routers: tokenRouterEnabled }, - }); - setLastUpdateTime(format(new Date(), 'yyyy-MM-dd HH:mm:ss')); - setData({ - supervisors: response.filter( - (item): item is ClusterInfo => item.node_type === 'Supervisor' - ), - workers: response.filter((item): item is ClusterInfo => item.node_type === 'Worker'), - routers: tokenRouterEnabled - ? response.filter( - (item): item is RouterNodeClusterInfo => - item.node_type === 'Router' && item.online && item.connectivity_status === 'online' - ) - : [], - }); - } catch (error) { - console.error(error); - } finally { - timerRef.current = setTimeout(fetchClusterInfo, 5000); - } - }, [tokenRouterEnabled]); - useEffect(() => { - void fetchClusterInfo(); + let cancelled = false; + let timer: ReturnType | undefined; + + const pollClusterInfo = async () => { + try { + const response = await request.get('/v1/cluster/info', { + params: { detailed: true, include_routers: tokenRouterEnabled }, + }); + if (cancelled) return; + setLastUpdateTime(format(new Date(), 'yyyy-MM-dd HH:mm:ss')); + setData({ + supervisors: response.filter( + (item): item is ClusterInfo => item.node_type === 'Supervisor' + ), + workers: response.filter((item): item is ClusterInfo => item.node_type === 'Worker'), + routers: tokenRouterEnabled + ? response.filter( + (item): item is RouterNodeClusterInfo => + item.node_type === 'Router' && + item.online && + item.connectivity_status === 'online' + ) + : [], + }); + } catch (error) { + if (!cancelled) console.error(error); + } finally { + if (!cancelled) { + timer = setTimeout(() => { + void pollClusterInfo(); + }, 5000); + } + } + }; + + void pollClusterInfo(); return () => { - if (timerRef.current) clearTimeout(timerRef.current); + cancelled = true; + if (timer) clearTimeout(timer); }; - }, [fetchClusterInfo]); + }, [tokenRouterEnabled]); const renderSummary = ( rows: Array<{ label: string; value: string | number; total?: string; title?: string }> From 766387c9ad59d0f93803b1a3ebb13e4a6f20d4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E6=98=8E=E5=86=8D?= Date: Sat, 22 Aug 2026 11:53:48 +0800 Subject: [PATCH 3/4] fix(ui): align router controls with auth scopes --- .../components/pages/token-router/index.tsx | 24 +++++-- .../token-router/router-capabilities.mjs | 29 +++++++++ .../token-router/router-capabilities.test.mjs | 49 ++++++++++++++ .../token-router/tokenizer-asset-section.tsx | 64 +++++++++++-------- 4 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/pages/token-router/router-capabilities.mjs create mode 100644 frontend/src/components/pages/token-router/router-capabilities.test.mjs diff --git a/frontend/src/components/pages/token-router/index.tsx b/frontend/src/components/pages/token-router/index.tsx index f2117c0c43..b40e9d600b 100644 --- a/frontend/src/components/pages/token-router/index.tsx +++ b/frontend/src/components/pages/token-router/index.tsx @@ -26,6 +26,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { useGlobal } from '@/contexts/global-context'; import { useI18n } from '@/contexts/i18n-context'; import { useMenuAuth } from '@/hooks/use-menu-auth'; import request from '@/lib/request'; @@ -39,6 +40,7 @@ import { isTypedTokenRouter } from '@/types/services'; import { RouterDetailDrawer } from './router-detail-drawer'; import { RouterFormDialog } from './router-form-dialog'; import { RouterNodeSection } from './router-node-section'; +import { resolveRouterCapabilities } from './router-capabilities.mjs'; import { routerBackendList } from './router-config-normalizer'; import { RouterStatusBadge } from './router-status-badge'; import { TokenizerAssetSection } from './tokenizer-asset-section'; @@ -52,7 +54,14 @@ type BusyAction = { export default function TokenRouterPage() { const { t } = useI18n(); + const { clusterUIConfig, globalReady } = useGlobal(); const auth = useMenuAuth(); + const { canWriteRouters, canOperateRouters } = resolveRouterCapabilities({ + globalReady, + authAdvanced: clusterUIConfig?.auth_advanced, + canWriteRouters: auth.canWriteRouters, + canOperateRouters: auth.canOperateRouters, + }); const [routers, setRouters] = useState([]); const [routerNodes, setRouterNodes] = useState([]); const [tokenizerAssets, setTokenizerAssets] = useState([]); @@ -167,7 +176,7 @@ export default function TokenRouterPage() { {!refreshing && } {t('common.refresh')} - {auth.canWriteRouters && ( + {canWriteRouters && ( - {auth.canWriteRouters && ( + {canWriteRouters && ( )} - {auth.canOperateRouters && ( + {canOperateRouters && ( )} - {auth.canOperateRouters && ( + {canOperateRouters && ( - - + {canOperate && ( + + )} + {canWrite && ( + <> + + + + )}
)} @@ -211,7 +223,7 @@ export function TokenizerAssetSection({ - {canOperate && ( + {canWrite && (