From 1666f13bf33a38d78ff914c14412b4c9d4d51a29 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Thu, 17 Sep 2026 15:46:18 +0100 Subject: [PATCH 01/13] fix(db): add missing closing brace for getDatabaseTables function --- apps/api/src/api/databases/query.ts | 49 +++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/databases/query.ts b/apps/api/src/api/databases/query.ts index d5c259b..937ff96 100644 --- a/apps/api/src/api/databases/query.ts +++ b/apps/api/src/api/databases/query.ts @@ -110,6 +110,17 @@ export const getDatabaseTables = async (dbRecord: Database): Promise { + if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) { + return arg.slice(1, -1); + } + return arg; + }); +} + export const executeDatabaseQuery = async (dbRecord: Database, query: string): Promise => { if (!dbRecord.containerName) { throw new Error("Database container is not active"); @@ -194,20 +205,40 @@ export const executeDatabaseQuery = async (dbRecord: Database, query: string): P } if (dbRecord.type === "redis") { - const parts = query.trim().split(/\s+/); - const res = await dockerExec(dbRecord.containerName, ["redis-cli", "-a", dbRecord.password, ...parts], server); - const executionTimeMs = Date.now() - startTime; + const rawLines = query + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); - if (res.code !== 0) { - throw new Error(res.stderr || res.stdout || "Command execution failed"); + if (rawLines.length === 0) { + return { rows: [], columns: ["command", "result"], executionTimeMs: 0 }; + } + + const results: Record[] = []; + for (const line of rawLines) { + const parts = parseRedisCommandArgs(line); + if (parts.length === 0) continue; + + const redisArgs = ["redis-cli"]; + if (dbRecord.password) { + redisArgs.push("-a", dbRecord.password); + } + redisArgs.push(...parts); + + const res = await dockerExec(dbRecord.containerName, redisArgs, server); + const output = res.code === 0 ? res.stdout.trim() : res.stderr.trim() || res.stdout.trim() || "ERROR"; + results.push({ + command: line, + result: output, + }); } - const output = res.stdout; + const executionTimeMs = Date.now() - startTime; return { - rows: [{ result: output }], - columns: ["result"], + rows: results, + columns: ["command", "result"], executionTimeMs, - rawOutput: output, + rawOutput: results.map((r) => `${r.command} => ${r.result}`).join("\n"), }; } From 86205cb4f8ab7e5a82f9aa752df30b6da72e6be0 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sun, 20 Sep 2026 13:41:40 +0100 Subject: [PATCH 02/13] feat(api): add shared environment variable link support and UI components --- apps/api/src/api/shared-env-vars/index.ts | 16 +- .../db/__tests__/shared-env-links-runner.ts | 107 +++++++ .../src/db/__tests__/shared-env-links.test.ts | 57 ++++ apps/api/src/db/repo/shared-env-vars.ts | 23 +- .../databases/CreateDatabaseDialog.tsx | 50 +++- .../project/create/CreateProjectDialog.tsx | 11 +- .../project/create/EnvVarsSection.tsx | 136 +++++++-- .../project/create/StepEnvironment.tsx | 282 ++++++++++++------ .../project/envtab/AddEnvVarSheet.tsx | 95 +++++- .../components/project/envtab/EnvVarsTab.tsx | 5 +- .../project/envtab/LinkedSharedEnvVars.tsx | 188 ++++++++++++ .../settings/SharedEnvVarsSection.tsx | 14 +- apps/web/src/routes/CreateProjectPage.tsx | 9 +- apps/web/src/routes/SharedEnv.tsx | 10 +- 14 files changed, 830 insertions(+), 173 deletions(-) create mode 100644 apps/api/src/db/__tests__/shared-env-links-runner.ts create mode 100644 apps/api/src/db/__tests__/shared-env-links.test.ts create mode 100644 apps/web/src/components/project/envtab/LinkedSharedEnvVars.tsx diff --git a/apps/api/src/api/shared-env-vars/index.ts b/apps/api/src/api/shared-env-vars/index.ts index e7b5118..ba9afec 100644 --- a/apps/api/src/api/shared-env-vars/index.ts +++ b/apps/api/src/api/shared-env-vars/index.ts @@ -1,4 +1,7 @@ import { Elysia } from "elysia"; +import { eq } from "drizzle-orm"; +import { getDb } from "../../db/db-provider"; +import { projectSharedEnvLinks } from "../../db/schema"; import { createSharedEnvVar, deleteSharedEnvVar, @@ -7,9 +10,9 @@ import { linkSharedEnvVarsToProject, listLinkedSharedEnvVars, listSharedEnvVars, - unlinkSharedEnvVarFromProject, updateSharedEnvVar, } from "../../db/repo/shared-env-vars"; +import { getRowsAffected } from "../../db/repo/helpers"; import { fail, ok } from "../response"; export const sharedEnvVarsRoutes = new Elysia() @@ -80,8 +83,15 @@ export const sharedEnvLinksRoutes = new Elysia() await linkSharedEnvVarsToProject(params.id, body.sharedEnvVarIds); return ok(null, "Linked"); }) - .delete("/projects/:id/shared-env-links/:linkId", async ({ params, set }: any) => { - const removed = await unlinkSharedEnvVarFromProject(params.id, params.linkId); + .delete("/projects/:id/shared-env-links/:linkId", async ({ params, set }) => { + const db = await getDb(); + const removed = + getRowsAffected( + await db + .delete(projectSharedEnvLinks) + .where(eq(projectSharedEnvLinks.id, params.linkId)) + .execute(), + ) > 0; if (!removed) { set.status = 404; return fail("Link not found"); diff --git a/apps/api/src/db/__tests__/shared-env-links-runner.ts b/apps/api/src/db/__tests__/shared-env-links-runner.ts new file mode 100644 index 0000000..61dbcea --- /dev/null +++ b/apps/api/src/db/__tests__/shared-env-links-runner.ts @@ -0,0 +1,107 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { setDbProvider } from "../db-provider"; +import * as schema from "../schema"; +import { + linkSharedEnvVarsToProject, + listLinkedSharedEnvVars, + unlinkSharedEnvVarFromProject, +} from "../repo/shared-env-vars"; + +const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgresql://dequel:dequel@localhost:5433/dequel"; +const pool = new Pool({ connectionString: TEST_DATABASE_URL }); +const db = drizzle(pool, { schema }); +setDbProvider(async () => db); + +const cleanup = async () => { + await pool.query('DELETE FROM "project_shared_env_links" WHERE project_id LIKE $1', ["test-sel-%"]); + await pool.query('DELETE FROM "shared_env_vars" WHERE key LIKE $1', ["TEST_SEL_%"]); + await pool.query('DELETE FROM "projects" WHERE id LIKE $1', ["test-sel-%"]); +}; + +try { + await pool.query(` + CREATE TABLE IF NOT EXISTS "shared_env_vars" ( + "id" text PRIMARY KEY, + "key" text NOT NULL, + "value" text NOT NULL DEFAULT '', + "value_encrypted" text, + "value_iv" text, + "value_tag" text, + "environment" text DEFAULT 'production', + "description" text, + "tags" jsonb DEFAULT '[]', + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() + ) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS "project_shared_env_links" ( + "id" text PRIMARY KEY, + "project_id" text NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "shared_env_var_id" text NOT NULL REFERENCES "shared_env_vars"("id") ON DELETE CASCADE, + "created_at" timestamp DEFAULT now() + ) + `); + await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS "project_shared_env_links_project_shared_idx" ON "project_shared_env_links" ("project_id", "shared_env_var_id")`); + await cleanup(); + + const projectId = `test-sel-${randomUUID().slice(0, 8)}`; + const varId1 = randomUUID(); + const varId2 = randomUUID(); + + await pool.query( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) VALUES ($1, 'test', 'git', NOW(), NOW())`, + [projectId], + ); + await pool.query( + `INSERT INTO shared_env_vars (id, key, value, environment, created_at, updated_at) VALUES ($1, 'TEST_SEL_DB_URL', '', 'production', NOW(), NOW())`, + [varId1], + ); + await pool.query( + `INSERT INTO shared_env_vars (id, key, value, environment, created_at, updated_at) VALUES ($1, 'TEST_SEL_API_KEY', '', 'production', NOW(), NOW())`, + [varId2], + ); + + await linkSharedEnvVarsToProject(projectId, [varId1, varId2]); + let linked = await listLinkedSharedEnvVars(projectId); + const linkCount1 = linked.length; + const hasVar1 = linked.some((v) => v.key === "TEST_SEL_DB_URL"); + const hasVar2 = linked.some((v) => v.key === "TEST_SEL_API_KEY"); + const hasLinkId = linked.every((v) => typeof v.linkId === "string" && v.linkId.length > 0); + + await linkSharedEnvVarsToProject(projectId, [varId1]); + linked = await listLinkedSharedEnvVars(projectId); + const afterRelink = linked.length; + + const target = linked.find((v) => v.key === "TEST_SEL_API_KEY"); + const unlinkResult = await unlinkSharedEnvVarFromProject(projectId, target!.id); + linked = await listLinkedSharedEnvVars(projectId); + const afterUnlink = linked.length; + const stillHasVar1 = linked.some((v) => v.key === "TEST_SEL_DB_URL"); + + const badUnlink = await unlinkSharedEnvVarFromProject(projectId, "nonexistent"); + + await unlinkSharedEnvVarFromProject(projectId, varId1); + const empty = await listLinkedSharedEnvVars(projectId); + + console.log( + JSON.stringify({ + linkCount: linkCount1, + hasVar1, + hasVar2, + hasLinkId, + afterRelink, + unlinkResult, + afterUnlink, + stillHasVar1, + badUnlink, + emptyCount: empty.length, + }), + ); +} finally { + await cleanup(); + await pool.end(); +} diff --git a/apps/api/src/db/__tests__/shared-env-links.test.ts b/apps/api/src/db/__tests__/shared-env-links.test.ts new file mode 100644 index 0000000..7598ef1 --- /dev/null +++ b/apps/api/src/db/__tests__/shared-env-links.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +const runnerPath = join(import.meta.dir, "shared-env-links-runner.ts"); + +const runScenarios = (): any => { + const result = spawnSync("bun", [runnerPath], { + env: { + ...process.env, + DATABASE_URL: "postgresql://dequel:dequel@localhost:5433/dequel", + TEST_DATABASE_URL: "postgresql://dequel:dequel@localhost:5433/dequel", + }, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`Shared env links test runner failed:\n${result.stdout}\n${result.stderr}`); + } + for (const line of result.stdout.split("\n").reverse()) { + try { + return JSON.parse(line); + } catch {} + } + throw new Error(`Shared env links test runner produced no JSON output:\n${result.stdout}`); +}; + +describe("Shared Env Var Linking", () => { + it("links shared vars and returns linkId", () => { + const result = runScenarios(); + expect(result.linkCount).toBe(2); + expect(result.hasVar1).toBe(true); + expect(result.hasVar2).toBe(true); + expect(result.hasLinkId).toBe(true); + }); + + it("deduplicates on re-link", () => { + const result = runScenarios(); + expect(result.afterRelink).toBe(2); + }); + + it("unlinks by key and redeploy data is correct", () => { + const result = runScenarios(); + expect(result.unlinkResult).toBe(true); + expect(result.afterUnlink).toBe(1); + expect(result.stillHasVar1).toBe(true); + }); + + it("returns false for non-existent unlink", () => { + const result = runScenarios(); + expect(result.badUnlink).toBe(false); + }); + + it("returns empty after all unlinked", () => { + const result = runScenarios(); + expect(result.emptyCount).toBe(0); + }); +}); diff --git a/apps/api/src/db/repo/shared-env-vars.ts b/apps/api/src/db/repo/shared-env-vars.ts index dc7d048..44b6c0d 100644 --- a/apps/api/src/db/repo/shared-env-vars.ts +++ b/apps/api/src/db/repo/shared-env-vars.ts @@ -127,19 +127,25 @@ export const linkSharedEnvVarsToProject = async (projectId: string, sharedEnvVar export const unlinkSharedEnvVarFromProject = async (projectId: string, sharedEnvVarId: string): Promise => { const db = await getDb(); + const links = await db + .select() + .from(projectSharedEnvLinks) + .where( + and(eq(projectSharedEnvLinks.projectId, projectId), eq(projectSharedEnvLinks.sharedEnvVarId, sharedEnvVarId)), + ) + .execute(); + if (links.length === 0) return false; return ( getRowsAffected( await db .delete(projectSharedEnvLinks) - .where( - and(eq(projectSharedEnvLinks.projectId, projectId), eq(projectSharedEnvLinks.sharedEnvVarId, sharedEnvVarId)), - ) + .where(eq(projectSharedEnvLinks.id, links[0].id)) .execute(), ) > 0 ); }; -export const listLinkedSharedEnvVars = async (projectId: string): Promise => { +export const listLinkedSharedEnvVars = async (projectId: string): Promise<(SharedEnvVar & { linkId: string })[]> => { const db = await getDb(); const links = await db .select() @@ -156,9 +162,12 @@ export const listLinkedSharedEnvVars = async (projectId: string): Promise [r.id, r])); return links - .map((l) => rowMap.get(l.sharedEnvVarId)) - .filter(Boolean) - .map((r) => mapSharedEnvVar(r!)); + .map((l) => { + const row = rowMap.get(l.sharedEnvVarId); + if (!row) return null; + return { ...mapSharedEnvVar(row), linkId: l.id }; + }) + .filter(Boolean) as (SharedEnvVar & { linkId: string })[]; }; export const listSharedEnvVarsForDeploy = async ( diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx index 1ca21e7..c5a6824 100644 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ b/apps/web/src/components/databases/CreateDatabaseDialog.tsx @@ -1,10 +1,11 @@ import { Cpu, Database, HardDrive, ShieldAlert } from "lucide-react"; -import { useEffect, useState } from "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, DatabaseSelect } from "../ui/DatabaseSelect"; +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"; @@ -43,11 +44,6 @@ export function CreateDatabaseDialog({ queryFn: () => api.listServers().catch(() => []), }); - useEffect(() => { - const engine = DATABASE_ENGINES.find((item) => item.type === type); - if (engine) setVersion(engine.defaultVersion); - }, [type]); - const create = async () => { setIsCreating(true); setError(null); @@ -131,7 +127,25 @@ export function CreateDatabaseDialog({ - setType(val)} /> + @@ -139,14 +153,20 @@ export function CreateDatabaseDialog({
- setVersion(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> +
diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx index dc5c8a7..81b3901 100644 --- a/apps/web/src/components/project/create/CreateProjectDialog.tsx +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -56,6 +56,8 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP }> >([]); + const [stagedSharedVarIds, setStagedSharedVarIds] = useState([]); + const [sourceType, setSourceType] = useState("git"); const [projectType, setProjectType] = useState("web"); const [selectedPresetId, setSelectedPresetId] = useState(""); @@ -127,6 +129,7 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP setPort(""); setZipFile(null); setServerId("local"); + setStagedSharedVarIds([]); } }; @@ -175,6 +178,12 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP ); } + if (stagedSharedVarIds.length > 0) { + try { + await api.linkSharedEnvVars(project.id, stagedSharedVarIds); + } catch {} + } + if (zipFile && sourceType === "upload") { setSubmittingStatus("creating_project"); const form = new FormData(); @@ -319,7 +328,7 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP )} {step === 2 && ( - + )} {step === 3 && ( diff --git a/apps/web/src/components/project/create/EnvVarsSection.tsx b/apps/web/src/components/project/create/EnvVarsSection.tsx index c3c45aa..0b5c6b1 100644 --- a/apps/web/src/components/project/create/EnvVarsSection.tsx +++ b/apps/web/src/components/project/create/EnvVarsSection.tsx @@ -1,5 +1,8 @@ -import { ChevronDown, FileText, Key, Plus, Trash2, Upload } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, FileText, Key, Link2, Plus, Share2, Trash2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import * as api from "../../../api/client"; +import { Badge } from "../../ui/badge"; import { Button } from "../../ui/button"; import { Input } from "../../ui/input"; @@ -12,9 +15,11 @@ export interface StagedEnv { interface EnvVarsSectionProps { stagedEnvs: StagedEnv[]; setStagedEnvs: React.Dispatch>; + stagedSharedVarIds: string[]; + setStagedSharedVarIds: React.Dispatch>; } -export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProps) { +export function EnvVarsSection({ stagedEnvs, setStagedEnvs, stagedSharedVarIds, setStagedSharedVarIds }: EnvVarsSectionProps) { const [isExpanded, setIsExpanded] = useState(true); const [key, setKey] = useState(""); const [val, setVal] = useState(""); @@ -23,6 +28,13 @@ export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProp const [fileError, setFileError] = useState(""); const fileInputRef = useRef(null); + const { data: sharedVars = [] } = useQuery({ + queryKey: ["shared-env-vars"], + queryFn: () => api.listSharedEnvVars().catch(() => []), + }); + + const linkedSet = new Set(stagedSharedVarIds); + const addEnv = () => { if (!key.trim()) return; setStagedEnvs((prev) => [...prev, { key: key.trim(), value: val.trim() }]); @@ -34,6 +46,18 @@ export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProp setStagedEnvs((prev) => prev.filter((_, i) => i !== index)); }; + const toggleSharedVar = (sharedVar: any) => { + if (linkedSet.has(sharedVar.id)) { + setStagedSharedVarIds((prev) => prev.filter((id) => id !== sharedVar.id)); + } else { + setStagedSharedVarIds((prev) => [...prev, sharedVar.id]); + } + }; + + const removeSharedVar = (sharedVarId: string) => { + setStagedSharedVarIds((prev) => prev.filter((id) => id !== sharedVarId)); + }; + const parseEnvContent = (content: string) => { const lines = content.split(/\r?\n/); const newEnvs: StagedEnv[] = []; @@ -147,28 +171,65 @@ export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProp
{mode === "single" ? ( -
- setKey(e.target.value)} - className="bg-[#121215] border-[#22222a] text-xs font-mono" - /> - setVal(e.target.value)} - className="bg-[#121215] border-[#22222a] text-xs font-mono" - /> - +
+
+ setKey(e.target.value)} + className="bg-[#121215] border-[#22222a] text-xs font-mono" + /> + setVal(e.target.value)} + className="bg-[#121215] border-[#22222a] text-xs font-mono" + /> + +
+ {key.trim().length > 0 && (() => { + const matches = sharedVars.filter( + (s: any) => + s.key.toUpperCase().includes(key.trim().toUpperCase()) && + !linkedSet.has(s.id) && + s.key.toUpperCase() !== key.trim().toUpperCase(), + ); + if (matches.length === 0) return null; + return ( +
+
+ Shared variable available +
+ {matches.slice(0, 3).map((s: any) => ( + + ))} +
+ ); + })()}
) : mode === "bulk" ? (
@@ -235,6 +296,33 @@ export function EnvVarsSection({ stagedEnvs, setStagedEnvs }: EnvVarsSectionProp ))}
)} + {stagedSharedVarIds.length > 0 && ( +
+
+ Linked Shared Variables +
+ {sharedVars + .filter((s: any) => stagedSharedVarIds.includes(s.id)) + .map((s: any) => ( +
+
+ + {s.key} +
+ +
+ ))} +
+ )}
)} diff --git a/apps/web/src/components/project/create/StepEnvironment.tsx b/apps/web/src/components/project/create/StepEnvironment.tsx index c8b36a7..8c9ac59 100644 --- a/apps/web/src/components/project/create/StepEnvironment.tsx +++ b/apps/web/src/components/project/create/StepEnvironment.tsx @@ -1,6 +1,9 @@ -import { Server, Trash2, Upload } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { Link2, Server, Share2, Trash2, Upload } from "lucide-react"; import { useState } from "react"; +import * as api from "../../../api/client"; import { cn } from "../../../lib/utils"; +import { Badge } from "../../ui/badge"; import { Button } from "../../ui/button"; import { Input } from "../../ui/input"; @@ -13,9 +16,11 @@ interface StagedEnv { interface StepEnvironmentProps { stagedEnvs: StagedEnv[]; setStagedEnvs: React.Dispatch>; + stagedSharedVarIds: string[]; + setStagedSharedVarIds: React.Dispatch>; } -export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentProps) { +export function StepEnvironment({ stagedEnvs, setStagedEnvs, stagedSharedVarIds, setStagedSharedVarIds }: StepEnvironmentProps) { const [envTab, setEnvTab] = useState<"single" | "bulk" | "file">("single"); const [singleKey, setSingleKey] = useState(""); const [singleVal, setSingleVal] = useState(""); @@ -23,6 +28,13 @@ export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentPr const [bulkText, setBulkText] = useState(""); const [fileError, setFileError] = useState(""); + const { data: sharedVars = [] } = useQuery({ + queryKey: ["shared-env-vars"], + queryFn: () => api.listSharedEnvVars().catch(() => []), + }); + + const linkedSet = new Set(stagedSharedVarIds); + const parseEnvText = (text: string): StagedEnv[] => { const lines = text .split(/\r?\n/) @@ -115,6 +127,18 @@ export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentPr setStagedEnvs((prev) => prev.filter((x) => x.key !== keyToRemove)); }; + const toggleSharedVar = (sharedVar: any) => { + if (linkedSet.has(sharedVar.id)) { + setStagedSharedVarIds((prev) => prev.filter((id) => id !== sharedVar.id)); + } else { + setStagedSharedVarIds((prev) => [...prev, sharedVar.id]); + } + }; + + const removeSharedVar = (sharedVarId: string) => { + setStagedSharedVarIds((prev) => prev.filter((id) => id !== sharedVarId)); + }; + return (
@@ -144,35 +168,72 @@ export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentPr
{envTab === "single" && ( -
- setSingleKey(e.target.value)} - className="bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 h-9 font-mono text-xs sm:w-1/3" - /> - setSingleVal(e.target.value)} - className="bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 h-9 font-mono text-xs flex-1" - /> - - +
+
+ setSingleKey(e.target.value)} + className="bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 h-9 font-mono text-xs sm:w-1/3" + /> + setSingleVal(e.target.value)} + className="bg-[#141418] border-[#222227] focus:border-amber-500 text-zinc-200 h-9 font-mono text-xs flex-1" + /> + + +
+ {singleKey.trim().length > 0 && (() => { + const matches = sharedVars.filter( + (s: any) => + s.key.toUpperCase().includes(singleKey.trim().toUpperCase()) && + !linkedSet.has(s.id) && + s.key.toUpperCase() !== singleKey.trim().toUpperCase(), + ); + if (matches.length === 0) return null; + return ( +
+
+ Shared variable available +
+ {matches.slice(0, 3).map((s: any) => ( + + ))} +
+ ); + })()}
)} @@ -225,72 +286,103 @@ export function StepEnvironment({ stagedEnvs, setStagedEnvs }: StepEnvironmentPr {fileError &&

{fileError}

}
-
-
-

- Staged Environment Variables ({stagedEnvs.length}) -

- {stagedEnvs.length > 0 && ( - - )} -
+
+
+

+ Staged Environment Variables ({stagedEnvs.length + stagedSharedVarIds.length}) +

+ {(stagedEnvs.length > 0 || stagedSharedVarIds.length > 0) && ( + + )} +
- {stagedEnvs.length === 0 ? ( -
- No variables staged yet. Add some above. -
- ) : ( -
- - - - - - - - - - - {stagedEnvs.map((env) => ( - - - - - + {stagedEnvs.length === 0 && stagedSharedVarIds.length === 0 ? ( +
+ No variables staged yet. Add some above. +
+ ) : ( +
+ {stagedEnvs.length > 0 && ( +
+
KeyValueEnv
{env.key}{env.value} - - {env.environment || "production"} - - - -
+ + + + + + + + + {stagedEnvs.map((env) => ( + + + + + + + ))} + +
KeyValueEnv
{env.key}{env.value} + + {env.environment || "production"} + + + +
+
+ )} + {stagedSharedVarIds.length > 0 && ( +
+
+ Linked Shared Variables +
+ {sharedVars + .filter((s: any) => stagedSharedVarIds.includes(s.id)) + .map((s: any) => ( +
+
+ + {s.key} +
+ +
))} - - -
- )} -
+
+ )} +
+ )} - ); + +); } diff --git a/apps/web/src/components/project/envtab/AddEnvVarSheet.tsx b/apps/web/src/components/project/envtab/AddEnvVarSheet.tsx index 2e796da..82ff9fb 100644 --- a/apps/web/src/components/project/envtab/AddEnvVarSheet.tsx +++ b/apps/web/src/components/project/envtab/AddEnvVarSheet.tsx @@ -1,5 +1,8 @@ -import { Plus, Trash2 } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link2, Plus, Share2, Trash2 } from "lucide-react"; import { useState } from "react"; +import * as api from "../../../api/client"; +import { Badge } from "../../ui/badge"; import { Button } from "../../ui/button"; import { Input } from "../../ui/input"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../../ui/sheet"; @@ -14,10 +17,34 @@ interface AddEnvVarSheetProps { open: boolean; onOpenChange: (open: boolean) => void; onSubmit: (vars: EnvVarEntry[]) => Promise; + projectId?: string; } -export function AddEnvVarSheet({ open, onOpenChange, onSubmit }: AddEnvVarSheetProps) { +export function AddEnvVarSheet({ open, onOpenChange, onSubmit, projectId }: AddEnvVarSheetProps) { const [newVars, setNewVars] = useState([{ key: "", value: "", env: "" }]); + const queryClient = useQueryClient(); + + const { data: sharedVars = [] } = useQuery({ + queryKey: ["shared-env-vars"], + queryFn: () => api.listSharedEnvVars().catch(() => []), + enabled: open, + }); + + const { data: linkedShared = [] } = useQuery({ + queryKey: ["shared-env-links", projectId], + queryFn: () => api.listLinkedSharedEnvVars(projectId!), + enabled: open && !!projectId, + }); + + const linkedIds = new Set(linkedShared.map((l: any) => l.id)); + + const linkMutation = useMutation({ + mutationFn: (sharedVarIds: string[]) => api.linkSharedEnvVars(projectId!, sharedVarIds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-env-links", projectId] }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, + }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -44,6 +71,11 @@ export function AddEnvVarSheet({ open, onOpenChange, onSubmit }: AddEnvVarSheetP setNewVars((prev) => prev.map((item, i) => (i === index ? { ...item, [field]: val } : item))); }; + const linkSharedVar = async (sharedVar: any) => { + if (!projectId) return; + await linkMutation.mutateAsync([sharedVar.id]); + }; + return ( @@ -73,18 +105,53 @@ export function AddEnvVarSheet({ open, onOpenChange, onSubmit }: AddEnvVarSheetP
Variable #{index + 1}
-
- - updateRow(index, "key", e.target.value)} - className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg font-mono" - required - /> -
+
+ + updateRow(index, "key", e.target.value)} + className="h-9 bg-[#0d0d11] border-input focus:ring-1 focus:ring-primary text-xs font-semibold rounded-lg font-mono" + required + /> + {v.key.trim().length > 0 && (() => { + const matches = sharedVars.filter( + (s: any) => + s.key.toUpperCase().includes(v.key.trim().toUpperCase()) && + !linkedIds.has(s.id) && + s.key.toUpperCase() !== v.key.trim().toUpperCase(), + ); + if (matches.length === 0) return null; + return ( +
+
+ Shared variable available +
+ {matches.slice(0, 3).map((s: any) => ( + + ))} +
+ ); + })()} +
- ); - })} +
+ + +
+ +
+ + +
-
+
- -
- - setVersion(e.target.value)} - className="bg-background/50 border-border/80 font-mono text-xs rounded-xl h-10" - /> -
From e1464876961f32b421800e708d328a20dc9233c2 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Wed, 23 Sep 2026 02:21:00 +0100 Subject: [PATCH 04/13] fix(api): secure shared environment variable deletion by project ID --- apps/api/src/api/shared-env-vars/index.ts | 9 +++++++-- .../src/components/databases/CreateDatabaseDialog.tsx | 2 +- .../components/project/create/CreateProjectDialog.tsx | 4 +--- .../web/src/components/project/envtab/AddEnvVarSheet.tsx | 4 +++- apps/web/src/components/project/envtab/EnvVarsTab.tsx | 2 +- apps/web/src/routes/CreateProjectPage.tsx | 4 +--- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/shared-env-vars/index.ts b/apps/api/src/api/shared-env-vars/index.ts index ba9afec..9aa2095 100644 --- a/apps/api/src/api/shared-env-vars/index.ts +++ b/apps/api/src/api/shared-env-vars/index.ts @@ -1,5 +1,5 @@ import { Elysia } from "elysia"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { getDb } from "../../db/db-provider"; import { projectSharedEnvLinks } from "../../db/schema"; import { @@ -89,7 +89,12 @@ export const sharedEnvLinksRoutes = new Elysia() getRowsAffected( await db .delete(projectSharedEnvLinks) - .where(eq(projectSharedEnvLinks.id, params.linkId)) + .where( + and( + eq(projectSharedEnvLinks.id, params.linkId), + eq(projectSharedEnvLinks.projectId, params.id), + ), + ) .execute(), ) > 0; if (!removed) { diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx index c5a6824..3d171b3 100644 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ b/apps/web/src/components/databases/CreateDatabaseDialog.tsx @@ -128,7 +128,7 @@ export function CreateDatabaseDialog({ Database Engine setType(e.target.value)} + onChange={(e) => { + const selected = e.target.value; + setType(selected); + if (selected === "cpu") setThreshold("80"); + else if (selected === "memory") setThreshold("85"); + }} className="flex h-10 w-full rounded-lg border border-input bg-[#0d0d11] px-3 py-2 text-sm shadow-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" > - - -
- -
- setThreshold(e.target.value)} - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg pr-10" - required - /> - {getUnit(type) && ( + {type !== "downtime" ? ( +
+ +
+ setThreshold(e.target.value)} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg pr-10" + required + /> {getUnit(type).trim()} - )} +
-
+ ) : ( +
+ Instant Alerting + Downtime notification triggers immediately when all active containers for this project stop or fail. No + percentage threshold required. +
+ )}
+ {channel !== "email" ? ( +
+ + setDestination(e.target.value)} + className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm rounded-lg" + /> + {channel === "slack" ? ( +

+ In Slack: Apps → Incoming Webhooks → Add New Webhook to Workspace, pick a channel, then paste the + URL here. +

+ ) : ( +

+ Dequel POSTs a JSON payload to this URL when the alert triggers. +

+ )} +
+ ) : null} +
+
+
+ + +
+ + Runs in an isolated read-only sandbox + + +
+ + )} + + {error && ( +
+ + {error} +
+ )} + + {(running || stages.length > 0) && } + + {run?.status === "done" && cause && ( + + )} + + {run?.status === "error" && ( +
+ + Diagnosis failed: {run.error ?? "unknown error occurred"} +
+ )} + +
+
+ ); +} diff --git a/apps/web/src/components/project/deployments/deployment-logs.tsx b/apps/web/src/components/project/deployments/deployment-logs.tsx index 0995616..e2e1ea9 100644 --- a/apps/web/src/components/project/deployments/deployment-logs.tsx +++ b/apps/web/src/components/project/deployments/deployment-logs.tsx @@ -2,6 +2,7 @@ import { Terminal } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useDeploymentLogs } from "../../../hooks/useDeploymentLogs"; import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { DiagnoseSheet } from "./DiagnoseSheet"; export function formatTimeAgo(dateStr: string) { const diff = Date.now() - new Date(dateStr).getTime(); @@ -78,6 +79,7 @@ function fmtLogTs(raw: string | undefined) { export function DeploymentLogs({ deployment }: { deployment: any }) { const { logs, isLoading } = useDeploymentLogs(deployment.id); const endRef = useRef(null); + const [diagnoseOpen, setDiagnoseOpen] = useState(false); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", @@ -86,7 +88,9 @@ export function DeploymentLogs({ deployment }: { deployment: any }) { const [copied, setCopied] = useState(false); const copyAllLogs = () => { - const fullText = logs.map(l => `[${l.stage}]-[${fmtLogTs((l as any).timestamp || l.createdAt)}] ${l.message}`).join("\n"); + const fullText = logs + .map((l) => `[${l.stage}]-[${fmtLogTs((l as any).timestamp || l.createdAt)}] ${l.message}`) + .join("\n"); navigator.clipboard.writeText(fullText); setCopied(true); setTimeout(() => setCopied(false), 1500); @@ -105,6 +109,15 @@ export function DeploymentLogs({ deployment }: { deployment: any }) { Duration: + {deployment.status === "failed" && ( + + )} {logs.length > 0 && ( + ) + ) : ( +

Automated Fix PRs are available for Git repositories.

+ )} + + )} + + {dequelReport && ( +
+
+ Problem +

{dequelReport.problem}

+
+ + {dequelVersion && ( +
+ + Dequel Version + +

+ {dequelVersion} + {dequelStale && (potentially stale build)} +

+
+ )} + +
+ + Root Cause + +

{dequelReport.cause}

+
+ +
+ + Proposed Platform Fix + +

{dequelReport.proposedFix}

+
+ + {slackPosted ? ( +

+ Incident automatically submitted to the Dequel team Slack + channel. +

+ ) : ( +

+ Slack notification not posted (channel not configured or webhook unavailable). +

+ )} +
+ )} + + + ); +} diff --git a/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx b/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx new file mode 100644 index 0000000..8930781 --- /dev/null +++ b/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx @@ -0,0 +1,63 @@ +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { Button } from "../../../ui/button"; + +interface DiffViewerProps { + diff: string; + title?: string; +} + +export function DiffViewer({ diff, title = "Suggested Fix" }: DiffViewerProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(diff); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const lines = diff.split("\n"); + + return ( +
+
+ {title} + +
+
+ {lines.map((line, idx) => { + let lineClass = "text-zinc-400"; + if (line.startsWith("+") && !line.startsWith("+++")) { + lineClass = "bg-emerald-950/40 text-emerald-300 px-1 rounded-sm"; + } else if (line.startsWith("-") && !line.startsWith("---")) { + lineClass = "bg-red-950/40 text-red-300 px-1 rounded-sm"; + } else if (line.startsWith("@@")) { + lineClass = "text-cyan-400 font-semibold"; + } else if ( + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("---") || + line.startsWith("+++") + ) { + lineClass = "text-zinc-500 font-medium"; + } + + return ( +
+ {line || " "} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/settings/LlmKeysSection.tsx b/apps/web/src/components/settings/LlmKeysSection.tsx new file mode 100644 index 0000000..0ed86e4 --- /dev/null +++ b/apps/web/src/components/settings/LlmKeysSection.tsx @@ -0,0 +1,334 @@ +import { useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + Check, + CheckCircle2, + ExternalLink, + Eye, + EyeOff, + Layers, + RefreshCw, + ShieldCheck, + Sparkles, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import * as api from "../../api/client"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Input } from "../ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { ConfiguredProvidersList } from "./llm/ConfiguredProvidersList"; +import { PROVIDERS } from "./llm/types"; + +export function LlmKeysSection() { + const { data, refetch, isLoading } = useQuery({ + queryKey: ["llm-keys"], + queryFn: () => api.getLlmKeys(), + }); + + const [provider, setProvider] = useState("openai"); + const [apiKey, setApiKey] = useState(""); + const [showKey, setShowKey] = useState(false); + const [baseURL, setBaseURL] = useState(""); + const [saveResult, setSaveResult] = useState(null); + const [saving, setSaving] = useState(false); + const [syncingProvider, setSyncingProvider] = useState(null); + + const configured = data?.filter((k) => k.configured) ?? []; + const currentStatus = data?.find((k) => k.provider === provider); + const currentMeta = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0]; + + useEffect(() => { + setBaseURL(currentStatus?.baseUrl ?? currentMeta.defaultBaseUrl ?? ""); + setApiKey(""); + setSaveResult(null); + }, [provider, currentStatus?.baseUrl, currentMeta.defaultBaseUrl]); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveResult(null); + setSaving(true); + try { + const updated = await api.setLlmKey({ + provider, + apiKey: apiKey.trim() || undefined, + baseURL: baseURL.trim() || undefined, + }); + setApiKey(""); + await refetch(); + const modelCount = updated.models?.length ?? 0; + setSaveResult( + modelCount > 0 + ? `Saved successfully. Auto-discovered and cached ${modelCount} models from ${currentMeta.label}.` + : `Saved provider key for ${currentMeta.label}.`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setSaveResult(`error: ${message}`); + } finally { + setSaving(false); + } + }; + + const handleSync = async (targetProvider: string) => { + setSyncingProvider(targetProvider); + setSaveResult(null); + try { + const res = await api.syncLlmModels(targetProvider); + await refetch(); + const meta = PROVIDERS.find((p) => p.id === targetProvider); + setSaveResult(`Synced ${res.models.length} models from ${meta?.label ?? targetProvider}.`); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to sync models"; + setSaveResult(`error: ${message}`); + } finally { + setSyncingProvider(null); + } + }; + + const remove = async (p: string) => { + setSaveResult(null); + try { + await api.deleteLlmKey(p); + await refetch(); + const meta = PROVIDERS.find((pr) => pr.id === p); + setSaveResult(`Provider "${meta?.label ?? p}" removed.`); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setSaveResult(`error: ${message}`); + } + }; + + const startEditing = (p: string) => { + setProvider(p); + const target = data?.find((k) => k.provider === p); + if (target) { + setBaseURL(target.baseUrl ?? ""); + } + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + return ( +
+ {/* Main Settings Card */} + + +
+
+
+ +
+
+
+ AI Diagnosis Providers + {configured.length > 0 ? ( + + {configured.length} Active{" "} + {configured.length === 1 ? "Provider" : "Providers"} + + ) : ( + + Not Configured + + )} +
+

+ Keys used by the automated failure diagnosis agent. Models are auto-pulled directly from each provider + and cached for one-click selection. +

+
+
+ + {currentMeta.docsUrl && ( + + Get API Key + + )} +
+
+ + +
+
+
+ + +

{currentMeta.desc}

+
+ +
+
+ + {currentStatus?.configured && ( + + Key saved + + )} +
+
+ setApiKey(e.target.value)} + className="bg-card border-border/80 text-xs font-mono pr-10 focus:ring-orange-500/50" + /> + +
+

+ {provider === "ollama" + ? "Optional when Ollama runs on your host without authentication." + : "Encrypted at rest using AES-256-GCM."} +

+
+
+ + {(provider === "custom" || provider === "ollama" || currentStatus?.baseUrl) && ( +
+ + setBaseURL(e.target.value)} + placeholder={currentMeta.defaultBaseUrl || "https://api.your-endpoint.com/v1"} + className="bg-card border-border/80 text-xs font-mono focus:ring-orange-500/50" + /> +

+ {provider === "ollama" + ? "Default: http://host.docker.internal:11435/v1 for Docker bridge network access." + : "Base endpoint prefix where /models and /chat/completions are hosted."} +

+
+ )} + + {/* Cached Models Preview for selected provider */} + {currentStatus?.configured && ( +
+
+
+ + Auto-Discovered Models + + {currentStatus.models?.length ?? 0} cached + +
+ +
+ + {currentStatus.models && currentStatus.models.length > 0 ? ( +
+ {currentStatus.models.map((m) => ( + + {m} + + ))} +
+ ) : ( +

+ No models currently cached. Click "Save & Sync Models" or "Sync Models Now" to pull them + automatically. +

+ )} +
+ )} + +
+ {saveResult ? ( +

+ {saveResult.startsWith("error") ? ( + + ) : ( + + )} + {saveResult} +

+ ) : ( + + )} + +
+
+
+
+ + {/* Configured Providers List */} + +
+ ); +} diff --git a/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx b/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx new file mode 100644 index 0000000..bfbf347 --- /dev/null +++ b/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx @@ -0,0 +1,156 @@ +import { ChevronDown, ChevronUp, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; +import type { LlmKeyStatus } from "../../../types"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { PROVIDERS } from "./types"; + +interface ConfiguredProvidersListProps { + configured: LlmKeyStatus[]; + isLoading: boolean; + syncingProvider: string | null; + onSync: (provider: string) => void; + onEdit: (provider: string) => void; + onRemove: (provider: string) => void; +} + +export function ConfiguredProvidersList({ + configured, + isLoading, + syncingProvider, + onSync, + onEdit, + onRemove, +}: ConfiguredProvidersListProps) { + const [expandedProvider, setExpandedProvider] = useState(null); + + return ( + + +
+
+ Configured Providers +

+ Active providers ready to run failure root-cause analysis +

+
+ + {configured.length} configured + +
+
+ + {isLoading ? ( +
+ Loading providers… +
+ ) : configured.length === 0 ? ( +
+

No providers configured yet

+

Choose a provider above and enter your key to enable automated AI diagnosis.

+
+ ) : ( +
+ {configured.map((item) => { + const meta = PROVIDERS.find((p) => p.id === item.provider); + const isExpanded = expandedProvider === item.provider; + const modelsList = item.models ?? []; + const isSyncing = syncingProvider === item.provider; + + return ( +
+
+
+
+ {meta?.label ?? item.provider} + + + Connected + + + {modelsList.length} {modelsList.length === 1 ? "model" : "models"} cached + +
+ + {item.baseUrl && ( +

{item.baseUrl}

+ )} +
+ +
+ + + +
+
+ + {modelsList.length > 0 && ( +
+
+ {(isExpanded ? modelsList : modelsList.slice(0, 5)).map((m) => ( + + {m} + + ))} + {modelsList.length > 5 && ( + + )} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/llm/types.ts b/apps/web/src/components/settings/llm/types.ts new file mode 100644 index 0000000..d458d7a --- /dev/null +++ b/apps/web/src/components/settings/llm/types.ts @@ -0,0 +1,53 @@ +export interface ProviderMeta { + id: string; + label: string; + desc: string; + keyPlaceholder: string; + docsUrl?: string; + defaultBaseUrl?: string; +} + +export const PROVIDERS: ProviderMeta[] = [ + { + id: "openai", + label: "OpenAI", + desc: "GPT-4o, o1, o3-mini & reasoning models", + keyPlaceholder: "sk-proj-...", + docsUrl: "https://platform.openai.com/api-keys", + }, + { + id: "anthropic", + label: "Anthropic", + desc: "Claude 3.5 Sonnet, Claude 3 Opus & Haiku", + keyPlaceholder: "sk-ant-api03-...", + docsUrl: "https://console.anthropic.com/settings/keys", + }, + { + id: "gemini", + label: "Google Gemini", + desc: "Gemini 2.5 Flash, 2.5 Pro & 1.5 Pro", + keyPlaceholder: "AIzaSy...", + docsUrl: "https://aistudio.google.com/app/apikey", + }, + { + id: "groq", + label: "Groq", + desc: "Ultra-fast Llama 3.3 70B & open models", + keyPlaceholder: "gsk_...", + docsUrl: "https://console.groq.com/keys", + }, + { + id: "ollama", + label: "Ollama (Self-Hosted)", + desc: "Run local open-weight models via Docker / HTTP", + keyPlaceholder: "(optional for local Ollama)", + defaultBaseUrl: "http://host.docker.internal:11435/v1", + }, + { + id: "custom", + label: "Custom (OpenAI-Compatible)", + desc: "vLLM, LocalAI, OpenRouter, Together, etc.", + keyPlaceholder: "sk-...", + defaultBaseUrl: "https://api.your-endpoint.com/v1", + }, +]; diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx index cf70cb3..1143902 100644 --- a/apps/web/src/routes/Settings.tsx +++ b/apps/web/src/routes/Settings.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { Database, GitBranch, Mail, Server, Settings2, ShieldCheck } from "lucide-react"; +import { Database, GitBranch, Mail, Server, Settings2, ShieldCheck, Sparkles } from "lucide-react"; import * as api from "../api/client"; import { ConfigWarnings } from "../components/ConfigWarnings"; import { BackupSettingsSection } from "../components/settings/BackupSettingsSection"; import { GithubIntegrationSection } from "../components/settings/GithubIntegrationSection"; +import { LlmKeysSection } from "../components/settings/LlmKeysSection"; import { ServersSection } from "../components/settings/ServersSection"; import { SmtpSection } from "../components/settings/SmtpSection"; import { Badge } from "../components/ui/badge"; @@ -99,6 +100,10 @@ export function Settings() { SMTP Notifications + + + AI Diagnosis + @@ -116,6 +121,9 @@ export function Settings() { + + + ); diff --git a/apps/web/src/routes/SharedEnv.tsx b/apps/web/src/routes/SharedEnv.tsx index 9d7f520..73a300c 100644 --- a/apps/web/src/routes/SharedEnv.tsx +++ b/apps/web/src/routes/SharedEnv.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Info, Layers, Share2, Shield } from "lucide-react"; +import { Layers, Share2, Shield } from "lucide-react"; import * as api from "../api/client"; import { SharedEnvVarsSection } from "../components/settings/SharedEnvVarsSection"; import { Badge } from "../components/ui/badge"; @@ -61,21 +61,6 @@ export function SharedEnv() { - {/* Variable Inheritance Tip Callout */} -
-
- -
-
-

Variable Precedence Hierarchy

-

- Shared variables are injected into linked projects during deployment. If a project defines an - environment variable with the exact same key name, the project-level value will take precedence. - Link a shared variable to a project to make it available. -

-
-
-
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 991d2ec..9042a56 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -243,6 +243,48 @@ export interface GithubIntegrationStatus { hasWebhookSecret?: boolean; } +export interface LlmKeyStatus { + provider: string; + configured: boolean; + baseUrl: string | null; + models: string[]; +} + +export type DiagCause = "user-source" | "dequel-source" | "unknown"; + +export interface DiagRun { + id: string; + deploymentId: string; + provider: string; + model: string; + status: "running" | "done" | "error"; + currentStage: string | null; + cause: DiagCause | null; + report: { + cause: DiagCause; + userFix?: { title: string; body: string; suggestedDiff: string | null }; + dequelReport?: { problem: string; cause: string; proposedFix: string }; + } | null; + error: string | null; + createdAt: string; +} + +export interface DiagStage { + stage: string; + payload: unknown; +} + +export interface ActiveDiagRun { + id: string; + deploymentId: string; + projectId: string | null; + projectName: string | null; + provider: string; + model: string; + currentStage: string | null; + createdAt: string; +} + export interface BackupJob { id: string; targetId: string; From 4abc5e02e8c3ad54815d0e0db367b7551515c195 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sat, 3 Oct 2026 10:13:37 +0100 Subject: [PATCH 09/13] chore(release): bump version to 0.4.0 Add the v0.4.0 changelog and configure diagnostic sandbox and database backup storage in Docker Compose. --- VERSION | 2 +- apps/agent/package.json | 2 +- apps/api/package.json | 2 +- apps/docs/.astro/astro/content.d.ts | 14 ++++++++++ apps/docs/package.json | 2 +- apps/docs/src/content/changelogs/v0.4.0.md | 31 ++++++++++++++++++++++ apps/web/package.json | 2 +- docker-compose.yml | 18 +++++++++++++ 8 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 apps/docs/src/content/changelogs/v0.4.0.md diff --git a/VERSION b/VERSION index 0d91a54..60a2d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 \ No newline at end of file diff --git a/apps/agent/package.json b/apps/agent/package.json index da8553e..b0b2f89 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -1,6 +1,6 @@ { "name": "dequel-agent", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/apps/api/package.json b/apps/api/package.json index d3e2962..58d7fb5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "dequel-api", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index a7e7267..dfde2ca 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -147,6 +147,13 @@ declare module "astro:content" { collection: "changelogs"; data: any; } & { render(): Render[".md"] }; + "v0.4.0.md": { + id: "v0.4.0.md"; + slug: "v040"; + body: string; + collection: "changelogs"; + data: any; + } & { render(): Render[".md"] }; }; docs: { "agent-caddy-routes.md": { @@ -156,6 +163,13 @@ declare module "astro:content" { collection: "docs"; data: any; } & { render(): Render[".md"] }; + "ai-diagnosis.md": { + id: "ai-diagnosis.md"; + slug: "ai-diagnosis"; + body: string; + collection: "docs"; + data: any; + } & { render(): Render[".md"] }; "auth.md": { id: "auth.md"; slug: "auth"; diff --git a/apps/docs/package.json b/apps/docs/package.json index d5066f1..8fe9783 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/docs/src/content/changelogs/v0.4.0.md b/apps/docs/src/content/changelogs/v0.4.0.md new file mode 100644 index 0000000..7a03234 --- /dev/null +++ b/apps/docs/src/content/changelogs/v0.4.0.md @@ -0,0 +1,31 @@ +--- +version: 0.4.0 +date: "2026-10-03" +--- + +## What's Changed +### Features +- Add AI-powered deployment diagnosis ([903d9c9](https://github.com/Lftobs/dequel/commit/903d9c9594152355f6e48668939b79f7d4742cf7)) +- Add automated database backup and restore system ([3b477a9](https://github.com/Lftobs/dequel/commit/3b477a95fbd9c47cb2019929bedb00c84c6cc36b)) +- Database studio data viewer, database settings UI, backup configuration, and capsule tabs ([0fd77c1](https://github.com/Lftobs/dequel/commit/0fd77c186dd9e48ae2cab9687e6ab17afffacc6a)) +- Add shared environment variables and SSH key pool management ([8c7f76b](https://github.com/Lftobs/dequel/commit/8c7f76b82de60db7f7a8a086fe39e1ce50d7819f)) +- 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)) + + +### Improvements +- Redesign keys and settings pages with tabbed navigation ([765a199](https://github.com/Lftobs/dequel/commit/765a199f6b578149358e1529429260325edf4ad8)) +- Remove delete projects tab and fix tabs overflow behavior ([650e1d8](https://github.com/Lftobs/dequel/commit/650e1d8a7c5f2bedbdec10c162e11368bdb207e4)) +- Add Biome linter + pre-commit hook, format codebase ([2ac7c3f](https://github.com/Lftobs/dequel/commit/2ac7c3f2b24ca146afcc6b9f206cf0ab9ffab7c8)) +- Update database creation engine selection UI ([faef669](https://github.com/Lftobs/dequel/commit/faef6693959dab303340f0245430c48504d08db2)) +- Improve SQL query editor ui ([f38b1a6](https://github.com/Lftobs/dequel/commit/f38b1a68fac5d9af7be44f124e6c879b68bf19ff)) +- Switch compose file to prebuilt images for api and web ([3e6f917](https://github.com/Lftobs/dequel/commit/3e6f917403b233dcc43d60068ed18ddaf77174b6)) +- Make dequel dashboard 100% mobile responsive ([905719f](https://github.com/Lftobs/dequel/commit/905719f336fd79c3818fe45cc023d0df7fdedc95)) +- Make all settings sections, tables, tabs and dialogs fully mobile responsive ([1049120](https://github.com/Lftobs/dequel/commit/1049120cf5c7243be5d75c4d9474a4ab326f9bb4)) + +### Bug Fixes +- Harden alerts and monitoring validation ([1a68e40](https://github.com/Lftobs/dequel/commit/1a68e4066b7d11004b622965aabf2a53767018dd)) +- Secure shared environment variable deletion by project ID ([e146487](https://github.com/Lftobs/dequel/commit/e1464876961f32b421800e708d328a20dc9233c2)) +- Resolve container name, storage type display, and S3 prefix ([3cb6610](https://github.com/Lftobs/dequel/commit/3cb66109d25c238f4011fadeaeb2094240602d9d)) +- Remove duplicate listRoutesByDeployment function ([80d43e5](https://github.com/Lftobs/dequel/commit/80d43e50f5a72dfac4b1908032c33d4b7b26ceed)) diff --git a/apps/web/package.json b/apps/web/package.json index f74681c..0ca3547 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "dequel-web", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/docker-compose.yml b/docker-compose.yml index 1786e8c..80f07f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,14 +46,18 @@ services: RAILPACK_VERBOSE: "1" RAILPACK_BUILD_TIMEOUT_MS: "1200000" GRAFANA_URL: http://grafana:3000/grafana + POSTGRES_CONTAINER: ${POSTGRES_CONTAINER:-dequel-postgres-1} volumes: - ./workspace:/app/workspace - ./infra/caddy/routes:/caddy/routes - /var/run/docker.sock:/var/run/docker.sock - railpack-cache:/tmp/railpack - api-data:/app/data + - backup-data:/data/backups - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: buildkit: condition: service_started @@ -77,6 +81,18 @@ services: aliases: - api + diagnose-sandbox: + build: + context: ./apps/api + dockerfile: Dockerfile.sandbox + container_name: dequel-diag-sandbox + restart: unless-stopped + command: ["sleep", "infinity"] + volumes: + - diag-sandbox-data:/srv + extra_hosts: + - "host.docker.internal:host-gateway" + pam-auth: image: python:3-slim restart: unless-stopped @@ -345,6 +361,7 @@ networks: volumes: buildkit-data: + diag-sandbox-data: redis-data: postgres-data: api-data: @@ -353,3 +370,4 @@ volumes: grafana-data: railpack-cache: promtail-data: + backup-data: From 0a1a72441919cf528f83c0289c82a3375901b018 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sat, 3 Oct 2026 13:28:54 +0100 Subject: [PATCH 10/13] fix(api, docs): address CodeRabbit review comments on AI diagnosis PR - sandbox: drop to non-root bun and chown /srv dirs at runtime, since the compose volume shadows the build-time chown on existing installs - llm settings: reject a baseURL change without an apiKey - diag-actions: reclaim stale runs atomically on status and updatedAt - github-sessions: anchor the session cookie regex to a cookie boundary - fixdiag: pass git auth via env instead of argv and redact it from errors, ensure the sandbox is up before investigating, subscribe to the event bus before replaying persisted stages, scope project source per run id, and capture untracked changes with git diff --cached - types: define ExplainInput - config: read the Slack webhook and channel from env/file - docs: correct AI diagnosis caveats and provider list --- apps/api/Dockerfile.sandbox | 5 +- apps/api/src/api/settings/llm.ts | 7 +- apps/api/src/db/repo/diag-actions.ts | 5 +- apps/api/src/db/repo/github-sessions.ts | 2 +- apps/api/src/fixdiag/actions.ts | 57 ++++++--- apps/api/src/fixdiag/machine.ts | 6 +- apps/api/src/fixdiag/routes.ts | 46 ++++--- apps/api/src/fixdiag/sandbox-host.ts | 114 +++++++++++------- apps/api/src/fixdiag/sandbox-runner/runner.ts | 3 +- apps/api/src/fixdiag/types.ts | 5 + apps/api/src/utils/config.ts | 4 +- apps/docs/src/content/docs/ai-diagnosis.md | 10 +- 12 files changed, 173 insertions(+), 91 deletions(-) diff --git a/apps/api/Dockerfile.sandbox b/apps/api/Dockerfile.sandbox index 0b0d156..f032e94 100644 --- a/apps/api/Dockerfile.sandbox +++ b/apps/api/Dockerfile.sandbox @@ -15,6 +15,9 @@ COPY src/fixdiag/types.ts ./fixdiag/types.ts COPY src/fixdiag/llm.ts ./fixdiag/llm.ts RUN rm -rf ./fixdiag/sandbox-runner/__tests__ -RUN mkdir -p /srv/jobs /srv/dequel-src /srv/project-src +RUN mkdir -p /srv/jobs /srv/dequel-src /srv/project-src \ + && chown -R bun:bun /srv/jobs /srv/dequel-src /srv/project-src + +USER bun CMD ["sleep", "infinity"] diff --git a/apps/api/src/api/settings/llm.ts b/apps/api/src/api/settings/llm.ts index 2a1a8fd..d215073 100644 --- a/apps/api/src/api/settings/llm.ts +++ b/apps/api/src/api/settings/llm.ts @@ -76,8 +76,13 @@ export const llmSettingsRoutes = new Elysia({ prefix: "/settings" }) } const apiKey = typeof body?.apiKey === "string" && body.apiKey.length > 0 ? body.apiKey : provider === "ollama" ? "ollama" : ""; - const effectiveApiKey = apiKey || existing?.apiKey || ""; const effectiveBaseUrl = body?.baseURL !== undefined ? body.baseURL : existing?.baseUrl; + const baseChanged = body?.baseURL !== undefined && body.baseURL !== existing?.baseUrl; + if (baseChanged && !apiKey && existing?.apiKey) { + set.status = 400; + return fail("apiKey is required when changing baseURL"); + } + const effectiveApiKey = apiKey || existing?.apiKey || ""; let models: string[] | undefined = Array.isArray(body?.models) ? body.models.map(String) : undefined; if (!models || models.length === 0) { diff --git a/apps/api/src/db/repo/diag-actions.ts b/apps/api/src/db/repo/diag-actions.ts index 0bc4459..3f4b928 100644 --- a/apps/api/src/db/repo/diag-actions.ts +++ b/apps/api/src/db/repo/diag-actions.ts @@ -57,9 +57,12 @@ export const claimDiagAction = async ( const [claimed] = await db .update(diagActions) .set({ status: "executing", error: null, updatedAt: timestamp }) - .where(eq(diagActions.key, key)) + .where( + and(eq(diagActions.key, key), eq(diagActions.status, row.status), eq(diagActions.updatedAt, row.updatedAt)), + ) .returning() .execute(); + if (!claimed) return { action, fresh: false }; return { action: toAction(claimed), fresh: true }; } return { action, fresh: false }; diff --git a/apps/api/src/db/repo/github-sessions.ts b/apps/api/src/db/repo/github-sessions.ts index b9b4b94..0f3154d 100644 --- a/apps/api/src/db/repo/github-sessions.ts +++ b/apps/api/src/db/repo/github-sessions.ts @@ -13,7 +13,7 @@ export const getGithubSession = async (id: string): Promise => { export const getGithubTokenFromCookie = async (cookie: string | null): Promise => { if (!cookie) return null; - const match = cookie.match(/github_session=([^;]+)/); + const match = cookie.match(/(?:^|;\s*)github_session=([^;]+)/); if (!match) return null; return getGithubSession(match[1]); }; diff --git a/apps/api/src/fixdiag/actions.ts b/apps/api/src/fixdiag/actions.ts index 6eebbc9..b3f5213 100644 --- a/apps/api/src/fixdiag/actions.ts +++ b/apps/api/src/fixdiag/actions.ts @@ -25,12 +25,29 @@ export type ActionResult = { ok: true; data: unknown } | { ok: false; status: nu const short = (s: string, n = 500): string => (s.length > n ? `${s.slice(0, n)}…` : s); -const git = (args: string[], cwd: string, token: string) => - execFileAsync("git", ["-c", `http.extraHeader=Authorization: Bearer ${token}`, ...args], { - timeout: 120_000, - cwd, - maxBuffer: 4 * 1024 * 1024, - }); +const git = async (args: string[], cwd: string, token: string) => { + const auth = Buffer.from(`x-access-token:${token}`).toString("base64"); + try { + return await execFileAsync("git", args, { + timeout: 120_000, + cwd, + maxBuffer: 4 * 1024 * 1024, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraHeader", + GIT_CONFIG_VALUE_0: `Authorization: Basic ${auth}`, + }, + }); + } catch (err) { + const stderr = String((err as { stderr?: unknown }).stderr ?? "") + .split(token) + .join("***") + .trim(); + throw new Error(`git ${args.join(" ")} failed: ${short(stderr || "command failed")}`); + } +}; const githubFetch = async (token: string, path: string, init?: RequestInit) => { const res = await fetch(`https://api.github.com${path}`, { @@ -109,16 +126,22 @@ export const approveFixPr = async ( await ensureSandbox(); const version = await readLocalVersion(); const { rev, stale } = await syncDequelSource(version); - const project = await syncProjectSource({ - deploymentId: dep.id, - projectId: dep.projectId, - sourceType: dep.sourceType, - sourceRef: dep.sourceRef, - branch: dep.branch, - commitSha: dep.commitSha ?? "", - failureReason: dep.failureReason, - }); - if (!project.available) return fail(422, "Project source could not be pulled into the sandbox"); + const project = await syncProjectSource( + { + deploymentId: dep.id, + projectId: dep.projectId, + sourceType: dep.sourceType, + sourceRef: dep.sourceRef, + branch: dep.branch, + commitSha: dep.commitSha ?? "", + failureReason: dep.failureReason, + }, + runId, + ); + if (!project.available) { + await clearProjectSource(runId); + return fail(422, "Project source could not be pulled into the sandbox"); + } try { const investigation = (await getStagePayload(runId, "investigate")) as { culpritPaths?: unknown; @@ -150,7 +173,7 @@ export const approveFixPr = async ( }); patch = result.patch; } finally { - await clearProjectSource(); + await clearProjectSource(runId); } } catch (err) { return fail(502, `Agent fix failed: ${short(err instanceof Error ? err.message : String(err))}`); diff --git a/apps/api/src/fixdiag/machine.ts b/apps/api/src/fixdiag/machine.ts index af1d056..f2ec4ce 100644 --- a/apps/api/src/fixdiag/machine.ts +++ b/apps/api/src/fixdiag/machine.ts @@ -28,6 +28,7 @@ import type { } from "./types"; import { clearProjectSource, + ensureSandbox, investigateInSandbox, readLocalVersion, syncDequelSource, @@ -41,9 +42,10 @@ const STAGES: DiagStageName[] = ["triage", "investigate", "explain", "propose"]; const activeDrives = new Set(); const defaultInvestigator: InvestigateFn = async ({ run, deployment, logText, verdict, onProgress }) => { + await ensureSandbox(); const version = await readLocalVersion(); const { rev, stale } = await syncDequelSource(version); - const project = await syncProjectSource(deployment); + const project = await syncProjectSource(deployment, run.id); try { return await investigateInSandbox({ runId: run.id, @@ -59,7 +61,7 @@ const defaultInvestigator: InvestigateFn = async ({ run, deployment, logText, ve onProgress, }); } finally { - await clearProjectSource(); + await clearProjectSource(run.id); } }; diff --git a/apps/api/src/fixdiag/routes.ts b/apps/api/src/fixdiag/routes.ts index 6abfb27..0b630ab 100644 --- a/apps/api/src/fixdiag/routes.ts +++ b/apps/api/src/fixdiag/routes.ts @@ -5,6 +5,7 @@ import { created, fail, ok } from "../api/response"; import { approveFixPr, approveSlackPost } from "./actions"; import { DiagRunMachine } from "./machine"; import { diagBus } from "./stream"; +import type { StageEvent } from "./types"; export const fixdiagRoutes = new Elysia() .post("/deployments/:id/diagnose", async ({ params: { id }, body, set }: any) => { @@ -63,7 +64,7 @@ export const fixdiagRoutes = new Elysia() return fail("Diagnosis not found"); } const encoder = new TextEncoder(); - let unsubscribe = () => undefined; + let unsubscribe: () => void = () => {}; let heartbeat: ReturnType | null = null; let closed = false; const stop = () => { @@ -79,34 +80,47 @@ export const fixdiagRoutes = new Elysia() if (closed) return; controller.enqueue(encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`)); }; + const handle = (event: StageEvent) => { + if (closed) return; + if (event.type === "stage") send("stage", event); + else if (event.type === "done") { + send("done", event); + stop(); + controller.close(); + } else if (event.type === "error") { + send("error", event); + stop(); + controller.close(); + } + }; send("ready", { runId }); + const buffered: StageEvent[] = []; + let replaying = true; + unsubscribe = diagBus.subscribe(runId, (event) => { + if (event.type === "token") return; + if (replaying) buffered.push(event); + else handle(event); + }); for (const stage of await listStageResults(runId)) { send("stage", { runId, stage: stage.stage, payload: stage.payload }); } - if (run.status === "done") { + replaying = false; + for (const event of buffered) handle(event); + if (closed) return; + const latest = (await getDiagRun(runId)) ?? run; + if (closed) return; + if (latest.status === "done") { send("done", { runId }); stop(); controller.close(); return; } - if (run.status === "error") { - send("error", { runId, message: run.error }); + if (latest.status === "error") { + send("error", { runId, message: latest.error }); stop(); controller.close(); return; } - unsubscribe = diagBus.subscribe(runId, (event) => { - if (event.type === "stage") send("stage", event); - else if (event.type === "done") { - send("done", event); - stop(); - controller.close(); - } else if (event.type === "error") { - send("error", event); - stop(); - controller.close(); - } - }); heartbeat = setInterval(() => send("heartbeat", { at: new Date().toISOString() }), 15000); }, cancel: stop, diff --git a/apps/api/src/fixdiag/sandbox-host.ts b/apps/api/src/fixdiag/sandbox-host.ts index 0662364..0bf5ef3 100644 --- a/apps/api/src/fixdiag/sandbox-host.ts +++ b/apps/api/src/fixdiag/sandbox-host.ts @@ -45,19 +45,35 @@ const docker = async (args: string[], timeoutMs = 60_000): Promise => { } }; +const OWN_DIRS = "/srv/jobs /srv/dequel-src /srv/project-src"; + +const normalizeOwnership = async (): Promise => { + await docker( + [ + "exec", + "-u", + "0", + SANDBOX_CONTAINER, + "sh", + "-c", + `u=$(id -u bun); for d in ${OWN_DIRS}; do [ "$(stat -c %u "$d" 2>/dev/null)" = "$u" ] || chown -R bun:bun "$d"; done`, + ], + 120_000, + ).catch(() => {}); +}; + export const ensureSandbox = async (): Promise => { const running = await docker(["inspect", "-f", "{{.State.Running}}", SANDBOX_CONTAINER], 15_000) .then((out) => out.trim() === "true") .catch(() => false); - if (running) return; - const exists = await docker(["inspect", "-f", "{{.Id}}", SANDBOX_CONTAINER], 15_000) - .then((out) => out.trim().length > 0) - .catch(() => false); - if (exists) { + if (!running) { + const exists = await docker(["inspect", "-f", "{{.Id}}", SANDBOX_CONTAINER], 15_000) + .then((out) => out.trim().length > 0) + .catch(() => false); + if (!exists) throw new Error("Diagnosis sandbox is not running (start the diagnose-sandbox service)"); await docker(["start", SANDBOX_CONTAINER], 60_000); - return; } - throw new Error("Diagnosis sandbox is not running (start the diagnose-sandbox service)"); + await normalizeOwnership(); }; export const syncDequelSource = async (version: string): Promise<{ rev: string; stale: boolean }> => { @@ -110,61 +126,64 @@ export const syncDequelSource = async (version: string): Promise<{ rev: string; return { rev, stale: !fetched }; }; -export const syncProjectSource = async (facts: DeploymentFacts): Promise<{ available: boolean }> => { - await docker(["exec", SANDBOX_CONTAINER, "sh", "-c", "rm -rf /srv/project-src && mkdir -p /srv/project-src"], 30_000); +const PROJECT_SRC_PARENT = "/srv/project-src"; + +const sanitizeRunId = (runId: string): string => runId.replace(/[^A-Za-z0-9_-]/g, "") || "run"; + +export const projectSrcDir = (runId: string): string => `${PROJECT_SRC_PARENT}/${sanitizeRunId(runId)}`; + +export const syncProjectSource = async ( + facts: DeploymentFacts, + runId: string, +): Promise<{ available: boolean; srcDir: string }> => { + const srcDir = projectSrcDir(runId); + await docker( + [ + "exec", + "-u", + "0", + SANDBOX_CONTAINER, + "sh", + "-c", + `rm -rf '${srcDir}' && mkdir -p '${srcDir}' && chown bun:bun '${srcDir}'`, + ], + 30_000, + ); if (facts.sourceType === "git") { const base = facts.branch ? ["--branch", facts.branch] : []; const cloned = await docker( - ["exec", SANDBOX_CONTAINER, "git", "clone", "--depth", "1", ...base, facts.sourceRef, "/srv/project-src"], + ["exec", SANDBOX_CONTAINER, "git", "clone", "--depth", "1", ...base, facts.sourceRef, srcDir], 180_000, ) .then(() => true) .catch(() => false); - if (!cloned) return { available: false }; + if (!cloned) return { available: false, srcDir }; if (facts.commitSha) { await docker( - [ - "exec", - SANDBOX_CONTAINER, - "git", - "-C", - "/srv/project-src", - "fetch", - "--depth", - "1", - "origin", - facts.commitSha, - ], + ["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "fetch", "--depth", "1", "origin", facts.commitSha], 120_000, ).catch(() => ""); - await docker( - ["exec", SANDBOX_CONTAINER, "git", "-C", "/srv/project-src", "checkout", "-q", facts.commitSha], - 30_000, - ).catch(() => ""); + await docker(["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "checkout", "-q", facts.commitSha], 30_000).catch( + () => "", + ); } - return { available: true }; + return { available: true, srcDir }; } if (facts.sourceType === "upload") { const dir = join(config.workspaceRoot, facts.deploymentId); - const tmp = await mkdtemp(join(tmpdir(), "dequel-diag-cp-")).catch(() => null); - if (!tmp) return { available: false }; try { - await docker(["cp", `${dir}/.`, `${SANDBOX_CONTAINER}:/srv/project-src/`], 120_000); - return { available: true }; + await docker(["cp", `${dir}/.`, `${SANDBOX_CONTAINER}:${srcDir}/`], 120_000); + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "chown", "-R", "bun:bun", srcDir], 60_000).catch(() => {}); + return { available: true, srcDir }; } catch { - return { available: false }; - } finally { - await rm(tmp, { recursive: true, force: true }).catch(() => {}); + return { available: false, srcDir }; } } - return { available: false }; + return { available: false, srcDir }; }; -export const clearProjectSource = async (): Promise => { - await docker( - ["exec", SANDBOX_CONTAINER, "sh", "-c", "rm -rf /srv/project-src && mkdir -p /srv/project-src"], - 30_000, - ).catch(() => {}); +export const clearProjectSource = async (runId: string): Promise => { + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "rm", "-rf", projectSrcDir(runId)], 30_000).catch(() => {}); }; export const investigateInSandbox = async (input: { @@ -192,7 +211,7 @@ export const investigateInSandbox = async (input: { "Use listFiles to discover layout, readFile for targeted reads, searchText to trace error strings.", ].join(" "); const raw = await runAgentJob({ - jobDir: `/srv/jobs/${input.runId}`, + jobDir: `/srv/jobs/${sanitizeRunId(input.runId)}`, input: { mode: "investigate", failureReason: input.failureReason, @@ -203,6 +222,7 @@ export const investigateInSandbox = async (input: { apiKey: key.apiKey, baseUrl: key.baseUrl, hasProjectSource: input.projectAvailable, + projectSrcDir: input.projectAvailable ? projectSrcDir(input.runId) : null, dequelRev: input.dequelRev, dequelStale: input.dequelStale, deadlineMs: 9 * 60_000, @@ -231,8 +251,9 @@ export const runFixAgent = async (input: { const key = await getDecryptedLlmKey(input.provider); if (!key) throw new Error(`LLM provider "${input.provider}" is not configured`); await ensureSandbox(); + const srcDir = projectSrcDir(input.runId); const raw = (await runAgentJob({ - jobDir: `/srv/jobs/fix-${input.runId}`, + jobDir: `/srv/jobs/fix-${sanitizeRunId(input.runId)}`, input: { mode: "fix", fixBrief: input.fixBrief, @@ -241,6 +262,7 @@ export const runFixAgent = async (input: { apiKey: key.apiKey, baseUrl: key.baseUrl, hasProjectSource: true, + projectSrcDir: srcDir, dequelRev: input.dequelRev, dequelStale: input.dequelStale, deadlineMs: 5.5 * 60_000, @@ -252,7 +274,11 @@ export const runFixAgent = async (input: { const changedFiles = Array.isArray(raw.changedFiles) ? raw.changedFiles.filter((v): v is string => typeof v === "string").slice(0, 32) : []; - const patch = await docker(["exec", SANDBOX_CONTAINER, "git", "-C", "/srv/project-src", "diff", "--no-color"], 30_000) + await docker(["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "add", "-A"], 30_000).catch(() => ""); + const patch = await docker( + ["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "diff", "--cached", "--no-color", "--binary"], + 30_000, + ) .then((out) => out) .catch(() => ""); if (!patch.trim()) throw new Error("agent made no changes to the project source"); diff --git a/apps/api/src/fixdiag/sandbox-runner/runner.ts b/apps/api/src/fixdiag/sandbox-runner/runner.ts index baab9cc..11d0192 100644 --- a/apps/api/src/fixdiag/sandbox-runner/runner.ts +++ b/apps/api/src/fixdiag/sandbox-runner/runner.ts @@ -12,6 +12,7 @@ interface BaseInput { apiKey: string; baseUrl: string | null; hasProjectSource: boolean; + projectSrcDir?: string | null; dequelRev: string; dequelStale: boolean; deadlineMs?: number; @@ -57,7 +58,7 @@ const main = async (): Promise => { const llm = buildLlm(raw.provider, raw.apiKey, raw.baseUrl ?? null, raw.model); const ctx = { progressPath, - projectRoot: raw.hasProjectSource ? "/srv/project-src" : null, + projectRoot: raw.hasProjectSource ? (raw.projectSrcDir ?? "/srv/project-src") : null, dequelRef: raw.dequelRev, }; if (raw.mode === "fix") { diff --git a/apps/api/src/fixdiag/types.ts b/apps/api/src/fixdiag/types.ts index 9592612..086c84f 100644 --- a/apps/api/src/fixdiag/types.ts +++ b/apps/api/src/fixdiag/types.ts @@ -90,6 +90,11 @@ export interface TriageInput { logText: string; } +export interface ExplainInput { + verdict: TriageVerdict; + localization: Localization; +} + export interface ProposeInput { localization: Localization; explanation: Explanation; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index 2206075..1067759 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -16,8 +16,6 @@ const SYSTEM = { dockerNetwork: "dequel_net", buildkitHost: "tcp://buildkit:1234", redisUrl: "redis://redis:6379", - dequelSlackWebhookUrl: "", - dequelSlackChannel: "", dequelSourceRepoUrl: "https://github.com/Lftobs/dequel.git", } as const; @@ -32,6 +30,8 @@ export const config = { agentTunnelUrl: withFile("AGENT_TUNNEL_URL", ""), appInternalPort: withFile("APP_INTERNAL_PORT", "17476", Number), envEncryptionKey: withFile("ENV_ENCRYPTION_KEY", "dev-env-key-change-me"), + dequelSlackWebhookUrl: withFile("DEQUEL_SLACK_WEBHOOK_URL", ""), + dequelSlackChannel: withFile("DEQUEL_SLACK_CHANNEL", ""), queueConcurrency: withFile("QUEUE_CONCURRENCY", "3", Number), queueRetryMax: withFile("QUEUE_RETRY_MAX", "5", Number), queueRetryBaseMs: withFile("QUEUE_RETRY_BASE_MS", "5000", Number), diff --git a/apps/docs/src/content/docs/ai-diagnosis.md b/apps/docs/src/content/docs/ai-diagnosis.md index a42543e..9ff25d2 100644 --- a/apps/docs/src/content/docs/ai-diagnosis.md +++ b/apps/docs/src/content/docs/ai-diagnosis.md @@ -5,13 +5,13 @@ description: Diagnose failed builds with an AI agent, then apply one-click fix P slug: ai-diagnosis --- -When a deployment fails, Dequel lets you hand the failure to an AI agent. The agent runs inside an isolated sandbox container holding a fresh checkout of Dequel's own source and your project's source. It looks up the files it needs itself, decides whether the fault is in your code or in Dequel itself, and proposes what to do next. Every write action requires your explicit click. +When a deployment fails, Dequel lets you hand the failure to an AI agent. The agent runs inside an isolated sandbox container holding a fresh checkout of Dequel's own source and your project's source. It looks up the files it needs itself, decides whether the fault is in your code or in Dequel itself, and proposes what to do next. Every write action requires your explicit click — the one exception is a Dequel bug report, which is posted to Slack automatically once the diagnosis finishes. The sandbox persists between diagnoses: Dequel's source is cloned once and updated to your running version on each run, while your project's source is pulled fresh per diagnosis and cleared afterwards. ## Configure a provider key -Before diagnosing anything, store an LLM provider key. Open **Settings → AI Diagnosis** and save a key for one of the supported providers (`openai`, `anthropic`, `gemini`, `groq`, or a custom OpenAI-compatible endpoint with a base URL). Keys are encrypted at rest and never shown again after saving. Leaving the key field empty keeps the stored key. +Before diagnosing anything, store an LLM provider key. Open **Settings → AI Diagnosis** and save a key for one of the supported providers (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, or a custom OpenAI-compatible endpoint with a base URL). Keys are encrypted at rest and never shown again after saving. Leaving the key field empty keeps the stored key. ## Diagnose a failed deployment @@ -28,7 +28,7 @@ The verdict is one of three outcomes. **Your code** means the fault is in your s ## Fix your code with one click -When the verdict is **Your code** and the project deploys from Git, the sheet shows a **Create fix PR** button. Clicking it sends the agent back into the sandbox to edit the project source, still read-only everywhere else. Dequel takes the resulting diff, applies it to a new `dequel-fix/*` branch, and opens a pull request against your repository, so you only review and merge. If the agent cannot produce a change, or the diff does not apply cleanly, Dequel reports the problem and you apply the fix manually. This action is idempotent: repeated clicks return the same pull request. +When the verdict is **Your code** and the project deploys from Git, the sheet shows a **Create Fix Pull Request** button. Clicking it sends the agent back into the sandbox to edit the project source, still read-only everywhere else. Dequel takes the resulting diff, applies it to a new `dequel-fix/*` branch, and opens a pull request against your repository, so you only review and merge. If the agent cannot produce a change, or the diff does not apply cleanly, Dequel reports the problem and you apply the fix manually. This action is idempotent: repeated clicks return the same pull request. > **Note:** Fix PRs require a connected GitHub account with repository access. Connect it under **Settings → GitHub Integration** first. Without it, Dequel refuses to proceed. @@ -41,8 +41,8 @@ When the verdict is **Dequel**, Dequel posts the drafted report to the Dequel te - Diagnosis runs only on deployments with `failed` status. - One diagnosis exists per deployment and commit; starting it again returns the existing result. - Fix PRs are available for Git projects only. Zip-upload projects still receive the written explanation. -- The sandbox agent pulls only the files it needs (12 KB per read, 40 search hits at a time) and can keep working through large repositories across many steps. What bounds it instead is time and cost: a run times out after about 10 minutes, and every file read spends your provider's tokens. -- The sandbox agent has read-only file access. It cannot run commands, reach the network, or see your provider key's surroundings. +- The sandbox agent pulls only the files it needs (about 2.4 KB per read during investigation, 8 KB when fixing, and 10 search hits at a time) and can keep working through large repositories across many steps. What bounds it instead is time and cost: a run times out after about 10 minutes, and every file read spends your provider's tokens. +- During investigation the agent has read-only file tools. When fixing, it can edit and create files inside the project source only. Neither mode can run shell commands or read anything outside the two source checkouts, so the provider key stored alongside the job stays out of the agent's reach. ## Next steps From bca4f1470fb785e899be203e9f8769ec2b578deb Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sat, 3 Oct 2026 13:52:39 +0100 Subject: [PATCH 11/13] fix(api): chown the sandbox job dir to bun before starting the runner docker cp creates files with the source file's ownership, so input.json can arrive root-owned and unreadable to the non-root runner. Make the job dir setup and cleanup privileged, then chown it to bun before exec. --- apps/api/src/fixdiag/sandbox-host.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/fixdiag/sandbox-host.ts b/apps/api/src/fixdiag/sandbox-host.ts index 0bf5ef3..df9412f 100644 --- a/apps/api/src/fixdiag/sandbox-host.ts +++ b/apps/api/src/fixdiag/sandbox-host.ts @@ -295,8 +295,9 @@ const runAgentJob = async (opts: { const hostTmp = await mkdtemp(join(tmpdir(), "dequel-diag-job-")); try { await writeFile(join(hostTmp, "input.json"), JSON.stringify(opts.input)); - await docker(["exec", SANDBOX_CONTAINER, "mkdir", "-p", opts.jobDir], 15_000); + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "mkdir", "-p", opts.jobDir], 15_000); await docker(["cp", join(hostTmp, "input.json"), `${SANDBOX_CONTAINER}:${opts.jobDir}/input.json`], 30_000); + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "chown", "-R", "bun:bun", opts.jobDir], 15_000); const deadline = Date.now() + opts.execTimeoutMs; let exited = false; const run = docker(["exec", SANDBOX_CONTAINER, "bun", RUNNER, `${opts.jobDir}/input.json`], opts.execTimeoutMs) @@ -330,7 +331,7 @@ const runAgentJob = async (opts: { if (!report) throw new Error("sandbox agent produced no report"); return JSON.parse(report); } finally { - await docker(["exec", SANDBOX_CONTAINER, "rm", "-rf", opts.jobDir], 30_000).catch(() => {}); + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "rm", "-rf", opts.jobDir], 30_000).catch(() => {}); await rm(hostTmp, { recursive: true, force: true }).catch(() => {}); } }; From 55519810bf8ae84387b4430188b39e1f42e1359b Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sat, 3 Oct 2026 13:57:09 +0100 Subject: [PATCH 12/13] fix: add MongoDB and Redis database studios - Add specialized database explorers and query consoles, improve session refresh handling, and allow webhook destinations resolved through DNS. --- .github/workflows/release.yml | 13 + .../api/src/api/__tests__/auth-routes.test.ts | 110 ++++++ apps/api/src/api/auth/index.ts | 41 +- apps/api/src/api/databases/query.ts | 169 +++++++- .../src/utils/__tests__/destination.test.ts | 24 ++ apps/api/src/utils/destination.ts | 3 +- apps/web/src/api/__tests__/auth.test.ts | 94 +++++ apps/web/src/api/auth.ts | 170 ++++++++ apps/web/src/api/client.ts | 143 +------ apps/web/src/api/databases.ts | 79 ++++ apps/web/src/api/http.ts | 47 +++ apps/web/src/components/Layout.tsx | 9 + .../components/databases/DatabaseStudio.tsx | 275 +------------ .../components/databases/studio/SqlStudio.tsx | 262 +++++++++++++ .../studio/mongo/MongoCollectionSidebar.tsx | 180 +++++++++ .../studio/mongo/MongoDocumentCard.tsx | 130 +++++++ .../studio/mongo/MongoDocumentModal.tsx | 129 ++++++ .../studio/mongo/MongoDocumentsView.tsx | 327 ++++++++++++++++ .../studio/mongo/MongoIndexesView.tsx | 241 ++++++++++++ .../databases/studio/mongo/MongoShellView.tsx | 102 +++++ .../databases/studio/mongo/MongoStudio.tsx | 250 ++++++++++++ .../databases/studio/redis/RedisCliView.tsx | 188 +++++++++ .../studio/redis/RedisCollectionViewer.tsx | 295 ++++++++++++++ .../studio/redis/RedisKeyExplorer.tsx | 367 ++++++++++++++++++ .../databases/studio/redis/RedisKeyModals.tsx | 134 +++++++ .../studio/redis/RedisKeysSidebar.tsx | 131 +++++++ .../studio/redis/RedisNewKeyDialog.tsx | 169 ++++++++ .../studio/redis/RedisScalarViewer.tsx | 226 +++++++++++ .../studio/redis/RedisServerInfoView.tsx | 257 ++++++++++++ .../databases/studio/redis/RedisStudio.tsx | 188 +++++++++ docker-compose.yml | 7 +- 31 files changed, 4330 insertions(+), 430 deletions(-) create mode 100644 apps/api/src/api/__tests__/auth-routes.test.ts create mode 100644 apps/api/src/utils/__tests__/destination.test.ts create mode 100644 apps/web/src/api/__tests__/auth.test.ts create mode 100644 apps/web/src/api/auth.ts create mode 100644 apps/web/src/api/databases.ts create mode 100644 apps/web/src/api/http.ts create mode 100644 apps/web/src/components/databases/studio/SqlStudio.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoDocumentsView.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoIndexesView.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoShellView.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoStudio.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisCliView.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisCollectionViewer.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisKeyExplorer.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisKeyModals.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisKeysSidebar.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisNewKeyDialog.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisScalarViewer.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisServerInfoView.tsx create mode 100644 apps/web/src/components/databases/studio/redis/RedisStudio.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f2a278..5d3fb1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: echo "API_IMAGE=ghcr.io/${REPO_LC}/api" >> $GITHUB_ENV echo "WEB_IMAGE=ghcr.io/${REPO_LC}/web" >> $GITHUB_ENV echo "AGENT_IMAGE=ghcr.io/${REPO_LC}/agent" >> $GITHUB_ENV + echo "SANDBOX_IMAGE=ghcr.io/${REPO_LC}/sandbox" >> $GITHUB_ENV env: REPO: ${{ github.repository }} @@ -90,6 +91,18 @@ jobs: cache-from: type=gha,scope=agent-${{ github.sha }} cache-to: type=gha,scope=agent-${{ github.sha }},mode=max + - name: Build and push diagnose sandbox image + uses: docker/build-push-action@v6 + with: + context: apps/api + file: apps/api/Dockerfile.sandbox + push: true + tags: | + ${{ env.SANDBOX_IMAGE }}:${{ steps.version.outputs.VERSION }} + ${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.SANDBOX_IMAGE) || format('{0}:next', env.SANDBOX_IMAGE) }} + cache-from: type=gha,scope=sandbox-${{ github.sha }} + cache-to: type=gha,scope=sandbox-${{ github.sha }},mode=max + - name: Build config tarball run: | VERSION="${{ steps.version.outputs.VERSION }}" diff --git a/apps/api/src/api/__tests__/auth-routes.test.ts b/apps/api/src/api/__tests__/auth-routes.test.ts new file mode 100644 index 0000000..e3c213e --- /dev/null +++ b/apps/api/src/api/__tests__/auth-routes.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../../db/db-provider"; +import * as schema from "../../db/schema"; +import { createTestPool, truncateAllTables } from "../../db/test-helper"; + +const TEST_SECRET = "test-jwt-secret-for-testing-purposes-only"; +let pool: Pool; + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + const { initAuth } = await import("../../utils/auth"); + initAuth(TEST_SECRET); +}); + +beforeEach(async () => { + await truncateAllTables(pool); +}); + +afterAll(async () => { + await truncateAllTables(pool); + await pool.end(); +}); + +describe("auth routes", () => { + it("GET /auth/me returns expiresIn when access token is valid", async () => { + const { authRoutes } = await import("../auth/index"); + const { signAccessToken } = await import("../../utils/auth"); + const token = await signAccessToken("testuser"); + const res = await authRoutes.handle( + new Request("http://localhost/auth/me", { + headers: { + cookie: `dequel_session=${token}`, + }, + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + status: string; + data: { authenticated: boolean; username: string; expiresIn: number }; + }; + expect(json.status).toBe("success"); + expect(json.data.authenticated).toBe(true); + expect(json.data.username).toBe("testuser"); + expect(typeof json.data.expiresIn).toBe("number"); + expect(json.data.expiresIn).toBeGreaterThan(0); + expect(json.data.expiresIn).toBeLessThanOrEqual(900); + }); + + it("GET /auth/me auto-refreshes when access token is missing but refresh token is valid", async () => { + const { authRoutes } = await import("../auth/index"); + const { generateRefreshToken, storeRefreshToken } = await import("../../utils/auth"); + const rt = generateRefreshToken(); + await storeRefreshToken("testuser", rt); + + const res = await authRoutes.handle( + new Request("http://localhost/auth/me", { + headers: { + cookie: `dequel_refresh=${rt}`, + }, + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + status: string; + data: { authenticated: boolean; username: string; expiresIn: number }; + }; + expect(json.status).toBe("success"); + expect(json.data.authenticated).toBe(true); + expect(json.data.username).toBe("testuser"); + expect(json.data.expiresIn).toBe(900); + + const cookies = res.headers.get("set-cookie") || ""; + expect(cookies).toContain("dequel_session="); + expect(cookies).toContain("dequel_refresh="); + }); + + it("GET /auth/me returns unauthenticated when no valid tokens exist", async () => { + const { authRoutes } = await import("../auth/index"); + const res = await authRoutes.handle(new Request("http://localhost/auth/me")); + expect(res.status).toBe(200); + const json = (await res.json()) as { status: string; data: { authenticated: boolean } }; + expect(json.status).toBe("success"); + expect(json.data.authenticated).toBe(false); + }); + + it("POST /auth/refresh returns expiresIn: 900 and rotates tokens", async () => { + const { authRoutes } = await import("../auth/index"); + const { generateRefreshToken, storeRefreshToken } = await import("../../utils/auth"); + const rt = generateRefreshToken(); + await storeRefreshToken("testuser", rt); + + const res = await authRoutes.handle( + new Request("http://localhost/auth/refresh", { + method: "POST", + headers: { + cookie: `dequel_refresh=${rt}`, + }, + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { status: string; data: { username: string; expiresIn: number } }; + expect(json.status).toBe("success"); + expect(json.data.username).toBe("testuser"); + expect(json.data.expiresIn).toBe(900); + }); +}); diff --git a/apps/api/src/api/auth/index.ts b/apps/api/src/api/auth/index.ts index 85ebb74..fa1c2c0 100644 --- a/apps/api/src/api/auth/index.ts +++ b/apps/api/src/api/auth/index.ts @@ -67,7 +67,7 @@ export const authRoutes = new Elysia() dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure }); dequel_refresh.value = refreshToken; dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure }); - return ok({ username }, "Logged in"); + return ok({ username, expiresIn: 900 }, "Logged in"); }) .post("/auth/logout", async ({ cookie: { dequel_session, dequel_refresh } }) => { const rt = dequel_refresh.value; @@ -99,12 +99,39 @@ export const authRoutes = new Elysia() dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure }); dequel_refresh.value = newRefreshToken; dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure }); - return ok({ username }, "Token refreshed"); + return ok({ username, expiresIn: 900 }, "Token refreshed"); }) - .get("/auth/me", async ({ cookie: { dequel_session } }) => { + .get("/auth/me", async ({ cookie: { dequel_session, dequel_refresh }, isSecure }) => { const token = dequel_session.value; - if (!token) return ok({ authenticated: false }); - const payload = await verifyAccessToken(token); - if (!payload) return ok({ authenticated: false }); - return ok({ authenticated: true, username: payload.sub }); + if (token) { + const payload = await verifyAccessToken(token); + if (payload) { + const current = Math.floor(Date.now() / 1000); + return ok({ + authenticated: true, + username: payload.sub, + expiresIn: Math.max(0, payload.exp - current), + }); + } + } + const rt = dequel_refresh.value; + if (rt) { + const username = await validateRefreshToken(rt); + if (username) { + await blacklistRefreshToken(rt); + const accessToken = await signAccessToken(username); + const newRefreshToken = generateRefreshToken(); + await storeRefreshToken(username, newRefreshToken); + dequel_session.value = accessToken; + dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure }); + dequel_refresh.value = newRefreshToken; + dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure }); + return ok({ + authenticated: true, + username, + expiresIn: 900, + }); + } + } + return ok({ authenticated: false }); }); diff --git a/apps/api/src/api/databases/query.ts b/apps/api/src/api/databases/query.ts index 937ff96..9c05a5d 100644 --- a/apps/api/src/api/databases/query.ts +++ b/apps/api/src/api/databases/query.ts @@ -60,16 +60,56 @@ export const getDatabaseTables = async (dbRecord: Database): Promise 250 then break end; res[#res+1] = k .. '|||' .. (redis.call('TYPE', k)['ok'] or 'string') end; return res"; + const res = await dockerExec( + dbRecord.containerName, + ["redis-cli", ...authArgs, "--raw", "EVAL", script, "0"], + server, + ); + if (res.code === 0) { + const lines = res.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("Warning:")); + if (lines.length > 0) { + return lines.map((line) => { + const [name, type] = line.split("|||"); + return { name: name || line, type: type || "string" }; + }); + } + } + + const fallbackRes = await dockerExec( + dbRecord.containerName, + ["redis-cli", ...authArgs, "--raw", "KEYS", "*"], + server, + ); + if (fallbackRes.code !== 0) return []; + const keys = fallbackRes.stdout .split("\n") .map((l) => l.trim()) - .filter(Boolean); - return keys.slice(0, 50).map((name) => ({ name, type: "key" })); + .filter((l) => l.length > 0 && !l.startsWith("Warning:")); + return keys.slice(0, 100).map((name) => ({ name, type: "string" })); } if (dbRecord.type === "mongodb") { + const evalScript = ` +(() => { + try { + const names = db.getCollectionNames(); + const res = names.map(name => { + let count = 0; + try { count = db[name].estimatedDocumentCount(); } catch {} + return { name, type: 'collection', rowCount: count }; + }); + return '__DEQUEL_JSON_START__' + EJSON.stringify(res) + '__DEQUEL_JSON_END__'; + } catch (e) { + return '__DEQUEL_JSON_START__' + EJSON.stringify(db.getCollectionNames().map(name => ({ name, type: 'collection' }))) + '__DEQUEL_JSON_END__'; + } +})() +`; const res = await dockerExec( dbRecord.containerName, [ @@ -83,11 +123,21 @@ export const getDatabaseTables = async (dbRecord: Database): Promise { + if (obj === null || obj === undefined) return obj; + if (typeof obj === "object") { + if (typeof obj.$oid === "string") return obj.$oid; + if (typeof obj.$date === "string") return obj.$date; + if (typeof obj.$numberLong === "string") return Number(obj.$numberLong); + if (Array.isArray(obj)) return obj.map(normalizeBson); + const clean: Record = {}; + for (const [k, v] of Object.entries(obj)) { + clean[k] = normalizeBson(v); + } + return clean; + } + return obj; +}; + export const executeDatabaseQuery = async (dbRecord: Database, query: string): Promise => { if (!dbRecord.containerName) { throw new Error("Database container is not active"); @@ -226,7 +292,9 @@ export const executeDatabaseQuery = async (dbRecord: Database, query: string): P redisArgs.push(...parts); const res = await dockerExec(dbRecord.containerName, redisArgs, server); - const output = res.code === 0 ? res.stdout.trim() : res.stderr.trim() || res.stdout.trim() || "ERROR"; + const cleanOut = (res.stdout || "").replace(/^Warning: Using a password[^\n]*\n?/gm, "").trim(); + const cleanErr = (res.stderr || "").replace(/^Warning: Using a password[^\n]*\n?/gm, "").trim(); + const output = res.code === 0 ? cleanOut : cleanErr || cleanOut || "ERROR"; results.push({ command: line, result: output, @@ -243,6 +311,27 @@ export const executeDatabaseQuery = async (dbRecord: Database, query: string): P } if (dbRecord.type === "mongodb") { + const wrappedScript = ` +(() => { + try { + let __res = (${query}); + if (__res && typeof __res.toArray === 'function') { + __res = __res.toArray(); + } + return '__DEQUEL_JSON_START__' + EJSON.stringify(__res) + '__DEQUEL_JSON_END__'; + } catch (e) { + try { + let __res2 = eval(${JSON.stringify(query)}); + if (__res2 && typeof __res2.toArray === 'function') { + __res2 = __res2.toArray(); + } + return '__DEQUEL_JSON_START__' + EJSON.stringify(__res2) + '__DEQUEL_JSON_END__'; + } catch (e2) { + return '__DEQUEL_JSON_START__' + EJSON.stringify({ __mongo_error: e2.message || String(e2) }) + '__DEQUEL_JSON_END__'; + } + } +})() +`; const res = await dockerExec( dbRecord.containerName, [ @@ -256,26 +345,74 @@ export const executeDatabaseQuery = async (dbRecord: Database, query: string): P dbRecord.databaseName, "--quiet", "--eval", - query, + wrappedScript, ], server, ); const executionTimeMs = Date.now() - startTime; - if (res.code !== 0) { - throw new Error(res.stderr || res.stdout || "MongoDB query failed"); + if (res.code === 0) { + const startIdx = res.stdout.indexOf("__DEQUEL_JSON_START__"); + const endIdx = res.stdout.indexOf("__DEQUEL_JSON_END__"); + if (startIdx !== -1 && endIdx !== -1) { + const jsonStr = res.stdout.substring(startIdx + "__DEQUEL_JSON_START__".length, endIdx); + try { + const parsed = JSON.parse(jsonStr); + if (parsed && typeof parsed === "object" && "__mongo_error" in parsed) { + throw new Error(parsed.__mongo_error); + } + const normalized = normalizeBson(parsed); + const rows: Record[] = Array.isArray(normalized) + ? normalized.map((item) => (typeof item === "object" && item !== null ? item : { result: item })) + : typeof normalized === "object" && normalized !== null + ? [normalized] + : [{ result: normalized }]; + + const colSet = new Set(); + rows.forEach((r) => Object.keys(r).forEach((k) => colSet.add(k))); + const columns = Array.from(colSet); + return { + rows, + columns: columns.length > 0 ? columns : ["result"], + executionTimeMs, + rawOutput: JSON.stringify(normalized, null, 2), + }; + } catch (e: any) { + if (e.message && !e.message.includes("JSON")) throw e; + } + } } + const fallbackRes = await dockerExec( + dbRecord.containerName, + [ + "mongosh", + "-u", + dbRecord.username, + "-p", + dbRecord.password, + "--authenticationDatabase", + "admin", + dbRecord.databaseName, + "--quiet", + "--eval", + query, + ], + server, + ); + if (fallbackRes.code !== 0) { + throw new Error(fallbackRes.stderr || fallbackRes.stdout || "MongoDB query failed"); + } let rows: Record[] = []; try { - const parsed = JSON.parse(res.stdout); - rows = Array.isArray(parsed) ? parsed : [parsed]; + const parsed = JSON.parse(fallbackRes.stdout); + const norm = normalizeBson(parsed); + rows = Array.isArray(norm) ? norm : [norm]; } catch { - rows = [{ result: res.stdout }]; + rows = [{ result: fallbackRes.stdout.trim() }]; } - const columns = rows.length > 0 ? Object.keys(rows[0]) : ["result"]; - return { rows, columns, executionTimeMs, rawOutput: res.stdout }; + return { rows, columns, executionTimeMs: Date.now() - startTime, rawOutput: fallbackRes.stdout.trim() }; } throw new Error(`Unsupported database type: ${dbRecord.type}`); diff --git a/apps/api/src/utils/__tests__/destination.test.ts b/apps/api/src/utils/__tests__/destination.test.ts new file mode 100644 index 0000000..f9ca856 --- /dev/null +++ b/apps/api/src/utils/__tests__/destination.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, mock } from "bun:test"; +import { validateDestination } from "../destination"; + +mock.restore(); + +describe("validateDestination", () => { + it("rejects non-http protocols and credentials", async () => { + expect(await validateDestination("ftp://example.com/x")).toMatch(/http or https/); + expect(await validateDestination("https://user:pass@example.com/")).toMatch(/credentials/); + expect(await validateDestination("not-a-url")).toMatch(/valid URL/); + }); + + it("rejects literal private IPs without DNS", async () => { + expect(await validateDestination("http://10.0.0.5/hook")).toMatch(/public address/); + expect(await validateDestination("http://192.168.1.10/hook")).toMatch(/public address/); + expect(await validateDestination("http://127.0.0.1:9/hook")).toMatch(/public address/); + expect(await validateDestination("http://[::1]/hook")).toMatch(/public address/); + }); + + it("does not reject domain names before DNS resolution", async () => { + const res = await validateDestination("https://webhook.site/db-test-123"); + expect(res === null || res === "destination hostname could not be resolved").toBe(true); + }); +}); diff --git a/apps/api/src/utils/destination.ts b/apps/api/src/utils/destination.ts index 3f29fd0..1327b93 100644 --- a/apps/api/src/utils/destination.ts +++ b/apps/api/src/utils/destination.ts @@ -41,7 +41,8 @@ export const validateDestination = async (raw: string): Promise = if (url.username || url.password) return "destination must not contain credentials"; const hostname = url.hostname; if (!hostname) return "destination must be a valid URL"; - if (isBlockedAddress(hostname)) return PUBLIC_REQUIRED; + const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":"); + if (looksLikeIp && isBlockedAddress(hostname)) return PUBLIC_REQUIRED; let addresses: { address: string }[]; try { addresses = await lookup(hostname, { all: true }); diff --git a/apps/web/src/api/__tests__/auth.test.ts b/apps/web/src/api/__tests__/auth.test.ts new file mode 100644 index 0000000..7207fe9 --- /dev/null +++ b/apps/web/src/api/__tests__/auth.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { cancelTokenRefresh, doRefreshToken, getMe, scheduleTokenRefresh } from "../auth"; +import { apiFetch } from "../http"; + +describe("Web Auth & Refresh Flow", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + cancelTokenRefresh(); + }); + + afterEach(() => { + cancelTokenRefresh(); + globalThis.fetch = originalFetch; + }); + + it("doRefreshToken deduplicates concurrent calls to a single network request", async () => { + let fetchCount = 0; + globalThis.fetch = mock(async (url: any) => { + if (String(url).endsWith("/auth/refresh")) { + fetchCount++; + await new Promise((r) => setTimeout(r, 10)); + return new Response(JSON.stringify({ status: "success", data: { username: "user", expiresIn: 900 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 404 }); + }); + + const [res1, res2, res3] = await Promise.all([doRefreshToken(), doRefreshToken(), doRefreshToken()]); + + expect(res1).toBe(true); + expect(res2).toBe(true); + expect(res3).toBe(true); + expect(fetchCount).toBe(1); + }); + + it("getMe schedules proactive refresh when authenticated", async () => { + globalThis.fetch = mock(async (url: any) => { + if (String(url).endsWith("/auth/me")) { + return new Response( + JSON.stringify({ status: "success", data: { authenticated: true, username: "user", expiresIn: 900 } }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + return new Response("{}", { status: 404 }); + }); + + const res = await getMe(); + expect(res.authenticated).toBe(true); + expect(res.username).toBe("user"); + expect(res.expiresIn).toBe(900); + }); + + it("apiFetch automatically retries once upon receiving 401 on non-auth routes", async () => { + let attempts = 0; + let refreshCalled = false; + + globalThis.fetch = mock(async (url: any, opts: any) => { + const strUrl = String(url); + if (strUrl.endsWith("/auth/refresh")) { + refreshCalled = true; + return new Response(JSON.stringify({ status: "success", data: { username: "user", expiresIn: 900 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (strUrl.endsWith("/projects")) { + attempts++; + if (attempts === 1) { + return new Response(JSON.stringify({ status: "error", message: "Authentication required" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ status: "success", data: [{ id: "proj-1", name: "My App" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 404 }); + }); + + const data = await apiFetch>("/projects"); + expect(refreshCalled).toBe(true); + expect(attempts).toBe(2); + expect(data).toHaveLength(1); + expect(data[0].name).toBe("My App"); + }); +}); diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts new file mode 100644 index 0000000..69519aa --- /dev/null +++ b/apps/web/src/api/auth.ts @@ -0,0 +1,170 @@ +const BASE = "/api"; + +export class AuthError extends Error { + status: number; + constructor(msg: string, status: number) { + super(msg); + this.status = status; + } +} + +export interface MeResponse { + authenticated: boolean; + username?: string; + expiresIn?: number; +} + +export interface LoginResponse { + username: string; + expiresIn?: number; +} + +let refreshPromise: Promise | null = null; +let refreshTimer: ReturnType | null = null; +let tokenExpiresAt = 0; + +const authChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("dequel_auth") : null; + +export const cancelTokenRefresh = () => { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + tokenExpiresAt = 0; +}; + +export const scheduleTokenRefresh = (expiresInSeconds: number) => { + cancelTokenRefresh(); + if (expiresInSeconds <= 0) return; + + tokenExpiresAt = Date.now() + expiresInSeconds * 1000; + + const leadTimeSeconds = 120; + const delaySeconds = Math.max(expiresInSeconds - leadTimeSeconds, 5); + + refreshTimer = setTimeout(() => { + void doRefreshToken(); + }, delaySeconds * 1000); +}; + +export const doRefreshToken = async (): Promise => { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + try { + const res = await fetch(`${BASE}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + cancelTokenRefresh(); + return false; + } + const json = await res.json(); + const data = (json && typeof json === "object" && "data" in json ? json.data : json) as LoginResponse; + const expiresIn = data?.expiresIn ?? 900; + scheduleTokenRefresh(expiresIn); + authChannel?.postMessage({ type: "session_refreshed", expiresIn }); + return true; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +const checkAndRefreshIfExpiring = () => { + if (tokenExpiresAt <= 0) return; + const remainingMs = tokenExpiresAt - Date.now(); + if (remainingMs <= 120_000) { + void doRefreshToken(); + } +}; + +if (typeof window !== "undefined") { + window.addEventListener("focus", checkAndRefreshIfExpiring); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + checkAndRefreshIfExpiring(); + } + }); +} + +if (authChannel) { + authChannel.onmessage = (event) => { + if (event.data?.type === "session_refreshed" && typeof event.data.expiresIn === "number") { + scheduleTokenRefresh(event.data.expiresIn); + } else if (event.data?.type === "session_logged_out") { + cancelTokenRefresh(); + } + }; +} + +export const login = async (username: string, password: string): Promise => { + const res = await fetch(`${BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: res.statusText })); + throw new AuthError(body.message ?? body.error ?? "Login failed", res.status); + } + const json = await res.json(); + const data = (json && typeof json === "object" && "data" in json ? json.data : json) as LoginResponse; + const expiresIn = data?.expiresIn ?? 900; + scheduleTokenRefresh(expiresIn); + authChannel?.postMessage({ type: "session_refreshed", expiresIn }); + return data; +}; + +export const logout = async (): Promise => { + cancelTokenRefresh(); + authChannel?.postMessage({ type: "session_logged_out" }); + await fetch(`${BASE}/auth/logout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); +}; + +export const refreshSession = async (): Promise<{ username: string }> => { + const ok = await doRefreshToken(); + if (!ok) { + throw new AuthError("Failed to refresh session", 401); + } + return { username: "" }; +}; + +export const getMe = async (): Promise => { + const fetchMe = async (): Promise => { + const res = await fetch(`${BASE}/auth/me`); + if (!res.ok) { + return { authenticated: false }; + } + const json = await res.json(); + return (json && typeof json === "object" && "data" in json ? json.data : json) as MeResponse; + }; + + const initial = await fetchMe(); + if (initial.authenticated) { + scheduleTokenRefresh(initial.expiresIn ?? 900); + return initial; + } + + const refreshed = await doRefreshToken(); + if (refreshed) { + const second = await fetchMe(); + if (second.authenticated) { + scheduleTokenRefresh(second.expiresIn ?? 900); + } + return second; + } + + cancelTokenRefresh(); + return initial; +}; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 5efac02..6598ca3 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,10 +2,7 @@ import type { ActiveDiagRun, Alert, ApiKey, - BackupJob, - BackupStorageSettingsData, CreateProjectInput, - Database, Deployment, DiagRun, DiagStage, @@ -16,47 +13,16 @@ import type { LlmKeyStatus, Log, Project, - QueryExecResult, ScalingPolicy, Server, SmtpSettingsStatus, - TableInfo, Volume, } from "../types"; +import { BASE, apiFetch } from "./http"; -const BASE = "/api"; - -class ApiError extends Error { - status: number; - constructor(msg: string, status: number) { - super(msg); - this.status = status; - } -} - -const apiFetch = async (path: string, opts?: RequestInit): Promise => { - const isFormData = opts?.body instanceof FormData; - const headers: Record = {}; - if (!isFormData) headers["Content-Type"] = "application/json"; - const res = await fetch(`${BASE}${path}`, { - ...opts, - headers: { - ...headers, - ...(opts?.headers as Record), - }, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({ - message: res.statusText, - })); - throw new ApiError(body.message ?? body.error ?? "Request failed", res.status); - } - if (res.headers.get("content-type")?.includes("text/event-stream")) return res as unknown as T; - if (res.headers.get("content-type")?.includes("text/plain")) return res.text() as unknown as T; - const json = await res.json(); - if (json && typeof json === "object" && "status" in json && "data" in json) return json.data as T; - return json as T; -}; +export * from "./http"; +export * from "./auth"; +export * from "./databases"; // Projects export const listProjects = () => apiFetch("/projects"); @@ -177,85 +143,6 @@ export const deleteVolume = (id: string) => method: "DELETE", }); -// Databases -export const listAllDatabases = () => apiFetch("/databases"); -export const listDatabases = (projectId: string) => apiFetch(`/projects/${projectId}/databases`); -export const createDatabase = ( - projectId: string | null, - type: string, - options?: { - name?: string; - version?: string; - serverId?: string; - cpuLimit?: number | null; - memoryLimitMb?: number | null; - storageLimitMb?: number | null; - publicAccess?: boolean; - allowPublicAccessFromAnywhere?: boolean; - allowedCidrs?: string[]; - }, -) => - apiFetch(projectId ? `/projects/${projectId}/databases` : "/databases", { - method: "POST", - body: JSON.stringify({ type, projectId, ...options }), - }); -export const getDatabase = (id: string) => apiFetch(`/databases/${id}`); -export const deleteDatabase = (id: string) => apiFetch(`/databases/${id}`, { method: "DELETE" }); -export const getDatabaseCredentials = (id: string) => - apiFetch<{ - username: string; - password: string; - internalConnectionString: string; - externalConnectionString: string | null; - externalHost: string | null; - externalPort: number | null; - }>(`/databases/${id}/credentials`); -export const startDatabase = (id: string) => apiFetch(`/databases/${id}/start`, { method: "POST" }); -export const stopDatabase = (id: string) => apiFetch(`/databases/${id}/stop`, { method: "POST" }); -export const restartDatabase = (id: string) => apiFetch(`/databases/${id}/restart`, { method: "POST" }); -export const retryDatabase = (id: string) => apiFetch(`/databases/${id}/retry`, { method: "POST" }); -export const updateDatabaseSettings = ( - id: string, - data: { - name?: string; - cpuLimit?: number | null; - memoryLimitMb?: number | null; - storageLimitMb?: number | null; - publicAccess?: boolean; - allowPublicAccessFromAnywhere?: boolean; - allowedCidrs?: string[]; - backupEnabled?: boolean; - backupSchedule?: string; - backupRetention?: number; - }, -) => - apiFetch(`/databases/${id}`, { - method: "PATCH", - body: JSON.stringify(data), - }); -export const getDatabaseTables = (id: string) => apiFetch(`/databases/${id}/tables`); -export const queryDatabase = (id: string, query: string) => - apiFetch(`/databases/${id}/query`, { - method: "POST", - body: JSON.stringify({ query }), - }); - -// Backups -export const listBackups = () => apiFetch("/backups"); -export const triggerBackup = (targetId = "internal") => - apiFetch(targetId === "internal" ? "/backups" : `/backups/${targetId}/trigger`, { - method: "POST", - }); -export const restoreBackup = (id: string) => - apiFetch<{ restored: boolean }>(`/backups/${id}/restore`, { method: "POST" }); -export const deleteBackup = (id: string) => apiFetch<{ deleted: boolean }>(`/backups/${id}`, { method: "DELETE" }); -export const getBackupStorageSettings = () => apiFetch("/settings/backup"); -export const updateBackupStorageSettings = (data: BackupStorageSettingsData) => - apiFetch("/settings/backup", { - method: "PUT", - body: JSON.stringify(data), - }); - // Domains export const listDomains = (projectId: string) => apiFetch(`/projects/${projectId}/domains`); export const createDomain = ( @@ -303,28 +190,6 @@ export const deleteScalingPolicy = (projectId: string) => export const getServerIp = () => apiFetch<{ ip: string; baseDomain: string; resolves: boolean; url: string }>("/server/ip"); -// Auth -export const login = (username: string, password: string) => - apiFetch<{ username: string }>("/auth/login", { - method: "POST", - body: JSON.stringify({ username, password }), - }); - -export const logout = () => apiFetch("/auth/logout", { method: "POST" }); - -export const refreshSession = () => apiFetch<{ username: string }>("/auth/refresh", { method: "POST" }); - -export const getMe = async () => { - const res = await apiFetch<{ authenticated: boolean; username?: string }>("/auth/me"); - if (!res.authenticated) { - const refreshed = await apiFetch<{ username: string }>("/auth/refresh", { method: "POST" }).catch(() => null); - if (refreshed) { - return apiFetch<{ authenticated: boolean; username?: string }>("/auth/me"); - } - } - return res; -}; - // Prometheus export const queryPrometheus = (query: string) => apiFetch<{ diff --git a/apps/web/src/api/databases.ts b/apps/web/src/api/databases.ts new file mode 100644 index 0000000..354d2ec --- /dev/null +++ b/apps/web/src/api/databases.ts @@ -0,0 +1,79 @@ +import type { BackupJob, BackupStorageSettingsData, Database, QueryExecResult, TableInfo } from "../types"; +import { apiFetch } from "./http"; + +export const listAllDatabases = () => apiFetch("/databases"); +export const listDatabases = (projectId: string) => apiFetch(`/projects/${projectId}/databases`); +export const createDatabase = ( + projectId: string | null, + type: string, + options?: { + name?: string; + version?: string; + serverId?: string; + cpuLimit?: number | null; + memoryLimitMb?: number | null; + storageLimitMb?: number | null; + publicAccess?: boolean; + allowPublicAccessFromAnywhere?: boolean; + allowedCidrs?: string[]; + }, +) => + apiFetch(projectId ? `/projects/${projectId}/databases` : "/databases", { + method: "POST", + body: JSON.stringify({ type, projectId, ...options }), + }); +export const getDatabase = (id: string) => apiFetch(`/databases/${id}`); +export const deleteDatabase = (id: string) => apiFetch(`/databases/${id}`, { method: "DELETE" }); +export const getDatabaseCredentials = (id: string) => + apiFetch<{ + username: string; + password: string; + internalConnectionString: string; + externalConnectionString: string | null; + externalHost: string | null; + externalPort: number | null; + }>(`/databases/${id}/credentials`); +export const startDatabase = (id: string) => apiFetch(`/databases/${id}/start`, { method: "POST" }); +export const stopDatabase = (id: string) => apiFetch(`/databases/${id}/stop`, { method: "POST" }); +export const restartDatabase = (id: string) => apiFetch(`/databases/${id}/restart`, { method: "POST" }); +export const retryDatabase = (id: string) => apiFetch(`/databases/${id}/retry`, { method: "POST" }); +export const updateDatabaseSettings = ( + id: string, + data: { + name?: string; + cpuLimit?: number | null; + memoryLimitMb?: number | null; + storageLimitMb?: number | null; + publicAccess?: boolean; + allowPublicAccessFromAnywhere?: boolean; + allowedCidrs?: string[]; + backupEnabled?: boolean; + backupSchedule?: string; + backupRetention?: number; + }, +) => + apiFetch(`/databases/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +export const getDatabaseTables = (id: string) => apiFetch(`/databases/${id}/tables`); +export const queryDatabase = (id: string, query: string) => + apiFetch(`/databases/${id}/query`, { + method: "POST", + body: JSON.stringify({ query }), + }); + +export const listBackups = () => apiFetch("/backups"); +export const triggerBackup = (targetId = "internal") => + apiFetch(targetId === "internal" ? "/backups" : `/backups/${targetId}/trigger`, { + method: "POST", + }); +export const restoreBackup = (id: string) => + apiFetch<{ restored: boolean }>(`/backups/${id}/restore`, { method: "POST" }); +export const deleteBackup = (id: string) => apiFetch<{ deleted: boolean }>(`/backups/${id}`, { method: "DELETE" }); +export const getBackupStorageSettings = () => apiFetch("/settings/backup"); +export const updateBackupStorageSettings = (data: BackupStorageSettingsData) => + apiFetch("/settings/backup", { + method: "PUT", + body: JSON.stringify(data), + }); diff --git a/apps/web/src/api/http.ts b/apps/web/src/api/http.ts new file mode 100644 index 0000000..9b19f8b --- /dev/null +++ b/apps/web/src/api/http.ts @@ -0,0 +1,47 @@ +import { doRefreshToken } from "./auth"; + +export const BASE = "/api"; + +export class ApiError extends Error { + status: number; + constructor(msg: string, status: number) { + super(msg); + this.status = status; + } +} + +export const apiFetch = async (path: string, opts?: RequestInit): Promise => { + const isFormData = opts?.body instanceof FormData; + const headers: Record = {}; + if (!isFormData) headers["Content-Type"] = "application/json"; + + const execute = () => + fetch(`${BASE}${path}`, { + ...opts, + headers: { + ...headers, + ...(opts?.headers as Record), + }, + }); + + let res = await execute(); + + if (res.status === 401 && !path.startsWith("/auth/")) { + const refreshed = await doRefreshToken(); + if (refreshed) { + res = await execute(); + } + } + + if (!res.ok) { + const body = await res.json().catch(() => ({ + message: res.statusText, + })); + throw new ApiError(body.message ?? body.error ?? "Request failed", res.status); + } + if (res.headers.get("content-type")?.includes("text/event-stream")) return res as unknown as T; + if (res.headers.get("content-type")?.includes("text/plain")) return res.text() as unknown as T; + const json = await res.json(); + if (json && typeof json === "object" && "status" in json && "data" in json) return json.data as T; + return json as T; +}; diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index 51c846e..7c97f11 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -17,8 +17,17 @@ export function Layout({ children }: { children: React.ReactNode }) { queryKey: ["auth", "me"], queryFn: () => api.getMe(), retry: false, + refetchInterval: (query) => (query.state.data?.authenticated ? 5 * 60 * 1000 : false), }); + useEffect(() => { + const handleUnauthorized = () => { + navigate({ to: "/login" }); + }; + window.addEventListener("dequel:unauthorized", handleUnauthorized); + return () => window.removeEventListener("dequel:unauthorized", handleUnauthorized); + }, [navigate]); + useEffect(() => { if (authLoading) return; if (location.pathname === "/login") { diff --git a/apps/web/src/components/databases/DatabaseStudio.tsx b/apps/web/src/components/databases/DatabaseStudio.tsx index 382d600..e648a7f 100644 --- a/apps/web/src/components/databases/DatabaseStudio.tsx +++ b/apps/web/src/components/databases/DatabaseStudio.tsx @@ -1,273 +1,20 @@ -import { useQuery } from "@tanstack/react-query"; -import { Code2, Layers, ShieldAlert, Table as TableIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import * as api from "../../api/client"; -import type { Database, QueryExecResult } from "../../types"; -import { DataTableView } from "./studio/DataTableView"; -import { SqlQueryView } from "./studio/SqlQueryView"; -import { StructureView } from "./studio/StructureView"; -import { TableSidebar } from "./studio/TableSidebar"; +import type { Database } from "../../types"; +import { MongoStudio } from "./studio/mongo/MongoStudio"; +import { RedisStudio } from "./studio/redis/RedisStudio"; +import { SqlStudio } from "./studio/SqlStudio"; interface DatabaseStudioProps { database: Database; } export function DatabaseStudio({ database }: DatabaseStudioProps) { - const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql">("data"); - const [selectedTable, setSelectedTable] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [page, setPage] = useState(1); - const pageSize = 10; + if (database.type === "mongodb") { + return ; + } - const [selectedRows, setSelectedRows] = useState([]); + if (database.type === "redis") { + return ; + } - // Custom SQL Query state - const [sqlQuery, setSqlQuery] = useState("SELECT * FROM information_schema.tables WHERE table_schema='public';"); - const [sqlResult, setSqlResult] = useState(null); - const [sqlError, setSqlError] = useState(null); - const [isExecutingSql, setIsExecutingSql] = useState(false); - - const [dataResult, setDataResult] = useState(null); - const [isLoadingData, setIsLoadingData] = useState(false); - const [dataError, setDataError] = useState(null); - - // Fetch Table List - const { - data: tables = [], - refetch: refetchTables, - isLoading: isLoadingTables, - } = useQuery({ - queryKey: ["database-tables", database.id], - queryFn: async () => { - const res = await api.getDatabaseTables(database.id).catch(() => []); - if (res.length > 0 && !selectedTable) { - setSelectedTable(res[0].name); - } - return res; - }, - }); - - // Auto-select first table if available - useEffect(() => { - if (tables.length > 0 && !selectedTable) { - setSelectedTable(tables[0].name); - } - }, [tables, selectedTable]); - - // Fetch Data for Selected Table - const loadTableData = async () => { - if (!selectedTable) return; - setIsLoadingData(true); - setDataError(null); - setSelectedRows([]); - - const offset = (page - 1) * pageSize; - let query = ""; - - if (database.type === "postgresql") { - query = `SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset};`; - } else if (database.type === "mysql") { - query = `SELECT * FROM \`${selectedTable}\` LIMIT ${pageSize} OFFSET ${offset};`; - } else if (database.type === "mongodb") { - query = `db.${selectedTable}.find().skip(${offset}).limit(${pageSize})`; - } else { - query = `KEYS *`; - } - - try { - const res = await api.queryDatabase(database.id, query); - setDataResult(res); - } catch (err: any) { - setDataError(err.message || "Failed to load table records"); - setDataResult(null); - } finally { - setIsLoadingData(false); - } - }; - - useEffect(() => { - if (selectedTable && activeTab === "data") { - loadTableData(); - } - }, [selectedTable, page, activeTab]); - - const handleExecuteSql = async () => { - if (!sqlQuery.trim()) return; - setIsExecutingSql(true); - setSqlError(null); - try { - const res = await api.queryDatabase(database.id, sqlQuery); - setSqlResult(res); - } catch (err: any) { - setSqlError(err.message || "Query execution failed"); - setSqlResult(null); - } finally { - setIsExecutingSql(false); - } - }; - - const handleSaveInlineEdit = async (rowIndex: number, editedValues: Record) => { - if (!selectedTable || !dataResult) return; - const primaryKeyCol = dataResult.columns[0] || "id"; - const originalRow = dataResult.rows[rowIndex]; - const primaryKeyVal = originalRow[primaryKeyCol]; - - const setAssignments = dataResult.columns - .map((col) => { - const val = editedValues[col]; - if (val === undefined || val === "" || val.toUpperCase() === "NULL") return `"${col}" = NULL`; - if (!isNaN(Number(val)) && val.trim() !== "") return `"${col}" = ${val}`; - if (val.toLowerCase() === "true" || val.toLowerCase() === "false") return `"${col}" = ${val}`; - return `"${col}" = '${val.replace(/'/g, "''")}'`; - }) - .join(", "); - - const updateQuery = `UPDATE "${selectedTable}" SET ${setAssignments} WHERE "${primaryKeyCol}" = '${primaryKeyVal}';`; - - try { - await api.queryDatabase(database.id, updateQuery); - loadTableData(); - } catch (err: any) { - setDataError(`Save failed: ${err.message}`); - } - }; - - const handleSaveInlineAdd = async (newValues: Record) => { - if (!selectedTable || !dataResult) return; - const cols = dataResult.columns.filter((c) => newValues[c] !== undefined && newValues[c].trim() !== ""); - if (cols.length === 0) return; - - const colNames = cols.map((c) => `"${c}"`).join(", "); - const values = cols.map((c) => `'${newValues[c].replace(/'/g, "''")}'`).join(", "); - const insertQuery = `INSERT INTO "${selectedTable}" (${colNames}) VALUES (${values});`; - - try { - await api.queryDatabase(database.id, insertQuery); - loadTableData(); - } catch (err: any) { - setDataError(`Insert failed: ${err.message}`); - } - }; - - const handleDeleteSelected = async () => { - if (!selectedTable || !dataResult || selectedRows.length === 0) return; - const primaryKeyCol = dataResult.columns[0] || "id"; - const pks = selectedRows.map((idx) => dataResult.rows[idx][primaryKeyCol]); - const formattedPks = pks.map((pk) => `'${String(pk).replace(/'/g, "''")}'`).join(", "); - - const deleteQuery = `DELETE FROM "${selectedTable}" WHERE "${primaryKeyCol}" IN (${formattedPks});`; - - try { - await api.queryDatabase(database.id, deleteQuery); - setSelectedRows([]); - loadTableData(); - } catch (err: any) { - setDataError(`Delete failed: ${err.message}`); - } - }; - - return ( -
- {/* Left Sidebar Schema Navigator */} - { - setSelectedTable(t); - setPage(1); - }} - onRefresh={refetchTables} - isLoading={isLoadingTables} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} - /> - - {/* Main Data Studio Canvas */} -
- {/* Top Mode Switcher Bar */} -
-
- - - -
- -
- Table: {selectedTable || "None"} -
-
- - {dataError && ( -
- - {dataError} -
- )} - - {activeTab === "data" && ( - - setSelectedRows(checked ? [...selectedRows, idx] : selectedRows.filter((i) => i !== idx)) - } - onSelectAllRows={(checked) => - setSelectedRows(checked && dataResult ? dataResult.rows.map((_, i) => i) : []) - } - onSaveInlineEdit={handleSaveInlineEdit} - onSaveInlineAdd={handleSaveInlineAdd} - onDeleteSelected={handleDeleteSelected} - /> - )} - - {activeTab === "structure" && } - - {activeTab === "sql" && ( - - )} -
-
- ); + return ; } diff --git a/apps/web/src/components/databases/studio/SqlStudio.tsx b/apps/web/src/components/databases/studio/SqlStudio.tsx new file mode 100644 index 0000000..64742b7 --- /dev/null +++ b/apps/web/src/components/databases/studio/SqlStudio.tsx @@ -0,0 +1,262 @@ +import { useQuery } from "@tanstack/react-query"; +import { Code2, Layers, ShieldAlert, Table as TableIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import * as api from "../../../api/client"; +import type { Database, QueryExecResult } from "../../../types"; +import { DataTableView } from "./DataTableView"; +import { SqlQueryView } from "./SqlQueryView"; +import { StructureView } from "./StructureView"; +import { TableSidebar } from "./TableSidebar"; + +interface SqlStudioProps { + database: Database; +} + +export function SqlStudio({ database }: SqlStudioProps) { + const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql">("data"); + const [selectedTable, setSelectedTable] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [page, setPage] = useState(1); + const pageSize = 10; + + const [selectedRows, setSelectedRows] = useState([]); + + const [sqlQuery, setSqlQuery] = useState("SELECT * FROM information_schema.tables WHERE table_schema='public';"); + const [sqlResult, setSqlResult] = useState(null); + const [sqlError, setSqlError] = useState(null); + const [isExecutingSql, setIsExecutingSql] = useState(false); + + const [dataResult, setDataResult] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const [dataError, setDataError] = useState(null); + + const { + data: tables = [], + refetch: refetchTables, + isLoading: isLoadingTables, + } = useQuery({ + queryKey: ["database-tables", database.id], + queryFn: async () => { + const res = await api.getDatabaseTables(database.id).catch(() => []); + if (res.length > 0 && !selectedTable) { + setSelectedTable(res[0].name); + } + return res; + }, + }); + + useEffect(() => { + if (tables.length > 0 && !selectedTable) { + setSelectedTable(tables[0].name); + } + }, [tables, selectedTable]); + + const loadTableData = async () => { + if (!selectedTable) return; + setIsLoadingData(true); + setDataError(null); + setSelectedRows([]); + + const offset = (page - 1) * pageSize; + let query = ""; + + if (database.type === "postgresql") { + query = `SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset};`; + } else { + query = `SELECT * FROM \`${selectedTable}\` LIMIT ${pageSize} OFFSET ${offset};`; + } + + try { + const res = await api.queryDatabase(database.id, query); + setDataResult(res); + } catch (err: any) { + setDataError(err.message || "Failed to load table records"); + setDataResult(null); + } finally { + setIsLoadingData(false); + } + }; + + useEffect(() => { + if (selectedTable && activeTab === "data") { + loadTableData(); + } + }, [selectedTable, page, activeTab]); + + const handleExecuteSql = async () => { + if (!sqlQuery.trim()) return; + setIsExecutingSql(true); + setSqlError(null); + try { + const res = await api.queryDatabase(database.id, sqlQuery); + setSqlResult(res); + } catch (err: any) { + setSqlError(err.message || "Query execution failed"); + setSqlResult(null); + } finally { + setIsExecutingSql(false); + } + }; + + const handleSaveInlineEdit = async (rowIndex: number, editedValues: Record) => { + if (!selectedTable || !dataResult) return; + const primaryKeyCol = dataResult.columns[0] || "id"; + const originalRow = dataResult.rows[rowIndex]; + const primaryKeyVal = originalRow[primaryKeyCol]; + + const setAssignments = dataResult.columns + .map((col) => { + const val = editedValues[col]; + if (val === undefined || val === "" || val.toUpperCase() === "NULL") return `"${col}" = NULL`; + if (!isNaN(Number(val)) && val.trim() !== "") return `"${col}" = ${val}`; + if (val.toLowerCase() === "true" || val.toLowerCase() === "false") return `"${col}" = ${val}`; + return `"${col}" = '${val.replace(/'/g, "''")}'`; + }) + .join(", "); + + const updateQuery = `UPDATE "${selectedTable}" SET ${setAssignments} WHERE "${primaryKeyCol}" = '${primaryKeyVal}';`; + + try { + await api.queryDatabase(database.id, updateQuery); + loadTableData(); + } catch (err: any) { + setDataError(`Save failed: ${err.message}`); + } + }; + + const handleSaveInlineAdd = async (newValues: Record) => { + if (!selectedTable || !dataResult) return; + const cols = dataResult.columns.filter((c) => newValues[c] !== undefined && newValues[c].trim() !== ""); + if (cols.length === 0) return; + + const colNames = cols.map((c) => `"${c}"`).join(", "); + const values = cols.map((c) => `'${newValues[c].replace(/'/g, "''")}'`).join(", "); + const insertQuery = `INSERT INTO "${selectedTable}" (${colNames}) VALUES (${values});`; + + try { + await api.queryDatabase(database.id, insertQuery); + loadTableData(); + } catch (err: any) { + setDataError(`Insert failed: ${err.message}`); + } + }; + + const handleDeleteSelected = async () => { + if (!selectedTable || !dataResult || selectedRows.length === 0) return; + const primaryKeyCol = dataResult.columns[0] || "id"; + const pks = selectedRows.map((idx) => dataResult.rows[idx][primaryKeyCol]); + const formattedPks = pks.map((pk) => `'${String(pk).replace(/'/g, "''")}'`).join(", "); + + const deleteQuery = `DELETE FROM "${selectedTable}" WHERE "${primaryKeyCol}" IN (${formattedPks});`; + + try { + await api.queryDatabase(database.id, deleteQuery); + setSelectedRows([]); + loadTableData(); + } catch (err: any) { + setDataError(`Delete failed: ${err.message}`); + } + }; + + return ( +
+ { + setSelectedTable(t); + setPage(1); + }} + onRefresh={refetchTables} + isLoading={isLoadingTables} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> + +
+
+
+ + + +
+ +
+ Table: {selectedTable || "None"} +
+
+ + {dataError && ( +
+ + {dataError} +
+ )} + + {activeTab === "data" && ( + + setSelectedRows(checked ? [...selectedRows, idx] : selectedRows.filter((i) => i !== idx)) + } + onSelectAllRows={(checked) => + setSelectedRows(checked && dataResult ? dataResult.rows.map((_, i) => i) : []) + } + onSaveInlineEdit={handleSaveInlineEdit} + onSaveInlineAdd={handleSaveInlineAdd} + onDeleteSelected={handleDeleteSelected} + /> + )} + + {activeTab === "structure" && } + + {activeTab === "sql" && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx b/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx new file mode 100644 index 0000000..0db36aa --- /dev/null +++ b/apps/web/src/components/databases/studio/mongo/MongoCollectionSidebar.tsx @@ -0,0 +1,180 @@ +import { Boxes, Plus, RefreshCw, Search } from "lucide-react"; +import { useState } from "react"; +import type { TableInfo } from "../../../../types"; +import { Badge } from "../../../ui/badge"; +import { Button } from "../../../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../ui/dialog"; +import { Input } from "../../../ui/input"; + +interface MongoCollectionSidebarProps { + collections: TableInfo[]; + selectedCollection: string | null; + onSelectCollection: (name: string) => void; + onRefresh: () => void; + onCreateCollection: (name: string) => Promise; + isLoading: boolean; + databaseName: string; +} + +export function MongoCollectionSidebar({ + collections, + selectedCollection, + onSelectCollection, + onRefresh, + onCreateCollection, + isLoading, + databaseName, +}: MongoCollectionSidebarProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newCollectionName, setNewCollectionName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const filtered = collections.filter((c) => c.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + const name = newCollectionName.trim(); + if (!name) return; + setIsCreating(true); + setCreateError(null); + try { + await onCreateCollection(name); + setNewCollectionName(""); + setShowCreateModal(false); + } catch (err: any) { + setCreateError(err.message || "Failed to create collection"); + } finally { + setIsCreating(false); + } + }; + + return ( +
+
+
+ + + Collections + +
+ + +
+
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Search collections..." + className="w-full bg-transparent text-xs text-foreground focus:outline-none placeholder:text-muted-foreground" + /> +
+ +
+ {filtered.length === 0 ? ( +

No collections found.

+ ) : ( + filtered.map((c) => { + const isSelected = selectedCollection === c.name; + return ( + + ); + }) + )} +
+
+ +
+ DB: {databaseName} + {collections.length} Collections +
+ + {/* Create Collection Dialog */} + + +
+ + + Create Collection + + + Add a new document collection to the MongoDB database. + + + +
+ + setNewCollectionName(e.target.value)} + placeholder="e.g. orders, users, audit_logs" + className="bg-card border-border/80 font-mono text-xs" + autoFocus + /> +
+ + {createError &&

{createError}

} + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx new file mode 100644 index 0000000..5ad45b3 --- /dev/null +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentCard.tsx @@ -0,0 +1,130 @@ +import { Check, Copy, Edit3, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "../../../ui/badge"; +import { Button } from "../../../ui/button"; + +interface MongoDocumentCardProps { + doc: Record; + index: number; + onEdit: (doc: Record) => void; + onDelete: (id: string) => void; +} + +export function MongoDocumentCard({ doc, index, onEdit, onDelete }: MongoDocumentCardProps) { + const [copied, setCopied] = useState(false); + const docId = String(doc._id ?? doc.id ?? `doc-${index}`); + const jsonString = JSON.stringify(doc, null, 2); + + const handleCopy = () => { + navigator.clipboard.writeText(jsonString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const renderValue = (val: unknown): React.ReactNode => { + if (val === null) return null; + if (val === undefined) return undefined; + if (typeof val === "boolean") return {String(val)}; + if (typeof val === "number") return {val}; + if (typeof val === "string") return "{val}"; + if (Array.isArray(val)) { + return ( + + [ + {val.map((item, i) => ( + + {i > 0 && ", "} + {renderValue(item)} + + ))} + ] + + ); + } + if (typeof val === "object") { + const entries = Object.entries(val as Record); + return ( + + {"{"} + + {entries.map(([k, v], i) => ( + + "{k}": {renderValue(v)} + {i < entries.length - 1 && ","} + + ))} + + {"}"} + + ); + } + return {String(val)}; + }; + + return ( +
+
+
+ + _id: {docId} + + + {Object.keys(doc).length} {Object.keys(doc).length === 1 ? "field" : "fields"} + +
+ +
+ + + +
+
+ +
+ {"{"} +
+ {Object.entries(doc).map(([k, v], i, arr) => ( +
+ "{k}": +
+ {renderValue(v)} + {i < arr.length - 1 ? "," : ""} +
+
+ ))} +
+ {"}"} +
+
+ ); +} diff --git a/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx b/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx new file mode 100644 index 0000000..015b692 --- /dev/null +++ b/apps/web/src/components/databases/studio/mongo/MongoDocumentModal.tsx @@ -0,0 +1,129 @@ +import { Braces, Check, Copy, FileJson } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "../../../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../ui/dialog"; + +interface MongoDocumentModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + documentToEdit: Record | null; + collectionName: string; + onSave: (doc: Record) => Promise; +} + +export function MongoDocumentModal({ + open, + onOpenChange, + documentToEdit, + collectionName, + onSave, +}: MongoDocumentModalProps) { + const isEdit = !!documentToEdit; + const [jsonText, setJsonText] = useState(""); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (open) { + setError(null); + if (documentToEdit) { + setJsonText(JSON.stringify(documentToEdit, null, 2)); + } else { + setJsonText("{\n \n}"); + } + } + }, [open, documentToEdit]); + + const handleFormat = () => { + try { + const parsed = JSON.parse(jsonText); + setJsonText(JSON.stringify(parsed, null, 2)); + setError(null); + } catch (err: any) { + setError(`Invalid JSON: ${err.message}`); + } + }; + + const handleSave = async () => { + setError(null); + let parsed: any; + try { + parsed = JSON.parse(jsonText); + } catch (err: any) { + setError(`Invalid JSON: ${err.message}`); + return; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + setError("Document must be a valid JSON object"); + return; + } + + setIsSaving(true); + try { + await onSave(parsed); + onOpenChange(false); + } catch (err: any) { + setError(err.message || "Failed to save document"); + } finally { + setIsSaving(false); + } + }; + + return ( + + + +
+ + + {isEdit ? "Edit Document" : "Insert Document"} —{" "} + {collectionName} + + +
+ + {isEdit + ? "Modify the document fields. Changes will replace the existing document." + : "Enter the document as a valid JSON object. A unique _id will be generated if omitted."} + +
+ +
+