"use client"; import { ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw } from "lucide-react"; import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"; import { useMemo, useState } from "react"; import { Badge, Button, Card, CardBody, CardHeader, type BadgeTone } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtInt } from "@/lib/format"; import { collapseDiff, diffLinesLcs, yamlTokens, type YamlTokenKind } from "./yaml"; /* ------------------------------------------------------------------ formatting helpers */ export function fmtMs(ms: number | null | undefined): string { if (ms == null || !Number.isFinite(ms)) return DASH; if (ms < 1000) return `${Math.round(ms)} ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`; const m = Math.floor(ms / 60_000); const s = Math.round((ms % 60_000) / 1000); return `${m} min ${s.toString().padStart(2, "0")} s`; } export function fmtBytes(b: number | null | undefined): string { if (b == null || !Number.isFinite(b)) return DASH; if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${(b / 1024).toFixed(b < 10 * 1024 ? 1 : 0)} kB`; if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`; return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`; } export function fmtPct01(v: number | null | undefined, digits = 0): string { if (v == null || !Number.isFinite(v)) return DASH; return `${(v * 100).toFixed(digits)}%`; } export function shortUrl(u: string | null | undefined, max = 72): string { if (!u) return DASH; const s = u.replace(/^https?:\/\//, ""); return s.length > max ? `${s.slice(0, max - 1)}…` : s; } export function durationBetween(a: string | null | undefined, b: string | null | undefined): number | null { if (!a) return null; const t0 = new Date(a).getTime(); const t1 = b ? new Date(b).getTime() : Date.now(); if (Number.isNaN(t0) || Number.isNaN(t1)) return null; return Math.max(0, t1 - t0); } /* ------------------------------------------------------------------ status */ export type HealthKey = "ok" | "degraded" | "failing" | "paused" | "never_run" | "blocked" | "schema_change" | "no_new_content" | "quarantine" | string; /** Spec status vocabulary (§11): HEALTHY · DEGRADED · BLOCKED · SCHEMA CHANGE · NO NEW CONTENT · FAILED · QUARANTINE (+ paused / never run / running). */ export const HEALTH: Record = { ok: { color: "var(--ok)", label: "HEALTHY", tone: "ok" }, degraded: { color: "var(--warn)", label: "DEGRADED", tone: "warn" }, blocked: { color: "var(--danger)", label: "BLOCKED", tone: "danger" }, schema_change: { color: "var(--st-permitting)", label: "SCHEMA CHANGE", tone: "info" }, no_new_content: { color: "var(--ink-3)", label: "NO NEW CONTENT", tone: "neutral" }, failing: { color: "var(--danger)", label: "FAILED", tone: "danger" }, quarantine: { color: "var(--st-construction)", label: "QUARANTINE", tone: "warn" }, paused: { color: "var(--ink-4)", label: "PAUSED", tone: "neutral" }, never_run: { color: "var(--ink-4)", label: "NEVER RUN", hollow: true, tone: "outline" }, running: { color: "var(--accent)", label: "RUNNING", tone: "accent" }, }; export function HealthBadge({ health, size = "xs", className }: { health: HealthKey; size?: "xs" | "sm"; className?: string }) { const h = HEALTH[health] ?? { color: "var(--ink-4)", label: health.replace(/_/g, " ").toUpperCase(), tone: "outline" as BadgeTone }; return ( {h.label} ); } export function HealthDot({ health, size = 8, className, withLabel }: { health: HealthKey; size?: number; className?: string; withLabel?: boolean }) { const h = HEALTH[health] ?? { color: "var(--ink-4)", label: health.replace(/_/g, " ").toUpperCase(), tone: "outline" as BadgeTone }; return ( {withLabel ? {h.label} : {h.label}} ); } const RUN_TONE: Record = { ok: "ok", success: "ok", completed: "ok", running: "accent", queued: "info", pending: "info", failed: "danger", error: "danger", aborted: "warn", partial: "warn", degraded: "warn" }; export function RunStatusBadge({ status, size = "xs" }: { status: string | null | undefined; size?: "xs" | "sm" }) { if (!status) return {DASH}; return ( {status} ); } const REVIEW_TONE: Record = { approved: "ok", rejected: "danger", pending: "warn", auto: "neutral", auto_merged: "info", auto_created: "neutral" }; export function ReviewBadge({ status, size = "xs" }: { status: string; size?: "xs" | "sm" }) { return ( {status.replace(/_/g, " ")} ); } export function LevelBadge({ level }: { level: number | null | undefined }) { if (level == null) return {DASH}; const label = ["", "L1 direct", "L2 browser UA", "L3 Firecrawl", "L4 Scrapfly"][level] ?? `L${level}`; return ( = 3 ? "accent" : "outline"} size="xs" mono title={label}> L{level} ); } /* ------------------------------------------------------------------ text */ export function Mono({ children, className, title, wrap }: { children: ReactNode; className?: string; title?: string; wrap?: boolean }) { return ( {children} ); } export function ExtLink({ href, children, className }: { href: string; children?: ReactNode; className?: string }) { return ( {children ?? shortUrl(href)} ); } /** Key / value rows (definition list). */ export function KV({ items, className, cols = 1 }: { items: Array<{ k: ReactNode; v: ReactNode; title?: string } | null | false | undefined>; className?: string; cols?: 1 | 2 | 3 }) { return (
{items.filter(Boolean).map((it, i) => { const item = it as { k: ReactNode; v: ReactNode; title?: string }; return (
{item.k}
{item.v ?? {DASH}}
); })}
); } export function Stat({ label, value, note, tone, className }: { label: ReactNode; value: ReactNode; note?: ReactNode; tone?: "ok" | "warn" | "danger"; className?: string }) { return (
{label}
{value}
{note &&
{note}
}
); } /* ------------------------------------------------------------------ JSON / code */ function jsonHighlight(json: string): ReactNode[] { const re = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g; const out: ReactNode[] = []; let last = 0; let m: RegExpExecArray | null; let i = 0; while ((m = re.exec(json))) { if (m.index > last) out.push(json.slice(last, m.index)); const t = m[0]; const cls = /^"/.test(t) ? (/:$/.test(t) ? "text-ink" : "text-st-operational") : /true|false|null/.test(t) ? "text-st-permitting" : "text-st-planned"; out.push( {t} , ); last = m.index + t.length; } if (last < json.length) out.push(json.slice(last)); return out; } export function JsonView({ value, className, maxHeight = 320, collapsible }: { value: unknown; className?: string; maxHeight?: number | null; collapsible?: boolean }) { const [open, setOpen] = useState(!collapsible); const text = useMemo(() => { try { return JSON.stringify(value, null, 2) ?? "undefined"; } catch { return String(value); } }, [value]); if (collapsible && !open) { return ( ); } return (
      {jsonHighlight(text)}
    
); } const YAML_CLS: Record = { 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" }; /** Line-numbered code block. `lang="yaml"` highlights; `search` highlights matches (case-insensitive). */ export 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 }) { const lines = useMemo(() => text.split("\n"), [text]); const needle = search?.trim().toLowerCase() ?? ""; const render = (line: string) => { if (needle) { const parts: ReactNode[] = []; let idx = 0; const lower = line.toLowerCase(); let hit = lower.indexOf(needle, idx); let k = 0; while (hit >= 0) { if (hit > idx) parts.push(line.slice(idx, hit)); parts.push( {line.slice(hit, hit + needle.length)} , ); idx = hit + needle.length; hit = lower.indexOf(needle, idx); } if (idx < line.length) parts.push(line.slice(idx)); return parts; } if (lang === "yaml") return yamlTokens(line).map((t, i) => ( {t.text} )); return line; }; return (
{lines.map((l, i) => { const match = needle && l.toLowerCase().includes(needle); return ( ); })}
{i + startLine} {render(l) || " "}
); } export function countMatches(text: string, needle: string): number { const n = needle.trim().toLowerCase(); if (!n) return 0; let c = 0; let i = text.toLowerCase().indexOf(n); while (i >= 0 && c < 100_000) { c++; i = text.toLowerCase().indexOf(n, i + n.length); } return c; } /** Unified line diff (LCS) with collapsed context. */ export function DiffView({ before, after, context = 3, className, maxHeight = 560, labels }: { before: string; after: string; context?: number; className?: string; maxHeight?: number | null; labels?: [string, string] }) { const ops = useMemo(() => collapseDiff(diffLinesLcs(before, after), context), [before, after, context]); const added = ops.filter((o) => o.type === "add").length; const removed = ops.filter((o) => o.type === "del").length; return (
+{added} −{removed} {labels && ( {labels[0]} → {labels[1]} )}
{added === 0 && removed === 0 ? (
No line differences.
) : (
{ops.map((o, i) => o.type === "skip" ? ( ) : ( ), )}
··· {o.count} unchanged lines ···
{o.type !== "add" ? o.a : ""} {o.type !== "del" ? o.b : ""} {o.type === "add" ? "+" : o.type === "del" ? "−" : " "} {o.text || " "}
)}
); } /* ------------------------------------------------------------------ forms */ const 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"; export function Field({ label, hint, error, children, className, inline }: { label: ReactNode; hint?: ReactNode; error?: ReactNode; children: ReactNode; className?: string; inline?: boolean }) { return ( ); } export function Input({ className, mono, ...rest }: InputHTMLAttributes & { mono?: boolean }) { return ; } export function Select({ className, children, ...rest }: SelectHTMLAttributes) { return ( ); } export function Textarea({ className, mono, ...rest }: TextareaHTMLAttributes & { mono?: boolean; ref?: React.Ref }) { return