feat(web): add runnable fleet console foundation

This commit is contained in:
chick
2026-07-17 16:02:15 +08:00
parent b4ae28c8f0
commit 4093634e13
16 changed files with 2730 additions and 7 deletions
+290
View File
@@ -0,0 +1,290 @@
import type { ReactNode } from 'react';
import {
FleetPage,
canonicalHttpOrigin,
type FleetDataSource,
type FleetSnapshot,
} from './fleet/fleet-page.js';
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.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;
fleetData?: FleetSnapshot;
}
const MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: 'Overview',
cellular: 'Cellular',
'device-network': 'Device Network',
messages: 'Messages',
calls: 'Calls',
esim: 'eSIM',
notifications: 'Notifications',
automation: 'Automation',
ota: 'OTA',
};
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 InstanceSidebar({
instance,
module,
}: {
instance: InstanceContext;
module: InstanceModule;
}) {
const origin = canonicalHttpOrigin(instance.origin);
return (
<aside className="instance-context" aria-label="Current instance">
<strong>{instance.name}</strong>
<code>{instance.id}</code>
<dl>
<div>
<dt>Status</dt>
<dd>{instance.status}</dd>
</div>
<div>
<dt>Authentication</dt>
<dd>{instance.authentication}</dd>
</div>
<div>
<dt>Freshness</dt>
<dd>{instance.freshness}</dd>
</div>
</dl>
{origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer">
Open original site
</a>
) : null}
<nav aria-label="Instance modules">
<ul>
{Object.entries(MODULE_LABELS).map(([key, label]) => (
<li key={key}>
<a
href={`/instances/${encodeURIComponent(instance.id)}/${key}`}
aria-current={module === key ? 'page' : undefined}
>
{label}
</a>
</li>
))}
</ul>
</nav>
</aside>
);
}
function Page({
route,
fleetDataSource,
fleetData,
instance,
}: {
route: ResolvedRoute;
fleetDataSource: FleetDataSource | undefined;
fleetData: FleetSnapshot | undefined;
instance: InstanceContext | undefined;
}): ReactNode {
if (route.kind === 'fleet')
return (
<FleetPage
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
{...(fleetData ? { initialData: fleetData } : {})}
/>
);
if (route.kind === 'not-found')
return (
<section>
<h1>Page not found</h1>
<p>The requested console page does not exist.</p>
<a href="/fleet">Return to Fleet</a>
</section>
);
if (route.kind.startsWith('instance-') && route.kind !== 'instance-new') {
const module = route.kind.slice('instance-'.length) as InstanceModule;
const ownsRoute = instance?.id === route.params?.instanceId;
return (
<>
<h1>{MODULE_LABELS[module]}</h1>
{ownsRoute ? (
<p>Inspect {MODULE_LABELS[module].toLowerCase()} data and available operations.</p>
) : (
<p>Instance context is unavailable for this route.</p>
)}
</>
);
}
const labels: Partial<Record<RouteKind, string>> = {
'instance-new': 'Add instance',
jobs: 'Jobs',
'job-detail': 'Job details',
audit: 'Audit',
'audit-detail': 'Audit event',
'settings-instances': 'Instance settings',
'settings-instance-detail': 'Instance settings',
'settings-system': 'System settings',
};
return (
<section>
<h1>{labels[route.kind] ?? 'Multi SimAdmin'}</h1>
<p>This route is ready for structured data and actions.</p>
</section>
);
}
export function AppShell({
pathname,
version = 'dev',
instance,
fleetDataSource,
fleetData,
}: AppShellProps) {
const resolved = resolveRoute(pathname);
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
const currentSection = section(route);
const instanceModule =
route.kind.startsWith('instance-') && route.kind !== 'instance-new'
? (route.kind.slice('instance-'.length) as InstanceModule)
: null;
const owner = instanceModule && instance?.id === route.params?.instanceId ? instance : undefined;
return (
<div className="app-shell" data-route={route.kind}>
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet">
Multi SimAdmin
</a>
<span className="connection-status">Console</span>
<span>Version {version}</span>
</header>
<div className="app-layout">
<nav className="global-navigation" aria-label="Global navigation">
<ul>
{(
[
['fleet', '/fleet', 'Fleet'],
['jobs', '/jobs', 'Jobs'],
['audit', '/audit', 'Audit'],
['settings', '/settings/instances', 'Settings'],
] as const
).map(([key, href, label]) => (
<li key={key}>
<a href={href} aria-current={currentSection === key ? 'page' : undefined}>
{label}
</a>
</li>
))}
</ul>
</nav>
{owner && instanceModule ? (
<InstanceSidebar instance={owner} module={instanceModule} />
) : null}
<main id="main-content" tabIndex={-1}>
<Page
route={route}
fleetDataSource={fleetDataSource ?? createFleetApiDataSource()}
fleetData={fleetData}
instance={instance}
/>
</main>
</div>
</div>
);
}