Files
vibn-frontend/components/project/table-viewer.tsx
Mark Henderson 7b359e399e feat(infra): collapse to 7 categories + live Postgres table inspection
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
2026-04-29 15:22:58 -07:00

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,
};