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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@ import {
|
||||
type CoolifyService,
|
||||
type CoolifyDatabase,
|
||||
} from "@/lib/coolify";
|
||||
import { getWorkspaceGcsState } from "@/lib/workspace-gcs";
|
||||
import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage";
|
||||
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com";
|
||||
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? "";
|
||||
@@ -105,6 +107,11 @@ interface InfraDatabase {
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
publicPort?: number;
|
||||
/** "host:port" for the in-cluster reachable DB (no creds). */
|
||||
internalAddress?: string;
|
||||
/** Stable env-var key apps should set to consume this DB
|
||||
* (DATABASE_URL for SQL, REDIS_URL for Redis, etc.). */
|
||||
consumerEnvKey: string;
|
||||
}
|
||||
|
||||
/** A non-database third-party provider detected by env-var pattern.
|
||||
@@ -113,9 +120,7 @@ interface InfraDatabase {
|
||||
interface InfraProvider {
|
||||
/** Stable id used by the UI for selection */
|
||||
id: string;
|
||||
category:
|
||||
| "auth" | "email" | "sms" | "payments"
|
||||
| "analytics" | "llm" | "storage" | "search" | "monitoring";
|
||||
category: "auth" | "email" | "payments" | "llm" | "storage";
|
||||
vendor: string; // "Stripe", "Resend", "OpenAI", …
|
||||
/** Where the env keys for this provider live */
|
||||
attachments: Array<{
|
||||
@@ -126,15 +131,32 @@ interface InfraProvider {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Workspace-bundled S3 (GCS) storage that Vibn provisions for each
|
||||
* workspace. Same record across every project in the workspace — we
|
||||
* surface it on each project's Infrastructure tab so users can see
|
||||
* what's available without going to settings. */
|
||||
interface BundledStorage {
|
||||
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
|
||||
bucketName?: string;
|
||||
hmacAccessId?: string;
|
||||
region?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface InfraSecretSummary {
|
||||
/** Total number of env vars across every app + service in the project */
|
||||
total: number;
|
||||
/** Per-resource breakdown for drill-down */
|
||||
/** Per-resource breakdown for drill-down. Includes the actual env-var
|
||||
* KEYS (never values) so the Secrets detail pane can show what's
|
||||
* set. Values are intentionally excluded from this surface — to
|
||||
* read or rotate them, route through apps.envs.* / services.envs.*
|
||||
* MCP tools which audit-log and tenant-scope every access. */
|
||||
byResource: Array<{
|
||||
resourceUuid: string;
|
||||
resourceName: string;
|
||||
resourceKind: "app" | "service";
|
||||
count: number;
|
||||
keys: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -152,6 +174,7 @@ interface Anatomy {
|
||||
infrastructure: {
|
||||
databases: InfraDatabase[];
|
||||
providers: InfraProvider[];
|
||||
bundledStorage: BundledStorage;
|
||||
secrets: InfraSecretSummary;
|
||||
};
|
||||
}
|
||||
@@ -319,18 +342,43 @@ function dbTypeOf(d: CoolifyDatabase): string {
|
||||
return raw || "database";
|
||||
}
|
||||
|
||||
/** Best-effort host:port from the in-cluster URL (creds stripped). */
|
||||
function parseInternalAddress(internalUrl: string | undefined): string | undefined {
|
||||
if (!internalUrl) return undefined;
|
||||
try {
|
||||
const u = new URL(internalUrl);
|
||||
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
||||
} catch {
|
||||
// Coolify sometimes returns non-URL formats (e.g. raw mongo conn strings)
|
||||
const m = internalUrl.match(/@([^/]+)\/?/);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function consumerKeyFor(type: string): string {
|
||||
if (type === "redis" || type === "keydb" || type === "dragonfly") return "REDIS_URL";
|
||||
if (type === "mongodb") return "MONGODB_URI";
|
||||
if (type === "clickhouse") return "CLICKHOUSE_URL";
|
||||
return "DATABASE_URL";
|
||||
}
|
||||
|
||||
async function loadDatabases(coolifyProjectUuid: string | undefined): Promise<InfraDatabase[]> {
|
||||
if (!coolifyProjectUuid) return [];
|
||||
try {
|
||||
const dbs = await listDatabasesInProject(coolifyProjectUuid);
|
||||
return dbs.map(d => ({
|
||||
uuid: d.uuid,
|
||||
name: d.name,
|
||||
type: dbTypeOf(d),
|
||||
status: d.status ?? "unknown",
|
||||
isPublic: !!d.is_public,
|
||||
publicPort: d.public_port,
|
||||
}));
|
||||
return dbs.map(d => {
|
||||
const type = dbTypeOf(d);
|
||||
return {
|
||||
uuid: d.uuid,
|
||||
name: d.name,
|
||||
type,
|
||||
status: d.status ?? "unknown",
|
||||
isPublic: !!d.is_public,
|
||||
publicPort: d.public_port,
|
||||
internalAddress: parseInternalAddress(d.internal_db_url),
|
||||
consumerEnvKey: consumerKeyFor(type),
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[anatomy] listDatabasesInProject failed:", err);
|
||||
return [];
|
||||
@@ -343,60 +391,48 @@ async function loadDatabases(coolifyProjectUuid: string | undefined): Promise<In
|
||||
* specific enough to avoid false positives (e.g. `STRIPE_*` not just
|
||||
* `*KEY*`).
|
||||
*/
|
||||
/**
|
||||
* Provider detection rules. Categories surfaced today: Auth, Email,
|
||||
* Payments, LLM. Storage is intentionally NOT auto-detected here —
|
||||
* it's served by the workspace's bundled GCS bucket on the same tab.
|
||||
*
|
||||
* (Earlier iteration also detected SMS, Analytics, Search and
|
||||
* Monitoring categories. Removed Apr 29 2026 to keep the surface
|
||||
* focused on what users are actually plumbing today; rules can be
|
||||
* added back when those categories get real UX.)
|
||||
*/
|
||||
const PROVIDER_RULES: Array<{
|
||||
category: InfraProvider["category"];
|
||||
vendor: string;
|
||||
pattern: RegExp;
|
||||
}> = [
|
||||
// Auth
|
||||
{ category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
|
||||
{ category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
|
||||
{ category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
|
||||
{ category: "auth", vendor: "Supabase Auth",pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/ },
|
||||
{ category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
|
||||
{ category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
|
||||
{ category: "auth", vendor: "Firebase Auth",pattern: /^FIREBASE_(AUTH|API_KEY)/ },
|
||||
{ category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
|
||||
{ category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
|
||||
{ category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
|
||||
{ category: "auth", vendor: "Supabase Auth",pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/ },
|
||||
{ category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
|
||||
{ category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
|
||||
{ category: "auth", vendor: "Firebase Auth",pattern: /^FIREBASE_(AUTH|API_KEY)/ },
|
||||
// Email
|
||||
{ category: "email", vendor: "Resend", pattern: /^RESEND_/ },
|
||||
{ category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
|
||||
{ category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
|
||||
{ category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
|
||||
{ category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
|
||||
{ category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
|
||||
// SMS
|
||||
{ category: "sms", vendor: "Twilio", pattern: /^TWILIO_/ },
|
||||
{ category: "sms", vendor: "Vonage", pattern: /^VONAGE_/ },
|
||||
{ category: "email", vendor: "Resend", pattern: /^RESEND_/ },
|
||||
{ category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
|
||||
{ category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
|
||||
{ category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
|
||||
{ category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
|
||||
{ category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
|
||||
// Payments
|
||||
{ category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/ },
|
||||
{ category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/ },
|
||||
{ category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
|
||||
// Analytics
|
||||
{ category: "analytics", vendor: "PostHog", pattern: /^(NEXT_PUBLIC_)?POSTHOG_/ },
|
||||
{ category: "analytics", vendor: "Mixpanel", pattern: /^(NEXT_PUBLIC_)?MIXPANEL_/ },
|
||||
{ category: "analytics", vendor: "Amplitude", pattern: /^(NEXT_PUBLIC_)?AMPLITUDE_/ },
|
||||
{ category: "analytics", vendor: "Plausible", pattern: /^PLAUSIBLE_/ },
|
||||
{ category: "analytics", vendor: "Umami", pattern: /^(NEXT_PUBLIC_)?UMAMI_/ },
|
||||
// LLM
|
||||
{ category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
|
||||
{ category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
|
||||
{ category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/ },
|
||||
{ category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
|
||||
{ category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
|
||||
{ category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
|
||||
{ category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
|
||||
// Storage
|
||||
{ category: "storage", vendor: "AWS S3", pattern: /^(AWS_S3_|S3_(ACCESS|SECRET|BUCKET|REGION))/ },
|
||||
{ category: "storage", vendor: "Cloudflare R2",pattern: /^(R2_|CLOUDFLARE_R2_)/ },
|
||||
{ category: "storage", vendor: "Google Cloud Storage", pattern: /^(GCS_|GCP_STORAGE_)/ },
|
||||
{ category: "storage", vendor: "Supabase Storage", pattern: /^SUPABASE_STORAGE_/ },
|
||||
// Search
|
||||
{ category: "search", vendor: "Algolia", pattern: /^ALGOLIA_/ },
|
||||
{ category: "search", vendor: "Meilisearch", pattern: /^MEILI(SEARCH)?_/ },
|
||||
{ category: "search", vendor: "Typesense", pattern: /^TYPESENSE_/ },
|
||||
// Monitoring
|
||||
{ category: "monitoring", vendor: "Sentry", pattern: /^(NEXT_PUBLIC_)?SENTRY_/ },
|
||||
{ category: "monitoring", vendor: "Datadog", pattern: /^(DD_|DATADOG_)/ },
|
||||
{ category: "monitoring", vendor: "LogSnag", pattern: /^LOGSNAG_/ },
|
||||
{ category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/ },
|
||||
{ category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/ },
|
||||
{ category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
|
||||
// LLM (a.k.a. Models)
|
||||
{ category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
|
||||
{ category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
|
||||
{ category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/ },
|
||||
{ category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
|
||||
{ category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
|
||||
{ category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
|
||||
{ category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
|
||||
];
|
||||
|
||||
interface ResourceEnvs {
|
||||
@@ -475,12 +511,31 @@ function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary {
|
||||
resourceName: e.resourceName,
|
||||
resourceKind: e.resourceKind,
|
||||
count: e.keys.length,
|
||||
keys: [...e.keys].sort(),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const total = byResource.reduce((sum, r) => sum + r.count, 0);
|
||||
return { total, byResource };
|
||||
}
|
||||
|
||||
async function loadBundledStorage(workspaceId: string | undefined): Promise<BundledStorage> {
|
||||
if (!workspaceId) return { status: "unprovisioned" };
|
||||
try {
|
||||
const ws = await getWorkspaceGcsState(workspaceId);
|
||||
if (!ws) return { status: "unprovisioned" };
|
||||
return {
|
||||
status: ws.gcp_provision_status ?? "unprovisioned",
|
||||
bucketName: ws.gcs_default_bucket_name ?? undefined,
|
||||
hmacAccessId: ws.gcs_hmac_access_id ?? undefined,
|
||||
region: VIBN_GCS_LOCATION,
|
||||
errorMessage: ws.gcp_provision_error ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[anatomy] getWorkspaceGcsState failed:", err);
|
||||
return { status: "error", errorMessage: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreviews(projectId: string): Promise<Preview[]> {
|
||||
try {
|
||||
const rows = await query<{
|
||||
@@ -529,8 +584,8 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
const rows = await query<{ data: Record<string, unknown>; vibn_workspace_id: string | null }>(
|
||||
`SELECT p.data, p.vibn_workspace_id FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
@@ -540,6 +595,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const data = rows[0].data;
|
||||
const workspaceId = rows[0].vibn_workspace_id ?? undefined;
|
||||
const giteaRepo = data?.giteaRepo as string | undefined;
|
||||
const coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;
|
||||
const projectName =
|
||||
@@ -547,7 +603,7 @@ export async function GET(
|
||||
(data?.name as string | undefined) ??
|
||||
"Project";
|
||||
|
||||
const [codebasesResult, repoApps, allServices, previews, databases] = await Promise.all([
|
||||
const [codebasesResult, repoApps, allServices, previews, databases, bundledStorage] = await Promise.all([
|
||||
giteaRepo
|
||||
? discoverCodebases(giteaRepo).catch(err => {
|
||||
console.error("[anatomy] discoverCodebases failed:", err);
|
||||
@@ -558,6 +614,7 @@ export async function GET(
|
||||
loadProjectServices(coolifyProjectUuid),
|
||||
loadPreviews(projectId),
|
||||
loadDatabases(coolifyProjectUuid),
|
||||
loadBundledStorage(workspaceId),
|
||||
]);
|
||||
|
||||
// Pull last-build summaries for repo apps in parallel (small N).
|
||||
@@ -630,6 +687,7 @@ export async function GET(
|
||||
infrastructure: {
|
||||
databases,
|
||||
providers: detectProviders(allEnvs),
|
||||
bundledStorage,
|
||||
secrets: summariseSecrets(allEnvs),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* GET /api/projects/[projectId]/databases/[dbUuid]/preview?schema=…&table=…
|
||||
*
|
||||
* Returns the first ~50 rows of the requested table. Read-only — backed
|
||||
* by lib/db-introspect.previewTable which only emits SELECT statements
|
||||
* with a hard LIMIT and rejects identifiers containing anything outside
|
||||
* /[A-Za-z0-9_]+/.
|
||||
*
|
||||
* Returns: { columns: string[], rows: Record<string,string>[], truncated: boolean }
|
||||
*
|
||||
* Same auth + tenancy chain as the tables route.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { listDatabasesInProject } from "@/lib/coolify";
|
||||
import { previewTable, IntrospectionUnsupportedError } from "@/lib/db-introspect";
|
||||
|
||||
function dbEngineOf(type: string | undefined): string {
|
||||
const raw = (type ?? "").toLowerCase();
|
||||
if (raw.includes("postgres")) return "postgresql";
|
||||
if (raw.includes("redis")) return "redis";
|
||||
if (raw.includes("mongo")) return "mongodb";
|
||||
if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql";
|
||||
if (raw.includes("clickhouse")) return "clickhouse";
|
||||
return raw || "unknown";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string; dbUuid: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId, dbUuid } = await params;
|
||||
const url = new URL(req.url);
|
||||
const schema = url.searchParams.get("schema") ?? "";
|
||||
const table = url.searchParams.get("table") ?? "";
|
||||
if (!schema || !table) {
|
||||
return NextResponse.json({ error: "Both 'schema' and 'table' are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
const coolifyProjectUuid = rows[0].data?.coolifyProjectUuid as string | undefined;
|
||||
if (!coolifyProjectUuid) {
|
||||
return NextResponse.json({ error: "Project has no Coolify project linked" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbs = await listDatabasesInProject(coolifyProjectUuid);
|
||||
const db = dbs.find(d => d.uuid === dbUuid);
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: "Database not in this project" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engine = dbEngineOf(db.type);
|
||||
try {
|
||||
const result = await previewTable(dbUuid, engine, schema, table);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof IntrospectionUnsupportedError) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message, unsupported: true },
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[db preview API]", err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to preview table" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* GET /api/projects/[projectId]/databases/[dbUuid]/tables
|
||||
*
|
||||
* Lists user-tables for a Coolify-managed database that belongs to the
|
||||
* given Vibn project. Postgres only for now; other engines return 501.
|
||||
*
|
||||
* Auth + tenancy chain:
|
||||
* - session.user.email must own the fs_projects row
|
||||
* - the database uuid must live in the project's coolify project
|
||||
*
|
||||
* Hard caps: 50 tables, 8s timeout (enforced inside lib/db-introspect).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { listDatabasesInProject } from "@/lib/coolify";
|
||||
import { listTables, IntrospectionUnsupportedError } from "@/lib/db-introspect";
|
||||
|
||||
function dbEngineOf(type: string | undefined): string {
|
||||
const raw = (type ?? "").toLowerCase();
|
||||
if (raw.includes("postgres")) return "postgresql";
|
||||
if (raw.includes("redis")) return "redis";
|
||||
if (raw.includes("mongo")) return "mongodb";
|
||||
if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql";
|
||||
if (raw.includes("clickhouse")) return "clickhouse";
|
||||
return raw || "unknown";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string; dbUuid: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId, dbUuid } = await params;
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
const coolifyProjectUuid = rows[0].data?.coolifyProjectUuid as string | undefined;
|
||||
if (!coolifyProjectUuid) {
|
||||
return NextResponse.json({ error: "Project has no Coolify project linked" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Authorise the dbUuid against this project
|
||||
const dbs = await listDatabasesInProject(coolifyProjectUuid);
|
||||
const db = dbs.find(d => d.uuid === dbUuid);
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: "Database not in this project" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engine = dbEngineOf(db.type);
|
||||
try {
|
||||
const tables = await listTables(dbUuid, engine);
|
||||
return NextResponse.json({ engine, tables });
|
||||
} catch (err) {
|
||||
if (err instanceof IntrospectionUnsupportedError) {
|
||||
return NextResponse.json(
|
||||
{ engine, tables: [], unsupported: true, message: err.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[db tables API]", err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to list tables" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
238
components/project/database-table-tree.tsx
Normal file
238
components/project/database-table-tree.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"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,
|
||||
};
|
||||
180
components/project/table-viewer.tsx
Normal file
180
components/project/table-viewer.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"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,
|
||||
};
|
||||
@@ -52,12 +52,12 @@ export interface Anatomy {
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
publicPort?: number;
|
||||
internalAddress?: string;
|
||||
consumerEnvKey: string;
|
||||
}>;
|
||||
providers: Array<{
|
||||
id: string;
|
||||
category:
|
||||
| "auth" | "email" | "sms" | "payments"
|
||||
| "analytics" | "llm" | "storage" | "search" | "monitoring";
|
||||
category: "auth" | "email" | "payments" | "llm" | "storage";
|
||||
vendor: string;
|
||||
attachments: Array<{
|
||||
resourceUuid: string;
|
||||
@@ -66,6 +66,13 @@ export interface Anatomy {
|
||||
keys: string[];
|
||||
}>;
|
||||
}>;
|
||||
bundledStorage: {
|
||||
status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
|
||||
bucketName?: string;
|
||||
hmacAccessId?: string;
|
||||
region?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
secrets: {
|
||||
total: number;
|
||||
byResource: Array<{
|
||||
@@ -73,6 +80,7 @@ export interface Anatomy {
|
||||
resourceName: string;
|
||||
resourceKind: "app" | "service";
|
||||
count: number;
|
||||
keys: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1248,15 +1248,19 @@ interface CoolifyProjectEnvResources {
|
||||
clickhouses?: CoolifyDatabase[];
|
||||
}
|
||||
|
||||
const DB_ARRAY_KEYS: Array<keyof CoolifyProjectEnvResources> = [
|
||||
'postgresqls',
|
||||
'mysqls',
|
||||
'mariadbs',
|
||||
'mongodbs',
|
||||
'redis',
|
||||
'keydbs',
|
||||
'dragonflies',
|
||||
'clickhouses',
|
||||
/** Maps Coolify's plural endpoint key → the engine label we want to
|
||||
* surface on every CoolifyDatabase record. Coolify's flattened
|
||||
* per-resource shape doesn't include `type`, so we derive it from
|
||||
* whichever array we pulled the row out of. */
|
||||
const DB_ARRAY_KEYS_TO_TYPE: Array<{ key: keyof CoolifyProjectEnvResources; type: string }> = [
|
||||
{ key: 'postgresqls', type: 'postgresql' },
|
||||
{ key: 'mysqls', type: 'mysql' },
|
||||
{ key: 'mariadbs', type: 'mariadb' },
|
||||
{ key: 'mongodbs', type: 'mongodb' },
|
||||
{ key: 'redis', type: 'redis' },
|
||||
{ key: 'keydbs', type: 'keydb' },
|
||||
{ key: 'dragonflies', type: 'dragonfly' },
|
||||
{ key: 'clickhouses', type: 'clickhouse' },
|
||||
];
|
||||
|
||||
async function getProjectEnvResources(
|
||||
@@ -1290,9 +1294,14 @@ export async function listDatabasesInProject(
|
||||
): Promise<CoolifyDatabase[]> {
|
||||
return forEachEnv(projectUuid, r => {
|
||||
const out: CoolifyDatabase[] = [];
|
||||
for (const k of DB_ARRAY_KEYS) {
|
||||
const arr = r[k];
|
||||
if (Array.isArray(arr)) out.push(...(arr as CoolifyDatabase[]));
|
||||
for (const { key, type } of DB_ARRAY_KEYS_TO_TYPE) {
|
||||
const arr = r[key];
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const db of arr as CoolifyDatabase[]) {
|
||||
// Always tag with the engine we derived from the array key —
|
||||
// Coolify itself doesn't set `type` on the individual record.
|
||||
out.push({ ...db, type: db.type ?? type });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
211
lib/db-introspect.ts
Normal file
211
lib/db-introspect.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Database introspection helpers — list tables and preview rows.
|
||||
*
|
||||
* Coolify-managed databases run in their own docker network, unreachable
|
||||
* from vibn-frontend's container. We route through the Coolify host via
|
||||
* SSH and `docker exec` into the database container itself, where the
|
||||
* native client (psql / redis-cli / mongosh) is already installed and
|
||||
* pre-authenticated via the container's own POSTGRES_USER / etc envs.
|
||||
*
|
||||
* v1 supports Postgres; Redis + MongoDB are stubbed and return helpful
|
||||
* "not yet supported" errors so the UI can render a useful empty state.
|
||||
*
|
||||
* Hard limits, by design:
|
||||
* - 50 tables max returned per call
|
||||
* - 50 rows max per table preview
|
||||
* - 200 chars truncation per cell value
|
||||
* - 8s wall-clock SSH timeout
|
||||
* - Only SELECT-style queries; never any mutation
|
||||
*/
|
||||
|
||||
import { runOnCoolifyHost } from "./coolify-ssh";
|
||||
import { listContainersForApp } from "./coolify-containers";
|
||||
|
||||
export interface IntrospectedTable {
|
||||
schema: string;
|
||||
name: string;
|
||||
/** Approximate row count from pg_class.reltuples — fast, not exact. */
|
||||
approxRows?: number;
|
||||
}
|
||||
|
||||
export interface PreviewedTable {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
truncated: boolean;
|
||||
totalRowsApprox?: number;
|
||||
}
|
||||
|
||||
const MAX_TABLES = 50;
|
||||
const MAX_ROWS = 50;
|
||||
const MAX_CELL_CHARS = 200;
|
||||
const SSH_TIMEOUT_MS = 8_000;
|
||||
|
||||
/** Find the running container for a Coolify database uuid. */
|
||||
async function resolveDbContainer(dbUuid: string): Promise<string> {
|
||||
const containers = await listContainersForApp(dbUuid);
|
||||
const running = containers.find(c => /up /i.test(c.status));
|
||||
const target = running ?? containers[0];
|
||||
if (!target) {
|
||||
throw new Error(`No container found for database ${dbUuid}. Database may be stopped.`);
|
||||
}
|
||||
return target.name;
|
||||
}
|
||||
|
||||
/** Single-quote escape a token for bash. */
|
||||
function sq(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Postgres
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List tables across every non-system schema. Returns at most MAX_TABLES.
|
||||
* Uses `\copy (SELECT …) TO STDOUT` style approach via psql -A -t for a
|
||||
* stable, parseable output (no horizontal lines, no headers).
|
||||
*/
|
||||
async function pgListTables(container: string): Promise<IntrospectedTable[]> {
|
||||
const sql = `
|
||||
SELECT n.nspname || '|' || c.relname || '|' || COALESCE(c.reltuples::bigint, 0)
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind IN ('r', 'p')
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
AND n.nspname NOT LIKE 'pg_%'
|
||||
ORDER BY n.nspname, c.relname
|
||||
LIMIT ${MAX_TABLES + 1};
|
||||
`.replace(/\s+/g, " ").trim();
|
||||
|
||||
const cmd =
|
||||
`docker exec ${sq(container)} bash -c ` +
|
||||
sq(`psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAF '|' -c ${sqInner(sql)}`);
|
||||
|
||||
const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
|
||||
}
|
||||
|
||||
const tables: IntrospectedTable[] = [];
|
||||
for (const line of res.stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [schema, name, rowsStr] = trimmed.split("|");
|
||||
if (!schema || !name) continue;
|
||||
const approxRows = Number(rowsStr);
|
||||
tables.push({ schema, name, approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined });
|
||||
}
|
||||
return tables.slice(0, MAX_TABLES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the first MAX_ROWS rows of a single table. Identifiers are
|
||||
* locked to /^[A-Za-z0-9_]+$/ so we can safely splice them into SQL —
|
||||
* Postgres identifiers can technically be wider, but anything outside
|
||||
* that range gets rejected up-front to keep the surface small.
|
||||
*/
|
||||
async function pgPreviewTable(
|
||||
container: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<PreviewedTable> {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(schema) || !/^[A-Za-z0-9_]+$/.test(table)) {
|
||||
throw new Error("Invalid identifier");
|
||||
}
|
||||
|
||||
// Build `SELECT row_to_json(t)::text FROM (SELECT * FROM s.t LIMIT N) t`
|
||||
// so each output line is a self-contained JSON object that's resilient
|
||||
// to embedded pipes / newlines.
|
||||
const sql =
|
||||
`SELECT row_to_json(t)::text ` +
|
||||
`FROM (SELECT * FROM "${schema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`;
|
||||
|
||||
const cmd =
|
||||
`docker exec ${sq(container)} bash -c ` +
|
||||
sq(`psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tA -c ${sqInner(sql)}`);
|
||||
|
||||
const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
|
||||
}
|
||||
|
||||
const lines = res.stdout.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
const truncated = lines.length > MAX_ROWS;
|
||||
const rowJson = lines.slice(0, MAX_ROWS);
|
||||
|
||||
const columnSet = new Set<string>();
|
||||
const parsed: Record<string, unknown>[] = [];
|
||||
for (const line of rowJson) {
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>;
|
||||
Object.keys(obj).forEach(k => columnSet.add(k));
|
||||
parsed.push(obj);
|
||||
} catch {
|
||||
// Skip non-JSON lines (shouldn't happen with -tA, but be safe).
|
||||
}
|
||||
}
|
||||
|
||||
// Stable column order: order they appeared in the first row, then
|
||||
// any extras at the end.
|
||||
const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : [];
|
||||
const extras = [...columnSet].filter(k => !firstRowKeys.includes(k));
|
||||
const columns = [...firstRowKeys, ...extras];
|
||||
|
||||
const rows = parsed.map(row => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const col of columns) {
|
||||
out[col] = formatCell(row[col]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
return { columns, rows, truncated };
|
||||
}
|
||||
|
||||
/** Inner sq for the bash -c '…' wrapper: we need to single-quote the
|
||||
* SQL itself, but we're already inside one layer of single quotes
|
||||
* for `bash -c '…'` — switch to double quotes for the inner level
|
||||
* and rely on the fact that psql's -c argument tolerates them. */
|
||||
function sqInner(s: string): string {
|
||||
return `"${s.replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`;
|
||||
}
|
||||
|
||||
function formatCell(v: unknown): string {
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "string") {
|
||||
return v.length > MAX_CELL_CHARS ? v.slice(0, MAX_CELL_CHARS) + "…" : v;
|
||||
}
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
const json = JSON.stringify(v);
|
||||
return json.length > MAX_CELL_CHARS ? json.slice(0, MAX_CELL_CHARS) + "…" : json;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export class IntrospectionUnsupportedError extends Error {
|
||||
constructor(public engine: string) {
|
||||
super(`Introspection not yet supported for ${engine}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTables(
|
||||
dbUuid: string,
|
||||
engine: string,
|
||||
): Promise<IntrospectedTable[]> {
|
||||
if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
|
||||
const container = await resolveDbContainer(dbUuid);
|
||||
return pgListTables(container);
|
||||
}
|
||||
|
||||
export async function previewTable(
|
||||
dbUuid: string,
|
||||
engine: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<PreviewedTable> {
|
||||
if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
|
||||
const container = await resolveDbContainer(dbUuid);
|
||||
return pgPreviewTable(container, schema, table);
|
||||
}
|
||||
Reference in New Issue
Block a user