feat(web): add runnable fleet console foundation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user