SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
22.6 KB · 460 lines tsx
Raw Blame History
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