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
+147 -25
View File
@@ -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)),
},
};
}