import { ConsoleAuthSettings } from './auth/console-auth-settings.js'; import { AuditPage, type AuditDataSource } from './audit/audit-page.js'; import { createAuditApiDataSource } from './audit/audit-api-data-source.js'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Tag } from 'animal-island-ui'; import { Icon } from './ui/icon.js'; import { AutomationPage, type AutomationDataSource as ScheduleDataSource, } from './automation/automation-page.js'; import { createAutomationApiDataSource } from './automation/automation-api-data-source.js'; import { useControlPlaneEvents } from './events/use-control-plane-events.js'; import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js'; import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js'; import { createFleetMessagesApiDataSource, type FleetMessagesDataSource, } from './fleet/fleet-messages-api-data-source.js'; import { AutomationModule, type AutomationDataSource } from './instances/automation-module.js'; import { CallsModule, type CallsDataSource } from './instances/calls-module.js'; import { CellularModule, type CellularDataSource } from './instances/cellular-module.js'; import { DeviceNetworkModule, type DeviceNetworkDataSource, } from './instances/device-network-module.js'; import { EsimModule, type EsimDataSource } from './instances/esim-module.js'; import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js'; import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js'; import { createInstanceApiDataSource } from './instances/instance-api-data-source.js'; import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js'; import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; import { createMessagesApiDataSource } from './instances/messages-api-data-source.js'; import { NotificationsModule, type NotificationsDataSource, } from './instances/notifications-module.js'; import { OtaModule, type OtaDataSource } from './instances/ota-module.js'; import { InstanceDetail, INSTANCE_MODULE_LABELS, type CapabilityDataSource, type InstanceCapabilityMap, } from './instances/instance-detail.js'; export type GlobalSection = 'fleet' | 'automation' | 'settings'; export type InstanceModule = | 'overview' | 'cellular' | 'device-network' | 'messages' | 'calls' | 'esim' | 'notifications' | 'automation' | 'ota'; export type RouteKind = | 'redirect' | 'fleet' | 'automation' | 'instance-new' | `instance-${InstanceModule}` | 'jobs' | 'job-detail' | 'audit' | 'audit-detail' | 'settings-instance-detail' | 'settings-system' | 'not-found'; export interface ResolvedRoute { kind: RouteKind; pathname: string; params?: Readonly>; to?: string; } export interface InstanceContext { id: string; name: string; origin: string; status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown'; authentication: 'authenticated' | 'auth-required' | 'unknown'; freshness: 'fresh' | 'stale' | 'expired' | 'unknown'; /** Control-plane config revision for prepare/execute CAS when known. */ revision?: number; resources?: Readonly<{ cpuPercent?: number; memoryPercent?: number; maxTemperatureCelsius?: number; phoneNumbers?: readonly string[]; }>; resourceSummaryState?: 'loading' | 'ready' | 'unavailable'; } export interface AppShellProps { pathname: string; version?: string; instance?: InstanceContext; fleetDataSource?: FleetDataSource; fleetMessagesDataSource?: FleetMessagesDataSource; fleetData?: FleetSnapshot; instanceDataSource?: InstanceDataSource; capabilities?: InstanceCapabilityMap; capabilityDataSource?: CapabilityDataSource; overviewDataSource?: OverviewDataSource; cellularDataSource?: CellularDataSource; deviceNetworkDataSource?: DeviceNetworkDataSource; messagesDataSource?: MessagesDataSource; callsDataSource?: CallsDataSource; esimDataSource?: EsimDataSource; notificationsDataSource?: NotificationsDataSource; automationDataSource?: AutomationDataSource; otaDataSource?: OtaDataSource; jobsDataSource?: JobsDataSource; auditDataSource?: AuditDataSource; scheduleDataSource?: ScheduleDataSource; eventStreamClient?: EventStreamClient; } const MODULE_LABELS = INSTANCE_MODULE_LABELS; const INSTANCE_MODULES = new Set(Object.keys(MODULE_LABELS)); function normalize(pathname: string): string { const path = pathname.split(/[?#]/u, 1)[0] ?? '/'; return path === '/' ? path : path.replace(/\/+$/u, '') || '/'; } function decode(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } export function resolveRoute(input: string): ResolvedRoute { const pathname = normalize(input); if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' }; if (pathname === '/settings/instances') return { kind: 'redirect', pathname, to: '/settings/system' }; const staticRoutes: Readonly> = { '/fleet': 'fleet', '/automation': 'automation', '/instances/new': 'instance-new', '/jobs': 'jobs', '/audit': 'audit', '/settings/system': 'settings-system', }; if (staticRoutes[pathname]) return { kind: staticRoutes[pathname], pathname }; const parts = pathname.split('/').filter(Boolean); if (parts[0] === 'instances' && parts.length === 3 && INSTANCE_MODULES.has(parts[2] ?? '')) return { kind: `instance-${parts[2] as InstanceModule}`, pathname, params: { instanceId: decode(parts[1] ?? '') }, }; if (parts[0] === 'jobs' && parts.length === 2) return { kind: 'job-detail', pathname, params: { jobId: decode(parts[1] ?? '') } }; if (parts[0] === 'audit' && parts.length === 2) return { kind: 'audit-detail', pathname, params: { eventId: decode(parts[1] ?? '') } }; if (parts[0] === 'settings' && parts[1] === 'instances' && parts.length === 3) return { kind: 'settings-instance-detail', pathname, params: { instanceId: decode(parts[2] ?? '') }, }; return { kind: 'not-found', pathname }; } function section(route: ResolvedRoute): GlobalSection | undefined { if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-')) return 'fleet'; if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit')) return 'automation'; if (route.kind.startsWith('settings')) return 'settings'; return undefined; } function Page({ route, fleetDataSource, fleetMessagesDataSource, fleetData, instance, instanceDataSource, capabilities, capabilityDataSource, overviewDataSource, cellularDataSource, deviceNetworkDataSource, messagesDataSource, callsDataSource, esimDataSource, notificationsDataSource, automationDataSource, otaDataSource, jobsDataSource, auditDataSource, scheduleDataSource, fleetRefreshSignal, detailRefreshSignal, }: { route: ResolvedRoute; fleetDataSource: FleetDataSource | undefined; fleetMessagesDataSource: FleetMessagesDataSource | undefined; fleetData: FleetSnapshot | undefined; instance: InstanceContext | undefined; instanceDataSource: InstanceDataSource | undefined; capabilities: InstanceCapabilityMap | undefined; capabilityDataSource: CapabilityDataSource | undefined; overviewDataSource: OverviewDataSource | undefined; cellularDataSource: CellularDataSource | undefined; deviceNetworkDataSource: DeviceNetworkDataSource | undefined; messagesDataSource: MessagesDataSource | undefined; callsDataSource: CallsDataSource | undefined; esimDataSource: EsimDataSource | undefined; notificationsDataSource: NotificationsDataSource | undefined; automationDataSource: AutomationDataSource | undefined; otaDataSource: OtaDataSource | undefined; jobsDataSource: JobsDataSource | undefined; auditDataSource: AuditDataSource | undefined; scheduleDataSource: ScheduleDataSource; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { if (route.kind === 'fleet') return ( ); if (route.kind === 'instance-new') return ( ); if (route.kind === 'settings-instance-detail') return ( ); if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit') return ( } recordsContent={ } /> ); if (route.kind === 'settings-system') return ; if (route.kind === 'not-found') return (

页面未找到

请求的控制台页面不存在。

返回实例总览
); if (route.kind.startsWith('instance-')) { const module = route.kind.slice('instance-'.length) as InstanceModule; return ( ), } : {})} {...(module === 'cellular' && instance ? { moduleContent: ( ), } : {})} {...(module === 'device-network' && instance ? { moduleContent: ( ), } : {})} {...(module === 'messages' && instance ? { moduleContent: ( ), } : {})} {...(module === 'calls' && instance ? { moduleContent: ( ), } : {})} {...(module === 'esim' && instance ? { moduleContent: ( ), } : {})} {...(module === 'notifications' && instance ? { moduleContent: ( ), } : {})} {...(module === 'automation' && instance ? { moduleContent: ( ), } : {})} {...(module === 'ota' && instance ? { moduleContent: ( ), } : {})} /> ); } const labels: Partial> = { 'instance-new': '添加实例', jobs: '任务', 'job-detail': '任务详情', audit: '审计', 'audit-detail': '审计事件', 'settings-instance-detail': '实例设置', 'settings-system': '系统设置', }; return (

{labels[route.kind] ?? '多实例 SimAdmin 管理台'}

此页面已准备好展示结构化数据与操作。

); } function displayConsoleVersion(version: string | undefined): string | undefined { if (!version) return undefined; const trimmed = version.trim(); if (!trimmed || trimmed.toLowerCase() === 'dev' || trimmed === '0.0.0') return undefined; return trimmed; } const GLOBAL_NAVIGATION: readonly { section: GlobalSection; href: string; label: string; icon: 'grid' | 'jobs' | 'settings'; }[] = [ { section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' }, { section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' }, { section: 'settings', href: '/settings/system', label: '设置', icon: 'settings' }, ]; const STREAM_LABELS = { connecting: '正在连接', open: '实时连接正常', reconnecting: '正在重新连接', resetting: '正在同步状态', closed: '实时连接已关闭', } as const; const STREAM_COLORS = { connecting: 'app-yellow', open: 'app-teal', reconnecting: 'app-orange', resetting: 'app-yellow', closed: 'app-red', } as const; export function AppShell({ pathname, version = 'dev', instance, fleetDataSource, fleetMessagesDataSource, fleetData, instanceDataSource, capabilities, capabilityDataSource, overviewDataSource, cellularDataSource, deviceNetworkDataSource, messagesDataSource, callsDataSource, esimDataSource, notificationsDataSource, automationDataSource, otaDataSource, jobsDataSource, auditDataSource, scheduleDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); const defaultFleetMessagesDataSource = useMemo(() => createFleetMessagesApiDataSource(), []); const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []); const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []); const resolvedJobsDataSource = useMemo( () => jobsDataSource ?? createJobsApiDataSource(), [jobsDataSource], ); const resolvedAuditDataSource = useMemo( () => auditDataSource ?? createAuditApiDataSource(), [auditDataSource], ); const resolvedScheduleDataSource = useMemo( () => scheduleDataSource ?? createAutomationApiDataSource(), [scheduleDataSource], ); const resolved = resolveRoute(pathname); const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved; const routeInstanceId = route.params?.instanceId; const resolvedInstanceDataSource = instanceDataSource ?? defaultInstanceDataSource; const [loadedInstance, setLoadedInstance] = useState(); const [instanceLoading, setInstanceLoading] = useState(false); const [instanceLoadFailed, setInstanceLoadFailed] = useState(false); useEffect(() => { setLoadedInstance(undefined); setInstanceLoadFailed(false); if (!routeInstanceId || instance?.id === routeInstanceId) { setInstanceLoading(false); return; } const controller = new AbortController(); let active = true; setInstanceLoading(true); const fleetLoad = (fleetDataSource ?? defaultFleetDataSource) .load(controller.signal, (partial) => { if (!active) return; const fleetOwner = partial.instances.find((candidate) => candidate.id === routeInstanceId); const status = partial.statuses.get(routeInstanceId); if (!fleetOwner && !status) return; setLoadedInstance((current) => { if (!current || current.id !== routeInstanceId) return current; const routeStatus = !status ? current.status : !status.reachable ? 'offline' : status.authenticated === false ? 'auth-required' : 'online'; const freshness = status?.summary?.freshness; const nextRevision = fleetOwner?.revision ?? current.revision; return { ...current, origin: fleetOwner?.url ?? current.origin, status: routeStatus, authentication: status?.authenticated === true ? 'authenticated' : status?.authenticated === false ? 'auth-required' : current.authentication, freshness: freshness === 'fresh' || freshness === 'stale' || freshness === 'expired' ? freshness : current.freshness, ...(nextRevision === undefined ? {} : { revision: nextRevision }), ...(status?.summary?.resources ? { resources: status.summary.resources } : {}), resourceSummaryState: status?.summary?.resources ? 'ready' : current.resourceSummaryState === 'ready' ? 'ready' : 'loading', }; }); }) .then( (fleet) => ({ ok: true as const, fleet }), () => ({ ok: false as const }), ); void resolvedInstanceDataSource.get(routeInstanceId).then( (owner) => { if (!active) return; if (owner.id !== routeInstanceId) { setInstanceLoadFailed(true); setInstanceLoading(false); return; } setLoadedInstance({ id: owner.id, name: owner.name, origin: owner.origin, status: 'unknown', authentication: 'unknown', freshness: 'unknown', ...(owner.revision === undefined ? {} : { revision: owner.revision }), resourceSummaryState: 'loading', }); setInstanceLoading(false); void fleetLoad.then((result) => { if (!active) return; if (!result.ok) { setLoadedInstance((current) => current?.id === routeInstanceId ? { ...current, resourceSummaryState: 'unavailable' } : current, ); return; } const fleet = result.fleet; const fleetOwner = fleet.instances.find((candidate) => candidate.id === routeInstanceId); const status = fleet.statuses.get(routeInstanceId); const routeStatus = !status ? 'unknown' : !status.reachable ? 'offline' : status.authenticated === false ? 'auth-required' : 'online'; const freshness = status?.summary?.freshness; const nextRevision = fleetOwner?.revision ?? owner.revision; setLoadedInstance({ id: owner.id, name: owner.name, origin: fleetOwner?.url ?? owner.origin, status: routeStatus, authentication: status?.authenticated === true ? 'authenticated' : status?.authenticated === false ? 'auth-required' : 'unknown', freshness: freshness === 'fresh' || freshness === 'stale' || freshness === 'expired' ? freshness : 'unknown', ...(nextRevision === undefined ? {} : { revision: nextRevision }), ...(status?.summary?.resources ? { resources: status.summary.resources } : {}), resourceSummaryState: status ? 'ready' : 'unavailable', }); }); }, () => { if (!active) return; setInstanceLoadFailed(true); setInstanceLoading(false); }, ); return () => { active = false; controller.abort(); }; }, [ defaultFleetDataSource, fleetDataSource, instance, resolvedInstanceDataSource, routeInstanceId, ]); const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance; const refresh = useControlPlaneEvents( routeInstanceId, eventStreamClient ?? defaultEventStreamClient, ); const currentSection = section(route); const consoleVersion = displayConsoleVersion(version); return (
跳到主要内容
SimAdmin Control
{STREAM_LABELS[refresh.stream]} {consoleVersion ? ( v{consoleVersion} ) : null}
{routeInstanceId && instanceLoading ? (

正在加载实例…

) : routeInstanceId && instanceLoadFailed ? (

无法加载此实例,请返回总览后重试。

) : ( )}
); }