1"use client";2import Link from "next/link";3import { useMemo } from "react";4import { RefreshCw } from "lucide-react";5import { api, type StorageOut } from "@/lib/api";6import { useAsync } from "@/lib/hooks";7import { useAuth } from "@/lib/auth";8import { fmtBytes, fmtCompact, fmtGB, fmtInt, fmtRelative, fmtTs } from "@/lib/format";9import { Banner, Card, Empty, ErrorState, KV, PageHeader, Skeleton, StatusBadge } from "@/components/ui";10import { DataTable } from "@/components/DataTable";11import { Meter, SegmentedBar } from "@/components/charts";12import { JsonView } from "@/components/JsonView";1314export default function StoragePage() {15 const { isOwner } = useAuth();16 const { data, error, loading, reload } = useAsync((s) => api.get<StorageOut>("/admin/storage", undefined, s), [], { enabled: isOwner });1718 const perDataset = useMemo(() => {19 const m = new Map<string, { dataset_id: string; base_files: number; base_bytes: number; base_rows: number; delta_files: number; delta_bytes: number; delta_rows: number; other_files: number; other_bytes: number }>();20 for (const r of data?.per_dataset ?? []) {21 const e = m.get(r.dataset_id) ?? { dataset_id: r.dataset_id, base_files: 0, base_bytes: 0, base_rows: 0, delta_files: 0, delta_bytes: 0, delta_rows: 0, other_files: 0, other_bytes: 0 };22 const b = Number(r.bytes ?? 0), rows = Number(r.rows ?? 0), f = Number(r.files ?? 0);23 if (r.layout === "base") { e.base_files += f; e.base_bytes += b; e.base_rows += rows; }24 else if (r.layout === "delta") { e.delta_files += f; e.delta_bytes += b; e.delta_rows += rows; }25 else { e.other_files += f; e.other_bytes += b; }26 m.set(r.dataset_id, e);27 }28 return Array.from(m.values()).sort((a, b) => b.base_bytes + b.delta_bytes - (a.base_bytes + a.delta_bytes));29 }, [data]);3031 if (!isOwner) return <Banner tone="warn">Storage details require the owner role.</Banner>;3233 const vol = data?.volume;34 const used = vol ? vol.total_gb - vol.free_gb : 0;35 const freeTone = vol && data ? (vol.free_gb < data.budget.hard_min_free_gb ? "crit" : vol.free_gb < data.budget.hard_min_free_gb * 2 ? "warn" : "accent") : "accent";36 const curatedGb = (data?.curated_bytes ?? 0) / 1e9;37 const bytesPerBar = useMemo(() => {38 const rows = perDataset.reduce((a, d) => a + d.base_rows + d.delta_rows, 0);39 const bytes = perDataset.reduce((a, d) => a + d.base_bytes + d.delta_bytes, 0);40 return rows > 0 ? bytes / rows : null;41 }, [perDataset]);4243 return (44 <>45 <PageHeader46 title="Storage"47 subtitle={data ? <>Lake root <code className="text-xs">{data.lake_root}</code></> : "Lake volume, budget and footprint"}48 actions={49 <button className="btn btn-sm" onClick={reload} disabled={loading}>50 <RefreshCw className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} /> Refresh51 </button>52 }53 />54 {error && <ErrorState error={error} retry={reload} />}55 {loading && !data && <Skeleton rows={6} />}56 {data && vol && (57 <>58 <div className="grid gap-4 lg:grid-cols-2">59 <Card title="Volume">60 <div className="mb-1 flex items-baseline justify-between">61 <span className="text-2xl font-semibold">{fmtGB(vol.free_gb, 0)} free</span>62 <span className="text-xs text-ink-2">of {fmtGB(vol.total_gb, 0)}</span>63 </div>64 <Meter value={used} max={vol.total_gb} tone={freeTone} label="Volume used" marks={[{ at: vol.total_gb - data.budget.hard_min_free_gb, label: `min free ${data.budget.hard_min_free_gb} GB` }]} className="mb-8 mt-2" />65 <KV66 items={[67 ["Used", `${fmtGB(used, 1)} (${((used / vol.total_gb) * 100).toFixed(0)} %)`],68 ["Hard minimum free", `${data.budget.hard_min_free_gb} GB`],69 ["Status", <StatusBadge key="s" status={freeTone === "crit" ? "error" : freeTone === "warn" ? "warning" : "ok"} label={freeTone === "crit" ? "disk critical" : freeTone === "warn" ? "disk low" : "healthy"} />],70 ]}71 />72 </Card>73 <Card title="Curated budget">74 <div className="mb-1 flex items-baseline justify-between">75 <span className="text-2xl font-semibold">{fmtGB(curatedGb, 1)}</span>76 <span className="text-xs text-ink-2">soft limit {data.budget.soft_limit_gb} GB</span>77 </div>78 <Meter value={curatedGb} max={Math.max(data.budget.soft_limit_gb, curatedGb)} tone={curatedGb > data.budget.soft_limit_gb ? "warn" : "accent"} label="Curated bytes vs budget" marks={[{ at: data.budget.soft_limit_gb, label: `budget ${data.budget.soft_limit_gb} GB` }]} className="mb-8 mt-2" />79 <SegmentedBar80 parts={[81 { label: `curated ${fmtBytes(data.curated_bytes)}`, value: data.curated_bytes, color: "var(--series-1)" },82 { label: `raw ${fmtBytes(data.raw_bytes)}`, value: data.raw_bytes, color: "var(--series-2)" },83 { label: `retired ${fmtBytes(data.retired_bytes)}`, value: data.retired_bytes, color: "var(--series-3)" },84 { label: `exports ${fmtBytes(data.exports_bytes)}`, value: data.exports_bytes, color: "var(--seq-200)" },85 { label: `postgres ${fmtBytes(data.postgres_bytes)}`, value: data.postgres_bytes, color: "var(--ink-3)" },86 ]}87 />88 </Card>89 </div>9091 <Card title={`Per dataset (${perDataset.length})`} className="mt-4">92 <DataTable93 rows={perDataset}94 rowKey={(r) => r.dataset_id}95 mobile="scroll"96 columns={[97 { key: "dataset_id", header: "Dataset", render: (r) => <Link href={`/datasets/detail/?id=${encodeURIComponent(r.dataset_id)}`} className="font-mono text-xs text-accent-ink hover:underline">{r.dataset_id}</Link> },98 { key: "base_bytes", header: "Base bytes", num: true, render: (r) => fmtBytes(r.base_bytes) },99 { key: "base_rows", header: "Base rows", num: true, render: (r) => <span title={fmtInt(r.base_rows)}>{fmtCompact(r.base_rows)}</span> },100 { key: "base_files", header: "Base files", num: true, hideSm: true, render: (r) => fmtInt(r.base_files) },101 { key: "delta_bytes", header: "Delta bytes", num: true, render: (r) => fmtBytes(r.delta_bytes) },102 { key: "delta_rows", header: "Delta rows", num: true, render: (r) => <span title={fmtInt(r.delta_rows)}>{fmtCompact(r.delta_rows)}</span> },103 { key: "delta_files", header: "Delta files", num: true, hideSm: true, render: (r) => fmtInt(r.delta_files) },104 { key: "other", header: "Other", num: true, hideSm: true, render: (r) => (r.other_files ? `${fmtInt(r.other_files)} · ${fmtBytes(r.other_bytes)}` : "—") },105 { key: "ratio", header: "Delta share", num: true, render: (r) => { const t = r.base_bytes + r.delta_bytes; return t ? `${((r.delta_bytes / t) * 100).toFixed(0)} %` : "—"; } },106 ]}107 empty="No lake files registered."108 />109 <p className="mt-2 text-xs text-ink-3">A high delta share means a <code>compaction</code> job would help (merges deltas into base files and publishes a new version).</p>110 </Card>111112 <div className="mt-4 grid gap-4 lg:grid-cols-3">113 <Card title="Raw archives">114 <DataTable rows={data.raw_archives} rowKey={(r) => r.status} mobile="scroll" columns={[{ key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> }, { key: "n", header: "Archives", num: true, render: (r) => fmtInt(r.n) }, { key: "bytes", header: "Bytes", num: true, render: (r) => fmtBytes(r.bytes) }]} empty="No raw archives." />115 </Card>116 <Card title="Exports">117 <DataTable rows={data.exports} rowKey={(r) => r.status} mobile="scroll" columns={[{ key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> }, { key: "n", header: "Count", num: true, render: (r) => fmtInt(r.n) }, { key: "bytes", header: "Bytes", num: true, render: (r) => fmtBytes(r.bytes) }]} empty="No exports." />118 <div className="mt-2 text-xs text-ink-3">Retired files: {fmtInt(data.retired_files.n)} · {fmtBytes(data.retired_files.bytes)} (reclaimed by <code>gc</code>)</div>119 </Card>120 <Card title="Postgres & backup">121 <KV122 items={[123 ["Database size", fmtBytes(data.postgres_bytes)],124 ["Last backup", data.last_backup ? `${fmtTs(data.last_backup.updated_at)} (${fmtRelative(data.last_backup.updated_at)})` : "none recorded"],125 ["Verified", data.last_backup ? <StatusBadge key="v" status={data.last_backup.value?.verified ? "verified" : "warning"} label={data.last_backup.value?.verified ? "yes" : "no"} /> : "—"],126 ]}127 />128 {data.last_backup && <div className="mt-2"><JsonView value={data.last_backup.value} collapsed /></div>}129 {!data.last_backup && <Empty>Enqueue a <code>backup</code> job from Ingestion.</Empty>}130 </Card>131 </div>132133 <Card title="Capacity note" className="mt-4">134 <p className="text-sm">135 Reference model: <strong>{data.capacity_model.bytes_per_1m_bar_curated} bytes</strong> per curated 1-minute bar.136 {bytesPerBar !== null && (137 <>138 {" "}Measured now: <strong>{bytesPerBar.toFixed(1)} bytes/row</strong> across all datasets. At this density the remaining budget ({fmtGB(Math.max(0, data.budget.soft_limit_gb - curatedGb), 0)}) holds about{" "}139 <strong>{fmtCompact(Math.max(0, (data.budget.soft_limit_gb - curatedGb) * 1e9) / bytesPerBar)}</strong> more bars.140 </>141 )}142 </p>143 <p className="mt-1 text-xs text-ink-3">{data.capacity_model.note}</p>144 </Card>145 </>146 )}147 </>148 );149}150