Files
vibn-frontend/components/project/use-anatomy.ts
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

145 lines
3.7 KiB
TypeScript

"use client";
/**
* Single-fetch anatomy hook shared by the Product / Hosting tabs.
* Hardened against silent failure: 10s timeout, error surfacing, and
* graceful unmount.
*/
import { useEffect, useState } from "react";
export interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebasesReason?: "no_repo" | "empty_repo";
product: {
codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
images: Array<{
uuid: string;
name: string;
image: string;
version: string;
serviceType?: string;
status?: string;
}>;
};
hosting: {
live: Array<{
uuid: string;
name: string;
source: "repo" | "image";
sourceLabel: string;
status: string;
fqdn?: string;
domains: string[];
branch?: string;
buildPack?: string;
lastBuild?: { status: string; finishedAt?: string; commit?: string };
}>;
previews: Array<{
id: string;
name: string;
port: number;
url: string;
state: string;
startedAt: string;
}>;
};
infrastructure: {
databases: Array<{
uuid: string;
name: string;
type: string;
status: string;
isPublic: boolean;
publicPort?: number;
internalAddress?: string;
consumerEnvKey: string;
}>;
providers: Array<{
id: string;
category: "auth" | "email" | "payments" | "llm" | "storage";
vendor: string;
attachments: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
keys: string[];
}>;
}>;
bundledStorage: {
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
bucketName?: string;
hmacAccessId?: string;
region?: string;
errorMessage?: string;
};
secrets: {
total: number;
byResource: Array<{
resourceUuid: string;
resourceName: string;
resourceKind: "app" | "service";
count: number;
keys: string[];
}>;
};
};
}
export interface UseAnatomyResult {
anatomy: Anatomy | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export function useAnatomy(projectId: string): UseAnatomyResult {
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
setLoading(true);
setError(null);
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
})
.then(async r => {
let body: unknown = {};
try { body = await r.json(); } catch { /* keep {} */ }
if (!r.ok) {
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then(data => {
if (!cancelled) setAnatomy(data);
})
.catch(err => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick(t => t + 1) };
}