732 lines
26 KiB
TypeScript
732 lines
26 KiB
TypeScript
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<Record<string, string>>;
|
|
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<Record<string, RouteKind>> = {
|
|
'/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 (
|
|
<FleetPage
|
|
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
|
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
|
|
{...(fleetData ? { initialData: fleetData } : {})}
|
|
refreshSignal={fleetRefreshSignal}
|
|
/>
|
|
);
|
|
if (route.kind === 'instance-new')
|
|
return (
|
|
<InstanceEditor
|
|
mode="create"
|
|
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
|
|
/>
|
|
);
|
|
if (route.kind === 'settings-instance-detail')
|
|
return (
|
|
<InstanceEditor
|
|
mode="edit"
|
|
{...(route.params?.instanceId ? { instanceId: route.params.instanceId } : {})}
|
|
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
|
|
/>
|
|
);
|
|
if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit')
|
|
return (
|
|
<AutomationPage
|
|
dataSource={scheduleDataSource}
|
|
initialTab={
|
|
route.kind === 'jobs' ? 'runs' : route.kind === 'audit' ? 'records' : 'schedules'
|
|
}
|
|
runsContent={
|
|
<JobsPage
|
|
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
|
|
refreshSignal={fleetRefreshSignal}
|
|
/>
|
|
}
|
|
recordsContent={
|
|
<AuditPage
|
|
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
|
|
refreshSignal={fleetRefreshSignal}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
if (route.kind === 'settings-system') return <ConsoleAuthSettings />;
|
|
if (route.kind === 'not-found')
|
|
return (
|
|
<section>
|
|
<h1>页面未找到</h1>
|
|
<p>请求的控制台页面不存在。</p>
|
|
<a href="/fleet">返回实例总览</a>
|
|
</section>
|
|
);
|
|
if (route.kind.startsWith('instance-')) {
|
|
const module = route.kind.slice('instance-'.length) as InstanceModule;
|
|
return (
|
|
<InstanceDetail
|
|
instanceId={route.params?.instanceId ?? ''}
|
|
module={module}
|
|
{...(instance ? { instance } : {})}
|
|
{...(capabilities ? { capabilities } : {})}
|
|
{...(capabilityDataSource ? { capabilityDataSource } : {})}
|
|
{...(module === 'overview' && instance
|
|
? {
|
|
moduleContent: (
|
|
<OverviewSystemPage
|
|
instance={instance}
|
|
{...(overviewDataSource ? { dataSource: overviewDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
{...(instance.revision !== undefined ? { revision: instance.revision } : {})}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'cellular' && instance
|
|
? {
|
|
moduleContent: (
|
|
<CellularModule
|
|
instance={instance}
|
|
{...(cellularDataSource ? { dataSource: cellularDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'device-network' && instance
|
|
? {
|
|
moduleContent: (
|
|
<DeviceNetworkModule
|
|
instance={instance}
|
|
{...(deviceNetworkDataSource ? { dataSource: deviceNetworkDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'messages' && instance
|
|
? {
|
|
moduleContent: (
|
|
<MessagesModule
|
|
instance={instance}
|
|
{...(messagesDataSource ? { dataSource: messagesDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'calls' && instance
|
|
? {
|
|
moduleContent: (
|
|
<CallsModule
|
|
instance={instance}
|
|
{...(callsDataSource ? { dataSource: callsDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'esim' && instance
|
|
? {
|
|
moduleContent: (
|
|
<EsimModule
|
|
instance={instance}
|
|
{...(esimDataSource ? { dataSource: esimDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'notifications' && instance
|
|
? {
|
|
moduleContent: (
|
|
<NotificationsModule
|
|
instance={instance}
|
|
{...(notificationsDataSource ? { dataSource: notificationsDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'automation' && instance
|
|
? {
|
|
moduleContent: (
|
|
<AutomationModule
|
|
instance={instance}
|
|
{...(automationDataSource ? { dataSource: automationDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
{...(module === 'ota' && instance
|
|
? {
|
|
moduleContent: (
|
|
<OtaModule
|
|
instance={instance}
|
|
{...(otaDataSource ? { dataSource: otaDataSource } : {})}
|
|
refreshSignal={detailRefreshSignal}
|
|
/>
|
|
),
|
|
}
|
|
: {})}
|
|
/>
|
|
);
|
|
}
|
|
const labels: Partial<Record<RouteKind, string>> = {
|
|
'instance-new': '添加实例',
|
|
jobs: '任务',
|
|
'job-detail': '任务详情',
|
|
audit: '审计',
|
|
'audit-detail': '审计事件',
|
|
'settings-instance-detail': '实例设置',
|
|
'settings-system': '系统设置',
|
|
};
|
|
return (
|
|
<section>
|
|
<h1>{labels[route.kind] ?? '多实例 SimAdmin 管理台'}</h1>
|
|
<p>此页面已准备好展示结构化数据与操作。</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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<InstanceContext>();
|
|
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 (
|
|
<div className="app-shell" data-route={route.kind} data-section={currentSection ?? 'other'}>
|
|
<a className="skip-link" href="#main-content">
|
|
跳到主要内容
|
|
</a>
|
|
<header className="app-topbar">
|
|
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
|
|
<span className="product-mark" aria-hidden="true">
|
|
<span className="product-signal">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
</span>
|
|
<span className="product-copy">
|
|
<strong>SimAdmin Control</strong>
|
|
</span>
|
|
</a>
|
|
<nav className="global-navigation" aria-label="全局导航">
|
|
{GLOBAL_NAVIGATION.map((item) => (
|
|
<a
|
|
key={item.section}
|
|
href={item.href}
|
|
aria-current={currentSection === item.section ? 'page' : undefined}
|
|
>
|
|
<Icon name={item.icon} />
|
|
<span>{item.label}</span>
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div className="topbar-actions">
|
|
<Tag
|
|
className="connection-status"
|
|
color={STREAM_COLORS[refresh.stream]}
|
|
variant="soft"
|
|
size="small"
|
|
>
|
|
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
|
|
{STREAM_LABELS[refresh.stream]}
|
|
</span>
|
|
</Tag>
|
|
{consoleVersion ? (
|
|
<Tag className="version-badge" color="brown" variant="soft" size="small">
|
|
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
|
|
</Tag>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
<div className="app-layout app-layout-single">
|
|
<main id="main-content" tabIndex={-1}>
|
|
{routeInstanceId && instanceLoading ? (
|
|
<p role="status" aria-label="实例加载状态">
|
|
正在加载实例…
|
|
</p>
|
|
) : routeInstanceId && instanceLoadFailed ? (
|
|
<p role="alert" className="state-panel state-error">
|
|
无法加载此实例,请返回总览后重试。
|
|
</p>
|
|
) : (
|
|
<Page
|
|
route={route}
|
|
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
|
fleetMessagesDataSource={fleetMessagesDataSource ?? defaultFleetMessagesDataSource}
|
|
fleetData={fleetData}
|
|
instance={routeInstance}
|
|
instanceDataSource={resolvedInstanceDataSource}
|
|
capabilities={capabilities}
|
|
capabilityDataSource={capabilityDataSource}
|
|
overviewDataSource={overviewDataSource}
|
|
cellularDataSource={cellularDataSource}
|
|
deviceNetworkDataSource={deviceNetworkDataSource}
|
|
messagesDataSource={messagesDataSource ?? defaultMessagesDataSource}
|
|
callsDataSource={callsDataSource}
|
|
esimDataSource={esimDataSource}
|
|
notificationsDataSource={notificationsDataSource}
|
|
automationDataSource={automationDataSource}
|
|
otaDataSource={otaDataSource}
|
|
jobsDataSource={resolvedJobsDataSource}
|
|
auditDataSource={resolvedAuditDataSource}
|
|
scheduleDataSource={resolvedScheduleDataSource}
|
|
fleetRefreshSignal={refresh.fleet}
|
|
detailRefreshSignal={refresh.detail}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
<footer className="app-footer" aria-label="项目与组件库信息">
|
|
<p>
|
|
UI: <a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
|
|
<span aria-hidden="true"> · </span>
|
|
<a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a>
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|