SPB Git

Repository Public

LiveBoard.tsx 9.0 KB 144 lines tsx

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
Files main
Menu

Theme