feat(web): complete fleet and instance management slices
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createFleetApiDataSource } from './fleet-api-data-source.js';
|
||||
|
||||
describe('Fleet API data source', () => {
|
||||
it('parses the frozen InstancePage envelope and maps origin to the Fleet URL', async () => {
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example/admin',
|
||||
tags: ['lab'],
|
||||
revision: 1,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 20, total: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
||||
expect(snapshot.instances).toEqual([
|
||||
{ id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'] },
|
||||
]);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/instances',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects legacy or malformed envelopes instead of rendering a false empty fleet', async () => {
|
||||
const fetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
||||
await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow(
|
||||
'Fleet response is invalid.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,27 @@ import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||
import type { FleetInstance } from './fleet-table-view-model.js';
|
||||
|
||||
interface InstancePage {
|
||||
readonly data?: readonly FleetInstance[];
|
||||
readonly items?: readonly unknown[];
|
||||
readonly page?: {
|
||||
readonly page?: unknown;
|
||||
readonly pageSize?: unknown;
|
||||
readonly total?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function parseInstance(value: unknown): FleetInstance {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof item.id !== 'string' ||
|
||||
typeof item.name !== 'string' ||
|
||||
typeof item.origin !== 'string' ||
|
||||
!Array.isArray(item.tags) ||
|
||||
!item.tags.every((tag) => typeof tag === 'string')
|
||||
)
|
||||
throw new Error('Fleet response is invalid.');
|
||||
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
||||
}
|
||||
|
||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||
@@ -15,8 +35,8 @@ export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDa
|
||||
});
|
||||
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() };
|
||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||
return { instances: body.items.map(parseInstance), statuses: new Map() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,18 @@ const instances: readonly FleetInstance[] = [
|
||||
const statuses: ReadonlyMap<string, FleetStatus> = new Map([
|
||||
[
|
||||
'charlie',
|
||||
{ reachable: true, authenticated: true, latencyMs: 55, summary: { carrier: 'Acme' } },
|
||||
{
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
latencyMs: 55,
|
||||
summary: {
|
||||
carrier: 'Acme',
|
||||
version: '2.4.1',
|
||||
capabilities: ['sms', 'ussd'],
|
||||
freshness: 'stale',
|
||||
anomalies: ['latency spike'],
|
||||
},
|
||||
},
|
||||
],
|
||||
['alpha', { reachable: false, latencyMs: 8 }],
|
||||
['bravo', { reachable: true, authenticated: false, latencyMs: 20 }],
|
||||
@@ -123,4 +134,69 @@ describe('fleet table view model', () => {
|
||||
buildFleetTableViewModel(instances, statuses, { sort: { column: 'name', direction: 'desc' } });
|
||||
expect(instances.map((instance) => instance.id)).toEqual(originalOrder);
|
||||
});
|
||||
|
||||
it('derives optional operational metadata from status summary with safe defaults', () => {
|
||||
const model = buildFleetTableViewModel(instances, statuses);
|
||||
const charlie = model.rows.find((row) => row.id === 'charlie');
|
||||
const alpha = model.rows.find((row) => row.id === 'alpha');
|
||||
|
||||
expect(charlie).toMatchObject({
|
||||
version: '2.4.1',
|
||||
capabilities: ['sms', 'ussd'],
|
||||
freshness: 'stale',
|
||||
anomalies: ['latency spike'],
|
||||
tags: ['west'],
|
||||
});
|
||||
expect(alpha).toMatchObject({
|
||||
version: null,
|
||||
capabilities: [],
|
||||
freshness: 'unknown',
|
||||
anomalies: [],
|
||||
tags: ['east'],
|
||||
});
|
||||
});
|
||||
|
||||
it('combines authentication, capability, version, and tag filters', () => {
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, {
|
||||
auth: 'authenticated',
|
||||
capability: 'sms',
|
||||
version: '2.4.1',
|
||||
tag: 'west',
|
||||
}).rows.map((row) => row.id),
|
||||
).toEqual(['charlie']);
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, { auth: 'required' }).rows.map((row) => row.id),
|
||||
).toEqual(['bravo']);
|
||||
});
|
||||
|
||||
it('sorts metadata columns, including scalar freshness, and paginates after filtering and sorting', () => {
|
||||
const model = buildFleetTableViewModel(instances, statuses, {
|
||||
sort: { column: 'version', direction: 'asc' },
|
||||
page: 2,
|
||||
pageSize: 2,
|
||||
});
|
||||
|
||||
expect(model.rows.map((row) => row.id)).toEqual(['bravo', 'delta']);
|
||||
expect(model.filteredCount).toBe(4);
|
||||
expect(model.page).toBe(2);
|
||||
expect(model.pageCount).toBe(2);
|
||||
expect(model.visibleSelection.rowCount).toBe(2);
|
||||
expect(
|
||||
buildFleetTableViewModel(instances, statuses, {
|
||||
sort: { column: 'freshness', direction: 'asc' },
|
||||
}).rows.map((row) => row.id),
|
||||
).toEqual(['charlie', 'alpha', 'bravo', 'delta']);
|
||||
});
|
||||
|
||||
it('clamps invalid pages and exposes filter choices from available data', () => {
|
||||
const model = buildFleetTableViewModel(instances, statuses, { page: 99, pageSize: 3 });
|
||||
expect(model.page).toBe(2);
|
||||
expect(model.rows.map((row) => row.id)).toEqual(['delta']);
|
||||
expect(model.facets).toEqual({
|
||||
capabilities: ['sms', 'ussd'],
|
||||
versions: ['2.4.1'],
|
||||
tags: ['east', 'west'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
||||
export type FleetFilter = 'all' | FleetStatusKind;
|
||||
export type FleetSortColumn = 'name' | 'status' | 'latency';
|
||||
export type FleetAuthFilter = 'all' | 'authenticated' | 'required' | 'unknown';
|
||||
export type FleetSortColumn =
|
||||
| 'name'
|
||||
| 'status'
|
||||
| 'latency'
|
||||
| 'version'
|
||||
| 'capabilities'
|
||||
| 'tags'
|
||||
| 'freshness'
|
||||
| 'anomalies';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
export type EmptyFleetReason = 'config' | 'search' | 'filter';
|
||||
|
||||
@@ -22,13 +31,18 @@ export interface FleetStatus {
|
||||
export interface FleetTableOptions {
|
||||
readonly query?: string;
|
||||
readonly filter?: FleetFilter;
|
||||
readonly sort?: Readonly<{
|
||||
column: FleetSortColumn;
|
||||
direction: SortDirection;
|
||||
}>;
|
||||
readonly auth?: FleetAuthFilter;
|
||||
readonly capability?: string;
|
||||
readonly version?: string;
|
||||
readonly tag?: string;
|
||||
readonly sort?: Readonly<{ column: FleetSortColumn; direction: SortDirection }>;
|
||||
readonly selectedIds?: ReadonlySet<string>;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
}
|
||||
|
||||
export type FleetFreshness = 'fresh' | 'stale' | 'unknown';
|
||||
|
||||
export interface FleetTableRow {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
@@ -37,6 +51,11 @@ export interface FleetTableRow {
|
||||
readonly statusKind: FleetStatusKind;
|
||||
readonly latencyMs?: number;
|
||||
readonly selected: boolean;
|
||||
readonly version: string | null;
|
||||
readonly capabilities: readonly string[];
|
||||
readonly tags: readonly string[];
|
||||
readonly freshness: FleetFreshness;
|
||||
readonly anomalies: readonly string[];
|
||||
}
|
||||
|
||||
export interface VisibleSelection {
|
||||
@@ -49,10 +68,19 @@ export interface VisibleSelection {
|
||||
export interface FleetTableViewModel {
|
||||
readonly rows: readonly FleetTableRow[];
|
||||
readonly totalCount: number;
|
||||
readonly filteredCount: number;
|
||||
readonly visibleCount: number;
|
||||
readonly page: number;
|
||||
readonly pageSize: number;
|
||||
readonly pageCount: number;
|
||||
readonly selectedIds: readonly string[];
|
||||
readonly visibleSelection: VisibleSelection;
|
||||
readonly emptyReason: EmptyFleetReason | null;
|
||||
readonly facets: Readonly<{
|
||||
capabilities: readonly string[];
|
||||
versions: readonly string[];
|
||||
tags: readonly string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const STATUS_RANK: Readonly<Record<FleetStatusKind, number>> = {
|
||||
@@ -74,13 +102,57 @@ function displayName(instance: FleetInstance): string {
|
||||
return name ? name : instance.id;
|
||||
}
|
||||
|
||||
function summaryString(status: FleetStatus | undefined, key: string): string | null {
|
||||
const value = status?.summary?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function summaryStrings(status: FleetStatus | undefined, key: string): readonly string[] {
|
||||
const value = status?.summary?.[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
|
||||
.map((item) => item.trim());
|
||||
}
|
||||
|
||||
function metadata(instance: FleetInstance, status: FleetStatus | undefined) {
|
||||
const freshnessValue = summaryString(status, 'freshness');
|
||||
const freshness: FleetFreshness =
|
||||
freshnessValue === 'fresh' || freshnessValue === 'stale' ? freshnessValue : 'unknown';
|
||||
return {
|
||||
version: summaryString(status, 'version'),
|
||||
capabilities: summaryStrings(status, 'capabilities'),
|
||||
tags: (instance.tags ?? []).filter((tag) => Boolean(tag.trim())),
|
||||
freshness,
|
||||
anomalies: summaryStrings(status, 'anomalies'),
|
||||
};
|
||||
}
|
||||
|
||||
type FleetMetadata = ReturnType<typeof metadata>;
|
||||
|
||||
type FleetMetadataSortColumn = Exclude<FleetSortColumn, 'name' | 'status' | 'latency'>;
|
||||
|
||||
function metadataSortValue(
|
||||
metadataValue: FleetMetadata,
|
||||
column: FleetMetadataSortColumn,
|
||||
): string | null {
|
||||
if (column === 'version') return metadataValue.version;
|
||||
if (column === 'freshness') return metadataValue.freshness;
|
||||
return metadataValue[column].join(', ') || null;
|
||||
}
|
||||
|
||||
function searchableText(instance: FleetInstance, status: FleetStatus | undefined): string {
|
||||
const details = metadata(instance, status);
|
||||
return [
|
||||
instance.id,
|
||||
instance.name ?? '',
|
||||
instance.url,
|
||||
instance.description ?? '',
|
||||
...(instance.tags ?? []),
|
||||
...details.tags,
|
||||
details.version ?? '',
|
||||
...details.capabilities,
|
||||
details.freshness,
|
||||
...details.anomalies,
|
||||
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
||||
]
|
||||
.join(' ')
|
||||
@@ -91,6 +163,11 @@ function compareNames(left: FleetInstance, right: FleetInstance): number {
|
||||
return displayName(left).localeCompare(displayName(right), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function compareOptional(left: string | null, right: string | null): number {
|
||||
if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1;
|
||||
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function compareInstances(
|
||||
left: FleetInstance,
|
||||
right: FleetInstance,
|
||||
@@ -100,7 +177,10 @@ function compareInstances(
|
||||
): number {
|
||||
const leftStatus = statuses.get(left.id);
|
||||
const rightStatus = statuses.get(right.id);
|
||||
let comparison: number;
|
||||
const leftMeta = metadata(left, leftStatus);
|
||||
const rightMeta = metadata(right, rightStatus);
|
||||
let comparison = 0;
|
||||
let missingComparison = false;
|
||||
|
||||
if (column === 'status') {
|
||||
comparison =
|
||||
@@ -108,16 +188,37 @@ function compareInstances(
|
||||
} 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 (leftMissing !== rightMissing) {
|
||||
comparison = leftMissing ? 1 : -1;
|
||||
missingComparison = true;
|
||||
} else comparison = (leftStatus?.latencyMs ?? 0) - (rightStatus?.latencyMs ?? 0);
|
||||
} else if (column === 'name') comparison = compareNames(left, right);
|
||||
else {
|
||||
const leftValue = metadataSortValue(leftMeta, column);
|
||||
const rightValue = metadataSortValue(rightMeta, column);
|
||||
comparison = compareOptional(leftValue, rightValue);
|
||||
missingComparison = (leftValue === null) !== (rightValue === null);
|
||||
}
|
||||
|
||||
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
|
||||
if (comparison !== 0)
|
||||
return missingComparison ? comparison : direction === 'asc' ? comparison : -comparison;
|
||||
return compareNames(left, right) || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function authenticationMatches(status: FleetStatus | undefined, filter: FleetAuthFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'unknown') return status?.authenticated === undefined;
|
||||
return filter === 'authenticated'
|
||||
? status?.authenticated === true
|
||||
: status?.authenticated === false;
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): readonly string[] {
|
||||
return [...new Set(values)].sort((left, right) =>
|
||||
left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFleetTableViewModel(
|
||||
instances: readonly FleetInstance[],
|
||||
statuses: ReadonlyMap<string, FleetStatus>,
|
||||
@@ -125,23 +226,32 @@ export function buildFleetTableViewModel(
|
||||
): FleetTableViewModel {
|
||||
const query = options.query?.trim().toLocaleLowerCase() ?? '';
|
||||
const filter = options.filter ?? 'all';
|
||||
const auth = options.auth ?? 'all';
|
||||
const sort = options.sort ?? { column: 'name', direction: 'asc' };
|
||||
const pageSize = Math.max(1, Math.floor(options.pageSize ?? 10));
|
||||
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 selectedIds = [...(options.selectedIds ?? [])].filter((id) => knownIds.has(id)).sort();
|
||||
const selectedIdSet = new Set(selectedIds);
|
||||
|
||||
const visibleInstances = instances
|
||||
const filteredInstances = instances
|
||||
.filter((instance) => {
|
||||
const status = statuses.get(instance.id);
|
||||
const details = metadata(instance, status);
|
||||
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false;
|
||||
if (!authenticationMatches(status, auth)) return false;
|
||||
if (options.capability && !details.capabilities.includes(options.capability)) return false;
|
||||
if (options.version && details.version !== options.version) return false;
|
||||
if (options.tag && !details.tags.includes(options.tag)) 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 filteredCount = filteredInstances.length;
|
||||
const pageCount = Math.max(1, Math.ceil(filteredCount / pageSize));
|
||||
const page = Math.min(pageCount, Math.max(1, Math.floor(options.page ?? 1)));
|
||||
const pageInstances = filteredInstances.slice((page - 1) * pageSize, page * pageSize);
|
||||
const rows: FleetTableRow[] = pageInstances.map((instance) => {
|
||||
const status = statuses.get(instance.id);
|
||||
return {
|
||||
id: instance.id,
|
||||
@@ -151,32 +261,44 @@ export function buildFleetTableViewModel(
|
||||
statusKind: fleetStatusKind(status),
|
||||
...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }),
|
||||
selected: selectedIdSet.has(instance.id),
|
||||
...metadata(instance, status),
|
||||
};
|
||||
});
|
||||
const visibleSelectedCount = rows.reduce((count, row) => count + (row.selected ? 1 : 0), 0);
|
||||
const rowCount = rows.length;
|
||||
const visibleSelectedCount = rows.reduce((count, row) => count + Number(row.selected), 0);
|
||||
const hasMetadataFilter =
|
||||
auth !== 'all' || Boolean(options.capability || options.version || options.tag);
|
||||
const emptyReason: EmptyFleetReason | null =
|
||||
instances.length === 0
|
||||
? 'config'
|
||||
: rowCount > 0
|
||||
: filteredCount > 0
|
||||
? null
|
||||
: query
|
||||
? 'search'
|
||||
: filter !== 'all'
|
||||
: filter !== 'all' || hasMetadataFilter
|
||||
? 'filter'
|
||||
: null;
|
||||
const allMetadata = instances.map((instance) => metadata(instance, statuses.get(instance.id)));
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCount: instances.length,
|
||||
visibleCount: rowCount,
|
||||
filteredCount,
|
||||
visibleCount: rows.length,
|
||||
page,
|
||||
pageSize,
|
||||
pageCount,
|
||||
selectedIds,
|
||||
visibleSelection: {
|
||||
checked: rowCount > 0 && visibleSelectedCount === rowCount,
|
||||
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rowCount,
|
||||
checked: rows.length > 0 && visibleSelectedCount === rows.length,
|
||||
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rows.length,
|
||||
selectedCount: visibleSelectedCount,
|
||||
rowCount,
|
||||
rowCount: rows.length,
|
||||
},
|
||||
emptyReason,
|
||||
facets: {
|
||||
capabilities: sortedUnique(allMetadata.flatMap((item) => item.capabilities)),
|
||||
versions: sortedUnique(allMetadata.flatMap((item) => (item.version ? [item.version] : []))),
|
||||
tags: sortedUnique(allMetadata.flatMap((item) => item.tags)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user