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