feat(web): complete fleet and instance management slices
This commit is contained in:
@@ -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