diff --git a/apps/api/src/api/servers/index.ts b/apps/api/src/api/servers/index.ts index 642fcaf..8910a07 100644 --- a/apps/api/src/api/servers/index.ts +++ b/apps/api/src/api/servers/index.ts @@ -6,8 +6,14 @@ import { testSshConnection } from "../../utils/ssh"; import { isPort, SERVER_HOST_RE } from "../../utils/validate"; import { created, fail, ok } from "../response"; +const redactServer = (server: T): T => { + if (server.sshKey) server.sshKey = "[redacted]"; + if (server.sshPassword) server.sshPassword = "[redacted]"; + return server; +}; + export const serversRoutes = new Elysia() - .get("/servers", async () => ok(await listServers())) + .get("/servers", async () => ok((await listServers()).map((server) => redactServer(server)))) .post("/servers", async ({ body, set }: any) => { if (!body?.name || !body?.host) { set.status = 400; @@ -26,17 +32,19 @@ export const serversRoutes = new Elysia() return fail("port must be between 1 and 65535"); } return created( - await createServer({ - name: body.name, - host: body.host, - port: body.port ? Number(body.port) : body.mode === "ssh" ? 22 : 2375, - mode: body.mode ?? "ssh", - sshUser: body.sshUser ?? "root", - sshKey: body.sshKey, - sshKeyId: body.sshKeyId, - sshPassword: body.sshPassword, - authToken: body.authToken, - }), + redactServer( + await createServer({ + name: body.name, + host: body.host, + port: body.port ? Number(body.port) : body.mode === "ssh" ? 22 : 2375, + mode: body.mode ?? "ssh", + sshUser: body.sshUser ?? "root", + sshKey: body.sshKey, + sshKeyId: body.sshKeyId, + sshPassword: body.sshPassword, + authToken: body.authToken, + }), + ), ); }) .get("/servers/:id", async ({ params: { id }, set }) => { @@ -45,7 +53,7 @@ export const serversRoutes = new Elysia() set.status = 404; return fail("Server not found"); } - return ok(server); + return ok(redactServer(server)); }) .get("/servers/:id/stats", async ({ params: { id }, set }) => { const server = await getServerById(id); @@ -166,8 +174,8 @@ export const serversRoutes = new Elysia() return fail("Server not found"); } if (server.mode === "ssh") { - const passed = await testSshConnection(server); - return ok({ ok: passed, mode: "ssh" }); + const result = await testSshConnection(server); + return ok({ ...result, mode: "ssh" }); } return ok({ ok: server.status === "connected", mode: server.mode }); }) diff --git a/apps/api/src/databases/__tests__/validation.test.ts b/apps/api/src/databases/__tests__/validation.test.ts index 6e66820..d626c89 100644 --- a/apps/api/src/databases/__tests__/validation.test.ts +++ b/apps/api/src/databases/__tests__/validation.test.ts @@ -25,6 +25,23 @@ describe("database input validation", () => { expect(result.ok).toBe(true); }); + it("preserves explicit backup settings including disabled backup", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + publicAccess: false, + backupEnabled: false, + backupSchedule: "0 0 * * *", + backupRetention: 14, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.input.backupEnabled).toBe(false); + expect(result.input.backupSchedule).toBe("0 0 * * *"); + expect(result.input.backupRetention).toBe(14); + } + }); + it("validates IPv4 CIDRs and rejects IPv6 ranges", () => { expect(isValidCidr("10.0.0.0/8")).toBe(true); expect(isValidCidr("203.0.113.4")).toBe(true); diff --git a/apps/api/src/databases/validation.ts b/apps/api/src/databases/validation.ts index 718ff12..5db70bb 100644 --- a/apps/api/src/databases/validation.ts +++ b/apps/api/src/databases/validation.ts @@ -77,6 +77,12 @@ export const validateDatabaseCreate = ( publicAccess, allowPublicAccessFromAnywhere, allowedCidrs, + backupEnabled: body.backupEnabled !== undefined ? Boolean(body.backupEnabled) : true, + backupSchedule: + typeof body.backupSchedule === "string" && body.backupSchedule.trim() + ? body.backupSchedule.trim() + : "0 */6 * * *", + backupRetention: typeof body.backupRetention === "number" && body.backupRetention > 0 ? body.backupRetention : 7, }, }; }; diff --git a/apps/api/src/db/repo/servers.ts b/apps/api/src/db/repo/servers.ts index c7b9d69..4180f58 100644 --- a/apps/api/src/db/repo/servers.ts +++ b/apps/api/src/db/repo/servers.ts @@ -6,6 +6,7 @@ import { decryptValue, encryptValue } from "../../utils/crypto"; import { getDb } from "../db-provider"; import { servers } from "../schema"; import { formatTimestamp, getRowsAffected, now } from "./helpers"; +import { resolveServerSshKey } from "./ssh-keys"; const mapServer = (row: typeof servers.$inferSelect): Server => ({ id: row.id, @@ -37,6 +38,14 @@ const mapServer = (row: typeof servers.$inferSelect): Server => ({ updatedAt: formatTimestamp(row.updatedAt), }); +const hydrateSshKey = async (server: T): Promise => { + if (server.sshKeyId && !server.sshKey) { + const pooled = await resolveServerSshKey({ sshKeyId: server.sshKeyId }); + if (pooled) server.sshKey = pooled; + } + return server; +}; + const parseJsonObject = (value: unknown): Record => { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value; if (typeof value !== "string") return {}; @@ -78,7 +87,7 @@ export const createServer = async (input: CreateServerInput): Promise => }) .execute(); const [row] = await db.select().from(servers).where(eq(servers.id, id)).execute(); - return mapServer(row); + return hydrateSshKey(mapServer(row)); }; export interface ServerConnection { @@ -111,15 +120,19 @@ export const listServerConnections = async (): Promise => { }) .from(servers) .execute(); - return rows.map((row) => ({ - ...row, - mode: row.mode as ServerMode, - sshKey: - row.sshKey && row.sshKeyIv && row.sshKeyTag - ? decryptValue(row.sshKey, row.sshKeyIv, row.sshKeyTag, config.envEncryptionKey) - : (row.sshKey ?? null), - sshKeyId: row.sshKeyId ?? null, - })); + return Promise.all( + rows.map((row) => + hydrateSshKey({ + ...row, + mode: row.mode as ServerMode, + sshKey: + row.sshKey && row.sshKeyIv && row.sshKeyTag + ? decryptValue(row.sshKey, row.sshKeyIv, row.sshKeyTag, config.envEncryptionKey) + : (row.sshKey ?? null), + sshKeyId: row.sshKeyId ?? null, + }), + ), + ); }; export const ensureLocalServer = async (): Promise => { @@ -151,13 +164,13 @@ export const ensureLocalServer = async (): Promise => { export const listServers = async (): Promise => { const db = await getDb(); const rows = await db.select().from(servers).orderBy(servers.name).execute(); - return rows.map(mapServer); + return Promise.all(rows.map((row) => hydrateSshKey(mapServer(row)))); }; export const getServerById = async (id: string): Promise => { const db = await getDb(); const [row] = await db.select().from(servers).where(eq(servers.id, id)).execute(); - return row ? mapServer(row) : null; + return row ? hydrateSshKey(mapServer(row)) : null; }; export const updateServerStatus = async ( diff --git a/apps/api/src/utils/__tests__/ingress.test.ts b/apps/api/src/utils/__tests__/ingress.test.ts index 7048758..433f6e3 100644 --- a/apps/api/src/utils/__tests__/ingress.test.ts +++ b/apps/api/src/utils/__tests__/ingress.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it, mock } from "bun:test"; import { ingressSite, projectServerSite, shouldRouteViaIngress } from "../ingress"; describe("ingress route shapes", () => { - it("writes a :80 listener site when the project server is behind the ingress", () => { + it("writes an HTTP-only hostname site when the project server is behind the ingress", () => { const snippet = projectServerSite("app.example.com", 3000, ["deploy-dep-1"], true); - expect(snippet).toContain(":80 {"); + expect(snippet).toContain("http://app.example.com {"); + expect(snippet).not.toContain(":80 {"); expect(snippet).toContain("reverse_proxy deploy-dep-1:3000"); }); diff --git a/apps/api/src/utils/ingress.ts b/apps/api/src/utils/ingress.ts index 929b04a..cdb3936 100644 --- a/apps/api/src/utils/ingress.ts +++ b/apps/api/src/utils/ingress.ts @@ -41,7 +41,7 @@ export const projectServerSite = ( const targets = containers.map((c) => `${c}:${port}`).join(" "); const proxy = caddyReverseProxy(targets); if (viaIngress) { - return caddySite(":80", proxy); + return caddySite(`http://${hostname}`, proxy); } return caddySite(hostname, proxy); }; diff --git a/apps/api/src/utils/ssh.ts b/apps/api/src/utils/ssh.ts index 520172f..1d51a7a 100644 --- a/apps/api/src/utils/ssh.ts +++ b/apps/api/src/utils/ssh.ts @@ -23,28 +23,6 @@ export const ensureSshKey = (server: { return writeKeyToDisk(server.host, server.sshKey, server.id); }; -export const ensureSshKeyAsync = async (server: { - host: string; - port?: number; - sshUser?: string | null; - sshKey?: string | null; - sshKeyId?: string | null; - id?: string; -}): Promise => { - if (server.sshKeyId) { - const { resolveServerSshKey } = await import("../db/repo/ssh-keys"); - const resolved = await resolveServerSshKey({ - sshKeyId: server.sshKeyId, - sshKey: server.sshKey ?? undefined, - sshKeyIv: undefined, - sshKeyTag: undefined, - }); - if (resolved) return writeKeyToDisk(server.host, resolved, server.id); - } - if (server.sshKey) return writeKeyToDisk(server.host, server.sshKey, server.id); - return null; -}; - const writeKeyToDisk = (host: string, sshKey: string, id?: string): string | null => { if (!existsSync(SSH_KEYS_DIR)) { mkdirSync(SSH_KEYS_DIR, { recursive: true, mode: 0o700 }); @@ -80,32 +58,76 @@ export const getDockerSshTarget = ( return `ssh://${user}@${server.host}:${port}`; }; -export const testSshConnection = (server: { +export interface SshConnectionResult { + ok: boolean; + ssh: boolean; + docker: boolean; + detail: string; +} + +const probeDockerOverSsh = (server: { host: string; port?: number; sshUser?: string | null; sshKey?: string | null; id?: string; -}): Promise => { - return new Promise((resolve) => { +}): Promise<{ ok: boolean; detail: string }> => + new Promise((resolve) => { const target = getDockerSshTarget(server); const child = spawn("docker", ["-H", target, "info", "--format", "{{.ServerVersion}}"], { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, }); let output = ""; + let errors = ""; child.stdout?.on("data", (chunk) => { output += String(chunk); }); + child.stderr?.on("data", (chunk) => { + errors += String(chunk); + }); child.on("close", (code) => { - resolve(code === 0 && output.trim().length > 0); + const version = output.trim(); + if (code === 0 && version) { + resolve({ ok: true, detail: `Docker ${version}` }); + return; + } + resolve({ ok: false, detail: (errors || output).trim() || `docker info exited with code ${code}` }); }); - child.on("error", () => resolve(false)); + child.on("error", (err) => resolve({ ok: false, detail: err instanceof Error ? err.message : String(err) })); child.on("timeout", () => { child.kill(); - resolve(false); + resolve({ ok: false, detail: "Timed out contacting the remote Docker daemon" }); }); }); + +export const testSshConnection = async (server: { + host: string; + port?: number; + sshUser?: string | null; + sshKey?: string | null; + id?: string; +}): Promise => { + const probe = await execRemoteCommand(server, "echo dequel-ok").catch((err: unknown) => ({ + code: 1, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + })); + if (probe.code !== 0 || probe.stdout.trim() !== "dequel-ok") { + return { + ok: false, + ssh: false, + docker: false, + detail: probe.stderr || probe.stdout || `ssh exited with code ${probe.code}`, + }; + } + const docker = await probeDockerOverSsh(server); + return { + ok: docker.ok, + ssh: true, + docker: docker.ok, + detail: docker.ok ? docker.detail : `SSH connected, but Docker is unavailable: ${docker.detail}`, + }; }; export const execDockerSshCommand = ( @@ -165,22 +187,26 @@ export const syncRemoteCaddyRoute = ( server: Server | { host: string; port?: number; sshUser?: string | null }, filename: string, content: string, -): Promise => { - return new Promise((resolve) => { +): Promise => + new Promise((resolve, reject) => { + const rejectRoute = (reason: string) => + reject(new Error(`Failed to sync Caddy route ${filename} to ${server.host}: ${reason}`)); + if (!/^[a-zA-Z0-9._-]+$/.test(filename) || filename.includes("..")) { - resolve(false); + rejectRoute("invalid route filename"); return; } const blockPattern = /^([^\n{]+?)\s*\{/gm; let match: RegExpExecArray | null; while ((match = blockPattern.exec(content)) !== null) { - const domainLine = match[1].trim(); - const domains = domainLine.split(",").map((d) => d.trim()); + const domains = match[1] + .trim() + .split(",") + .map((d) => d.trim()); for (const d of domains) { if (/^:\d+$/.test(d)) { - console.error(`Rejected catch-all Caddy route in ${filename}: "${d}" — must include a hostname`); - resolve(false); + rejectRoute(`route block "${d}" must include a hostname`); return; } } @@ -200,7 +226,7 @@ export const syncRemoteCaddyRoute = ( "ConnectTimeout=10", ...keyArgs, `${user}@${server.host}`, - `sudo mkdir -p /etc/caddy/routes && sudo tee /etc/caddy/routes/${filename} > /dev/null && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)`, + `sudo mkdir -p /etc/caddy/routes && sudo tee /etc/caddy/routes/${filename} > /dev/null && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload --config /etc/caddy/Caddyfile || true)`, ], { stdio: ["pipe", "pipe", "pipe"] }, ); @@ -208,12 +234,19 @@ export const syncRemoteCaddyRoute = ( sshCmd.stdin?.write(content); sshCmd.stdin?.end(); + let stderr = ""; + sshCmd.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); sshCmd.on("close", (code) => { - resolve(code === 0); + if (code === 0) { + resolve(); + return; + } + rejectRoute(stderr.trim() || `ssh exited with code ${code}`); }); - sshCmd.on("error", () => resolve(false)); + sshCmd.on("error", (err) => rejectRoute(err.message)); }); -}; export const removeRemoteCaddyRoute = ( server: Server | { host: string; port?: number; sshUser?: string | null; sshKey?: string | null; id?: string }, @@ -239,7 +272,7 @@ export const removeRemoteCaddyRoute = ( "ConnectTimeout=10", ...keyArgs, `${user}@${server.host}`, - `sudo rm -f /etc/caddy/routes/${filename} && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)`, + `sudo rm -f /etc/caddy/routes/${filename} && (sudo systemctl reload caddy || sudo caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload --config /etc/caddy/Caddyfile || true)`, ], { stdio: ["ignore", "pipe", "pipe"] }, ); diff --git a/apps/docs/src/content/changelogs/v0.4.0.md b/apps/docs/src/content/changelogs/v0.4.0.md index 0eb4236..505c5f3 100644 --- a/apps/docs/src/content/changelogs/v0.4.0.md +++ b/apps/docs/src/content/changelogs/v0.4.0.md @@ -12,6 +12,7 @@ date: "2026-10-03" - Add shared environment variable link support and UI components ([86205cb](https://github.com/Lftobs/dequel/commit/86205cb4f8ab7e5a82f9aa752df30b6da72e6be0)) - Add project health and failure alerts ([a42c2b3](https://github.com/Lftobs/dequel/commit/a42c2b3e3526dbf98ea83d902971b4e09075739d)) - Track anonymous usage events ([8abdf74](https://github.com/Lftobs/dequel/commit/8abdf7420877621e10ed277ccb63a70df97a3523)) +- Add inline MongoDB document editing ([4ead155](https://github.com/Lftobs/dequel/commit/4ead15502a7aaa24cd09b2477409dea1c277a494)) ### Improvements @@ -33,3 +34,4 @@ date: "2026-10-03" - Address CodeRabbit review comments on AI diagnosis PR ([0a1a724](https://github.com/Lftobs/dequel/commit/0a1a72441919cf528f83c0289c82a3375901b018)) - Chown the sandbox job dir to bun before starting the runner ([bca4f14](https://github.com/Lftobs/dequel/commit/bca4f1470fb785e899be203e9f8769ec2b578deb)) - Add MongoDB and Redis database studios ([5551981](https://github.com/Lftobs/dequel/commit/55519810bf8ae84387b4430188b39e1f42e1359b)) +- Redact credentials and improve SSH checks ([a5c3de8](https://github.com/Lftobs/dequel/commit/a5c3de8b6238cde2e08295f1f2be85afcf1cf584)) diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx deleted file mode 100644 index 3d171b3..0000000 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Cpu, Database, HardDrive, ShieldAlert } from "lucide-react"; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import * as api from "../../api/client"; -import type { DatabaseType, Project } from "../../types"; -import { Button } from "../ui/button"; -import { DATABASE_ENGINES } from "../ui/DatabaseSelect"; -import { getDatabaseLogo } from "../logos/DatabaseLogos"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; -import { Input } from "../ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; -import { ServerSelect } from "./ServerSelect"; - -interface CreateDatabaseDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - projects: Project[]; - defaultProjectId?: string | null; - onCreated: () => void; -} - -export function CreateDatabaseDialog({ - open, - onOpenChange, - projects, - defaultProjectId = null, - onCreated, -}: CreateDatabaseDialogProps) { - const [name, setName] = useState(""); - const [projectId, setProjectId] = useState(defaultProjectId ?? "standalone"); - const [type, setType] = useState("postgresql"); - const [version, setVersion] = useState("16"); - const [cpu, setCpu] = useState("1"); - const [memory, setMemory] = useState("512"); - const [storage, setStorage] = useState("10240"); - const [allowAnywhere, setAllowAnywhere] = useState(false); - const [cidrs, setCidrs] = useState(""); - const [serverId, setServerId] = useState("local"); - const [isCreating, setIsCreating] = useState(false); - const [error, setError] = useState(null); - - const { data: servers = [] } = useQuery({ - queryKey: ["servers"], - queryFn: () => api.listServers().catch(() => []), - }); - - const create = async () => { - setIsCreating(true); - setError(null); - try { - await api.createDatabase(projectId === "standalone" ? null : projectId, type, { - name, - version, - serverId, - cpuLimit: Number(cpu), - memoryLimitMb: Number(memory), - storageLimitMb: Number(storage), - publicAccess: true, - allowPublicAccessFromAnywhere: allowAnywhere, - allowedCidrs: cidrs - .split(/[\n,]/) - .map((value) => value.trim()) - .filter(Boolean), - }); - onCreated(); - onOpenChange(false); - setName(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Database creation failed"); - } finally { - setIsCreating(false); - } - }; - - return ( - - - -
-
- -
-
- Provision Managed Database - - Public access endpoint enabled with optional CIDR network IP allowlisting. - -
-
-
- -
-
- - setName(event.target.value)} - placeholder="e.g. Production PostgreSQL DB" - className="bg-background/50 border-border/80 text-xs focus:ring-orange-500/50" - /> -
- -
-
- - -
- -
- - -
-
- - - -
- - -
- -
- - - Resource Limits & Storage Allocation - -
-
- - setCpu(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
- - setMemory(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
- - setStorage(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
-
- -
-
-
-

- - Allow Access From Anywhere -

-

- Uncheck to restrict connection requests to specific IP ranges. -

-
- setAllowAnywhere(event.target.checked)} - className="h-4 w-4 accent-orange-500 rounded" - aria-label="Allow public database access from anywhere" - /> -
- - {!allowAnywhere && ( -
- - setCidrs(event.target.value)} - placeholder="203.0.113.4/32, 10.0.0.0/8" - className="bg-background/50 border-border/80 font-mono text-xs" - /> -
- )} -
- - {error && ( -

- - {error} -

- )} - -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/components/databases/DatabaseOverview.tsx b/apps/web/src/components/databases/DatabaseOverview.tsx index 72da21b..41c6fed 100644 --- a/apps/web/src/components/databases/DatabaseOverview.tsx +++ b/apps/web/src/components/databases/DatabaseOverview.tsx @@ -12,70 +12,46 @@ interface DatabaseOverviewProps { export function DatabaseOverview({ database, creds }: DatabaseOverviewProps) { const [showPassword, setShowPassword] = useState(false); - const [copied, setCopied] = useState(null); + const [copied, setCopied] = useState(false); - const copyToClipboard = (text: string, label: string) => { + const connectionUrl = creds?.externalConnectionString || creds?.internalConnectionString || database.connectionString; + + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - setCopied(label); - setTimeout(() => setCopied(null), 2000); + setCopied(true); + setTimeout(() => setCopied(false), 2000); }; return ( - Connection Strings & Credentials + Connection String & Credentials - Use internal mesh URL inside Dequel project containers, or external endpoint for remote clients. + Connect your applications and database clients using this connection URL.
- +
- {creds?.externalConnectionString && ( -
- -
- - -
-
- )} -
Username diff --git a/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx b/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx index 0db36aa..24ff32e 100644 --- a/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx +++ b/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx @@ -55,7 +55,7 @@ export function MongoCollectionSidebar({
- + Collections
@@ -63,7 +63,7 @@ export function MongoCollectionSidebar({ variant="ghost" size="sm" onClick={() => setShowCreateModal(true)} - className="h-7 w-7 p-0 text-muted-foreground hover:text-emerald-400 rounded-lg" + className="h-7 w-7 p-0 text-muted-foreground hover:text-orange-400 rounded-lg" title="New Collection" > @@ -104,7 +104,7 @@ export function MongoCollectionSidebar({ onClick={() => onSelectCollection(c.name)} className={`w-full text-left font-mono text-xs px-3 py-2.5 rounded-xl transition-all flex items-center justify-between border ${ isSelected - ? "bg-gradient-to-r from-emerald-500/20 to-teal-500/10 border-emerald-500/50 text-emerald-300 font-semibold shadow-md" + ? "bg-gradient-to-r from-orange-500/20 to-amber-500/10 border-orange-500/50 text-orange-400 font-semibold shadow-md" : "bg-background/20 border-transparent text-foreground hover:bg-white/5" }`} > @@ -133,7 +133,7 @@ export function MongoCollectionSidebar({
- Create Collection + Create Collection Add a new document collection to the MongoDB database. @@ -167,7 +167,7 @@ export function MongoCollectionSidebar({ type="submit" size="sm" disabled={isCreating || !newCollectionName.trim()} - className="bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium" + className="bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium" > {isCreating ? "Creating…" : "Create Collection"} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx index 5ad45b3..598f53f 100644 --- a/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx @@ -62,12 +62,12 @@ export function MongoDocumentCard({ doc, index, onEdit, onDelete }: MongoDocumen }; return ( -
+
_id: {docId} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx index 015b692..a5c953a 100644 --- a/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx @@ -1,4 +1,4 @@ -import { Braces, Check, Copy, FileJson } from "lucide-react"; +import { Braces, FileJson } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "../../../ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../ui/dialog"; @@ -76,9 +76,9 @@ export function MongoDocumentModal({
- + {isEdit ? "Edit Document" : "Insert Document"} —{" "} - {collectionName} + {collectionName} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentsTableView.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentsTableView.tsx new file mode 100644 index 0000000..02cf4ae --- /dev/null +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentsTableView.tsx @@ -0,0 +1,276 @@ +import { Braces, Check, Edit3, Trash2, X } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "../../../ui/badge"; +import { Button } from "../../../ui/button"; + +interface MongoDocumentsTableViewProps { + docs: Record[]; + columns: string[]; + onInsertDocument: (doc: Record) => Promise; + onUpdateDocument: (doc: Record) => Promise; + onDeleteDocument: (id: string) => Promise; + onOpenEditModal: (doc: Record) => void; + isAddingInline: boolean; + onCancelAddInline: () => void; +} + +const parseFieldValue = (v: string): unknown => { + const trimmed = v.trim(); + if (trimmed === "" || trimmed === "null") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (!isNaN(Number(trimmed)) && trimmed !== "") return Number(trimmed); + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) { + try { + return JSON.parse(trimmed); + } catch { + return v; + } + } + return v; +}; + +export function MongoDocumentsTableView({ + docs, + columns, + onInsertDocument, + onUpdateDocument, + onDeleteDocument, + onOpenEditModal, + isAddingInline, + onCancelAddInline, +}: MongoDocumentsTableViewProps) { + const [newRowValues, setNewRowValues] = useState>({}); + const [editingRowIndex, setEditingRowIndex] = useState(null); + const [editingRowValues, setEditingRowValues] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + const handleSaveInlineAdd = async () => { + setIsSaving(true); + try { + const doc: Record = {}; + Object.entries(newRowValues).forEach(([k, v]) => { + if (v !== undefined && v !== "") { + doc[k] = parseFieldValue(v); + } + }); + await onInsertDocument(doc); + setNewRowValues({}); + onCancelAddInline(); + } finally { + setIsSaving(false); + } + }; + + const handleStartInlineEdit = (idx: number, doc: Record) => { + const init: Record = {}; + columns.forEach((col) => { + const val = doc[col]; + init[col] = val === null || val === undefined ? "" : typeof val === "object" ? JSON.stringify(val) : String(val); + }); + setEditingRowIndex(idx); + setEditingRowValues(init); + }; + + const handleSaveInlineEdit = async (idx: number) => { + setIsSaving(true); + try { + const originalDoc = docs[idx]; + const doc: Record = { ...originalDoc }; + Object.entries(editingRowValues).forEach(([k, v]) => { + if (k === "_id") return; + doc[k] = parseFieldValue(v); + }); + await onUpdateDocument(doc); + setEditingRowIndex(null); + setEditingRowValues({}); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + + {/* Inline Adding Row */} + {isAddingInline && ( + + {columns.map((col) => { + if (col === "_id") { + return ( + + ); + } + return ( + + ); + })} + + + )} + + {/* Document Rows */} + {docs.map((doc, idx) => { + const docId = String(doc._id ?? doc.id ?? idx); + const isEditing = editingRowIndex === idx; + + if (isEditing) { + return ( + + {columns.map((col) => { + if (col === "_id") { + return ( + + ); + } + return ( + + ); + })} + + + ); + } + + return ( + + {columns.map((col) => { + const val = doc[col]; + const isObject = typeof val === "object" && val !== null; + return ( + + ); + })} + + + ); + })} + +
+ {col} + Actions
+ (auto generated) + + setNewRowValues({ ...newRowValues, [col]: e.target.value })} + placeholder={`enter ${col}...`} + className="w-full bg-black/80 border border-orange-500/60 focus:border-orange-400 text-xs font-mono text-orange-300 px-2.5 py-1 rounded-lg focus:outline-none" + /> + +
+ + +
+
+ {String(doc._id)} + + setEditingRowValues({ ...editingRowValues, [col]: e.target.value })} + className="w-full bg-black/80 border border-orange-500/60 focus:border-orange-400 text-xs font-mono text-orange-300 px-2.5 py-1 rounded-lg focus:outline-none" + /> + +
+ + +
+
+ {isObject ? ( + + {JSON.stringify(val)} + + ) : val === null || val === undefined ? ( + null + ) : typeof val === "boolean" ? ( + {String(val)} + ) : typeof val === "number" ? ( + {val} + ) : ( + {String(val)} + )} + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentsView.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentsView.tsx index e65b40f..68e847d 100644 --- a/apps/web/src/components/databases/studio/mongo/MongoDocumentsView.tsx +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentsView.tsx @@ -1,23 +1,21 @@ import { + Braces, ChevronLeft, ChevronRight, Database, - Edit3, FileJson, Filter, - Layers, Plus, RefreshCw, SlidersHorizontal, Table as TableIcon, - Trash2, } from "lucide-react"; import { useState } from "react"; import type { QueryExecResult } from "../../../../types"; -import { Badge } from "../../../ui/badge"; import { Button } from "../../../ui/button"; import { MongoDocumentCard } from "./MongoDocumentCard"; import { MongoDocumentModal } from "./MongoDocumentModal"; +import { MongoDocumentsTableView } from "./MongoDocumentsTableView"; interface MongoDocumentsViewProps { selectedCollection: string | null; @@ -54,19 +52,29 @@ export function MongoDocumentsView({ onUpdateDocument, onDeleteDocument, }: MongoDocumentsViewProps) { - const [viewMode, setViewMode] = useState<"json" | "table">("json"); + const [viewMode, setViewMode] = useState<"json" | "table">("table"); const [isModalOpen, setIsModalOpen] = useState(false); const [editingDoc, setEditingDoc] = useState | null>(null); + const [isAddingInline, setIsAddingInline] = useState(false); const docs = dataResult?.rows ?? []; const columns = dataResult?.columns ?? []; - const handleOpenInsert = () => { + const handleStartAdd = () => { + if (columns.length === 0 || viewMode === "json") { + setEditingDoc(null); + setIsModalOpen(true); + return; + } + setIsAddingInline(true); + }; + + const handleOpenInsertModal = () => { setEditingDoc(null); setIsModalOpen(true); }; - const handleOpenEdit = (doc: Record) => { + const handleOpenEditModal = (doc: Record) => { setEditingDoc(doc); setIsModalOpen(true); }; @@ -82,7 +90,7 @@ export function MongoDocumentsView({ if (isLoading) { return (
- +
); } @@ -95,36 +103,47 @@ export function MongoDocumentsView({
+ {viewMode === "table" && ( + + )}
@@ -167,7 +186,7 @@ export function MongoDocumentsView({
{/* Documents List */} - {docs.length === 0 ? ( + {docs.length === 0 && !isAddingInline ? (
@@ -178,8 +197,8 @@ export function MongoDocumentsView({
@@ -191,82 +210,22 @@ export function MongoDocumentsView({ key={String(doc._id ?? doc.id ?? idx)} doc={doc} index={idx} - onEdit={handleOpenEdit} + onEdit={handleOpenEditModal} onDelete={onDeleteDocument} /> ))}
) : ( - /* Table View */ -
- - - - {columns.map((col) => ( - - ))} - - - - - {docs.map((doc, idx) => { - const docId = String(doc._id ?? doc.id ?? idx); - return ( - - {columns.map((col) => { - const val = doc[col]; - const isObject = typeof val === "object" && val !== null; - return ( - - ); - })} - - - ); - })} - -
- {col} - Actions
- {isObject ? ( - - {JSON.stringify(val)} - - ) : val === null || val === undefined ? ( - null - ) : typeof val === "boolean" ? ( - {String(val)} - ) : typeof val === "number" ? ( - {val} - ) : ( - {String(val)} - )} - -
- - -
-
-
+ setIsAddingInline(false)} + /> )} {/* Pagination Footer */} diff --git a/apps/web/src/components/databases/studio/mongo/MongoIndexesView.tsx b/apps/web/src/components/databases/studio/mongo/MongoIndexesView.tsx index 050d712..dc41583 100644 --- a/apps/web/src/components/databases/studio/mongo/MongoIndexesView.tsx +++ b/apps/web/src/components/databases/studio/mongo/MongoIndexesView.tsx @@ -83,7 +83,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV

- + Indexes for "{selectedCollection || "collection"}"

@@ -104,7 +104,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV size="sm" onClick={() => setShowCreateModal(true)} disabled={!selectedCollection} - className="bg-emerald-600 hover:bg-emerald-500 text-white text-xs rounded-xl gap-1.5 shadow-md h-8" + className="bg-orange-500 hover:bg-orange-600 text-white text-xs rounded-xl gap-1.5 shadow-md h-8" > Create Index @@ -115,7 +115,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV {isLoading ? (

- Loading indexes… + Loading indexes…
) : indexes.length === 0 ? (

No indexes found on this collection.

@@ -138,12 +138,12 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV {idx.name} {isPrimary && ( - + primary )} - {JSON.stringify(idx.key)} + {JSON.stringify(idx.key)} {idx.unique && ( @@ -184,7 +184,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV - Create Index + Create Index Specify the index key pattern (1 for ascending, -1 for descending). @@ -197,7 +197,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV value={indexKeyJson} onChange={(e) => setIndexKeyJson(e.target.value)} rows={4} - className="w-full bg-black/50 border border-border/80 rounded-xl p-3 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-emerald-500/50" + className="w-full bg-black/50 border border-border/80 rounded-xl p-3 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-orange-500/50" />
@@ -207,7 +207,7 @@ export function MongoIndexesView({ database, selectedCollection }: MongoIndexesV id="unique-chk" checked={isUnique} onChange={(e) => setIsUnique(e.target.checked)} - className="rounded border-border text-emerald-600 focus:ring-emerald-500 h-4 w-4" + className="rounded border-border text-orange-500 focus:ring-orange-500 h-4 w-4" />