Repository Public
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import { getDict, type Locale } from "@/lib/i18n";6import type { LiveNode } from "@/lib/live-types";7import { Meter, Sparkline, StatusDot } from "./bits";8import { fmtNum, fmtPct, fmtRate, fmtUptime } from "./format";9import { HistoryCard } from "./HistoryCard";10import { useAge, useLive, useSpark } from "./store";1112export type BoardNode = { id: string; number: number; name: string; family: string; chip: string | null; cpuCores: number | null; memoryGB: number | null; familyLabel: string };1314type SortKey = "number" | "cpu" | "memory" | "gpu";15type Group = "all" | "Mac Studio" | "Mac mini" | "laptop";1617export function Freshness({ locale }: { locale: Locale }) {18 const t = getDict(locale).live;19 const { ready, snap } = useLive();20 const age = useAge(snap?.ts);21 return (22 <p className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 text-[0.8125rem] text-body shadow-card" role="status" data-testid="live-freshness" data-state={snap ? "live" : ready ? "unavailable" : "loading"}>23 <StatusDot state={snap ? "on" : "off"} />24 {snap ? <><span className="font-medium text-fg">{t.badge}</span><span className="num text-mute">{age !== null ? t.updated(age) : "…"}</span></> : <span>{ready ? t.unavailable.short : t.loading}</span>}25 </p>26 );27}2829export function Kpis({ locale }: { locale: Locale }) {30 const t = getDict(locale).live;31 const { snap } = useLive();32 const c = snap?.cluster ?? null;33 const dash = "—";34 const tiles = [35 { k: "online", label: t.kpi.online, value: c ? `${c.online}` : dash, unit: c ? `/ ${c.total}` : "", sub: c ? t.kpi.onlineSub(c.total - c.online) : "", pct: null as number | null },36 { k: "cpu", label: t.kpi.cpu, value: c ? fmtPct(c.cpu, locale, 1) : dash, unit: "", sub: c ? t.kpi.cpuSub(c.coresOnline) : "", pct: c ? c.cpu : null },37 { k: "mem", label: t.kpi.memory, value: c ? fmtPct(c.memPct, locale, 1) : dash, unit: "", sub: c ? t.kpi.memorySub(fmtNum(c.memUsedGB, locale), fmtNum(c.memTotalGB, locale)) : "", pct: c ? c.memPct : null },38 { k: "gpu", label: t.kpi.gpu, value: c && c.gpu !== null ? fmtPct(c.gpu, locale, 1) : dash, unit: "", sub: c ? t.kpi.gpuSub : "", pct: c ? c.gpu : null },39 { k: "net", label: t.kpi.network, value: c ? fmtRate(c.netInKBs, locale, t.units) : dash, unit: "", sub: c ? t.kpi.networkSub(fmtRate(c.netOutKBs, locale, t.units)) : "", pct: null },40 ];41 return (42 <ul className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5" data-testid="live-kpis">43 {tiles.map((x, i) => (44 <li key={x.k} className={`card p-4 md:p-5 ${i === 4 ? "col-span-2 md:col-span-1" : ""}`}>45 <p className="text-[0.8125rem] text-mute">{x.label}</p>46 <p className="num mt-2 text-[1.75rem] font-semibold leading-none tracking-[-0.03em] md:text-[2rem]">47 {x.value}{x.unit ? <span className="ml-1.5 text-[0.5em] font-normal tracking-normal text-mute">{x.unit}</span> : null}48 </p>49 {x.pct !== null ? (50 <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-viz-track" aria-hidden="true"><div className="meter-fill h-full rounded-full bg-viz-cpu" style={{ width: `${Math.max(x.pct, 1.5)}%` }} /></div>51 ) : null}52 <p className="mt-2 min-h-[1.1rem] text-[0.75rem] leading-snug text-mute">{x.sub}</p>53 </li>54 ))}55 </ul>56 );57}5859function Tile({ node, live, spark, locale }: { node: BoardNode; live: LiveNode | undefined; spark: number[]; locale: Locale }) {60 const t = getDict(locale).live;61 const on = live?.online ?? false;62 return (63 <li>64 <Link href={`/${locale}/nodes/${node.id}`} className="card card-hover group flex h-full flex-col p-3 sm:p-4" aria-label={`${node.name} — ${on ? t.nodes.online : live ? t.nodes.offline : t.nodes.noReading}`} data-online={on}>65 <div className="flex items-start justify-between gap-3">66 <div className="min-w-0">67 <p className="flex items-center gap-2">68 <StatusDot state={on ? "on" : "off"} />69 <span className="truncate text-[0.9375rem] font-semibold sm:text-[1.0625rem] tracking-[-0.02em] group-hover:text-accent">{node.name}</span>70 </p>71 <p className="mt-0.5 truncate text-[0.75rem] text-mute">{node.familyLabel}{node.chip ? ` · ${node.chip.replace(/^Apple\s+/, "")}` : ""}</p>72 </div>73 <span className="num hidden font-mono text-[0.6875rem] text-mute sm:inline">{String(node.number).padStart(2, "0")}</span>74 </div>75 {on && live ? (76 <>77 <div className="mt-3 space-y-2 sm:mt-4 sm:space-y-2.5">78 <Meter label={t.chart.cpu} value={live.cpu} text={fmtPct(live.cpu, locale)} />79 <Meter label={t.chart.memory} value={live.memPct} text={`${fmtNum(live.memUsedGB, locale)} / ${live.memTotalGB} ${getDict(locale).common.gb}`} shortText={fmtPct(live.memPct, locale)} severity />80 <Meter label={t.chart.gpu} value={live.gpu} text={live.gpu !== null ? fmtPct(live.gpu, locale) : "—"} muted={live.gpu === null} />81 </div>82 <div className="mt-4 hidden items-end justify-between gap-3 border-t border-line pt-3 sm:flex">83 <dl className="num grid grid-cols-2 gap-x-4 font-mono text-[0.6875rem]">84 <div><dt className="text-mute">{t.nodes.load}</dt><dd className="mt-0.5 text-fg">{fmtNum(live.load1, locale, 2)}</dd></div>85 <div><dt className="text-mute">{t.nodes.uptime}</dt><dd className="mt-0.5 whitespace-nowrap text-fg">{fmtUptime(live.uptimeS, t.units)}</dd></div>86 </dl>87 <span title={t.nodes.spark}><Sparkline values={spark} width={84} height={26} /></span>88 </div>89 </>90 ) : (91 <p className="mt-3 flex flex-1 items-center rounded-xl bg-surface-2 px-3 py-4 text-[0.8125rem] leading-snug text-mute sm:mt-4 sm:py-6">{live ? t.nodes.offlineBody : t.nodes.noReading}</p>92 )}93 </Link>94 </li>95 );96}9798export function LiveBoard({ nodes, locale }: { nodes: BoardNode[]; locale: Locale }) {99 const t = getDict(locale).live;100 const { ready, snap } = useLive();101 const spark = useSpark();102 const [sort, setSort] = useState<SortKey>("number");103 const [group, setGroup] = useState<Group>("all");104 const byId = useMemo(() => new Map((snap?.nodes ?? []).map((n) => [n.id, n])), [snap]);105 const shown = useMemo(() => {106 const list = nodes.filter((n) => group === "all" || (group === "laptop" ? n.family.startsWith("MacBook") : n.family === group));107 const val = (n: BoardNode) => { const l = byId.get(n.id); return !l?.online ? -1 : sort === "cpu" ? l.cpu : sort === "memory" ? l.memPct : (l.gpu ?? -0.5); };108 return sort === "number" ? list : [...list].sort((a, b) => val(b) - val(a) || a.number - b.number);109 }, [nodes, group, sort, byId]);110 const sorts: [SortKey, string][] = [["number", t.nodes.sortNumber], ["cpu", t.chart.cpu], ["memory", t.chart.memory], ["gpu", t.chart.gpu]];111 const groups: [Group, string][] = [["all", t.nodes.all], ["Mac Studio", "Mac Studio"], ["Mac mini", "Mac mini"], ["laptop", t.nodes.laptops]];112113 return (114 <div className="space-y-6 md:space-y-8">115 {ready && !snap ? (116 <div className="card border-dashed p-6 md:p-8" data-testid="live-unavailable">117 <h2 className="text-lg font-semibold tracking-[-0.02em]">{t.unavailable.title}</h2>118 <p className="mt-2 max-w-[62ch] text-[0.9375rem] leading-relaxed text-body">{t.unavailable.body}</p>119 </div>120 ) : null}121 <Kpis locale={locale} />122 <HistoryCard target="cluster" locale={locale} title={t.chart.title} headingId="cluster-history-h" />123 <section aria-labelledby="machines-h">124 <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">125 <h2 id="machines-h" className="h2">{t.nodes.title}</h2>126 <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-5">127 <div role="group" aria-label={t.nodes.filter} className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">128 {groups.map(([g, label]) => <button key={g} type="button" className="chip" aria-pressed={group === g} onClick={() => setGroup(g)}>{label}</button>)}129 </div>130 <div role="group" aria-label={t.nodes.sort} className="-mx-1 flex items-center gap-1.5 overflow-x-auto px-1 pb-1">131 <span className="label mr-1 whitespace-nowrap" aria-hidden="true">{t.nodes.sort}</span>132 {sorts.map(([s, label]) => <button key={s} type="button" className="chip" aria-pressed={sort === s} onClick={() => setSort(s)}>{label}</button>)}133 </div>134 </div>135 </div>136 <ul className="mt-5 grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4" data-testid="live-tiles">137 {shown.map((n) => <Tile key={n.id} node={n} live={byId.get(n.id)} spark={spark[n.id] ?? []} locale={locale} />)}138 </ul>139 </section>140 <p className="max-w-[78ch] text-[0.8125rem] leading-relaxed text-mute">{t.note}</p>141 </div>142 );143}144