feat: phase 2 databases via DBX (v0.3.0)

- dbx-mcp persistent stdio subprocess in Go backend (internal/dbx),
  lazy start + single retry, tool failures surface as 502
- core.db_connections registry: per-project connections, credentials
  server-side only, managed entries protected (409)
- platform-postgres + platform-dragonfly auto-register per project
- REST proxy: list/create/delete/test, databases, tables, describe,
  schema context, SQL query, redis command
- Databases page: connection list, schema/table browser, column
  inspector, SQL editor with result grid + row detail, redis console
- backend image moved to debian-slim for glibc; pinned dbx release
  with SHA256 verification

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 14:54:33 +02:00
1 parent 420502eec1
commit 627bd89d5e
71 files changed
+3685 -49

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/frontend",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
+11
View File
@@ -39,6 +39,7 @@ import {
AuthPage,
type AuthUser,
CollectionsPage,
DatabasesPage,
LoginPage,
MembersPage,
ProjectsPage,
@@ -51,6 +52,7 @@ import {
IconProjects,
IconMembers,
IconStorage,
IconDatabases,
IconCollections,
IconAudit,
IconAuth,
@@ -1408,6 +1410,7 @@ export default function App() {
{ id: "nav-dashboard", label: "Go to Overview", category: "Navigate", icon: <IconOverview class="w-4 h-4" />, keywords: ["home", "dashboard"], action: () => setActiveView("dashboard") },
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <IconProjects class="w-4 h-4" />, keywords: ["project"], action: () => setActiveView("projects") },
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1613,6 +1616,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "databases"}>
<DatabasesPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
+6 -2
View File
@@ -7,6 +7,7 @@ import {
IconMembers,
IconStorage,
IconCollections,
IconDatabases,
IconAudit,
IconAuth,
IconSettings,
@@ -24,6 +25,7 @@ export type ViewType =
| "members"
| "storage"
| "collections"
| "databases"
| "auth"
| "audit"
| "settings";
@@ -37,14 +39,15 @@ interface NavEntry {
const projectNav: NavEntry[] = [
{ id: "dashboard", label: "Overview", icon: IconOverview },
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
];
// Instagram-style bottom bar caps at five slots — Audit stays reachable via Menu.
const bottomNav = projectNav.slice(0, 4);
// Instagram-style bottom bar caps at five slots — Collections and Audit stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections");
const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects },
@@ -80,6 +83,7 @@ const viewTitle: Record<ViewType, string> = {
members: "Members",
storage: "Storage",
collections: "Collections",
databases: "Databases",
auth: "Authentication",
audit: "Audit log",
settings: "Settings",
+18
View File
@@ -63,6 +63,24 @@ export const IconCollections = icon(() => (
</>
));
export const IconDatabases = icon(() => (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18" />
<path d="M9 9v12" />
<path d="M15 9v12" />
</>
));
export const IconTerminal = icon(() => (
<>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</>
));
export const IconPlay = icon(() => (<polygon points="6 3 20 12 6 21 6 3" />));
export const IconAuth = icon(() => (
<>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
+142
View File
@@ -14,6 +14,7 @@ import type {
Bucket,
BucketObject,
Collection,
DBConnection,
Document,
MeResponse,
OrganizationSummary,
@@ -323,6 +324,52 @@ let documents: Document[] = [
},
];
let dbConnections: DBConnection[] = [
{
id: "dbc-1",
project_id: "proj-1",
name: "platform-postgres",
db_type: "postgres",
host: "postgres",
port: 5432,
database: "primora",
username: "primora",
ssl: null,
is_managed: true,
has_password: true,
},
{
id: "dbc-2",
project_id: "proj-1",
name: "platform-dragonfly",
db_type: "redis",
host: "dragonfly",
port: 6379,
database: "",
username: "",
ssl: null,
is_managed: true,
has_password: false,
},
];
const demoTables = ["users", "projects", "api_keys", "audit_logs", "buckets"];
const demoColumns: Record<string, string[][]> = {
users: [
["id", "uuid", "NO", "PK"],
["email", "text", "NO", "UNIQUE"],
["name", "text", "YES", ""],
["created_at", "timestamptz", "NO", ""],
],
projects: [
["id", "uuid", "NO", "PK"],
["organization_id", "uuid", "NO", "FK"],
["name", "text", "NO", ""],
["slug", "text", "NO", "UNIQUE"],
],
};
const pushAudit = (action: string, resourceType: string, resourceId: string, metadata: Record<string, unknown> = {}) => {
auditLogs = [
{
@@ -715,6 +762,101 @@ class DemoService {
}
async bootstrapPlatform() { await this.delay(); return {}; }
/* ---- databases (DBX) ---- */
async getDbxStatus() {
await this.delay();
return { available: true, command: "dbx-mcp" };
}
async listDbConnections() {
await this.delay();
return { items: dbConnections };
}
async createDbConnection(data: { requestBody?: { name?: string; db_type?: string; host?: string; port?: number; database?: string; username?: string; password?: string; ssl?: boolean } }) {
await this.delay();
const req = data.requestBody ?? {};
const conn: DBConnection = {
id: `dbc-${Date.now()}`,
project_id: "proj-1",
name: req.name ?? "connection",
db_type: req.db_type ?? "postgres",
host: req.host ?? "",
port: req.port ?? null,
database: req.database ?? "",
username: req.username ?? "",
ssl: req.ssl ?? null,
is_managed: false,
has_password: !!req.password,
};
dbConnections = [...dbConnections, conn];
pushAudit("db_connection.created", "db_connection", conn.id);
return conn;
}
async deleteDbConnection(data: { connectionId?: string }) {
await this.delay();
dbConnections = dbConnections.filter((c) => c.id !== data.connectionId);
pushAudit("db_connection.deleted", "db_connection", data.connectionId ?? "");
return {};
}
async testDbConnection() {
await this.delay();
return { ok: true };
}
async listDbDatabases() {
await this.delay();
return { databases: ["primora", "postgres"] };
}
async listDbTables() {
await this.delay();
return {
tables: demoTables.map((t) => ({ name: t, kind: "BASE TABLE" })),
};
}
async describeDbTable(data: { table?: string }) {
await this.delay();
return {
table: {
columns: ["Column", "Type", "Nullable", "Key"],
rows: demoColumns[data.table ?? ""] ?? [
["id", "uuid", "NO", "PK"],
["created_at", "timestamptz", "NO", ""],
],
},
};
}
async getDbSchemaContext() {
await this.delay();
return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" };
}
async executeDbQuery() {
await this.delay();
return {
table: {
columns: ["id", "email", "created_at"],
rows: [
["a4f3…", "[email protected]", "2024-01-01 00:00:00"],
["b8c1…", "[email protected]", "2024-01-02 00:00:00"],
],
note: "2 rows in 4ms (demo)",
},
};
}
async executeDbRedisCommand(data: { requestBody?: { command?: string } }) {
await this.delay();
const cmd = data.requestBody?.command ?? "";
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
}
}
export const demoService = new DemoService();
+847
View File
@@ -0,0 +1,847 @@
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
import {
DatabasesService,
CreateDBConnectionRequest,
type DBConnection,
type DBMDTable,
type DBObject,
type DBQueryResponse,
type DBXStatus,
} from "@primora/api-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
import { Input, Select, Textarea } from "../components/Input";
import {
IconChevronRight,
IconDatabases,
IconPlay,
IconPlus,
IconRefresh,
IconTerminal,
IconTrash,
IconZap,
} from "../components/Icons";
interface DatabasesPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type WorkspaceTab = "browse" | "query" | "console";
interface ConnForm {
name: string;
db_type: string;
host: string;
port: string;
database: string;
username: string;
password: string;
ssl: boolean;
}
const emptyForm: ConnForm = {
name: "",
db_type: "postgres",
host: "",
port: "",
database: "",
username: "",
password: "",
ssl: false,
};
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
const sqlish = (t?: string) => t !== "redis";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
/* the demo service mirrors the generated DatabasesService call shapes */
const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof DatabasesService) : DatabasesService);
function ResultTable(props: { table: DBMDTable; onRow?: (row: string[]) => void }) {
return (
<div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<For each={props.table.columns}>{(col) => <th>{col}</th>}</For>
</tr>
</thead>
<tbody>
<For
each={props.table.rows}
fallback={
<tr>
<td colspan={Math.max(1, props.table.columns.length)} class="py-8 text-center text-text-3">
No rows returned
</td>
</tr>
}
>
{(row) => (
<tr
class={props.onRow ? "clickable" : ""}
onClick={() => props.onRow?.(row)}
>
<For each={row}>
{(cell) => (
<td class="font-mono text-xs text-text-1 whitespace-nowrap" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis">
{cell}
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Show when={props.table.note}>
<p class="px-4 py-2 text-xs text-text-3">{props.table.note}</p>
</Show>
</div>
);
}
export function DatabasesPage(props: DatabasesPageProps) {
const [status, setStatus] = createSignal<DBXStatus | null>(null);
const [conns, setConns] = createSignal<DBConnection[]>([]);
const [activeID, setActiveID] = createSignal<string>();
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal("");
const [message, setMessage] = createSignal("");
const [tab, setTab] = createSignal<WorkspaceTab>("browse");
const [databases, setDatabases] = createSignal<string[]>([]);
const [selectedDB, setSelectedDB] = createSignal("");
const [schemaInput, setSchemaInput] = createSignal("");
const [appliedSchema, setAppliedSchema] = createSignal("");
const [tables, setTables] = createSignal<DBObject[] | null>(null);
const [tablesRaw, setTablesRaw] = createSignal("");
const [browseBusy, setBrowseBusy] = createSignal(false);
const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>();
const [describeBusy, setDescribeBusy] = createSignal(false);
const [sql, setSql] = createSignal("SELECT 1;");
const [queryResult, setQueryResult] = createSignal<DBQueryResponse>();
const [queryBusy, setQueryBusy] = createSignal(false);
const [redisCmd, setRedisCmd] = createSignal("");
const [consoleLog, setConsoleLog] = createSignal<{ cmd: string; out: string }[]>([]);
const [consoleBusy, setConsoleBusy] = createSignal(false);
const [detailRow, setDetailRow] = createSignal<{ columns: string[]; row: string[] }>();
const [addOpen, setAddOpen] = createSignal(false);
const [confirmDelete, setConfirmDelete] = createSignal<string>();
const [form, setForm] = createSignal<ConnForm>({ ...emptyForm });
const [addBusy, setAddBusy] = createSignal(false);
const [testBusy, setTestBusy] = createSignal("");
const active = createMemo(() => conns().find((c) => c.id === activeID()));
const load = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const [st, list] = await Promise.all([
svc(props.demoMode).getDbxStatus(),
svc(props.demoMode).listDbConnections({ projectId: props.projectID }),
]);
setStatus(st);
setConns(list.items);
const first = list.items.find((c) => c.id === activeID()) ?? list.items[0];
if (first) void select(first.id);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
const loadTables = async (conn: DBConnection, database: string, schema: string) => {
setBrowseBusy(true);
try {
const r = await svc(props.demoMode).listDbTables({
projectId: props.projectID!,
connectionId: conn.id,
database: database || undefined,
schema: schema || undefined,
});
setTables(r.tables ?? null);
setTablesRaw(r.raw ?? "");
} catch (e) {
setError(err(e));
setTables(null);
} finally {
setBrowseBusy(false);
}
};
const select = async (id: string) => {
setActiveID(id);
setError("");
setDatabases([]);
setSelectedDB("");
setSchemaInput("");
setAppliedSchema("");
setTables(null);
setTablesRaw("");
setDescribed(undefined);
setQueryResult(undefined);
setConsoleLog([]);
const conn = conns().find((c) => c.id === id);
if (!conn) return;
if (!sqlish(conn.db_type)) {
setTab("console");
return;
}
setTab("browse");
setBrowseBusy(true);
try {
const dbs = await svc(props.demoMode).listDbDatabases({
projectId: props.projectID!,
connectionId: conn.id,
});
setDatabases(dbs.databases);
const db =
conn.database && dbs.databases.includes(conn.database)
? conn.database
: (dbs.databases[0] ?? conn.database ?? "");
setSelectedDB(db);
await loadTables(conn, db, "");
} catch (e) {
setError(err(e));
} finally {
setBrowseBusy(false);
}
};
const describe = async (name: string) => {
const conn = active();
if (!conn) return;
setDescribeBusy(true);
try {
const r = await svc(props.demoMode).describeDbTable({
projectId: props.projectID!,
connectionId: conn.id,
table: name,
database: selectedDB() || undefined,
schema: appliedSchema() || undefined,
});
setDescribed({ name, table: r.table, raw: r.raw });
} catch (e) {
setError(err(e));
} finally {
setDescribeBusy(false);
}
};
const runQuery = async () => {
const conn = active();
if (!conn || !sql().trim()) return;
setQueryBusy(true);
setError("");
try {
const r = await svc(props.demoMode).executeDbQuery({
projectId: props.projectID!,
connectionId: conn.id,
requestBody: { sql: sql(), database: selectedDB() || undefined },
});
setQueryResult(r);
} catch (e) {
setError(err(e));
} finally {
setQueryBusy(false);
}
};
const runRedis = async () => {
const conn = active();
const cmd = redisCmd().trim();
if (!conn || !cmd) return;
setConsoleBusy(true);
setError("");
try {
const r = await svc(props.demoMode).executeDbRedisCommand({
projectId: props.projectID!,
connectionId: conn.id,
requestBody: { command: cmd },
});
setConsoleLog((l) => [{ cmd, out: r.output }, ...l]);
setRedisCmd("");
} catch (e) {
setError(err(e));
} finally {
setConsoleBusy(false);
}
};
const createConnection = async () => {
const f = form();
if (!f.name.trim() || !f.host.trim()) return;
setAddBusy(true);
try {
await svc(props.demoMode).createDbConnection({
projectId: props.projectID!,
requestBody: {
name: f.name.trim(),
db_type: f.db_type as CreateDBConnectionRequest.db_type,
host: f.host.trim(),
port: f.port ? Number(f.port) : undefined,
database: f.database.trim() || undefined,
username: f.username.trim() || undefined,
password: f.password || undefined,
ssl: f.ssl || undefined,
},
});
setAddOpen(false);
setForm({ ...emptyForm });
setMessage("Connection added");
await load();
} catch (e) {
setError(err(e));
} finally {
setAddBusy(false);
}
};
const removeConnection = async (id: string) => {
try {
await svc(props.demoMode).deleteDbConnection({
projectId: props.projectID!,
connectionId: id,
});
setMessage("Connection removed");
if (activeID() === id) setActiveID(undefined);
await load();
} catch (e) {
setError(err(e));
}
};
const testConnection = async (conn: DBConnection) => {
setTestBusy(conn.id);
setError("");
setMessage("");
try {
const r = await svc(props.demoMode).testDbConnection({
projectId: props.projectID!,
connectionId: conn.id,
});
if (r.ok) setMessage(`${conn.name}: connection OK`);
else setError(`${conn.name}: ${r.error ?? "connection failed"}`);
} catch (e) {
setError(`${conn.name}: ${err(e)}`);
} finally {
setTestBusy("");
}
};
createEffect(() => {
if (props.projectID) void load();
});
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Databases</h1>
<p class="page-description">
Saved connections backed by DBX — browse schemas, inspect tables, and run queries.
</p>
</div>
<div class="page-actions">
<button
class="btn btn-primary"
onClick={() => setAddOpen(true)}
disabled={!props.canManage}
>
<IconPlus class="w-4 h-4" />
Add connection
</button>
</div>
</div>
<Show when={status() && !status()!.available}>
<div class="message message-error">
DBX is not available: {status()!.reason ?? "unknown reason"}
<Show when={status()!.install}>
<span> — install: {status()!.install}</span>
</Show>
</div>
</Show>
<Show when={error()}>
<div class="message message-error">{error()}</div>
</Show>
<Show when={message()}>
<div class="message message-neutral">{message()}</div>
</Show>
<div class="storage-grid">
{/* connections */}
<div class="card card-flush">
<div
class="card-header"
style="padding:1rem 1rem 0.75rem;margin-bottom:0;border-bottom:1px solid var(--border)"
>
<span class="card-header-title">Connections</span>
</div>
<div class="p-1.5">
<For
each={conns()}
fallback={
<div class="p-4 text-center">
<p class="text-xs text-text-3">
{loading() ? "Loading…" : "No connections yet"}
</p>
</div>
}
>
{(conn) => (
<button
class={`nav-item w-full ${conn.id === activeID() ? "active" : ""}`}
onClick={() => void select(conn.id)}
>
<IconDatabases class="w-4 h-4" />
<span class="flex-1 truncate">{conn.name}</span>
<Badge variant={conn.is_managed ? "primary" : "neutral"}>{conn.db_type}</Badge>
</button>
)}
</For>
</div>
</div>
{/* workspace */}
<div class="card card-flush" style="min-height:28rem">
<Show
when={active()}
fallback={
<div class="empty-state" style="min-height:28rem">
<div class="empty-state-icon">
<IconDatabases class="w-5 h-5" />
</div>
<p class="empty-state-title">Select a connection</p>
<p class="empty-state-description">
Choose a connection on the left to browse its databases and tables.
</p>
</div>
}
>
<div
class="card-header"
style="padding:1rem 1.25rem;margin-bottom:0;border-bottom:1px solid var(--border)"
>
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="card-header-title">{active()!.name}</span>
<Show when={active()!.is_managed}>
<Badge variant="primary">managed</Badge>
</Show>
</div>
<span class="card-header-description">
{active()!.db_type}
{active()!.host ? ` · ${active()!.host}` : ""}
{active()!.port ? `:${active()!.port}` : ""}
{active()!.database ? ` / ${active()!.database}` : ""}
</span>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-secondary btn-sm"
onClick={() => void testConnection(active()!)}
disabled={testBusy() === active()!.id}
>
<IconZap class="w-3.5 h-3.5" />
{testBusy() === active()!.id ? "Testing…" : "Test"}
</button>
<Show when={!active()!.is_managed}>
<button
class="icon-btn"
title="Delete connection"
onClick={() => setConfirmDelete(active()!.id)}
disabled={!props.canManage}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</div>
</div>
</div>
{/* tabs */}
<div class="flex items-center gap-1 px-4 pt-3 border-b" style="border-color:var(--border)">
<Show when={sqlish(active()!.db_type)}>
<button
class={`btn btn-ghost btn-sm ${tab() === "browse" ? "text-accent" : ""}`}
onClick={() => setTab("browse")}
>
Browse
</button>
<button
class={`btn btn-ghost btn-sm ${tab() === "query" ? "text-accent" : ""}`}
onClick={() => setTab("query")}
>
Query
</button>
</Show>
<Show when={!sqlish(active()!.db_type)}>
<button class="btn btn-ghost btn-sm text-accent">
<IconTerminal class="w-3.5 h-3.5" />
Console
</button>
</Show>
</div>
{/* browse tab */}
<Show when={tab() === "browse" && sqlish(active()!.db_type)}>
<div class="flex items-end gap-2 px-4 pt-3 flex-wrap">
<div style="min-width:10rem">
<span class="label">Database</span>
<Select
class="input-sm"
value={selectedDB()}
onChange={(e) => {
setSelectedDB(e.currentTarget.value);
setDescribed(undefined);
void loadTables(active()!, e.currentTarget.value, appliedSchema());
}}
>
<For each={databases()}>{(d) => <option value={d}>{d}</option>}</For>
</Select>
</div>
<div style="width:9rem">
<span class="label">Schema</span>
<Input
class="input-sm"
placeholder="default"
value={schemaInput()}
onInput={(e) => setSchemaInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setAppliedSchema(schemaInput().trim());
setDescribed(undefined);
void loadTables(active()!, selectedDB(), schemaInput().trim());
}
}}
/>
</div>
<button
class="icon-btn"
title="Refresh tables"
disabled={browseBusy()}
onClick={() => {
setAppliedSchema(schemaInput().trim());
setDescribed(undefined);
void loadTables(active()!, selectedDB(), schemaInput().trim());
}}
>
<IconRefresh class="w-4 h-4" />
</button>
</div>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>Table</th>
<th style="width:1%">Kind</th>
<th style="width:1%" />
</tr>
</thead>
<tbody>
<For
each={tables() ?? []}
fallback={
<tr>
<td colspan={3} class="py-10 text-center text-text-3">
{browseBusy()
? "Loading…"
: tablesRaw() ||
"No tables found. Pick a database or enter a schema name."}
</td>
</tr>
}
>
{(t) => (
<tr
class={`clickable ${described()?.name === t.name ? "bg-surface-2" : ""}`}
onClick={() => void describe(t.name)}
>
<td class="font-mono text-xs text-text-1">{t.name}</td>
<td class="text-xs text-text-3 whitespace-nowrap">{t.kind}</td>
<td>
<IconChevronRight class="w-4 h-4 text-text-3" />
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Show when={described()}>
<div class="px-4 py-3 border-t" style="border-color:var(--border)">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-medium text-text-1">{described()!.name}</span>
<Show when={describeBusy()}>
<span class="text-xs text-text-3">loading…</span>
</Show>
</div>
<Show
when={described()!.table}
fallback={
<pre class="text-xs text-text-2 whitespace-pre-wrap font-mono">
{described()!.raw ?? "No column metadata returned."}
</pre>
}
>
{(t) => <ResultTable table={t()} />}
</Show>
</div>
</Show>
</Show>
{/* query tab */}
<Show when={tab() === "query" && sqlish(active()!.db_type)}>
<div class="px-4 pt-3">
<Textarea
class="font-mono text-sm"
rows={5}
value={sql()}
onInput={(e) => setSql(e.currentTarget.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void runQuery();
}
}}
placeholder="SELECT * FROM …"
/>
<div class="flex items-center gap-2 mt-2">
<button
class="btn btn-primary btn-sm"
onClick={() => void runQuery()}
disabled={queryBusy() || !sql().trim()}
>
<IconPlay class="w-3.5 h-3.5" />
{queryBusy() ? "Running…" : "Run query"}
</button>
<span class="text-xs text-text-3">⌘/Ctrl + Enter</span>
</div>
</div>
<Show when={queryResult()}>
<div class="mt-3 border-t" style="border-color:var(--border)">
<Show
when={queryResult()!.table}
fallback={
<pre class="p-4 text-xs text-text-2 whitespace-pre-wrap font-mono">
{queryResult()!.raw ?? "No result returned."}
</pre>
}
>
{(t) => (
<ResultTable
table={t()}
onRow={(row) => setDetailRow({ columns: t().columns, row })}
/>
)}
</Show>
</div>
</Show>
</Show>
{/* redis console */}
<Show when={tab() === "console" || !sqlish(active()!.db_type)}>
<div class="px-4 pt-3">
<div class="flex items-center gap-2">
<Input
class="input-sm font-mono flex-1"
placeholder="PING, GET key, INFO…"
value={redisCmd()}
onInput={(e) => setRedisCmd(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void runRedis();
}}
/>
<button
class="btn btn-primary btn-sm"
onClick={() => void runRedis()}
disabled={consoleBusy() || !redisCmd().trim()}
>
<IconPlay class="w-3.5 h-3.5" />
Run
</button>
</div>
</div>
<div class="p-4 space-y-3">
<For
each={consoleLog()}
fallback={<p class="text-xs text-text-3">Run a command to see output here.</p>}
>
{(entry) => (
<div>
<div class="font-mono text-xs text-accent">$ {entry.cmd}</div>
<pre class="font-mono text-xs text-text-2 whitespace-pre-wrap mt-1">
{entry.out}
</pre>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
{/* row detail */}
<Modal
open={!!detailRow()}
onClose={() => setDetailRow(undefined)}
title="Row detail"
size="lg"
>
<div class="table-container">
<table class="table">
<tbody>
<For each={detailRow()?.columns ?? []}>
{(col, i) => (
<tr>
<td class="font-mono text-xs text-text-3 whitespace-nowrap">{col}</td>
<td class="font-mono text-xs text-text-1 whitespace-pre-wrap">
{detailRow()!.row[i()]}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Modal>
{/* add connection */}
<Modal
open={addOpen()}
onClose={() => setAddOpen(false)}
title="Add connection"
description="Credentials are stored server-side and never sent back to the browser."
>
<div class="space-y-4">
<div>
<span class="label">Name</span>
<Input
placeholder="analytics-replica"
value={form().name}
onInput={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
</div>
<div>
<span class="label">Type</span>
<Select
value={form().db_type}
onChange={(e) => setForm((f) => ({ ...f, db_type: e.currentTarget.value }))}
>
<For each={dbTypes}>{(t) => <option value={t}>{t}</option>}</For>
</Select>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<span class="label">{form().db_type === "sqlite" ? "File path" : "Host"}</span>
<Input
placeholder={form().db_type === "sqlite" ? "/data/app.db" : "db.internal"}
value={form().host}
onInput={(e) => setForm((f) => ({ ...f, host: e.currentTarget.value }))}
/>
</div>
<div>
<span class="label">Port</span>
<Input
placeholder="5432"
inputmode="numeric"
value={form().port}
onInput={(e) => setForm((f) => ({ ...f, port: e.currentTarget.value }))}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="label">Database</span>
<Input
value={form().database}
onInput={(e) => setForm((f) => ({ ...f, database: e.currentTarget.value }))}
/>
</div>
<div>
<span class="label">Username</span>
<Input
value={form().username}
onInput={(e) => setForm((f) => ({ ...f, username: e.currentTarget.value }))}
/>
</div>
</div>
<div>
<span class="label">Password</span>
<Input
type="password"
value={form().password}
onInput={(e) => setForm((f) => ({ ...f, password: e.currentTarget.value }))}
/>
</div>
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
checked={form().ssl}
onChange={(e) => setForm((f) => ({ ...f, ssl: e.currentTarget.checked }))}
/>
Require SSL
</label>
</div>
<ModalFooter>
<button class="btn btn-ghost" onClick={() => setAddOpen(false)}>
Cancel
</button>
<button
class="btn btn-primary"
onClick={() => void createConnection()}
disabled={addBusy() || !form().name.trim() || !form().host.trim()}
>
{addBusy() ? "Adding…" : "Add connection"}
</button>
</ModalFooter>
</Modal>
{/* delete confirm */}
<Modal
open={!!confirmDelete()}
onClose={() => setConfirmDelete(undefined)}
title="Delete connection"
size="sm"
>
<p class="text-sm text-text-2">
This removes the saved connection and its DBX profile. This cannot be undone.
</p>
<ModalFooter>
<button class="btn btn-ghost" onClick={() => setConfirmDelete(undefined)}>
Cancel
</button>
<button
class="btn btn-danger"
onClick={() => {
const id = confirmDelete();
setConfirmDelete(undefined);
if (id) void removeConnection(id);
}}
>
Delete permanently
</button>
</ModalFooter>
</Modal>
</div>
);
}
+1
View File
@@ -4,6 +4,7 @@ export { ProjectsPage } from "./ProjectsPage";
export { MembersPage } from "./MembersPage";
export { StoragePage } from "./StoragePage";
export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";