spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw } from "lucide-react";4import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";5import { useMemo, useState } from "react";6import { Badge, Button, Card, CardBody, CardHeader, type BadgeTone } from "@/components/ui";7import { cn } from "@/components/ui/cn";8import { DASH, fmtInt } from "@/lib/format";9import { collapseDiff, diffLinesLcs, yamlTokens, type YamlTokenKind } from "./yaml";1011/* ------------------------------------------------------------------ formatting helpers */1213export function fmtMs(ms: number | null | undefined): string {14 if (ms == null || !Number.isFinite(ms)) return DASH;15 if (ms < 1000) return `${Math.round(ms)} ms`;16 if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;17 const m = Math.floor(ms / 60_000);18 const s = Math.round((ms % 60_000) / 1000);19 return `${m} min ${s.toString().padStart(2, "0")} s`;20}2122export function fmtBytes(b: number | null | undefined): string {23 if (b == null || !Number.isFinite(b)) return DASH;24 if (b < 1024) return `${b} B`;25 if (b < 1024 * 1024) return `${(b / 1024).toFixed(b < 10 * 1024 ? 1 : 0)} kB`;26 if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;27 return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;28}2930export function fmtPct01(v: number | null | undefined, digits = 0): string {31 if (v == null || !Number.isFinite(v)) return DASH;32 return `${(v * 100).toFixed(digits)}%`;33}3435export function shortUrl(u: string | null | undefined, max = 72): string {36 if (!u) return DASH;37 const s = u.replace(/^https?:\/\//, "");38 return s.length > max ? `${s.slice(0, max - 1)}…` : s;39}4041export function durationBetween(a: string | null | undefined, b: string | null | undefined): number | null {42 if (!a) return null;43 const t0 = new Date(a).getTime();44 const t1 = b ? new Date(b).getTime() : Date.now();45 if (Number.isNaN(t0) || Number.isNaN(t1)) return null;46 return Math.max(0, t1 - t0);47}4849/* ------------------------------------------------------------------ status */5051export type HealthKey = "ok" | "degraded" | "failing" | "paused" | "never_run" | "blocked" | "schema_change" | "no_new_content" | "quarantine" | string;52/** Spec status vocabulary (§11): HEALTHY · DEGRADED · BLOCKED · SCHEMA CHANGE · NO NEW CONTENT · FAILED · QUARANTINE (+ paused / never run / running). */53export const HEALTH: Record<string, { color: string; label: string; hollow?: boolean; tone: BadgeTone }> = {54 ok: { color: "var(--ok)", label: "HEALTHY", tone: "ok" },55 degraded: { color: "var(--warn)", label: "DEGRADED", tone: "warn" },56 blocked: { color: "var(--danger)", label: "BLOCKED", tone: "danger" },57 schema_change: { color: "var(--st-permitting)", label: "SCHEMA CHANGE", tone: "info" },58 no_new_content: { color: "var(--ink-3)", label: "NO NEW CONTENT", tone: "neutral" },59 failing: { color: "var(--danger)", label: "FAILED", tone: "danger" },60 quarantine: { color: "var(--st-construction)", label: "QUARANTINE", tone: "warn" },61 paused: { color: "var(--ink-4)", label: "PAUSED", tone: "neutral" },62 never_run: { color: "var(--ink-4)", label: "NEVER RUN", hollow: true, tone: "outline" },63 running: { color: "var(--accent)", label: "RUNNING", tone: "accent" },64};6566export function HealthBadge({ health, size = "xs", className }: { health: HealthKey; size?: "xs" | "sm"; className?: string }) {67 const h = HEALTH[health] ?? { color: "var(--ink-4)", label: health.replace(/_/g, " ").toUpperCase(), tone: "outline" as BadgeTone };68 return (69 <Badge tone={h.tone} size={size} className={cn("tracking-[0.04em]", className)} title={`Health: ${h.label}`}>70 <span aria-hidden className={cn("inline-block size-[6px] shrink-0 rounded-full", health === "running" && "animate-pulse-soft")} style={{ background: h.hollow ? "transparent" : h.color, border: h.hollow ? `1.5px solid ${h.color}` : undefined }} />71 {h.label}72 </Badge>73 );74}7576export function HealthDot({ health, size = 8, className, withLabel }: { health: HealthKey; size?: number; className?: string; withLabel?: boolean }) {77 const h = HEALTH[health] ?? { color: "var(--ink-4)", label: health.replace(/_/g, " ").toUpperCase(), tone: "outline" as BadgeTone };78 return (79 <span className={cn("inline-flex items-center gap-1.5", className)} title={h.label}>80 <span aria-hidden className={cn("inline-block shrink-0 rounded-full", health === "running" && "animate-pulse-soft")} style={{ width: size, height: size, background: h.hollow ? "transparent" : h.color, border: h.hollow ? `1.5px solid ${h.color}` : undefined }} />81 {withLabel ? <span className="text-[12px] text-ink-2">{h.label}</span> : <span className="sr-only">{h.label}</span>}82 </span>83 );84}8586const RUN_TONE: Record<string, BadgeTone> = { ok: "ok", success: "ok", completed: "ok", running: "accent", queued: "info", pending: "info", failed: "danger", error: "danger", aborted: "warn", partial: "warn", degraded: "warn" };87export function RunStatusBadge({ status, size = "xs" }: { status: string | null | undefined; size?: "xs" | "sm" }) {88 if (!status) return <span className="text-ink-4">{DASH}</span>;89 return (90 <Badge tone={RUN_TONE[status] ?? "neutral"} size={size} mono>91 {status}92 </Badge>93 );94}9596const REVIEW_TONE: Record<string, BadgeTone> = { approved: "ok", rejected: "danger", pending: "warn", auto: "neutral", auto_merged: "info", auto_created: "neutral" };97export function ReviewBadge({ status, size = "xs" }: { status: string; size?: "xs" | "sm" }) {98 return (99 <Badge tone={REVIEW_TONE[status] ?? "neutral"} size={size}>100 {status.replace(/_/g, " ")}101 </Badge>102 );103}104105export function LevelBadge({ level }: { level: number | null | undefined }) {106 if (level == null) return <span className="text-ink-4">{DASH}</span>;107 const label = ["", "L1 direct", "L2 browser UA", "L3 Firecrawl", "L4 Scrapfly"][level] ?? `L${level}`;108 return (109 <Badge tone={level >= 3 ? "accent" : "outline"} size="xs" mono title={label}>110 L{level}111 </Badge>112 );113}114115/* ------------------------------------------------------------------ text */116117export function Mono({ children, className, title, wrap }: { children: ReactNode; className?: string; title?: string; wrap?: boolean }) {118 return (119 <span className={cn("font-mono text-[12px] text-ink-2", wrap ? "break-all" : "truncate", className)} title={title}>120 {children}121 </span>122 );123}124125export function ExtLink({ href, children, className }: { href: string; children?: ReactNode; className?: string }) {126 return (127 <a href={href} target="_blank" rel="noopener noreferrer" className={cn("inline-flex min-w-0 items-center gap-1 font-mono text-[12px] text-ink-2 hover:text-accent hover:underline", className)} title={href}>128 <span className="truncate">{children ?? shortUrl(href)}</span>129 <ExternalLink className="size-3 shrink-0 text-ink-4" />130 </a>131 );132}133134/** Key / value rows (definition list). */135export function KV({ items, className, cols = 1 }: { items: Array<{ k: ReactNode; v: ReactNode; title?: string } | null | false | undefined>; className?: string; cols?: 1 | 2 | 3 }) {136 return (137 <dl className={cn("grid gap-x-4 gap-y-1.5 text-[12.5px]", cols === 2 && "sm:grid-cols-2", cols === 3 && "sm:grid-cols-3", className)}>138 {items.filter(Boolean).map((it, i) => {139 const item = it as { k: ReactNode; v: ReactNode; title?: string };140 return (141 <div key={i} className="flex min-w-0 items-baseline justify-between gap-3 border-b border-hair/70 py-1 last:border-0" title={item.title}>142 <dt className="shrink-0 text-ink-3">{item.k}</dt>143 <dd className="min-w-0 truncate text-right text-ink">{item.v ?? <span className="text-ink-4">{DASH}</span>}</dd>144 </div>145 );146 })}147 </dl>148 );149}150151export function Stat({ label, value, note, tone, className }: { label: ReactNode; value: ReactNode; note?: ReactNode; tone?: "ok" | "warn" | "danger"; className?: string }) {152 return (153 <div className={cn("min-w-0 border-l-2 pl-2.5", tone === "ok" ? "border-ok" : tone === "warn" ? "border-warn" : tone === "danger" ? "border-danger" : "border-hair-2", className)}>154 <div className="label-caps">{label}</div>155 <div className="figure text-[18px] leading-tight text-ink">{value}</div>156 {note && <div className="mt-0.5 text-[11.5px] text-ink-3">{note}</div>}157 </div>158 );159}160161/* ------------------------------------------------------------------ JSON / code */162163function jsonHighlight(json: string): ReactNode[] {164 const re = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;165 const out: ReactNode[] = [];166 let last = 0;167 let m: RegExpExecArray | null;168 let i = 0;169 while ((m = re.exec(json))) {170 if (m.index > last) out.push(json.slice(last, m.index));171 const t = m[0];172 const cls = /^"/.test(t) ? (/:$/.test(t) ? "text-ink" : "text-st-operational") : /true|false|null/.test(t) ? "text-st-permitting" : "text-st-planned";173 out.push(174 <span key={i++} className={cls}>175 {t}176 </span>,177 );178 last = m.index + t.length;179 }180 if (last < json.length) out.push(json.slice(last));181 return out;182}183184export function JsonView({ value, className, maxHeight = 320, collapsible }: { value: unknown; className?: string; maxHeight?: number | null; collapsible?: boolean }) {185 const [open, setOpen] = useState(!collapsible);186 const text = useMemo(() => {187 try {188 return JSON.stringify(value, null, 2) ?? "undefined";189 } catch {190 return String(value);191 }192 }, [value]);193 if (collapsible && !open) {194 return (195 <button type="button" onClick={() => setOpen(true)} className="font-mono text-[12px] text-ink-3 hover:text-ink">196 {text.length > 90 ? `${text.slice(0, 90)}…` : text} <span className="text-accent">expand</span>197 </button>198 );199 }200 return (201 <pre className={cn("overflow-auto rounded-xs border border-hair bg-paper px-2.5 py-2 font-mono text-[11.5px] leading-[1.5] text-ink-2", className)} style={maxHeight ? { maxHeight } : undefined}>202 {jsonHighlight(text)}203 </pre>204 );205}206207const YAML_CLS: Record<YamlTokenKind, string> = { key: "text-ink font-medium", string: "text-st-operational", number: "text-st-planned", bool: "text-st-permitting", comment: "text-ink-4 italic", punct: "text-ink-3", text: "text-ink-2", anchor: "text-accent" };208209/** Line-numbered code block. `lang="yaml"` highlights; `search` highlights matches (case-insensitive). */210export function Code({ text, lang = "text", className, maxHeight = 480, search, wrap, startLine = 1 }: { text: string; lang?: "yaml" | "text" | "html"; className?: string; maxHeight?: number | null; search?: string; wrap?: boolean; startLine?: number }) {211 const lines = useMemo(() => text.split("\n"), [text]);212 const needle = search?.trim().toLowerCase() ?? "";213 const render = (line: string) => {214 if (needle) {215 const parts: ReactNode[] = [];216 let idx = 0;217 const lower = line.toLowerCase();218 let hit = lower.indexOf(needle, idx);219 let k = 0;220 while (hit >= 0) {221 if (hit > idx) parts.push(line.slice(idx, hit));222 parts.push(223 <mark key={k++} className="rounded-[1px] bg-accent/30 text-ink">224 {line.slice(hit, hit + needle.length)}225 </mark>,226 );227 idx = hit + needle.length;228 hit = lower.indexOf(needle, idx);229 }230 if (idx < line.length) parts.push(line.slice(idx));231 return parts;232 }233 if (lang === "yaml") return yamlTokens(line).map((t, i) => (234 <span key={i} className={YAML_CLS[t.kind]}>235 {t.text}236 </span>237 ));238 return line;239 };240 return (241 <div className={cn("overflow-auto rounded-xs border border-hair bg-paper", className)} style={maxHeight ? { maxHeight } : undefined}>242 <table className="w-full border-collapse font-mono text-[11.5px] leading-[1.55]">243 <tbody>244 {lines.map((l, i) => {245 const match = needle && l.toLowerCase().includes(needle);246 return (247 <tr key={i} className={cn(match && "bg-accent/5")}>248 <td className="select-none border-r border-hair px-2 text-right align-top text-ink-4 tnum" style={{ width: 1 }}>249 {i + startLine}250 </td>251 <td className={cn("px-2.5 align-top text-ink-2", wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre")}>{render(l) || " "}</td>252 </tr>253 );254 })}255 </tbody>256 </table>257 </div>258 );259}260261export function countMatches(text: string, needle: string): number {262 const n = needle.trim().toLowerCase();263 if (!n) return 0;264 let c = 0;265 let i = text.toLowerCase().indexOf(n);266 while (i >= 0 && c < 100_000) {267 c++;268 i = text.toLowerCase().indexOf(n, i + n.length);269 }270 return c;271}272273/** Unified line diff (LCS) with collapsed context. */274export function DiffView({ before, after, context = 3, className, maxHeight = 560, labels }: { before: string; after: string; context?: number; className?: string; maxHeight?: number | null; labels?: [string, string] }) {275 const ops = useMemo(() => collapseDiff(diffLinesLcs(before, after), context), [before, after, context]);276 const added = ops.filter((o) => o.type === "add").length;277 const removed = ops.filter((o) => o.type === "del").length;278 return (279 <div className={className}>280 <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">281 <span className="font-mono text-st-operational">+{added}</span>282 <span className="font-mono text-danger">−{removed}</span>283 {labels && (284 <span className="ml-auto truncate">285 {labels[0]} → {labels[1]}286 </span>287 )}288 </div>289 {added === 0 && removed === 0 ? (290 <div className="rounded-xs border border-hair bg-paper px-3 py-4 text-center text-[12.5px] text-ink-3">No line differences.</div>291 ) : (292 <div className="overflow-auto rounded-xs border border-hair bg-paper" style={maxHeight ? { maxHeight } : undefined}>293 <table className="w-full border-collapse font-mono text-[11.5px] leading-[1.5]">294 <tbody>295 {ops.map((o, i) =>296 o.type === "skip" ? (297 <tr key={i} className="bg-surface-2/60 text-ink-4">298 <td colSpan={3} className="px-2.5 py-0.5 text-center text-[11px]">299 ··· {o.count} unchanged lines ···300 </td>301 </tr>302 ) : (303 <tr key={i} className={cn(o.type === "add" && "bg-ok/10", o.type === "del" && "bg-danger/10")}>304 <td className="select-none border-r border-hair px-1.5 text-right text-ink-4 tnum" style={{ width: 1 }}>305 {o.type !== "add" ? o.a : ""}306 </td>307 <td className="select-none border-r border-hair px-1.5 text-right text-ink-4 tnum" style={{ width: 1 }}>308 {o.type !== "del" ? o.b : ""}309 </td>310 <td className={cn("whitespace-pre-wrap break-all px-2.5 align-top", o.type === "add" ? "text-st-operational" : o.type === "del" ? "text-danger" : "text-ink-2")}>311 <span className="mr-1.5 select-none text-ink-4">{o.type === "add" ? "+" : o.type === "del" ? "−" : " "}</span>312 {o.text || " "}313 </td>314 </tr>315 ),316 )}317 </tbody>318 </table>319 </div>320 )}321 </div>322 );323}324325/* ------------------------------------------------------------------ forms */326327const control = "h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none placeholder:text-ink-4 focus:border-ink disabled:opacity-60";328329export function Field({ label, hint, error, children, className, inline }: { label: ReactNode; hint?: ReactNode; error?: ReactNode; children: ReactNode; className?: string; inline?: boolean }) {330 return (331 <label className={cn("block min-w-0", inline && "flex items-center gap-2", className)}>332 <span className={cn("label-caps block", inline ? "mb-0" : "mb-1")}>{label}</span>333 {children}334 {hint && !error && <span className="mt-1 block text-[11.5px] text-ink-4">{hint}</span>}335 {error && <span className="mt-1 block text-[11.5px] text-danger">{error}</span>}336 </label>337 );338}339340export function Input({ className, mono, ...rest }: InputHTMLAttributes<HTMLInputElement> & { mono?: boolean }) {341 return <input className={cn(control, mono && "font-mono text-[12.5px]", className)} {...rest} />;342}343344export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {345 return (346 <select className={cn(control, "appearance-none bg-[length:12px] bg-[right_8px_center] bg-no-repeat pr-7", className)} style={{ backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7178' stroke-width='2'><path d='m6 9 6 6 6-6'/></svg>\")" }} {...rest}>347 {children}348 </select>349 );350}351352export function Textarea({ className, mono, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement> & { mono?: boolean; ref?: React.Ref<HTMLTextAreaElement> }) {353 return <textarea className={cn("w-full rounded-sm border border-hair-2 bg-surface px-2.5 py-2 text-[13px] leading-[1.5] text-ink outline-none placeholder:text-ink-4 focus:border-ink disabled:opacity-60", mono && "font-mono text-[12px]", className)} spellCheck={false} {...rest} />;354}355356export function Checkbox({ label, className, ...rest }: InputHTMLAttributes<HTMLInputElement> & { label: ReactNode }) {357 return (358 <label className={cn("inline-flex min-h-9 cursor-pointer items-center gap-2 text-[13px] text-ink-2", className)}>359 <input type="checkbox" className="size-4 accent-[var(--accent)]" {...rest} />360 <span>{label}</span>361 </label>362 );363}364365/** Small segmented control for filters / tabs (button-based, controlled). */366export function Segmented<T extends string>({ value, onChange, options, className, label }: { value: T; onChange: (v: T) => void; options: Array<{ value: T; label: ReactNode; count?: number | null }>; className?: string; label?: string }) {367 return (368 <div role="tablist" aria-label={label} className={cn("inline-flex max-w-full overflow-x-auto rounded-sm border border-hair-2 bg-surface no-scrollbar", className)}>369 {options.map((o) => {370 const active = o.value === value;371 return (372 <button key={o.value} type="button" role="tab" aria-selected={active} onClick={() => onChange(o.value)} className={cn("inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap border-r border-hair-2 px-2.5 text-[12.5px] last:border-r-0", active ? "bg-ink text-paper" : "text-ink-2 hover:bg-surface-2")}>373 {o.label}374 {o.count != null && <span className={cn("figure text-[11px]", active ? "text-paper/70" : "text-ink-4")}>{fmtInt(o.count)}</span>}375 </button>376 );377 })}378 </div>379 );380}381382/* ------------------------------------------------------------------ layout */383384export function Panel({ title, description, aside, children, className, padded = true, id }: { title: ReactNode; description?: ReactNode; aside?: ReactNode; children: ReactNode; className?: string; padded?: boolean; id?: string }) {385 return (386 <Card className={cn("min-w-0", className)} id={id}>387 <CardHeader title={title} description={description} aside={aside} />388 <CardBody padded={padded}>{children}</CardBody>389 </Card>390 );391}392393export function Spinner({ className }: { className?: string }) {394 return <Loader2 className={cn("size-4 animate-spin text-ink-3", className)} aria-label="Loading" />;395}396397export function RefreshButton({ onClick, busy, updatedAt, label = "Refresh" }: { onClick: () => void; busy?: boolean; updatedAt?: number | null; label?: string }) {398 return (399 <div className="flex items-center gap-2">400 {updatedAt && <span className="hidden text-[11.5px] text-ink-4 sm:inline">updated {new Date(updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</span>}401 <Button size="xs" variant="ghost" onClick={onClick} disabled={busy} aria-label={label}>402 <RefreshCw className={cn("size-3.5", busy && "animate-spin")} strokeWidth={1.75} />403 <span className="hidden sm:inline">{label}</span>404 </Button>405 </div>406 );407}408409export function ErrorNote({ error, onRetry, className }: { error: string | null | undefined; onRetry?: () => void; className?: string }) {410 if (!error) return null;411 return (412 <div role="alert" className={cn("flex flex-wrap items-center gap-2 rounded-xs border border-danger/30 bg-danger/10 px-2.5 py-2 text-[12.5px] text-danger", className)}>413 <span className="min-w-0 flex-1 break-words">{error}</span>414 {onRetry && (415 <Button size="xs" variant="ghost" onClick={onRetry}>416 Retry417 </Button>418 )}419 </div>420 );421}422423/** Button-driven pager (client state). */424export function Pager({ page, perPage, total, onPage, className }: { page: number; perPage: number; total: number; onPage: (p: number) => void; className?: string }) {425 const pages = Math.max(1, Math.ceil(total / perPage));426 const from = total === 0 ? 0 : (page - 1) * perPage + 1;427 const to = Math.min(total, page * perPage);428 return (429 <div className={cn("flex items-center justify-between gap-3 py-2 text-[12.5px] text-ink-3", className)}>430 <span className="tnum">431 {fmtInt(from)}–{fmtInt(to)} of {fmtInt(total)}432 </span>433 <div className="flex items-center gap-1">434 <Button size="xs" variant="secondary" disabled={page <= 1} onClick={() => onPage(page - 1)} aria-label="Previous page">435 <ChevronLeft className="size-3.5" />436 </Button>437 <span className="figure px-1 text-ink-2">438 {page} / {pages}439 </span>440 <Button size="xs" variant="secondary" disabled={page >= pages} onClick={() => onPage(page + 1)} aria-label="Next page">441 <ChevronRight className="size-3.5" />442 </Button>443 </div>444 </div>445 );446}447448/** Collapsible details row. */449export function Disclosure({ summary, children, defaultOpen, className }: { summary: ReactNode; children: ReactNode; defaultOpen?: boolean; className?: string }) {450 return (451 <details className={cn("group rounded-xs border border-hair", className)} open={defaultOpen}>452 <summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 px-2.5 text-[12.5px] text-ink-2 hover:bg-surface-2 [&::-webkit-details-marker]:hidden">453 <ChevronRight className="size-3.5 shrink-0 text-ink-4 transition-transform group-open:rotate-90" />454 <span className="min-w-0 flex-1">{summary}</span>455 </summary>456 <div className="border-t border-hair px-2.5 py-2">{children}</div>457 </details>458 );459}460