SPB Git

spb/findata

Public
9.9 KB · 150 lines tsx
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
Files · main
Menu

Theme