Repository Public
1"use client";23import Link from "next/link";4import { getDict, type Locale } from "@/lib/i18n";5import { StatusDot } from "./bits";6import { fmtPct } from "./format";7import { Kpis, type BoardNode } from "./LiveBoard";8import { useLive } from "./store";910/** Sequential blue ramp (light to dark) for CPU load; ink flips to white on the two darkest steps. */11const STEPS = [12 { max: 10, bg: "#cde2fb", fg: "#0c1322" },13 { max: 25, bg: "#86b6ef", fg: "#0c1322" },14 { max: 50, bg: "#3987e5", fg: "#0c1322" },15 { max: 75, bg: "#1c5cab", fg: "#ffffff" },16 { max: 101, bg: "#0d366b", fg: "#ffffff" },17];1819/** The "wall": one cell per machine, shaded by CPU load, each linking to its record. */20export function NodeWall({ nodes, locale }: { nodes: BoardNode[]; locale: Locale }) {21 const t = getDict(locale).live;22 const { snap } = useLive();23 const byId = new Map((snap?.nodes ?? []).map((n) => [n.id, n]));24 return (25 <div className="card p-5 md:p-6">26 <div className="flex flex-wrap items-center justify-between gap-3">27 <h3 className="text-[0.9375rem] font-semibold">{t.home.wall}</h3>28 <p className="flex items-center gap-2 text-[0.75rem] text-mute" aria-hidden="true">29 030 <span className="flex gap-0.5">{STEPS.map((s) => <span key={s.bg} className="h-2.5 w-5 rounded-[3px]" style={{ background: s.bg }} />)}</span>31 {fmtPct(100, locale)}32 </p>33 </div>34 <ul className="mt-4 grid grid-cols-6 gap-1.5 sm:grid-cols-10 lg:grid-cols-[repeat(15,minmax(0,1fr))]" data-testid="node-wall">35 {nodes.map((n) => {36 const l = byId.get(n.id);37 const on = l?.online ?? false;38 const step = on ? STEPS.find((s) => l!.cpu < s.max)! : null;39 const label = `${n.name} — ${on ? `${t.chart.cpu} ${fmtPct(l!.cpu, locale)}` : snap ? t.nodes.offline : t.nodes.noReading}`;40 return (41 <li key={n.id}>42 <Link href={`/${locale}/nodes/${n.id}`} title={label} aria-label={label} className={`num flex aspect-square items-center justify-center rounded-lg font-mono text-[0.6875rem] font-medium transition-transform hover:scale-110 ${step ? "" : "border border-dashed border-line-strong bg-surface-2 text-mute"}`} style={step ? { background: step.bg, color: step.fg } : undefined}>43 {String(n.number).padStart(2, "0")}44 </Link>45 </li>46 );47 })}48 </ul>49 <p className="mt-3 text-[0.75rem] text-mute">{t.home.wallNote}</p>50 </div>51 );52}5354export function LiveOverview({ nodes, locale }: { nodes: BoardNode[]; locale: Locale }) {55 return (56 <div className="space-y-3">57 <Kpis locale={locale} />58 <NodeWall nodes={nodes} locale={locale} />59 </div>60 );61}6263/** Compact live summary used in the hero and the header. */64export function LivePill({ locale, className = "" }: { locale: Locale; className?: string }) {65 const t = getDict(locale).live;66 const { ready, snap } = useLive();67 const c = snap?.cluster;68 return (69 <Link href={`/${locale}/live`} className={`inline-flex max-w-full items-center gap-2.5 rounded-full border border-line bg-surface py-1.5 pl-3 pr-4 text-[0.8125rem] text-body shadow-card transition-colors hover:border-accent ${className}`} data-testid="live-pill">70 <StatusDot state={c ? "on" : "off"} />71 <span className="font-medium text-fg">{t.badge}</span>72 <span className="num truncate">{c ? t.pill(c.online, c.total, fmtPct(c.cpu, locale)) : ready ? t.unavailable.short : t.loading}</span>73 </Link>74 );75}76