UX rework after iteration with the user:
- Drop SMS, Analytics, Search, Monitoring categories from the rail.
They were detection-only with no first-class UX behind them; surface
is cleaner without them and they can return when each gets real
flows (auth-style "edit configurables", payment-style "connect").
- Storage no longer tries to detect S3/R2/GCS env vars. Instead it
surfaces the workspace's bundled Vibn-provisioned GCS bucket
(S3-compatible HMAC), with status, region, access id, and a
one-shot env snippet for app config.
- Email category no longer mixes in SMS providers.
- LLM renamed to "Models"; empty state mentions BYOK as upcoming.
- Payments empty state has a "Connect Stripe (coming soon)" CTA;
Stripe detail surfaces the webhook URL guidance.
- Secrets detail now lists actual env-var key names per resource,
grouped by detected provider (Stripe block, OpenAI block, etc.)
with an "Other (project-defined)" catch-all. Each row has Edit +
Rotate icon buttons (currently disabled with tooltips — wire-up
to apps.envs.upsert / services.envs.upsert lands in iter 2).
Live database inspection (Postgres only for now):
- New /api/projects/[id]/databases/[uuid]/tables — auth-scoped, lists
user-tables across non-system schemas via SSH-exec into the
database container's psql. Hard caps: 50 tables, 8s timeout, no
mutating queries possible (only SELECT row_to_json with LIMIT).
- New /api/projects/[id]/databases/[uuid]/preview — returns first 50
rows of a single table. Identifiers locked to /[A-Za-z0-9_]+/ so
splicing them into the SELECT is safe.
- DatabaseTableTree (lazy-fetch, schema-grouped, public-flat,
approximate row counts from pg_class.reltuples) and TableViewer
(sticky-header data grid, zebra rows, per-cell ellipsis at 360px).
- Fix in lib/coolify.ts: listDatabasesInProject was flattening every
db endpoint array (postgresqls, redises, mongodbs…) without
tagging the output rows with the engine. Every consumer was
seeing type=undefined which then bucketed as "unknown" and
blocked the table inspector. Now we tag at the flatten step so
every CoolifyDatabase has a stable type.
- Infrastructure tab: database tile is now expandable inline like
Codebases on Product. Auto-expands the first DB; click any table
to preview rows on the right.
Made-with: Cursor
239 lines
7.2 KiB
TypeScript
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,
|
|
};
|