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

324 lines
7.7 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,
Database,
} from "lucide-react";
import { THEME } from "@/components/project/dashboard-ui";
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={13} className="animate-spin" /> Inspecting database
</div>
);
}
if (error) {
return (
<div style={errorBox}>
<AlertCircle size={13} /> {error}
</div>
);
}
if (!data) return null;
if (data.unsupported) {
return (
<div style={infoBox}>
<Info size={13} />
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={13} />
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 ? THEME.subtleBg : "transparent",
borderColor: "transparent",
color: active ? THEME.ink : THEME.mid,
fontWeight: active ? 500 : 400,
}}
aria-pressed={active}
>
<Table
size={14}
style={{
color: active ? THEME.ink : THEME.muted,
flexShrink: 0,
}}
/>
<span style={tableName}>{t.name}</span>
{typeof t.approxRows === "number" && t.approxRows >= 0 && (
<span style={rowCount}>{formatCount(t.approxRows)}</span>
)}
</button>
</li>
);
})}
</ul>
);
if (isPublic) return items;
return (
<div>
{/*
We remove the schema chevron wrapper entirely, which flattens the tree.
Tables are now a single list, just like files inside a flat directory.
*/}
{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";
}
// ──────────────────────────────────────────────────
// Clean up unused styles
const treeWrap: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 0,
};
const list: React.CSSProperties = {
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 0,
};
const row: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "4px 8px 4px 6px",
border: "1px solid transparent",
borderRadius: THEME.radiusSm,
cursor: "pointer",
font: "inherit",
color: "inherit",
textAlign: "left",
transition: "all 0.1s ease",
};
const tableName: React.CSSProperties = {
fontSize: "0.875rem",
color: THEME.ink,
flex: 1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
marginLeft: 4,
};
const rowCount: React.CSSProperties = {
fontSize: "0.65rem",
fontWeight: 600,
color: THEME.mid,
background: THEME.borderSoft,
padding: "2px 6px",
borderRadius: 999,
flexShrink: 0,
marginLeft: "auto",
};
const schemaHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 6px",
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: THEME.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: THEME.mid,
};
const infoBox: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 6,
padding: "8px 10px",
fontSize: "0.74rem",
color: THEME.mid,
background: THEME.subtleBg,
border: `1px dashed ${THEME.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,
};