1"use client";2import Link from "next/link";3import { RefreshCw } from "lucide-react";4import { api, type HealthSummary } from "@/lib/api";5import { useAsync, useInterval } from "@/lib/hooks";6import { fmtCompact, fmtGB, fmtInt, fmtRelative, fmtTs } from "@/lib/format";7import { Banner, Card, ErrorState, KpiTile, PageHeader, SeverityBadge, Skeleton, StatusBadge } from "@/components/ui";8import { DataTable } from "@/components/DataTable";9import { Job } from "@/lib/api";10import { useAuth } from "@/lib/auth";11import { PublicOverview } from "@/components/PublicOverview";1213export default function OverviewPage() {14 const { isOwner, me } = useAuth();15 const signedIn = !!me && me.mfa_passed;16 return (17 <>18 <PublicOverview signedIn={signedIn} />19 {signedIn && <OperatorOverview isOwner={isOwner} />}20 </>21 );22}2324function OperatorOverview({ isOwner }: { isOwner: boolean }) {25 const { data, error, loading, reload } = useAsync((signal) => api.get<HealthSummary>("/health/summary", undefined, signal), []);26 useInterval(reload, 60_000);2728 const h = data?.health;29 const lake = h?.components.lake;30 const jobs = data?.jobs ?? {};31 const anomalies = data?.anomalies_7d ?? {};32 const anomaliesTotal = Object.values(anomalies).reduce((a, b) => a + b, 0);33 const primaryDs = h?.datasets?.find((d) => d.dataset_id === "market_bars.stock.1m.raw") ?? h?.datasets?.[0];34 const dead = jobs.dead ?? 0;35 const freeTone = lake ? (lake.free_gb < lake.hard_min_free_gb ? "crit" : lake.free_gb < lake.hard_min_free_gb * 2 ? "warn" : undefined) : undefined;3637 return (38 <div className="mt-6">39 <PageHeader40 title="Operations"41 subtitle={h ? <>Health checked {fmtRelative(h.checked_at)} · auto-refresh every 60 s</> : "Lakehouse status at a glance"}42 actions={43 <>44 {h && <StatusBadge status={h.status} label={h.status === "ok" ? "all systems ok" : h.status} />}45 <button className="btn btn-sm" onClick={reload} disabled={loading}>46 <RefreshCw className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} /> Refresh47 </button>48 </>49 }50 />5152 <Banner tone="warn" className="mb-4">53 <strong>FirstRate Data — private use only.</strong> Raw bars and tables may not be redistributed (no public API, CSV or tables). Derived results may be published with attribution.54 </Banner>5556 {error && <ErrorState error={error} retry={reload} />}57 {!data && !error && <Skeleton rows={6} />}5859 {data && (60 <>61 {h && h.alerts.length > 0 && (62 <Card title={`Alerts (${h.alerts.length})`} className="mb-4">63 <ul className="flex flex-col gap-1.5">64 {h.alerts.map((a, i) => (65 <li key={i} className="flex flex-wrap items-center gap-2 text-sm">66 <SeverityBadge severity={a.severity} />67 <code className="text-xs text-ink-3">{a.code}</code>68 <span>{a.message}</span>69 </li>70 ))}71 </ul>72 </Card>73 )}7475 <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">76 <KpiTile label="1-min symbols" value={fmtInt(data.bars_1m.symbols)} sub={`${fmtInt(data.instruments.active)} active instruments`} href="/symbols/" />77 <KpiTile label="Sessions" value={fmtInt(data.bars_1m.sessions)} sub="with 1-min bars" href="/coverage/" />78 <KpiTile label="1-min bars" value={fmtCompact(data.bars_1m.bars)} sub={data.bars_1m.bars ? fmtInt(data.bars_1m.bars) : undefined} href="/bars/" />79 <KpiTile label="Last session" value={data.bars_1m.last ?? "—"} sub={primaryDs?.days_since_last_session !== null && primaryDs?.days_since_last_session !== undefined ? `${primaryDs.days_since_last_session} d ago` : undefined} />80 <KpiTile81 label="Vendor lag"82 value={primaryDs?.vendor_lag_days === null || primaryDs?.vendor_lag_days === undefined ? "—" : `${primaryDs.vendor_lag_days} d`}83 sub={primaryDs?.vendor_last_update ? `vendor ${primaryDs.vendor_last_update}` : "no vendor checkpoint"}84 tone={primaryDs?.vendor_lag_days ? "warn" : undefined}85 />86 <KpiTile87 label="Lake free"88 value={lake ? fmtGB(lake.free_gb, 0) : "—"}89 sub={lake ? `curated ${fmtGB(lake.curated_gb, 2)} / budget ${lake.soft_limit_gb} GB · min free ${lake.hard_min_free_gb} GB` : undefined}90 tone={freeTone}91 href={isOwner ? "/storage/" : undefined}92 />93 <KpiTile label="Jobs" value={`${fmtInt(jobs.queued ?? 0)} / ${fmtInt(jobs.running ?? 0)} / ${fmtInt(dead)}`} sub="queued / running / dead" tone={dead ? "crit" : undefined} href={isOwner ? "/ingestion/" : undefined} />94 <KpiTile label="Fundamentals" value={fmtInt(data.fundamentals.entities)} sub={`${fmtCompact(data.fundamentals.facts)} facts`} href="/fundamentals/" />95 <KpiTile label="Anomalies (7 d)" value={fmtInt(anomaliesTotal)} sub={Object.entries(anomalies).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"} tone={anomalies.error ? "crit" : anomaliesTotal ? "warn" : undefined} href="/quality/" />96 <KpiTile label="Datasets" value={fmtInt(data.datasets.active)} sub={`${data.datasets.planned} planned · ${data.datasets.pending} pending · ${data.datasets.unsupported} unsupported`} href="/datasets/" />97 </div>9899 <div className="grid gap-4 lg:grid-cols-[1fr_minmax(280px,0.6fr)]">100 <Card title="Recent jobs" actions={isOwner && <Link href="/ingestion/" className="text-xs text-accent-ink hover:underline">All jobs →</Link>}>101 <DataTable<Job>102 rows={data.recent_jobs}103 rowKey={(r) => r.id}104 mobile="scroll"105 columns={[106 { key: "kind", header: "Kind", render: (r) => <span className="font-medium">{r.kind}</span> },107 { key: "dataset_id", header: "Dataset", mono: true, hideSm: true },108 { key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> },109 { key: "started_at", header: "Started", render: (r) => <span className="tnum text-xs">{fmtTs(r.started_at)}</span>, hideSm: true },110 { key: "finished_at", header: "Finished", render: (r) => <span className="tnum text-xs">{fmtTs(r.finished_at)}</span>, hideSm: true },111 { key: "last_error", header: "Error", render: (r) => (r.last_error ? <span className="text-xs text-crit-ink break-words">{r.last_error}</span> : <span className="text-ink-3">—</span>) },112 ]}113 />114 </Card>115 <Card title="Components">116 <ul className="flex flex-col gap-2 text-sm">117 <li className="flex items-center justify-between gap-2">118 <span>Postgres</span>119 <StatusBadge status={h?.components.postgres?.startsWith("error") ? "error" : h?.components.postgres} label={h?.components.postgres} />120 </li>121 <li className="flex items-center justify-between gap-2">122 <span>Ingest worker</span>123 <StatusBadge status={h?.components.ingest_worker === "running" ? "ok" : "error"} label={h?.components.ingest_worker ?? "unknown"} />124 </li>125 <li className="flex items-center justify-between gap-2">126 <span>Backup</span>127 <span className="text-right text-xs">128 {h?.components.backup?.last ? (129 <>130 <StatusBadge status={h.components.backup.verified ? "verified" : "warning"} label={h.components.backup.verified ? "verified" : "unverified"} />{" "}131 <span className="text-ink-2">{fmtRelative(h.components.backup.last)}</span>132 </>133 ) : (134 <StatusBadge status="warning" label="none yet" />135 )}136 </span>137 </li>138 <li className="flex items-center justify-between gap-2">139 <span>SSD SMART</span>140 <StatusBadge status={h?.components.ssd_smart === "Verified" ? "ok" : h?.components.ssd_smart === "unavailable" || h?.components.ssd_smart === "Not Supported" ? "neutral" : "error"} label={h?.components.ssd_smart ?? "unknown"} />141 </li>142 </ul>143 {h?.datasets && h.datasets.length > 0 && (144 <div className="mt-4">145 <div className="mb-1 text-xs font-semibold text-ink-2">Dataset freshness</div>146 <ul className="flex flex-col gap-1 text-xs">147 {h.datasets.map((d) => (148 <li key={d.dataset_id} className="flex flex-wrap items-center justify-between gap-1">149 <Link href={`/datasets/detail/?id=${encodeURIComponent(d.dataset_id)}`} className="font-mono text-accent-ink hover:underline">150 {d.dataset_id}151 </Link>152 <span className="tnum text-ink-2">153 v{d.version ?? "—"} · {d.last_session ?? "no sessions"}154 {d.vendor_lag_days ? <span className="ml-1 text-warn-ink">· lag {d.vendor_lag_days} d</span> : null}155 </span>156 </li>157 ))}158 </ul>159 </div>160 )}161 </Card>162 </div>163 </>164 )}165 </div>166 );167}168