314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
export type FleetStatusKind = 'online' | 'auth' | 'offline' | 'unknown';
|
|
export type FleetFilter = 'all' | FleetStatusKind;
|
|
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';
|
|
|
|
export interface FleetInstance {
|
|
readonly id: string;
|
|
readonly name?: string;
|
|
readonly url: string;
|
|
readonly description?: string;
|
|
readonly tags?: readonly string[];
|
|
}
|
|
|
|
export interface FleetStatus {
|
|
readonly reachable: boolean;
|
|
readonly authenticated?: boolean;
|
|
readonly latencyMs?: number;
|
|
readonly summary?: Readonly<
|
|
Record<string, unknown> & {
|
|
resources?: Readonly<{
|
|
cpuPercent?: number;
|
|
memoryPercent?: number;
|
|
maxTemperatureCelsius?: number;
|
|
phoneNumbers?: readonly string[];
|
|
}>;
|
|
}
|
|
>;
|
|
}
|
|
|
|
export interface FleetTableOptions {
|
|
readonly query?: string;
|
|
readonly filter?: FleetFilter;
|
|
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;
|
|
readonly instance: FleetInstance;
|
|
readonly status?: FleetStatus;
|
|
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 {
|
|
readonly checked: boolean;
|
|
readonly indeterminate: boolean;
|
|
readonly selectedCount: number;
|
|
readonly rowCount: number;
|
|
}
|
|
|
|
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>> = {
|
|
online: 0,
|
|
auth: 1,
|
|
offline: 2,
|
|
unknown: 3,
|
|
};
|
|
|
|
export function fleetStatusKind(status: FleetStatus | undefined): FleetStatusKind {
|
|
if (status === undefined) return 'unknown';
|
|
if (!status.reachable) return 'offline';
|
|
if (status.authenticated === false) return 'auth';
|
|
return 'online';
|
|
}
|
|
|
|
function displayName(instance: FleetInstance): string {
|
|
const name = instance.name?.trim();
|
|
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 ?? '',
|
|
...details.tags,
|
|
details.version ?? '',
|
|
...details.capabilities,
|
|
details.freshness,
|
|
...details.anomalies,
|
|
status?.summary === undefined ? '' : JSON.stringify(status.summary),
|
|
]
|
|
.join(' ')
|
|
.toLocaleLowerCase();
|
|
}
|
|
|
|
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,
|
|
statuses: ReadonlyMap<string, FleetStatus>,
|
|
column: FleetSortColumn,
|
|
direction: SortDirection,
|
|
): number {
|
|
const leftStatus = statuses.get(left.id);
|
|
const rightStatus = statuses.get(right.id);
|
|
const leftMeta = metadata(left, leftStatus);
|
|
const rightMeta = metadata(right, rightStatus);
|
|
let comparison = 0;
|
|
let missingComparison = false;
|
|
|
|
if (column === 'status') {
|
|
comparison =
|
|
STATUS_RANK[fleetStatusKind(leftStatus)] - STATUS_RANK[fleetStatusKind(rightStatus)];
|
|
} else if (column === 'latency') {
|
|
const leftMissing = leftStatus?.latencyMs === undefined;
|
|
const rightMissing = rightStatus?.latencyMs === undefined;
|
|
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 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>,
|
|
options: FleetTableOptions = {},
|
|
): 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();
|
|
const selectedIdSet = new Set(selectedIds);
|
|
|
|
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 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,
|
|
displayName: displayName(instance),
|
|
instance,
|
|
...(status === undefined ? {} : { status }),
|
|
statusKind: fleetStatusKind(status),
|
|
...(status?.latencyMs === undefined ? {} : { latencyMs: status.latencyMs }),
|
|
selected: selectedIdSet.has(instance.id),
|
|
...metadata(instance, status),
|
|
};
|
|
});
|
|
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'
|
|
: filteredCount > 0
|
|
? null
|
|
: query
|
|
? 'search'
|
|
: filter !== 'all' || hasMetadataFilter
|
|
? 'filter'
|
|
: null;
|
|
const allMetadata = instances.map((instance) => metadata(instance, statuses.get(instance.id)));
|
|
|
|
return {
|
|
rows,
|
|
totalCount: instances.length,
|
|
filteredCount,
|
|
visibleCount: rows.length,
|
|
page,
|
|
pageSize,
|
|
pageCount,
|
|
selectedIds,
|
|
visibleSelection: {
|
|
checked: rows.length > 0 && visibleSelectedCount === rows.length,
|
|
indeterminate: visibleSelectedCount > 0 && visibleSelectedCount < rows.length,
|
|
selectedCount: visibleSelectedCount,
|
|
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)),
|
|
},
|
|
};
|
|
}
|