Repository Public
1"use client";23import Link from "next/link";4import { usePathname, useSearchParams } from "next/navigation";5import { useCallback, useMemo } from "react";6import { NodeLiveInline } from "@/components/live/NodeLive";7import { facetValues, filterNodes, SORT_KEYS, sortNodes, type NodeFilters, type SortKey } from "@/lib/inventory";8import { formatDate, getDict, type Locale } from "@/lib/i18n";9import type { NodeRecord } from "@/lib/schemas";10import { chipShort, LifecycleBadge } from "./bits";1112const FILTER_KEYS = ["family", "chip", "memory", "role", "lifecycle", "rack", "q"] as const;13type Setter = (patch: Record<string, string>) => void;1415function Select({ id, label, value, set, children }: { id: string; label: string; value: string; set: Setter; children: React.ReactNode }) {16 return (17 <div className="min-w-0">18 <label htmlFor={`f-${id}`} className="label mb-1.5 block truncate">{label}</label>19 <select id={`f-${id}`} className="field !py-2.5 text-[0.9375rem]" value={value} onChange={(e) => set({ [id]: e.target.value })}>20 {children}21 </select>22 </div>23 );24}2526function SortTh({ k, label, numeric = false, sort, dir, set }: { k: SortKey; label: string; numeric?: boolean; sort: SortKey; dir: "asc" | "desc"; set: Setter }) {27 return (28 <th scope="col" aria-sort={sort === k ? (dir === "asc" ? "ascending" : "descending") : "none"} className={`whitespace-nowrap px-3 py-3 font-normal ${numeric ? "text-right" : "text-left"}`}>29 <button type="button" className="label inline-flex items-center gap-1.5 hover:text-fg" onClick={() => { const cur = new URLSearchParams(window.location.search); const same = (cur.get("sort") ?? "number") === k; set({ sort: k, dir: same && cur.get("dir") !== "desc" ? "desc" : "asc" }); }}>30 {label}31 <span aria-hidden="true" className={sort === k ? "text-accent" : "opacity-0"}>{dir === "asc" ? "↑" : "↓"}</span>32 </button>33 </th>34 );35}3637/** Filterable, sortable inventory. All state lives in the query string, so any view can be linked or bookmarked. */38export function NodesExplorer({ nodes, locale }: { nodes: NodeRecord[]; locale: Locale }) {39 const t = getDict(locale);40 const n = t.nodes;41 const pathname = usePathname();42 const params = useSearchParams();4344 const filters: NodeFilters = Object.fromEntries(FILTER_KEYS.map((k) => [k, params.get(k) ?? ""]).filter(([, v]) => v));45 const sortParam = params.get("sort") ?? "number";46 const sort: SortKey = (SORT_KEYS as readonly string[]).includes(sortParam) ? (sortParam as SortKey) : "number";47 const dir = params.get("dir") === "desc" ? "desc" : "asc";48 const view = params.get("view") === "table" ? "table" : "grid";4950 // The URL is updated with the History API: no server round trip, so filters respond instantly51 // (useSearchParams stays in sync with history.replaceState in the App Router).52 const go = useCallback(53 (qs: string) => {54 window.history.replaceState(null, "", qs ? `${pathname}?${qs}` : pathname);55 },56 [pathname],57 );58 const set = useCallback(59 (patch: Record<string, string>) => {60 const next = new URLSearchParams(window.location.search);61 for (const [k, v] of Object.entries(patch)) {62 if (v) next.set(k, v);63 else next.delete(k);64 }65 go(next.toString());66 },67 [go],68 );6970 const facets = useMemo(() => facetValues(nodes), [nodes]);71 const shown = useMemo(() => sortNodes(filterNodes(nodes, filters), sort, dir), [nodes, filters, sort, dir]);72 const hasFilter = Object.keys(filters).length > 0;73 const nd = t.common.notDocumented;7475 return (76 <div>77 <form role="search" aria-label={n.filters.heading} className="card grid grid-cols-2 gap-3 p-4 md:gap-4 md:p-6 lg:grid-cols-4" onSubmit={(e) => e.preventDefault()}>78 <div className="col-span-2 lg:col-span-1">79 <label htmlFor="f-q" className="label mb-1.5 block">{n.filters.search}</label>80 <input id="f-q" type="search" className="field !py-2.5 text-[0.9375rem]" defaultValue={filters.q ?? ""} onChange={(e) => set({ q: e.target.value })} autoComplete="off" />81 </div>82 <Select set={set} id="family" label={n.filters.family} value={filters.family ?? ""}>83 <option value="">{n.filters.any}</option>84 {facets.families.map((f) => <option key={f} value={f}>{t.common.families[f]}</option>)}85 </Select>86 <Select set={set} id="chip" label={n.filters.chip} value={filters.chip ?? ""}>87 <option value="">{n.filters.any}</option>88 {facets.chips.map((c) => <option key={c} value={c}>{chipShort(c)}</option>)}89 </Select>90 <Select set={set} id="memory" label={n.filters.memory} value={filters.memory ?? ""}>91 <option value="">{n.filters.any}</option>92 {facets.memories.map((m) => <option key={m} value={String(m)}>{n.filters.atLeast(m)}</option>)}93 </Select>94 <Select set={set} id="role" label={n.filters.role} value={filters.role ?? ""}>95 <option value="">{n.filters.any}</option>96 {facets.roles.map((r) => <option key={r} value={r}>{t.common.roles[r]}</option>)}97 </Select>98 <Select set={set} id="lifecycle" label={n.filters.lifecycle} value={filters.lifecycle ?? ""}>99 <option value="">{n.filters.any}</option>100 {facets.lifecycles.map((l) => <option key={l} value={l}>{t.common.lifecycle[l]}</option>)}101 </Select>102 <Select set={set} id="rack" label={n.filters.rack} value={filters.rack ?? ""}>103 <option value="">{n.filters.any}</option>104 {facets.racks.map((r) => <option key={r} value={r}>{n.filters.rackLabel(r)}</option>)}105 <option value="none">{n.filters.rackNone}</option>106 </Select>107 <Select set={set} id="sort" label={n.sort.label} value={sort}>108 {SORT_KEYS.map((k) => <option key={k} value={k}>{n.sort[k]}</option>)}109 </Select>110 </form>111112 <div className="mt-5 flex flex-wrap items-center justify-between gap-3">113 <p className="num font-mono text-[0.8125rem] text-body" role="status" aria-live="polite" data-testid="result-count">{n.results(shown.length, nodes.length)}</p>114 <div className="flex flex-wrap items-center gap-2">115 {hasFilter ? <button type="button" className="link mr-1 text-[0.875rem]" onClick={() => go(view === "table" ? "view=table" : "")}>{n.filters.reset}</button> : null}116 <button type="button" className="chip" onClick={() => set({ dir: dir === "asc" ? "desc" : "" })} aria-label={`${n.sort.label}: ${dir === "asc" ? n.sort.asc : n.sort.desc}`}>117 <span aria-hidden="true">{dir === "asc" ? "↑" : "↓"}</span> {dir === "asc" ? n.sort.asc : n.sort.desc}118 </button>119 <div role="group" aria-label={n.view.label} className="flex gap-1.5">120 {(["grid", "table"] as const).map((v) => (121 <button key={v} type="button" aria-pressed={view === v} onClick={() => set({ view: v === "grid" ? "" : "table" })} className="chip">122 {n.view[v]}123 </button>124 ))}125 </div>126 </div>127 </div>128129 {shown.length === 0 ? (130 <p className="card mt-5 p-10 text-center text-body">{n.empty}</p>131 ) : view === "grid" ? (132 <ul className="mt-5 grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4" data-testid="node-grid">133 {shown.map((node) => (134 <li key={node.id}>135 <Link href={`/${locale}/nodes/${node.id}`} className="card card-hover group flex h-full flex-col p-3 sm:p-4 md:p-5">136 <div className="flex flex-wrap items-center justify-between gap-2">137 <span className="num font-mono text-[0.6875rem] tracking-[0.1em] text-mute">{String(node.number).padStart(2, "0")}</span>138 <LifecycleBadge state={node.lifecycle} label={t.common.lifecycle[node.lifecycle]} />139 </div>140 <h2 className="mt-3 text-[1.0625rem] font-semibold sm:mt-4 sm:text-[1.3125rem] tracking-[-0.025em] group-hover:text-accent">{node.publicName}</h2>141 <p className="mt-0.5 text-[0.8125rem] text-body sm:text-[0.875rem]">{t.common.families[node.family]} · {node.chip ? chipShort(node.chip) : nd}</p>142 <p className="num mt-2 font-mono text-[0.75rem] text-mute sm:hidden">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"} · {node.cpuCores ?? "—"} {t.common.cores}</p>143 <dl className="num mt-4 hidden grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 font-mono text-[0.75rem] sm:grid">144 <div><dt className="text-mute">{n.cols.memory}</dt><dd className="mt-0.5 text-fg">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"}</dd></div>145 <div><dt className="text-mute">{n.cols.cpu}</dt><dd className="mt-0.5 text-fg">{node.cpuCores ?? "—"}</dd></div>146 <div><dt className="text-mute">{n.cols.gpu}</dt><dd className="mt-0.5 text-fg">{node.gpuCores ?? "—"}</dd></div>147 </dl>148 <p className="mt-3 hidden text-[0.8125rem] text-mute sm:block">{node.role.map((r) => t.common.roles[r]).join(" · ")}</p>149 <p className="mt-auto min-h-[1.75rem] pt-3"><NodeLiveInline id={node.id} locale={locale} /></p>150 </Link>151 </li>152 ))}153 </ul>154 ) : (155 <div className="card mt-5 overflow-x-auto" tabIndex={0} role="region" aria-label={n.title}>156 <table className="num w-full min-w-[64rem] border-collapse text-[0.875rem]" data-testid="node-table">157 <caption className="sr-only">{n.title}</caption>158 <thead className="border-b border-line bg-surface-2">159 <tr>160 <SortTh sort={sort} dir={dir} set={set} k="number" label="#" />161 <SortTh sort={sort} dir={dir} set={set} k="name" label={n.cols.node} />162 <SortTh sort={sort} dir={dir} set={set} k="family" label={n.cols.family} />163 <SortTh sort={sort} dir={dir} set={set} k="chip" label={n.cols.chip} />164 <SortTh sort={sort} dir={dir} set={set} k="cpu" label={n.cols.cpu} numeric />165 <SortTh sort={sort} dir={dir} set={set} k="gpu" label={n.cols.gpu} numeric />166 <SortTh sort={sort} dir={dir} set={set} k="memory" label={n.cols.memory} numeric />167 <SortTh sort={sort} dir={dir} set={set} k="storage" label={n.cols.storage} numeric />168 <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.role}</th>169 <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.status}</th>170 <th scope="col" className="label px-3 py-3 text-left font-normal">{n.cols.verified}</th>171 </tr>172 </thead>173 <tbody>174 {shown.map((node) => (175 <tr key={node.id} className="border-b border-line last:border-0 hover:bg-bg">176 <td className="px-3 py-3 font-mono text-mute">{String(node.number).padStart(2, "0")}</td>177 <th scope="row" className="px-3 py-3 text-left font-medium"><Link className="link" href={`/${locale}/nodes/${node.id}`}>{node.publicName}</Link></th>178 <td className="px-3 py-3 text-body">{t.common.families[node.family]}</td>179 <td className="px-3 py-3 text-body">{node.chip ? chipShort(node.chip) : nd}</td>180 <td className="px-3 py-3 text-right font-mono">{node.cpuCores ?? "—"}</td>181 <td className="px-3 py-3 text-right font-mono">{node.gpuCores ?? <span title={nd}>—</span>}</td>182 <td className="px-3 py-3 text-right font-mono">{node.unifiedMemoryGB !== null ? `${node.unifiedMemoryGB} ${t.common.gb}` : "—"}</td>183 <td className="px-3 py-3 text-right font-mono">{node.storageGB !== null ? `${node.storageGB} ${t.common.gb}` : "—"}</td>184 <td className="px-3 py-3 text-body">{node.role.map((r) => t.common.roles[r]).join(", ")}</td>185 <td className="px-3 py-3"><LifecycleBadge state={node.lifecycle} label={t.common.lifecycle[node.lifecycle]} /></td>186 <td className="whitespace-nowrap px-3 py-3 text-mute">{node.verifiedAt ? <time dateTime={node.verifiedAt}>{formatDate(node.verifiedAt, locale)}</time> : nd}</td>187 </tr>188 ))}189 </tbody>190 </table>191 </div>192 )}193 <p className="mt-5 max-w-[70ch] text-[0.8125rem] leading-relaxed text-mute">{n.statusNote}</p>194 </div>195 );196}197