feat(web): add runnable fleet console foundation
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Multi SimAdmin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,23 @@
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"jsdom": "27.2.0",
|
||||
"vite": "7.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppShell, type InstanceContext } from './app-shell.js';
|
||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const snapshot: FleetSnapshot = {
|
||||
instances: [
|
||||
{
|
||||
id: 'bravo',
|
||||
name: 'Bravo',
|
||||
url: 'http://user:secret@bravo.example:8080/private',
|
||||
tags: ['west'],
|
||||
},
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin' },
|
||||
],
|
||||
statuses: new Map([
|
||||
['bravo', { reachable: true, authenticated: false, latencyMs: 40 }],
|
||||
['alpha', { reachable: true, authenticated: true, latencyMs: 10 }],
|
||||
]),
|
||||
};
|
||||
|
||||
function source(load: FleetDataSource['load']): FleetDataSource {
|
||||
return { load };
|
||||
}
|
||||
|
||||
describe('React AppShell and Fleet vertical slice', () => {
|
||||
it('loads real injected data and renders canonical origins and owner routes', async () => {
|
||||
let resolve!: (value: FleetSnapshot) => void;
|
||||
const dataSource = source(() => new Promise((done) => (resolve = done)));
|
||||
render(<AppShell pathname="/fleet" version="0.1.0" fleetDataSource={dataSource} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Fleet loading status' }).textContent).toContain(
|
||||
'Loading instances',
|
||||
);
|
||||
resolve(snapshot);
|
||||
|
||||
const alpha = await screen.findByRole('row', { name: /Alpha/ });
|
||||
expect(within(alpha).getByRole('link', { name: 'Alpha' }).getAttribute('href')).toBe(
|
||||
'/instances/alpha/overview',
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole('row', { name: /Bravo/ }))
|
||||
.getByRole('link', { name: 'Open Bravo origin' })
|
||||
.getAttribute('href'),
|
||||
).toBe('http://bravo.example:8080');
|
||||
expect(screen.getByText('Version 0.1.0')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||
await screen.findByRole('row', { name: /Alpha/ });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Sort by latency/i }));
|
||||
let rows = screen.getAllByRole('row').slice(1);
|
||||
expect(rows[0]?.textContent).toContain('Alpha');
|
||||
await user.click(screen.getByRole('button', { name: /Sort by latency/i }));
|
||||
rows = screen.getAllByRole('row').slice(1);
|
||||
expect(rows[0]?.textContent).toContain('Bravo');
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'auth');
|
||||
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Select all visible instances' }));
|
||||
expect(
|
||||
(screen.getByRole('checkbox', { name: 'Select Bravo' }) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
expect(screen.getByText('1 selected')).toBeTruthy();
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search instances' }), 'missing');
|
||||
expect(screen.getByText(/No instances match your search/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes error, retry, configured-empty, and unsafe-origin states without claiming a backend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<FleetDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('collector unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
instances: [{ id: 'unsafe', name: 'Unsafe', url: 'javascript:alert(1)' }],
|
||||
statuses: new Map(),
|
||||
});
|
||||
const first = render(<AppShell pathname="/fleet" fleetDataSource={source(load)} />);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('collector unavailable');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry loading instances' }));
|
||||
expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('link', { name: 'Open Unsafe origin' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
const { unmount } = render(
|
||||
<AppShell
|
||||
pathname="/fleet"
|
||||
fleetDataSource={source(async () => ({ instances: [], statuses: new Map() }))}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/No instances are configured/i)).toBeTruthy();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('binds instance context to the route owner and never leaks mismatched context', () => {
|
||||
const instance: InstanceContext = {
|
||||
id: 'owner',
|
||||
name: 'Owner modem',
|
||||
origin: 'https://owner.example/path',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
};
|
||||
const { rerender } = render(
|
||||
<AppShell pathname="/instances/owner/messages" instance={instance} />,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Messages' })).toBeTruthy();
|
||||
expect(screen.getByText('Owner modem')).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: 'Open original site' }).getAttribute('href')).toBe(
|
||||
'https://owner.example',
|
||||
);
|
||||
|
||||
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||
import type { FleetInstance } from './fleet-table-view-model.js';
|
||||
|
||||
interface InstancePage {
|
||||
readonly data?: readonly FleetInstance[];
|
||||
}
|
||||
|
||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||
return {
|
||||
async load(): Promise<FleetSnapshot> {
|
||||
const response = await fetcher('/api/v1/instances', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||
const body = (await response.json()) as InstancePage;
|
||||
if (!Array.isArray(body.data)) throw new Error('Fleet response is invalid.');
|
||||
return { instances: body.data, statuses: new Map() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildFleetTableViewModel,
|
||||
type FleetFilter,
|
||||
type FleetInstance,
|
||||
type FleetSortColumn,
|
||||
type FleetStatus,
|
||||
type SortDirection,
|
||||
} from './fleet-table-view-model.js';
|
||||
|
||||
export interface FleetSnapshot {
|
||||
readonly instances: readonly FleetInstance[];
|
||||
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
||||
}
|
||||
|
||||
export interface FleetDataSource {
|
||||
load(): Promise<FleetSnapshot>;
|
||||
}
|
||||
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
online: 'Online',
|
||||
auth: 'Authentication required',
|
||||
offline: 'Offline',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export function canonicalHttpOrigin(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function FleetPage({ dataSource, initialData }: FleetPageProps) {
|
||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<FleetFilter>('all');
|
||||
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
|
||||
column: 'name',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setSnapshot(initialData);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!dataSource) {
|
||||
setSnapshot(EMPTY_SNAPSHOT);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setSnapshot(null);
|
||||
setError(null);
|
||||
void dataSource.load().then(
|
||||
(data) => {
|
||||
if (active) setSnapshot(data);
|
||||
},
|
||||
(reason: unknown) => {
|
||||
if (active)
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to load instances.');
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [attempt, dataSource, initialData]);
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
||||
query,
|
||||
filter,
|
||||
sort,
|
||||
selectedIds,
|
||||
}),
|
||||
[filter, query, selectedIds, snapshot, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current)
|
||||
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
||||
}, [model.visibleSelection.indeterminate]);
|
||||
|
||||
function changeSort(column: FleetSortColumn): void {
|
||||
setSort((current) => ({
|
||||
column,
|
||||
direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc',
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleOne(id: string, checked: boolean): void {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisible(checked: boolean): void {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const row of model.rows) {
|
||||
if (checked) next.add(row.id);
|
||||
else next.delete(row.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="fleet-panel" aria-labelledby="fleet-title">
|
||||
<div className="fleet-heading">
|
||||
<div>
|
||||
<h1 id="fleet-title">Fleet</h1>
|
||||
<p>Find, compare, and manage SimAdmin instances.</p>
|
||||
</div>
|
||||
<span className="selection-summary" aria-live="polite">
|
||||
{model.selectedIds.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="fleet-toolbar">
|
||||
<label>
|
||||
<span>Search instances</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="Name, ID, tag, origin…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.currentTarget.value as FleetFilter)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="auth">Authentication required</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!snapshot && !error ? (
|
||||
<p role="status" aria-label="Fleet loading status">
|
||||
Loading instances…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<p>Could not load instances: {error}</p>
|
||||
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Retry loading instances
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot && model.emptyReason ? (
|
||||
<div className="state-panel">
|
||||
{model.emptyReason === 'config' ? <p>No instances are configured.</p> : null}
|
||||
{model.emptyReason === 'search' ? (
|
||||
<p>No instances match your search and filters.</p>
|
||||
) : null}
|
||||
{model.emptyReason === 'filter' ? <p>No instances match this status filter.</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot && model.rows.length > 0 ? (
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="dense-table">
|
||||
<caption>Fleet instances</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="select-column">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
aria-label="Select all visible instances"
|
||||
checked={model.visibleSelection.checked}
|
||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||
/>
|
||||
</th>
|
||||
{(['name', 'status', 'latency'] as const).map((column) => {
|
||||
const label = column[0]!.toUpperCase() + column.slice(1);
|
||||
const active = sort.column === column;
|
||||
return (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
aria-sort={
|
||||
active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(column)}
|
||||
aria-label={`Sort by ${column}, currently ${active ? sort.direction : 'not sorted'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th scope="col">Origin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{model.rows.map((row) => {
|
||||
const origin = canonicalHttpOrigin(row.instance.url);
|
||||
return (
|
||||
<tr key={row.id} aria-selected={row.selected}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</td>
|
||||
<th scope="row">
|
||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
||||
{row.displayName}
|
||||
</a>
|
||||
<small>{row.id}</small>
|
||||
</th>
|
||||
<td>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>
|
||||
<td>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${row.displayName} origin`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
<span>Invalid origin</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildFleetTableViewModel,
|
||||
fleetStatusKind,
|
||||
type FleetInstance,
|
||||
type FleetStatus,
|
||||
} from './fleet-table-view-model.js';
|
||||
|
||||
const instances: readonly FleetInstance[] = [
|
||||
{
|
||||
id: 'charlie',
|
||||
name: 'Charlie',
|
||||
url: 'https://charlie.example',
|
||||
description: 'Warehouse router',
|
||||
tags: ['west'],
|
||||
},
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example', tags: ['east'] },
|
||||
{ id: 'bravo', name: 'Bravo', url: 'https://bravo.example' },
|
||||
{ id: 'delta', name: '', url: 'https://delta.example' },
|
||||
];
|
||||
|
||||
const statuses: ReadonlyMap<string, FleetStatus> = new Map([
|
||||
[
|
||||
'charlie',
|
||||
{ reachable: true, authenticated: true, latencyMs: 55, summary: { carrier: 'Acme' } },
|
||||
],
|
||||
['alpha', { reachable: false, latencyMs: 8 }],
|
||||
['bravo', { reachable: true, authenticated: false, latencyMs: 20 }],
|
||||
]);
|
||||
|
||||
describe('fleet table view model', () => {
|
||||
it('classifies missing, unreachable, authentication-required, and online status', () => {
|
||||
expect(fleetStatusKind(undefined)).toBe('unknown');
|
||||
expect(fleetStatusKind({ reachable: false })).toBe('offline');
|
||||
expect(fleetStatusKind({ reachable: true, authenticated: false })).toBe('auth');
|
||||
expect(fleetStatusKind({ reachable: true, authenticated: true })).toBe('online');
|
||||
});
|
||||
|
||||
it('builds stable rows and defaults to ascending display-name order', () => {
|
||||
const model = buildFleetTableViewModel(instances, statuses);
|
||||
|
||||
expect(model.rows.map((row) => [row.id, row.displayName, row.statusKind])).toEqual([
|
||||
['alpha', 'Alpha', 'offline'],
|
||||
['bravo', 'Bravo', 'auth'],
|
||||
['charlie', 'Charlie', 'online'],
|
||||
['delta', 'delta', 'unknown'],
|
||||
]);
|
||||
expect(model.totalCount).toBe(4);
|
||||
expect(model.visibleCount).toBe(4);
|
||||
expect(model.emptyReason).toBeNull();
|
||||
});
|
||||
|
||||
it('searches user-visible instance and status fields case-insensitively', () => {
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { query: ' ACME ' }).rows.map((row) => row.id),
|
||||
).toEqual(['charlie']);
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { query: 'WEST' }).rows.map((row) => row.id),
|
||||
).toEqual(['charlie']);
|
||||
});
|
||||
|
||||
it('filters by status and reports a useful empty reason', () => {
|
||||
const filtered = buildFleetTableViewModel(instances, statuses, { filter: 'online' });
|
||||
expect(filtered.rows.map((row) => row.id)).toEqual(['charlie']);
|
||||
|
||||
expect(buildFleetTableViewModel([], new Map()).emptyReason).toBe('config');
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { query: 'not present' }).emptyReason,
|
||||
).toBe('search');
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { filter: 'online', query: 'Alpha' })
|
||||
.emptyReason,
|
||||
).toBe('search');
|
||||
expect(
|
||||
buildFleetTableViewModel([instances[1]!], statuses, { filter: 'online' }).emptyReason,
|
||||
).toBe('filter');
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { filter: 'online', query: ' ' }).emptyReason,
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { filter: 'offline', query: 'Bravo' })
|
||||
.emptyReason,
|
||||
).toBe('search');
|
||||
});
|
||||
|
||||
it('sorts latency with missing latency last and status by operational priority', () => {
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, {
|
||||
sort: { column: 'latency', direction: 'asc' },
|
||||
}).rows.map((row) => row.id),
|
||||
).toEqual(['alpha', 'bravo', 'charlie', 'delta']);
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, {
|
||||
sort: { column: 'latency', direction: 'desc' },
|
||||
}).rows.map((row) => row.id),
|
||||
).toEqual(['charlie', 'bravo', 'alpha', 'delta']);
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, {
|
||||
sort: { column: 'status', direction: 'asc' },
|
||||
}).rows.map((row) => row.id),
|
||||
).toEqual(['charlie', 'bravo', 'alpha', 'delta']);
|
||||
});
|
||||
|
||||
it('normalizes selection to known instances and exposes visible selection state', () => {
|
||||
const model = buildFleetTableViewModel(instances, statuses, {
|
||||
filter: 'online',
|
||||
selectedIds: new Set(['charlie', 'alpha', 'missing']),
|
||||
});
|
||||
|
||||
expect(model.rows[0]?.selected).toBe(true);
|
||||
expect(model.selectedIds).toEqual(['alpha', 'charlie']);
|
||||
expect(model.visibleSelection).toEqual({
|
||||
checked: true,
|
||||
indeterminate: false,
|
||||
selectedCount: 1,
|
||||
rowCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate input arrays while sorting', () => {
|
||||
const originalOrder = instances.map((instance) => instance.id);
|
||||
buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } });
|
||||
expect(instances.map((instance) => instance.id)).toEqual(originalOrder);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
||||
export type FleetFilter = 'all' | FleetStatusKind;
|
||||
export type FleetSortColumn = 'name' | 'status' | 'latency';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
export type EmptyFleetReason = 'config' | 'search' | 'filter';
|
||||
|
||||
export interface FleetInstance {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly url: string;
|
||||
readonly description?: string;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
export interface FleetStatus {
|
||||
readonly reachable: boolean;
|
||||
readonly authenticated?: boolean;
|
||||
readonly latencyMs?: number;
|
||||
readonly summary?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface FleetTableOptions {
|
||||
readonly query?: string;
|
||||
readonly filter?: FleetFilter;
|
||||
readonly sort?: Readonly<{
|
||||
column: FleetSortColumn;
|
||||
direction: SortDirection;
|
||||
}>;
|
||||
readonly selectedIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface FleetTableRow {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly instance: FleetInstance;
|
||||
readonly status?: FleetStatus;
|
||||
readonly statusKind: FleetStatusKind;
|
||||
readonly latencyMs?: number;
|
||||
readonly selected: boolean;
|
||||
}
|
||||
|
||||
export interface VisibleSelection {
|
||||
readonly checked: boolean;
|
||||
readonly indeterminate: boolean;
|
||||
readonly selectedCount: number;
|
||||
readonly rowCount: number;
|
||||
}
|
||||
|
||||
export interface FleetTableViewModel {
|
||||
readonly rows: readonly FleetTableRow[];
|
||||
readonly totalCount: number;
|
||||
readonly visibleCount: number;
|
||||
readonly selectedIds: readonly string[];
|
||||
readonly visibleSelection: VisibleSelection;
|
||||
readonly emptyReason: EmptyFleetReason | null;
|
||||
}
|
||||
|
||||
const STATUS_RANK: Readonly<Record<FleetStatusKind, number>> = {
|
||||
online: 0,
|
||||
auth: 1,
|
||||
offline: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
|
||||
export function fleetStatusKind(status: FleetStatus | undefined): FleetStatusKind {
|
||||
if (status === undefined) return 'unknown';
|
||||
if (!status.reachable) return 'offline';
|
||||
if (status.authenticated === false) return 'auth';
|
||||
return 'online';
|
||||
}
|
||||
|
||||
function displayName(instance: FleetInstance): string {
|
||||
const name = instance.name?.trim();
|
||||
return name ? name : instance.id;
|
||||
}
|
||||
|
||||
function searchableText(instance: FleetInstance, status: FleetStatus | undefined): string {
|
||||
return [
|
||||
instance.id,
|
||||
instance.name ?? '',
|
||||
instance.url,
|
||||
instance.description ?? '',
|
||||
...(instance.tags ?? []),
|
||||
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
||||
]
|
||||
.join(' ')
|
||||
.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function compareNames(left: FleetInstance, right: FleetInstance): number {
|
||||
return displayName(left).localeCompare(displayName(right), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function compareInstances(
|
||||
left: FleetInstance,
|
||||
right: FleetInstance,
|
||||
statuses: ReadonlyMap<string, FleetStatus>,
|
||||
column: FleetSortColumn,
|
||||
direction: SortDirection,
|
||||
): number {
|
||||
const leftStatus = statuses.get(left.id);
|
||||
const rightStatus = statuses.get(right.id);
|
||||
let comparison: number;
|
||||
|
||||
if (column === 'status') {
|
||||
comparison =
|
||||
STATUS_RANK[fleetStatusKind(leftStatus)] - STATUS_RANK[fleetStatusKind(rightStatus)];
|
||||
} else if (column === 'latency') {
|
||||
const leftMissing = leftStatus?.latencyMs === undefined;
|
||||
const rightMissing = rightStatus?.latencyMs === undefined;
|
||||
if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
|
||||
comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0);
|
||||
} else {
|
||||
comparison = compareNames(left, right);
|
||||
}
|
||||
|
||||
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
|
||||
return compareNames(left, right) || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function buildFleetTableViewModel(
|
||||
instances: readonly FleetInstance[],
|
||||
statuses: ReadonlyMap<string, FleetStatus>,
|
||||
options: FleetTableOptions = {},
|
||||
): FleetTableViewModel {
|
||||
const query = options.query?.trim().toLocaleLowerCase() ?? '';
|
||||
const filter = options.filter ?? 'all';
|
||||
const sort = options.sort ?? { column: 'name', direction: 'asc' };
|
||||
const knownIds = new Set(instances.map((instance) => instance.id));
|
||||
const selectedIds = [...(options.selectedIds ?? [])]
|
||||
.filter((id) => knownIds.has(id))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const selectedIdSet = new Set(selectedIds);
|
||||
|
||||
const visibleInstances = instances
|
||||
.filter((instance) => {
|
||||
const status = statuses.get(instance.id);
|
||||
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
||||
return !query || searchableText(instance, status).includes(query);
|
||||
})
|
||||
.slice()
|
||||
.sort((left, right) => compareInstances(left, right, statuses, sort.column, sort.direction));
|
||||
|
||||
const rows: FleetTableRow[] = visibleInstances.map((instance) => {
|
||||
const status = statuses.get(instance.id);
|
||||
return {
|
||||
id: instance.id,
|
||||
displayName: displayName(instance),
|
||||
instance,
|
||||
...(status === undefined ? {} : { status }),
|
||||
statusKind: fleetStatusKind(status),
|
||||
...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }),
|
||||
selected: selectedIdSet.has(instance.id),
|
||||
};
|
||||
});
|
||||
const visibleSelectedCount = rows.reduce((count, row) => count + (row.selected ? 1 : 0), 0);
|
||||
const rowCount = rows.length;
|
||||
const emptyReason: EmptyFleetReason | null =
|
||||
instances.length === 0
|
||||
? 'config'
|
||||
: rowCount > 0
|
||||
? null
|
||||
: query
|
||||
? 'search'
|
||||
: filter !== 'all'
|
||||
? 'filter'
|
||||
: null;
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCount: instances.length,
|
||||
visibleCount: rowCount,
|
||||
selectedIds,
|
||||
visibleSelection: {
|
||||
checked: rowCount > 0 && visibleSelectedCount === rowCount,
|
||||
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rowCount,
|
||||
selectedCount: visibleSelectedCount,
|
||||
rowCount,
|
||||
},
|
||||
emptyReason,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,19 @@
|
||||
export {
|
||||
AppShell,
|
||||
resolveRoute,
|
||||
type AppShellProps,
|
||||
type InstanceContext,
|
||||
type InstanceModule,
|
||||
type ResolvedRoute,
|
||||
type RouteKind,
|
||||
} from './app-shell.js';
|
||||
export {
|
||||
FleetPage,
|
||||
canonicalHttpOrigin,
|
||||
type FleetDataSource,
|
||||
type FleetPageProps,
|
||||
type FleetSnapshot,
|
||||
} from './fleet/fleet-page.js';
|
||||
export * from './fleet/fleet-table-view-model.js';
|
||||
|
||||
export const webWorkspaceReady = true;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { AppShell } from './app-shell.js';
|
||||
import './styles.css';
|
||||
|
||||
const root = document.querySelector<HTMLElement>('#root');
|
||||
if (!root) throw new Error('Missing application root');
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<AppShell pathname={window.location.pathname} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,263 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
color: #e8edf5;
|
||||
background: #0b1018;
|
||||
--surface: #121a26;
|
||||
--surface-raised: #192434;
|
||||
--border: #2b394d;
|
||||
--muted: #a9b6c8;
|
||||
--accent: #75a7ff;
|
||||
--danger: #ff8b91;
|
||||
--radius: 0.5rem;
|
||||
--space: clamp(0.75rem, 2vw, 1.5rem);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 20rem;
|
||||
background: #0b1018;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
color: inherit;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
a {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: -5rem;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
background: white;
|
||||
color: black;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
.app-topbar {
|
||||
min-height: 3.5rem;
|
||||
padding: 0 var(--space);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.product-name {
|
||||
margin-right: auto;
|
||||
font-weight: 750;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.connection-status {
|
||||
color: var(--muted);
|
||||
}
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
min-height: calc(100vh - 3.5rem);
|
||||
}
|
||||
.global-navigation,
|
||||
.instance-context {
|
||||
padding: var(--space);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.global-navigation ul,
|
||||
.instance-context ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.global-navigation a,
|
||||
.instance-context nav a {
|
||||
display: block;
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[aria-current='page'] {
|
||||
background: var(--surface-raised);
|
||||
color: white;
|
||||
}
|
||||
.instance-context {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.instance-context + main {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.instance-context code {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
.instance-context dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
main {
|
||||
min-width: 0;
|
||||
padding: var(--space);
|
||||
}
|
||||
.fleet-heading,
|
||||
.fleet-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fleet-toolbar {
|
||||
justify-content: flex-start;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.fleet-toolbar label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: min(100%, 14rem);
|
||||
}
|
||||
.fleet-toolbar label:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.selection-summary,
|
||||
small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
white-space: nowrap;
|
||||
}
|
||||
caption {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
tbody tr:hover,
|
||||
tr[aria-selected='true'] {
|
||||
background: var(--surface);
|
||||
}
|
||||
tbody th small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
}
|
||||
.select-column {
|
||||
width: 3rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.status-online {
|
||||
color: #75e6a3;
|
||||
}
|
||||
.status-offline,
|
||||
.status-auth {
|
||||
color: #ffbd76;
|
||||
}
|
||||
.state-panel {
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.state-error {
|
||||
color: var(--danger);
|
||||
border-color: currentColor;
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.app-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding-block: 0.6rem;
|
||||
}
|
||||
.app-layout {
|
||||
display: block;
|
||||
}
|
||||
.global-navigation {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.global-navigation ul {
|
||||
display: flex;
|
||||
}
|
||||
.instance-context {
|
||||
border-right: 0;
|
||||
}
|
||||
.fleet-toolbar label {
|
||||
width: 100%;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
}
|
||||
.dense-table,
|
||||
.dense-table tbody,
|
||||
.dense-table tr,
|
||||
.dense-table th,
|
||||
.dense-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.dense-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
.dense-table tbody tr {
|
||||
margin-block: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dense-table tbody td,
|
||||
.dense-table tbody th {
|
||||
border: 0;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src"
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user