444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import {
|
|
buildFleetTableViewModel,
|
|
type FleetAuthFilter,
|
|
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;
|
|
readonly refreshSignal?: number;
|
|
}
|
|
|
|
const EMPTY_SNAPSHOT: FleetSnapshot = { instances: [], statuses: new Map() };
|
|
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
|
online: 'Online',
|
|
auth: 'Authentication required',
|
|
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 {
|
|
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, refreshSignal = 0 }: 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 [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(() => {
|
|
if (initialData && refreshSignal === 0) {
|
|
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, refreshSignal]);
|
|
|
|
const model = useMemo(
|
|
() =>
|
|
buildFleetTableViewModel(snapshot?.instances ?? [], snapshot?.statuses ?? new Map(), {
|
|
query,
|
|
filter,
|
|
auth,
|
|
...(capability ? { capability } : {}),
|
|
...(version ? { version } : {}),
|
|
...(tag ? { tag } : {}),
|
|
sort,
|
|
selectedIds,
|
|
page,
|
|
}),
|
|
[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);
|
|
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;
|
|
});
|
|
}
|
|
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">
|
|
<div className="fleet-heading">
|
|
<div>
|
|
<h1 id="fleet-title">Fleet</h1>
|
|
<p>Find, compare, and manage SimAdmin instances.</p>
|
|
</div>
|
|
<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) => resetPage(() => setQuery(event.currentTarget.value))}
|
|
placeholder="Name, ID, tag, origin…"
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>Status</span>
|
|
<select
|
|
value={filter}
|
|
onChange={(event) =>
|
|
resetPage(() => 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>
|
|
<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 ? (
|
|
<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 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>
|
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
|
<th
|
|
key={column}
|
|
scope="col"
|
|
aria-sort={
|
|
column !== 'origin' && sort.column === column
|
|
? sort.direction === 'asc'
|
|
? 'ascending'
|
|
: 'descending'
|
|
: undefined
|
|
}
|
|
>
|
|
{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>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|