324 lines
7.7 KiB
TypeScript
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={16}
|
|
style={{
|
|
color: active ? THEME.ink : THEME.muted,
|
|
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>
|
|
{/*
|
|
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";
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
|
|
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: "fit-content",
|
|
padding: "4px 8px 4px 0",
|
|
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: INK.ink,
|
|
flex: 1,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
};
|
|
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,
|
|
};
|