Files
multi-simadmin/apps/web/src/app-shell.tsx
T

546 lines
19 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 { 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 { InstanceSettingsPage } from './settings/instance-settings-page.js';
import {
InstanceDetail,
INSTANCE_MODULE_LABELS,
type CapabilityDataSource,
type InstanceCapabilityMap,
} from './instances/instance-detail.js';
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
export type InstanceModule =
| 'overview'
| 'cellular'
| 'device-network'
| 'messages'
| 'calls'
| 'esim'
| 'notifications'
| 'automation'
| 'ota';
export type RouteKind =
| 'redirect'
| 'fleet'
| 'instance-new'
| `instance-${InstanceModule}`
| 'jobs'
| 'job-detail'
| 'audit'
| 'audit-detail'
| 'settings-instances'
| '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';
}
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;
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' };
const staticRoutes: Readonly<Record<string, RouteKind>> = {
'/fleet': 'fleet',
'/instances/new': 'instance-new',
'/jobs': 'jobs',
'/audit': 'audit',
'/settings/instances': 'settings-instances',
'/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.startsWith('job')) return 'jobs';
if (route.kind.startsWith('audit')) return 'audit';
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,
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;
fleetRefreshSignal: number;
detailRefreshSignal: number;
}): ReactNode {
if (route.kind === 'fleet')
return (
<FleetPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
{...(instanceDataSource ? { instanceDataSource } : {})}
{...(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 === 'jobs')
return (
<JobsPage
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'audit')
return (
<AuditPage
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'settings-instances')
return (
<InstanceSettingsPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})}
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}
/>
),
}
: {})}
{...(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-instances': '实例设置',
'settings-instance-detail': '实例设置',
'settings-system': '系统设置',
};
return (
<section>
<h1>{labels[route.kind] ?? '多实例 SimAdmin 管理台'}</h1>
<p>此页面已准备好展示结构化数据与操作。</p>
</section>
);
}
export function AppShell({
pathname,
version = 'dev',
instance,
fleetDataSource,
fleetMessagesDataSource,
fleetData,
instanceDataSource,
capabilities,
capabilityDataSource,
overviewDataSource,
cellularDataSource,
deviceNetworkDataSource,
messagesDataSource,
callsDataSource,
esimDataSource,
notificationsDataSource,
automationDataSource,
otaDataSource,
jobsDataSource,
auditDataSource,
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 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;
}
let active = true;
setInstanceLoading(true);
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',
});
setInstanceLoading(false);
},
() => {
if (!active) return;
setInstanceLoadFailed(true);
setInstanceLoading(false);
},
);
return () => {
active = false;
};
}, [instance, resolvedInstanceDataSource, routeInstanceId]);
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
const refresh = useControlPlaneEvents(
routeInstanceId,
eventStreamClient ?? defaultEventStreamClient,
);
const currentSection = section(route);
return (
<div className="app-shell" data-route={route.kind}>
<a className="skip-link" href="#main-content">
跳到主要内容
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet">
多实例 SimAdmin 管理台
</a>
<span className="connection-status">控制台</span>
<span>版本 {version}</span>
</header>
<div className="app-layout">
<nav className="global-navigation" aria-label="全局导航">
<ul>
{(
[
['fleet', '/fleet', '实例总览'],
['settings', '/settings/system', '设置'],
] as const
).map(([key, href, label]) => (
<li key={key}>
<a href={href} aria-current={currentSection === key ? 'page' : undefined}>
{label}
</a>
</li>
))}
</ul>
</nav>
<main id="main-content" tabIndex={-1}>
{routeInstanceId && instanceLoading ? (
<p role="status">正在加载实例…</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}
fleetRefreshSignal={refresh.fleet}
detailRefreshSignal={refresh.detail}
/>
)}
</main>
</div>
</div>
);
}