From 4ead15502a7aaa24cd09b2477409dea1c277a494 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Tue, 6 Oct 2026 12:38:07 +0100 Subject: [PATCH 1/3] feat(databases): add inline MongoDB document editing --- .../databases/__tests__/validation.test.ts | 17 ++ apps/api/src/databases/validation.ts | 6 + .../databases/CreateDatabaseDialog.tsx | 284 ------------------ .../components/databases/DatabaseOverview.tsx | 48 +-- .../studio/mongo/MongoCollectionSidebar.tsx | 10 +- .../studio/mongo/MongoDocumentCard.tsx | 4 +- .../studio/mongo/MongoDocumentModal.tsx | 10 +- .../studio/mongo/MongoDocumentsTableView.tsx | 276 +++++++++++++++++ .../studio/mongo/MongoDocumentsView.tsx | 145 ++++----- .../studio/mongo/MongoIndexesView.tsx | 18 +- .../databases/studio/mongo/MongoShellView.tsx | 12 +- .../databases/studio/mongo/MongoStudio.tsx | 6 +- .../databases/studio/redis/RedisCliView.tsx | 109 +++---- .../studio/redis/RedisServerInfoView.tsx | 257 ---------------- .../databases/studio/redis/RedisStudio.tsx | 20 +- apps/web/src/routes/Databases.tsx | 11 +- 16 files changed, 444 insertions(+), 789 deletions(-) delete mode 100644 apps/web/src/components/databases/CreateDatabaseDialog.tsx create mode 100644 apps/web/src/components/databases/studio/mongo/MongoDocumentsTableView.tsx delete mode 100644 apps/web/src/components/databases/studio/redis/RedisServerInfoView.tsx diff --git a/apps/api/src/databases/__tests__/validation.test.ts b/apps/api/src/databases/__tests__/validation.test.ts index 6e66820..d626c89 100644 --- a/apps/api/src/databases/__tests__/validation.test.ts +++ b/apps/api/src/databases/__tests__/validation.test.ts @@ -25,6 +25,23 @@ describe("database input validation", () => { expect(result.ok).toBe(true); }); + it("preserves explicit backup settings including disabled backup", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + publicAccess: false, + backupEnabled: false, + backupSchedule: "0 0 * * *", + backupRetention: 14, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.input.backupEnabled).toBe(false); + expect(result.input.backupSchedule).toBe("0 0 * * *"); + expect(result.input.backupRetention).toBe(14); + } + }); + it("validates IPv4 CIDRs and rejects IPv6 ranges", () => { expect(isValidCidr("10.0.0.0/8")).toBe(true); expect(isValidCidr("203.0.113.4")).toBe(true); diff --git a/apps/api/src/databases/validation.ts b/apps/api/src/databases/validation.ts index 718ff12..5db70bb 100644 --- a/apps/api/src/databases/validation.ts +++ b/apps/api/src/databases/validation.ts @@ -77,6 +77,12 @@ export const validateDatabaseCreate = ( publicAccess, allowPublicAccessFromAnywhere, allowedCidrs, + backupEnabled: body.backupEnabled !== undefined ? Boolean(body.backupEnabled) : true, + backupSchedule: + typeof body.backupSchedule === "string" && body.backupSchedule.trim() + ? body.backupSchedule.trim() + : "0 */6 * * *", + backupRetention: typeof body.backupRetention === "number" && body.backupRetention > 0 ? body.backupRetention : 7, }, }; }; diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx deleted file mode 100644 index 3d171b3..0000000 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Cpu, Database, HardDrive, ShieldAlert } from "lucide-react"; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import * as api from "../../api/client"; -import type { DatabaseType, Project } from "../../types"; -import { Button } from "../ui/button"; -import { DATABASE_ENGINES } from "../ui/DatabaseSelect"; -import { getDatabaseLogo } from "../logos/DatabaseLogos"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; -import { Input } from "../ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; -import { ServerSelect } from "./ServerSelect"; - -interface CreateDatabaseDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - projects: Project[]; - defaultProjectId?: string | null; - onCreated: () => void; -} - -export function CreateDatabaseDialog({ - open, - onOpenChange, - projects, - defaultProjectId = null, - onCreated, -}: CreateDatabaseDialogProps) { - const [name, setName] = useState(""); - const [projectId, setProjectId] = useState(defaultProjectId ?? "standalone"); - const [type, setType] = useState("postgresql"); - const [version, setVersion] = useState("16"); - const [cpu, setCpu] = useState("1"); - const [memory, setMemory] = useState("512"); - const [storage, setStorage] = useState("10240"); - const [allowAnywhere, setAllowAnywhere] = useState(false); - const [cidrs, setCidrs] = useState(""); - const [serverId, setServerId] = useState("local"); - const [isCreating, setIsCreating] = useState(false); - const [error, setError] = useState(null); - - const { data: servers = [] } = useQuery({ - queryKey: ["servers"], - queryFn: () => api.listServers().catch(() => []), - }); - - const create = async () => { - setIsCreating(true); - setError(null); - try { - await api.createDatabase(projectId === "standalone" ? null : projectId, type, { - name, - version, - serverId, - cpuLimit: Number(cpu), - memoryLimitMb: Number(memory), - storageLimitMb: Number(storage), - publicAccess: true, - allowPublicAccessFromAnywhere: allowAnywhere, - allowedCidrs: cidrs - .split(/[\n,]/) - .map((value) => value.trim()) - .filter(Boolean), - }); - onCreated(); - onOpenChange(false); - setName(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Database creation failed"); - } finally { - setIsCreating(false); - } - }; - - return ( - - - -
-
- -
-
- Provision Managed Database - - Public access endpoint enabled with optional CIDR network IP allowlisting. - -
-
-
- -
-
- - setName(event.target.value)} - placeholder="e.g. Production PostgreSQL DB" - className="bg-background/50 border-border/80 text-xs focus:ring-orange-500/50" - /> -
- -
-
- - -
- -
- - -
-
- - - -
- - -
- -
- - - Resource Limits & Storage Allocation - -
-
- - setCpu(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
- - setMemory(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
- - setStorage(event.target.value)} - className="bg-background/50 border-border/80 text-xs font-mono" - /> -
-
-
- -
-
-
-

- - Allow Access From Anywhere -

-

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

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

- - {error} -

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

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

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

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

No indexes found on this collection.

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