Files
vibn-agent-runner/vibn-frontend/components/project/database-table-tree.tsx

239 lines
7.2 KiB
TypeScript

"use client";
/**
* Inline tree view of tables inside a Coolify-managed database.
* Mirrors the pattern of GiteaFileTree: lazy-fetched on first mount,
* one-level deep, click a table to "preview" it on the right.
*
* Tables are grouped by schema. The default `public` schema is rendered
* flat (no schema header) since 95% of small projects only have one
* schema and the extra heading is just noise.
*/
import { useEffect, useState } from "react";
import { Table, ChevronDown, ChevronRight, Loader2, AlertCircle, Info } from "lucide-react";
interface IntrospectedTable {
schema: string;
name: string;
approxRows?: number;
}
interface ApiResp {
engine: string;
tables: IntrospectedTable[];
unsupported?: boolean;
message?: string;
}
interface Props {
projectId: string;
dbUuid: string;
selectedTable?: { schema: string; name: string };
onSelectTable: (t: { schema: string; name: string }) => void;
}
export function DatabaseTableTree({
projectId, dbUuid, selectedTable, onSelectTable,
}: Props) {
const [data, setData] = useState<ApiResp | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12_000);
setLoading(true);
setError(null);
fetch(`/api/projects/${projectId}/databases/${dbUuid}/tables`, {
credentials: "include", signal: ctrl.signal,
})
.then(async r => {
let body: unknown = {};
try { body = await r.json(); } catch {/* keep {} */}
if (!r.ok) {
throw new Error((body as { error?: string }).error || `HTTP ${r.status}`);
}
return body as ApiResp;
})
.then(d => { if (!cancelled) setData(d); })
.catch(err => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Timed out after 12s.");
else setError(err?.message || "Failed to list tables");
})
.finally(() => { clearTimeout(t); if (!cancelled) setLoading(false); });
return () => { cancelled = true; ctrl.abort(); clearTimeout(t); };
}, [projectId, dbUuid]);
if (loading) {
return (
<div style={inline}>
<Loader2 size={12} className="animate-spin" /> Inspecting database
</div>
);
}
if (error) {
return (
<div style={errorBox}>
<AlertCircle size={12} /> {error}
</div>
);
}
if (!data) return null;
if (data.unsupported) {
return (
<div style={infoBox}>
<Info size={12} />
Table inspection isn't wired up for {data.engine} yet — Postgres
is the only engine supported today.
</div>
);
}
if (data.tables.length === 0) {
return (
<div style={infoBox}>
<Info size={12} />
No user-tables found. (Pre-deploy databases often start empty.)
</div>
);
}
// Group by schema; flatten `public` since most projects only use it.
const bySchema = new Map<string, IntrospectedTable[]>();
for (const t of data.tables) {
if (!bySchema.has(t.schema)) bySchema.set(t.schema, []);
bySchema.get(t.schema)!.push(t);
}
const schemas = [...bySchema.keys()].sort();
return (
<div style={treeWrap}>
{schemas.map(schema => (
<SchemaGroup
key={schema}
schema={schema}
tables={bySchema.get(schema)!}
selectedTable={selectedTable}
onSelectTable={onSelectTable}
/>
))}
</div>
);
}
function SchemaGroup({
schema, tables, selectedTable, onSelectTable,
}: {
schema: string;
tables: IntrospectedTable[];
selectedTable?: { schema: string; name: string };
onSelectTable: (t: { schema: string; name: string }) => void;
}) {
const isPublic = schema === "public";
const [open, setOpen] = useState(true);
const items = (
<ul style={list}>
{tables.map(t => {
const active = selectedTable?.schema === t.schema && selectedTable?.name === t.name;
return (
<li key={`${t.schema}.${t.name}`}>
<button
type="button"
onClick={() => onSelectTable({ schema: t.schema, name: t.name })}
style={{
...row,
background: active ? "#fffdf8" : "transparent",
borderColor: active ? INK.ink : "transparent",
}}
aria-pressed={active}
>
<Table size={11} style={{ color: INK.mid, flexShrink: 0 }} />
<span style={tableName}>{t.name}</span>
{t.approxRows != null && t.approxRows > 0 && (
<span style={rowCount}>~{formatCount(t.approxRows)}</span>
)}
</button>
</li>
);
})}
</ul>
);
if (isPublic) return items;
return (
<div>
<button type="button" onClick={() => setOpen(o => !o)} style={schemaHeader}>
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
{schema}
</button>
{open && items}
</div>
);
}
function formatCount(n: number) {
if (n < 1_000) return String(n);
if (n < 1_000_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "k";
return (n / 1_000_000).toFixed(1) + "M";
}
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
borderSoft: "#efebe1",
} as const;
const treeWrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6 };
const list: React.CSSProperties = { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 1 };
const row: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
width: "100%", padding: "5px 8px",
border: "1px solid transparent", borderRadius: 5,
cursor: "pointer", font: "inherit", color: "inherit",
textAlign: "left",
};
const tableName: React.CSSProperties = {
fontSize: "0.78rem", color: INK.ink, flex: 1,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
};
const rowCount: React.CSSProperties = {
fontSize: "0.68rem", color: INK.muted, flexShrink: 0,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
};
const schemaHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 4,
padding: "4px 6px",
fontSize: "0.7rem", fontWeight: 600,
letterSpacing: "0.06em", textTransform: "uppercase",
color: INK.muted,
background: "transparent", border: "none",
cursor: "pointer", font: "inherit",
};
const inline: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "8px 10px", fontSize: "0.76rem", color: INK.mid,
};
const infoBox: React.CSSProperties = {
display: "flex", alignItems: "flex-start", gap: 6,
padding: "8px 10px", fontSize: "0.74rem", color: INK.mid,
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 6,
lineHeight: 1.4,
};
const errorBox: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "8px 10px", fontSize: "0.74rem", color: "#7a1f15",
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 6,
};