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
181 lines
5.4 KiB
TypeScript
181 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Right-pane table preview. Shows the first ~50 rows of the selected
|
|
* table as a compact data grid. Cells longer than 200 chars are
|
|
* truncated server-side; we render the rest as-is. Read-only.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Loader2, AlertCircle, Info } from "lucide-react";
|
|
|
|
interface PreviewedTable {
|
|
columns: string[];
|
|
rows: Array<Record<string, string>>;
|
|
truncated: boolean;
|
|
unsupported?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
dbUuid: string;
|
|
schema: string;
|
|
table: string;
|
|
}
|
|
|
|
export function TableViewer({ projectId, dbUuid, schema, table }: Props) {
|
|
const [data, setData] = useState<PreviewedTable | 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);
|
|
setData(null);
|
|
|
|
const url = `/api/projects/${projectId}/databases/${dbUuid}/preview` +
|
|
`?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`;
|
|
|
|
fetch(url, { 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 PreviewedTable;
|
|
})
|
|
.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 load preview");
|
|
})
|
|
.finally(() => { clearTimeout(t); if (!cancelled) setLoading(false); });
|
|
|
|
return () => { cancelled = true; ctrl.abort(); clearTimeout(t); };
|
|
}, [projectId, dbUuid, schema, table]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={center}>
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span style={{ marginLeft: 8 }}>Querying {schema}.{table}…</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div style={errorBox}>
|
|
<AlertCircle size={13} /> {error}
|
|
</div>
|
|
);
|
|
}
|
|
if (!data) return null;
|
|
|
|
if (data.rows.length === 0) {
|
|
return (
|
|
<div style={infoBox}>
|
|
<Info size={13} />
|
|
Table is empty.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={wrap}>
|
|
<div style={meta}>
|
|
Showing {data.rows.length} row{data.rows.length === 1 ? "" : "s"}
|
|
{data.truncated && " (truncated to first 50)"} ·{" "}
|
|
{data.columns.length} column{data.columns.length === 1 ? "" : "s"} ·{" "}
|
|
<code style={qual}>{schema}.{table}</code>
|
|
</div>
|
|
<div style={tableScroll}>
|
|
<table style={tableEl}>
|
|
<thead>
|
|
<tr>
|
|
{data.columns.map(c => (
|
|
<th key={c} style={th}>{c}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.rows.map((row, i) => (
|
|
<tr key={i} style={i % 2 === 0 ? trEven : trOdd}>
|
|
{data.columns.map(c => (
|
|
<td key={c} style={td} title={row[c]}>
|
|
{row[c]}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
|
|
const INK = {
|
|
ink: "#1a1a1a",
|
|
mid: "#5f5e5a",
|
|
muted: "#a09a90",
|
|
borderSoft: "#efebe1",
|
|
border: "#e8e4dc",
|
|
} as const;
|
|
|
|
const wrap: React.CSSProperties = {
|
|
display: "flex", flexDirection: "column", gap: 8, minHeight: 0, flex: 1,
|
|
};
|
|
const meta: React.CSSProperties = {
|
|
fontSize: "0.74rem", color: INK.mid,
|
|
};
|
|
const qual: React.CSSProperties = {
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
color: INK.ink,
|
|
};
|
|
const tableScroll: React.CSSProperties = {
|
|
flex: 1, minHeight: 0, overflow: "auto",
|
|
border: `1px solid ${INK.borderSoft}`, borderRadius: 6,
|
|
};
|
|
const tableEl: React.CSSProperties = {
|
|
borderCollapse: "collapse",
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
fontSize: "0.76rem",
|
|
width: "100%",
|
|
};
|
|
const th: React.CSSProperties = {
|
|
position: "sticky", top: 0,
|
|
textAlign: "left", padding: "6px 10px",
|
|
background: "#fafaf6", color: INK.ink,
|
|
fontWeight: 600, fontSize: "0.72rem",
|
|
borderBottom: `1px solid ${INK.border}`,
|
|
whiteSpace: "nowrap",
|
|
};
|
|
const td: React.CSSProperties = {
|
|
padding: "5px 10px", color: INK.ink,
|
|
borderBottom: `1px solid ${INK.borderSoft}`,
|
|
whiteSpace: "nowrap", maxWidth: 360,
|
|
overflow: "hidden", textOverflow: "ellipsis",
|
|
};
|
|
const trEven: React.CSSProperties = { background: "#fff" };
|
|
const trOdd: React.CSSProperties = { background: "#fcfaf3" };
|
|
const center: React.CSSProperties = {
|
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
|
color: INK.mid, fontSize: "0.85rem",
|
|
};
|
|
const errorBox: React.CSSProperties = {
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
padding: "10px 12px", fontSize: "0.82rem", color: "#7a1f15",
|
|
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8,
|
|
};
|
|
const infoBox: React.CSSProperties = {
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
padding: "10px 12px", fontSize: "0.82rem", color: INK.mid,
|
|
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
|
|
};
|