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
@@ -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() };
},
};
}
+274
View File
@@ -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,
};
}