1"use client";2import clsx from "clsx";3import { AlertTriangle, CheckCircle2, CircleDashed, Info, XCircle, type LucideIcon } from "lucide-react";4import Link from "next/link";5import type { ReactNode } from "react";6import { isApiError } from "@/lib/api";78// ---------------------------------------------------------------- page header9export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {10 return (11 <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">12 <div>13 <h1 className="section-title">{title}</h1>14 {subtitle && <p className="mt-0.5 text-sm text-ink-2">{subtitle}</p>}15 </div>16 {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}17 </div>18 );19}2021export function Card({ title, children, className, actions, id }: { title?: ReactNode; children: ReactNode; className?: string; actions?: ReactNode; id?: string }) {22 return (23 <section className={clsx("card", className)} id={id}>24 {(title || actions) && (25 <div className="card-title">26 <div>{title}</div>27 {actions && <div className="flex items-center gap-2">{actions}</div>}28 </div>29 )}30 {children}31 </section>32 );33}3435// ---------------------------------------------------------------- status badge36type Tone = "ok" | "warn" | "serious" | "crit" | "neutral" | "accent";3738const TONE: Record<Tone, string> = {39 ok: "bg-ok-soft text-ok-ink",40 warn: "bg-warn-soft text-warn-ink",41 serious: "bg-serious-soft text-serious-ink",42 crit: "bg-crit-soft text-crit-ink",43 neutral: "bg-surface-2 text-ink-2",44 accent: "bg-accent-soft text-accent-ink",45};46const TONE_ICON: Record<Tone, LucideIcon> = { ok: CheckCircle2, warn: AlertTriangle, serious: AlertTriangle, crit: XCircle, neutral: CircleDashed, accent: Info };4748export function toneFor(status: string | null | undefined): Tone {49 switch ((status ?? "").toLowerCase()) {50 case "ok":51 case "active":52 case "succeeded":53 case "ready":54 case "running":55 case "verified":56 case "allowed":57 case "success":58 return status?.toLowerCase() === "running" ? "accent" : "ok";59 case "warning":60 case "planned":61 case "queued":62 case "paused":63 case "pending_configuration":64 case "pending":65 case "stale":66 case "expired":67 case "cancelled":68 return "warn";69 case "degraded":70 case "failed":71 case "error":72 case "dead":73 case "denied":74 case "unsupported":75 case "critical":76 return "crit";77 case "serious":78 case "high":79 return "serious";80 default:81 return "neutral";82 }83}8485export function StatusBadge({ status, label, icon = true, className }: { status: string | null | undefined; label?: string; icon?: boolean; className?: string }) {86 const tone = toneFor(status);87 const Icon = TONE_ICON[tone];88 const text = label ?? (status ?? "unknown").replace(/_/g, " ");89 return (90 <span className={clsx("pill", TONE[tone], className)}>91 {icon && <Icon className="h-3 w-3" aria-hidden />}92 {text}93 </span>94 );95}9697export function SeverityBadge({ severity }: { severity: string | null | undefined }) {98 const s = (severity ?? "").toLowerCase();99 const tone: Tone = s === "error" || s === "critical" || s === "high" ? "crit" : s === "warning" || s === "medium" ? "warn" : s === "info" || s === "low" ? "accent" : "neutral";100 const Icon = TONE_ICON[tone];101 return (102 <span className={clsx("pill", TONE[tone])}>103 <Icon className="h-3 w-3" aria-hidden />104 {s || "—"}105 </span>106 );107}108109// ---------------------------------------------------------------- KPI tile110export function KpiTile({ label, value, sub, tone, href, className }: { label: string; value: ReactNode; sub?: ReactNode; tone?: Tone; href?: string; className?: string }) {111 const body = (112 <>113 <div className="text-xs text-ink-2">{label}</div>114 <div className={clsx("mt-1 text-2xl font-semibold tracking-tight", tone === "crit" ? "text-crit-ink" : tone === "warn" ? "text-warn-ink" : tone === "ok" ? "text-ok-ink" : "text-ink-1")}>115 {value}116 </div>117 {sub && <div className="mt-0.5 text-xs text-ink-3">{sub}</div>}118 </>119 );120 const cls = clsx("card min-w-0", href && "transition hover:border-accent/60", className);121 return href ? (122 <Link href={href} className={cls}>123 {body}124 </Link>125 ) : (126 <div className={cls}>{body}</div>127 );128}129130// ---------------------------------------------------------------- misc131export function Banner({ tone = "accent", children, className }: { tone?: Tone; children: ReactNode; className?: string }) {132 const Icon = TONE_ICON[tone];133 const border = { ok: "border-ok/50", warn: "border-warn/60", serious: "border-serious/60", crit: "border-crit/60", neutral: "border-border", accent: "border-accent/40" }[tone];134 return (135 <div className={clsx("banner flex items-start gap-2", TONE[tone].split(" ")[0], border, className)}>136 <Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />137 <div className="min-w-0 flex-1">{children}</div>138 </div>139 );140}141142export function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {143 const msg = isApiError(error) ? `${error.message} (${error.code}, HTTP ${error.status})` : error instanceof Error ? error.message : "Unknown error";144 const rid = isApiError(error) ? error.request_id : null;145 const forbidden = isApiError(error) && error.status === 403;146 return (147 <div className="banner flex items-start gap-2 border-crit/60 bg-crit-soft text-crit-ink">148 <XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />149 <div className="min-w-0 flex-1">150 <div className="font-medium">{forbidden ? "Not allowed" : "Could not load"}</div>151 <div className="text-xs break-words">{msg}</div>152 {rid && <div className="mt-0.5 font-mono text-[11px] opacity-80">request_id {rid}</div>}153 </div>154 {retry && (155 <button className="btn btn-sm" onClick={retry}>156 Retry157 </button>158 )}159 </div>160 );161}162163export function Empty({ children = "Nothing here yet." }: { children?: ReactNode }) {164 return <div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-ink-3">{children}</div>;165}166167export function Skeleton({ rows = 4, className }: { rows?: number; className?: string }) {168 return (169 <div className={clsx("flex flex-col gap-2", className)} aria-busy="true" aria-label="Loading">170 {Array.from({ length: rows }).map((_, i) => (171 <div key={i} className="skeleton h-4" style={{ width: `${90 - (i % 3) * 15}%` }} />172 ))}173 </div>174 );175}176177export function Field({ label, children, hint, className }: { label: string; children: ReactNode; hint?: ReactNode; className?: string }) {178 return (179 <label className={clsx("block min-w-0", className)}>180 <span className="label">{label}</span>181 {children}182 {hint && <span className="mt-0.5 block text-[11px] text-ink-3">{hint}</span>}183 </label>184 );185}186187export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {188 return (189 <dl className={clsx("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm", className)}>190 {items.map(([k, v], i) => (191 <div key={i} className="contents">192 <dt className="text-ink-2 whitespace-nowrap">{k}</dt>193 <dd className="min-w-0 break-words text-ink-1">{v ?? "—"}</dd>194 </div>195 ))}196 </dl>197 );198}199200export function Mono({ children, className }: { children: ReactNode; className?: string }) {201 return <span className={clsx("font-mono text-[12.5px]", className)}>{children}</span>;202}203204export function Tabs<T extends string>({ value, onChange, items }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[] }) {205 return (206 <div role="tablist" className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">207 {items.map((it) => (208 <button209 key={it.value}210 role="tab"211 aria-selected={value === it.value}212 className={clsx("rounded px-2.5 py-1 text-xs font-medium", value === it.value ? "bg-surface-1 text-ink-1 shadow-sm" : "text-ink-2 hover:text-ink-1")}213 onClick={() => onChange(it.value)}214 >215 {it.label}216 </button>217 ))}218 </div>219 );220}221