Repository Public
1import type { Family, Lifecycle, NodeRecord, Role } from "./schemas";23/** Pure functions over node records — shared by server pages, the client explorer and unit tests. */45export function publicNodes(nodes: NodeRecord[]): NodeRecord[] {6 return nodes.filter((n) => n.publicVisibility).sort((a, b) => a.number - b.number);7}89export type Aggregate = {10 documented: number;11 active: number;12 /** null when at least one counted record lacks the value: never present a partial sum as a total */13 cpuCores: number | null;14 gpuCores: number | null;15 unifiedMemoryGB: number | null;16 storageGB: number | null;17 byFamily: { family: Family; count: number; memoryGB: number | null; cpuCores: number | null }[];18 byLifecycle: Partial<Record<Lifecycle, number>>;19 chips: string[];20 lastVerifiedAt: string | null;21};2223function sumOrNull(values: (number | null)[]): number | null {24 if (values.length === 0) return 0;25 return values.every((v): v is number => v !== null) ? values.reduce((a, b) => a + b, 0) : null;26}2728/** Planned / retired / unverified hardware never enters the totals. */29export const COUNTED: Lifecycle[] = ["active", "offline", "maintenance"];3031export function aggregate(nodes: NodeRecord[]): Aggregate {32 const counted = nodes.filter((n) => n.publicVisibility && COUNTED.includes(n.lifecycle));33 const families = [...new Set(counted.map((n) => n.family))];34 const byLifecycle: Partial<Record<Lifecycle, number>> = {};35 for (const n of nodes.filter((x) => x.publicVisibility)) byLifecycle[n.lifecycle] = (byLifecycle[n.lifecycle] ?? 0) + 1;36 const verified = counted.map((n) => n.verifiedAt).filter((d): d is string => d !== null).sort();37 return {38 documented: counted.length,39 active: counted.filter((n) => n.lifecycle === "active").length,40 cpuCores: sumOrNull(counted.map((n) => n.cpuCores)),41 gpuCores: sumOrNull(counted.map((n) => n.gpuCores)),42 unifiedMemoryGB: sumOrNull(counted.map((n) => n.unifiedMemoryGB)),43 storageGB: sumOrNull(counted.map((n) => n.storageGB)),44 byFamily: families45 .map((family) => {46 const group = counted.filter((n) => n.family === family);47 return {48 family,49 count: group.length,50 memoryGB: sumOrNull(group.map((n) => n.unifiedMemoryGB)),51 cpuCores: sumOrNull(group.map((n) => n.cpuCores)),52 };53 })54 .sort((a, b) => b.count - a.count || a.family.localeCompare(b.family)),55 byLifecycle,56 chips: [...new Set(counted.map((n) => n.chip).filter((c): c is string => c !== null))].sort(),57 lastVerifiedAt: verified.at(-1) ?? null,58 };59}6061export type NodeFilters = {62 family?: string;63 chip?: string;64 memory?: string; // minimum GB65 role?: string;66 lifecycle?: string;67 rack?: string;68 q?: string;69};7071export function filterNodes(nodes: NodeRecord[], f: NodeFilters): NodeRecord[] {72 const minMem = f.memory ? Number(f.memory) : null;73 const q = f.q?.trim().toLowerCase();74 return nodes.filter((n) => {75 if (f.family && n.family !== f.family) return false;76 if (f.chip && n.chip !== f.chip) return false;77 if (minMem !== null && !Number.isNaN(minMem) && (n.unifiedMemoryGB === null || n.unifiedMemoryGB < minMem)) return false;78 if (f.role && !n.role.includes(f.role as Role)) return false;79 if (f.lifecycle && n.lifecycle !== f.lifecycle) return false;80 if (f.rack && (f.rack === "none" ? n.rack !== null : n.rack !== f.rack)) return false;81 if (q && ![n.publicName, n.id, n.chip ?? "", n.family].some((s) => s.toLowerCase().includes(q))) return false;82 return true;83 });84}8586export const SORT_KEYS = ["number", "name", "family", "chip", "cpu", "gpu", "memory", "storage"] as const;87export type SortKey = (typeof SORT_KEYS)[number];8889const pick: Record<SortKey, (n: NodeRecord) => number | string | null> = {90 number: (n) => n.number,91 name: (n) => n.publicName.toLowerCase(),92 family: (n) => n.family,93 chip: (n) => n.chip,94 cpu: (n) => n.cpuCores,95 gpu: (n) => n.gpuCores,96 memory: (n) => n.unifiedMemoryGB,97 storage: (n) => n.storageGB,98};99100/** Stable sort; unknown (null) values always go last, whatever the direction. */101export function sortNodes(nodes: NodeRecord[], key: SortKey, dir: "asc" | "desc" = "asc"): NodeRecord[] {102 const get = pick[key] ?? pick.number;103 const sign = dir === "desc" ? -1 : 1;104 return nodes105 .map((n, i) => ({ n, i }))106 .sort((a, b) => {107 const va = get(a.n);108 const vb = get(b.n);109 if (va === null && vb === null) return a.n.number - b.n.number;110 if (va === null) return 1;111 if (vb === null) return -1;112 const c = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));113 return c !== 0 ? c * sign : a.n.number - b.n.number || a.i - b.i;114 })115 .map((x) => x.n);116}117118export function facetValues(nodes: NodeRecord[]) {119 const uniq = <T,>(xs: T[]) => [...new Set(xs)];120 return {121 families: uniq(nodes.map((n) => n.family)),122 chips: uniq(nodes.map((n) => n.chip).filter((c): c is string => c !== null)).sort(),123 memories: uniq(nodes.map((n) => n.unifiedMemoryGB).filter((m): m is number => m !== null)).sort((a, b) => a - b),124 roles: uniq(nodes.flatMap((n) => n.role)),125 lifecycles: uniq(nodes.map((n) => n.lifecycle)),126 racks: uniq(nodes.map((n) => n.rack).filter((r): r is string => r !== null)).sort(),127 };128}129