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
+254 -86
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import {
buildFleetTableViewModel,
type FleetAuthFilter,
type FleetFilter,
type FleetInstance,
type FleetSortColumn,
@@ -13,11 +14,9 @@ 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;
@@ -30,6 +29,18 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
offline: 'Offline',
unknown: 'Unknown',
};
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
name: 'Name',
status: 'Status',
latency: 'Latency',
version: 'Version',
capabilities: 'Capabilities',
tags: 'Tags',
freshness: 'Freshness',
anomalies: 'Anomalies',
origin: 'Origin',
};
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
export function canonicalHttpOrigin(value: string): string | null {
try {
@@ -49,11 +60,19 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
const [attempt, setAttempt] = useState(0);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<FleetFilter>('all');
const [auth, setAuth] = useState<FleetAuthFilter>('all');
const [capability, setCapability] = useState('');
const [version, setVersion] = useState('');
const [tag, setTag] = useState('');
const [page, setPage] = useState(1);
const [sort, setSort] = useState<{ column: FleetSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
const [columnsOpen, setColumnsOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -88,24 +107,36 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
query,
filter,
auth,
...(capability ? { capability } : {}),
...(version ? { version } : {}),
...(tag ? { tag } : {}),
sort,
selectedIds,
page,
}),
[filter, query, selectedIds, snapshot, sort],
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
);
useEffect(() => {
if (selectAllRef.current)
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
}, [model.visibleSelection.indeterminate]);
useEffect(() => {
if (page !== model.page) setPage(model.page);
}, [model.page, page]);
function resetPage(action: () => void): void {
action();
setPage(1);
}
function changeSort(column: FleetSortColumn): void {
setSort((current) => ({
column,
direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc',
}));
setPage(1);
}
function toggleOne(id: string, checked: boolean): void {
setSelectedIds((current) => {
const next = new Set(current);
@@ -114,7 +145,6 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
return next;
});
}
function toggleVisible(checked: boolean): void {
setSelectedIds((current) => {
const next = new Set(current);
@@ -125,6 +155,37 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
return next;
});
}
function toggleColumn(column: string): void {
setShownColumns((current) => {
const next = new Set(current);
if (next.has(column)) next.delete(column);
else next.add(column);
return next;
});
}
const selectFilter = (
label: string,
value: string,
setValue: (value: string) => void,
choices: readonly string[],
allLabel: string,
) => (
<label>
<span>{label}</span>
<select
value={value}
onChange={(event) => resetPage(() => setValue(event.currentTarget.value))}
>
<option value="">{allLabel}</option>
{choices.map((choice) => (
<option key={choice} value={choice}>
{choice}
</option>
))}
</select>
</label>
);
return (
<section className="fleet-panel" aria-labelledby="fleet-title">
@@ -133,17 +194,32 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
<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 className="fleet-actions">
<span className="selection-summary" aria-live="polite">
{model.selectedIds.length} selected
</span>
<button
type="button"
disabled={model.selectedIds.length === 0}
onClick={() => setBatchOpen((open) => !open)}
>
Batch actions
</button>
</div>
</div>
{batchOpen ? (
<div className="batch-entry" role="region" aria-label="Batch action entry">
Choose an action for {model.selectedIds.length} selected{' '}
{model.selectedIds.length === 1 ? 'instance' : 'instances'}.
</div>
) : null}
<div className="fleet-toolbar">
<label>
<span>Search instances</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
placeholder="Name, ID, tag, origin…"
/>
</label>
@@ -151,7 +227,9 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
<span>Status</span>
<select
value={filter}
onChange={(event) => setFilter(event.currentTarget.value as FleetFilter)}
onChange={(event) =>
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
}
>
<option value="all">All statuses</option>
<option value="online">Online</option>
@@ -160,6 +238,54 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
<option value="unknown">Unknown</option>
</select>
</label>
<label>
<span>Authentication</span>
<select
value={auth}
onChange={(event) =>
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
}
>
<option value="all">All authentication</option>
<option value="authenticated">Authenticated</option>
<option value="required">Authentication required</option>
<option value="unknown">Unknown</option>
</select>
</label>
{selectFilter(
'Capability',
capability,
setCapability,
model.facets.capabilities,
'All capabilities',
)}
{selectFilter('Version', version, setVersion, model.facets.versions, 'All versions')}
{selectFilter('Tag', tag, setTag, model.facets.tags, 'All tags')}
<div className="column-picker">
<button
type="button"
aria-expanded={columnsOpen}
onClick={() => setColumnsOpen((open) => !open)}
>
Choose columns
</button>
{columnsOpen ? (
<fieldset>
<legend>Visible columns</legend>
{ALL_COLUMNS.map((column) => (
<label key={column}>
<input
type="checkbox"
aria-label={`Show ${column} column`}
checked={shownColumns.has(column)}
onChange={() => toggleColumn(column)}
/>
{COLUMN_LABELS[column]}
</label>
))}
</fieldset>
) : null}
</div>
</div>
{!snapshot && !error ? (
@@ -181,93 +307,135 @@ export function FleetPage({ dataSource, initialData }: FleetPageProps) {
{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}
{model.emptyReason === 'filter' ? <p>No instances match the active filters.</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 (
<>
<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>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<th
key={column}
scope="col"
aria-sort={
active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined
column !== 'origin' && sort.column === column
? 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>
{column === 'origin' ? (
COLUMN_LABELS[column]
) : (
<button
type="button"
onClick={() => changeSort(column)}
aria-label={`Sort by ${column}, currently ${sort.column === column ? sort.direction : 'not sorted'}`}
>
{COLUMN_LABELS[column]}
</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{model.rows.map((row) => {
const origin = canonicalHttpOrigin(row.instance.url);
const cells: Readonly<Record<string, React.ReactNode>> = {
name: (
<th scope="row">
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
{row.displayName}
</a>
<small>{row.id}</small>
</th>
),
status: (
<td>
<span className={`status status-${row.statusKind}`}>
{STATUS_LABELS[row.statusKind]}
</span>
</td>
),
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
version: <td>{row.version ?? '—'}</td>,
capabilities: <td>{row.capabilities.join(', ') || '—'}</td>,
tags: <td>{row.tags.join(', ') || '—'}</td>,
freshness: <td>{row.freshness}</td>,
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
origin: (
<td>
{origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${row.displayName} origin`}
>
{origin}
</a>
) : (
<span>Invalid origin</span>
)}
</td>
),
};
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>
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
<Fragment key={column}>{cells[column]}</Fragment>
))}
</tr>
);
})}
<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>
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Fleet pagination">
<button
type="button"
aria-label="Previous page"
disabled={model.page === 1}
onClick={() => setPage((value) => value - 1)}
>
Previous
</button>
<span>
Page {model.page} of {model.pageCount}
</span>
<button
type="button"
aria-label="Next page"
disabled={model.page === model.pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next
</button>
</nav>
</>
) : null}
</section>
);