feat(web): complete fleet and instance management slices

This commit is contained in:
chick
2026-07-17 17:12:35 +08:00
parent 4093634e13
commit 6e817f380a
15 changed files with 1737 additions and 203 deletions
+47 -84
View File
@@ -1,12 +1,14 @@
import type { ReactNode } from 'react';
import {
FleetPage,
canonicalHttpOrigin,
type FleetDataSource,
type FleetSnapshot,
} from './fleet/fleet-page.js';
import { FleetPage, type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
import { createFleetApiDataSource } from './fleet/fleet-api-data-source.js';
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.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 =
@@ -52,19 +54,12 @@ export interface AppShellProps {
instance?: InstanceContext;
fleetDataSource?: FleetDataSource;
fleetData?: FleetSnapshot;
instanceDataSource?: InstanceDataSource;
capabilities?: InstanceCapabilityMap;
capabilityDataSource?: CapabilityDataSource;
}
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 MODULE_LABELS = INSTANCE_MODULE_LABELS;
const INSTANCE_MODULES = new Set(Object.keys(MODULE_LABELS));
function normalize(pathname: string): string {
@@ -120,65 +115,22 @@ function section(route: ResolvedRoute): GlobalSection | undefined {
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,
instanceDataSource,
capabilities,
capabilityDataSource,
}: {
route: ResolvedRoute;
fleetDataSource: FleetDataSource | undefined;
fleetData: FleetSnapshot | undefined;
instance: InstanceContext | undefined;
instanceDataSource: InstanceDataSource | undefined;
capabilities: InstanceCapabilityMap | undefined;
capabilityDataSource: CapabilityDataSource | undefined;
}): ReactNode {
if (route.kind === 'fleet')
return (
@@ -187,6 +139,21 @@ function Page({
{...(fleetData ? { initialData: fleetData } : {})}
/>
);
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 === 'not-found')
return (
<section>
@@ -195,18 +162,16 @@ function Page({
<a href="/fleet">Return to Fleet</a>
</section>
);
if (route.kind.startsWith('instance-') && route.kind !== 'instance-new') {
if (route.kind.startsWith('instance-')) {
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>
)}
</>
<InstanceDetail
instanceId={route.params?.instanceId ?? ''}
module={module}
{...(instance ? { instance } : {})}
{...(capabilities ? { capabilities } : {})}
{...(capabilityDataSource ? { capabilityDataSource } : {})}
/>
);
}
const labels: Partial<Record<RouteKind, string>> = {
@@ -233,15 +198,13 @@ export function AppShell({
instance,
fleetDataSource,
fleetData,
instanceDataSource,
capabilities,
capabilityDataSource,
}: 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">
@@ -273,15 +236,15 @@ export function AppShell({
))}
</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}
instanceDataSource={instanceDataSource}
capabilities={capabilities}
capabilityDataSource={capabilityDataSource}
/>
</main>
</div>