feat: rescope to AI-first DB harness with multi-DB chat agent

Removes enterprise/DBA features and replaces the marginal AI bar with a
central chat agent that has progressive-discovery tools, cross-session
memory, saved-query reuse, and inline result actions. Adds ClickHouse
support alongside PostgreSQL/Greenplum.

Cleanup
- Drop ~10k LOC of advanced features: Docker, Snapshots, Validation,
  Index Advisor, Role/User Management, Data Generator, ERD, Lookup.
- Trim deps: drop @xyflow/react, dagre, @types/dagre; cut tokio features
  to rt-multi-thread/sync/time/net/macros.
- Remove unused TuskError variants and dead helpers (topological_sort,
  invalidate_schema_cache).

Multi-DB (PostgreSQL + ClickHouse)
- New src-tauri/src/db/ module: ChClient (HTTP-based, reuses reqwest),
  sql_guard (cross-flavor read-only whitelist with 8 tests).
- ConnectionConfig gains db_flavor and secure fields with serde defaults
  for backwards-compatible connections.json.
- All connection/query/schema/data commands dispatch by flavor; CH
  covers connect, execute_query, list_databases/schemas/tables/views/
  columns/completion_schema, paginated table fetch.
- Frontend: dbCapabilities matrix, ConnectionDialog engine selector
  with port auto-swap and HTTPS toggle, SqlEditor switches to
  StandardSQL dialect for CH, TableDataView surfaces CH connections as
  read-only.

AI-first chat agent
- New src/components/chat/ panel with composer, message rendering,
  collapsible tool-call/result blocks, top-level ErrorBoundary.
- Backend agent loop in commands/chat.rs with strict-JSON tool
  protocol. Nine tools: list_databases, list_tables, get_columns,
  switch_database, run_query, remember, save_query, find_queries, final.
  Forgiving parser accepts both flat and nested-input shapes.
- Compressed history: only the last 4 run_query results carry sample
  rows (≤10, cells truncated to 200 chars) into LLM context; older
  results marked omitted.
- System prompt uses lite OVERVIEW (DB list + active-DB tables only)
  instead of full DDL — schema details are loaded on demand via
  get_columns. CH OVERVIEW shows cross-DB tables since CH allows
  db.table queries.

Cross-session memory (F1)
- Per-connection markdown file at app_data_dir/memory/<connection_id>.md,
  16KB cap with oldest-block eviction. Agent appends via remember()
  tool; the file is injected into LEARNED NOTES section of every system
  prompt.
- New Memory sidebar tab with editable textarea, badge for note count,
  empty-state with template. Edits picked up on the next agent turn.

Saved-query reuse (F2)
- Tools save_query and find_queries scoped to current connection.
  save_query attaches a UUID + timestamp; find_queries returns top 10
  matches with SQL preview ≤500 chars.
- Storage shared with the sidebar Saved panel.

Inline result actions (F3)
- run_query result block in chat gets Open-full (90vw × 80vh modal with
  full ResultsTable, no row cap) and Export (reuses ExportDialog for
  CSV/JSON via existing exportCsv/exportJson commands).

Verification
- cargo check clean, zero warnings.
- cargo test --lib: 50 pass (20 chat parser + 4 memory + 8 sql_guard +
  6 clean_sql + 12 escape_ident).
- npx tsc --noEmit clean.
- npx vitest run: 20 pass.
This commit is contained in:
2026-05-06 19:30:44 +03:00
parent 652937f7f5
commit 4f7afc17f4
89 changed files with 4151 additions and 10756 deletions

View File

@@ -0,0 +1,64 @@
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Send } from "lucide-react";
interface Props {
onSend: (text: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatComposer({ onSend, disabled, placeholder }: Props) {
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
const handleSend = () => {
if (disabled) return;
const text = value.trim();
if (!text) return;
onSend(text);
setValue("");
requestAnimationFrame(() => {
if (ref.current) ref.current.style.height = "auto";
});
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const autoresize = (el: HTMLTextAreaElement) => {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
return (
<div className="flex items-end gap-2">
<textarea
ref={ref}
className="flex-1 resize-none rounded-md border border-border/50 bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20 disabled:opacity-60"
rows={1}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value);
autoresize(e.target);
}}
onKeyDown={handleKeyDown}
/>
<Button
size="sm"
onClick={handleSend}
disabled={disabled || !value.trim()}
className="h-8 gap-1.5"
>
<Send className="h-3.5 w-3.5" />
Send
</Button>
</div>
);
}

View File

@@ -0,0 +1,385 @@
import { useState } from "react";
import { ResultsTable } from "@/components/results/ResultsTable";
import { ExportDialog } from "@/components/export/ExportDialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChevronDown,
ChevronRight,
AlertCircle,
Sparkles,
User,
Wrench,
Database,
Columns,
Layers,
RefreshCw,
StickyNote,
Bookmark,
BookmarkPlus,
Maximize2,
Download,
} from "lucide-react";
import type { ChatMessage } from "@/types";
interface Props {
message: ChatMessage;
}
export function ChatMessageView({ message }: Props) {
switch (message.role) {
case "user":
return <UserBubble text={message.text} />;
case "assistant":
return <AssistantBubble text={message.text} />;
case "tool_call":
return <ToolCallBlock tool={message.tool} inputJson={message.input_json} />;
case "tool_result":
return (
<ToolResultBlock
tool={message.tool}
isError={message.is_error}
text={message.text ?? null}
result={message.result ?? null}
/>
);
}
}
function UserBubble({ text }: { text: string }) {
return (
<div className="flex gap-2">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-3 w-3" />
</span>
<div className="flex-1 whitespace-pre-wrap rounded-md bg-accent/30 px-3 py-2 text-sm">
{text}
</div>
</div>
);
}
function AssistantBubble({ text }: { text: string }) {
return (
<div className="flex gap-2">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-3 w-3" />
</span>
<div className="flex-1 whitespace-pre-wrap text-sm leading-relaxed">{text}</div>
</div>
);
}
function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string }) {
const [expanded, setExpanded] = useState(false);
const preview = extractToolPreview(tool, inputJson);
const Icon = iconForTool(tool);
return (
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Icon className="h-3 w-3" />
<span className="font-medium">{labelForTool(tool)}</span>
{preview && (
<span className="ml-1 truncate text-muted-foreground/70">
{preview.slice(0, 80)}
{preview.length > 80 ? "…" : ""}
</span>
)}
</button>
{expanded && (
<div className="border-t border-border/30 p-2">
{tool === "run_query" && preview ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px]">
{preview}
</pre>
) : (
<pre className="overflow-x-auto rounded bg-background/60 p-2 font-mono text-[11px]">
{prettyJson(inputJson)}
</pre>
)}
</div>
)}
</div>
);
}
function ToolResultBlock({
tool,
isError,
text,
result,
}: {
tool: string;
isError: boolean;
text: string | null;
result: { columns: string[]; types: string[]; rows: unknown[][]; row_count: number; execution_time_ms: number } | null;
}) {
if (isError) {
return (
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">{labelForTool(tool)} failed</div>
{text && <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{text}</div>}
</div>
</div>
);
}
// Legacy schema tool — keep a one-line indicator for old threads.
if (tool === "get_schema") {
return (
<div className="ml-8 flex items-center gap-2 rounded-md border border-border/40 bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span>Loaded schema context ({text?.length ?? 0} chars)</span>
</div>
);
}
// Text-only tools (chat v2/v3): list_databases, list_tables, get_columns, switch_database,
// remember, save_query, find_queries.
if (
tool === "list_databases" ||
tool === "list_tables" ||
tool === "get_columns" ||
tool === "switch_database" ||
tool === "remember" ||
tool === "save_query" ||
tool === "find_queries"
) {
return <TextToolResult tool={tool} text={text} />;
}
// run_query — full results table with Open-full / Export actions.
if (result) {
return <RunQueryResultBlock result={result} />;
}
return null;
}
function RunQueryResultBlock({
result,
}: {
result: {
columns: string[];
types: string[];
rows: unknown[][];
row_count: number;
execution_time_ms: number;
};
}) {
const cap = 100;
const [fullOpen, setFullOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const previewRows = result.rows.slice(0, cap);
const hasMore = result.rows.length > cap;
return (
<>
<div className="ml-8 overflow-hidden rounded-md border border-border/40 bg-background">
<div className="flex items-center gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
<Database className="h-3 w-3" />
<span>
{result.row_count} row{result.row_count === 1 ? "" : "s"} ·{" "}
{result.execution_time_ms} ms
</span>
{hasMore && (
<span className="text-muted-foreground/60">· showing first {cap}</span>
)}
<div className="ml-auto flex items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setFullOpen(true)}
title="Open full result"
disabled={result.rows.length === 0}
>
<Maximize2 className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setExportOpen(true)}
title="Export"
disabled={result.rows.length === 0}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
<div className="max-h-72 overflow-auto">
<ResultsTable
columns={result.columns}
types={result.types}
rows={previewRows}
/>
</div>
</div>
<Dialog open={fullOpen} onOpenChange={setFullOpen}>
<DialogContent className="flex h-[80vh] max-w-[90vw] flex-col gap-2 sm:max-w-[90vw]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Database className="h-3.5 w-3.5" />
Full result · {result.row_count} row{result.row_count === 1 ? "" : "s"} ·{" "}
{result.execution_time_ms} ms
<Button
size="xs"
variant="outline"
className="ml-auto gap-1.5"
onClick={() => setExportOpen(true)}
>
<Download className="h-3 w-3" />
Export
</Button>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border/40">
<ResultsTable
columns={result.columns}
types={result.types}
rows={result.rows}
/>
</div>
</DialogContent>
</Dialog>
<ExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
columns={result.columns}
rows={result.rows}
/>
</>
);
}
function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
const [expanded, setExpanded] = useState(tool === "switch_database");
const Icon = iconForTool(tool);
const lineCount = text ? text.split("\n").length : 0;
return (
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Icon className="h-3 w-3" />
<span className="font-medium">{labelForTool(tool)}</span>
{text && (
<span className="ml-1 text-muted-foreground/60">
{lineCount} line{lineCount === 1 ? "" : "s"}
</span>
)}
</button>
{expanded && text && (
<div className="border-t border-border/30 p-2">
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px] leading-relaxed">
{text}
</pre>
</div>
)}
</div>
);
}
function labelForTool(tool: string): string {
switch (tool) {
case "run_query":
return "Run SQL";
case "list_databases":
return "List databases";
case "list_tables":
return "List tables";
case "get_columns":
return "Inspect columns";
case "switch_database":
return "Switch database";
case "remember":
return "Remember";
case "save_query":
return "Save query";
case "find_queries":
return "Find saved queries";
case "get_schema":
return "Load schema";
default:
return tool;
}
}
function iconForTool(tool: string) {
switch (tool) {
case "run_query":
return Wrench;
case "list_databases":
return Database;
case "list_tables":
return Layers;
case "get_columns":
return Columns;
case "switch_database":
return RefreshCw;
case "remember":
return StickyNote;
case "save_query":
return BookmarkPlus;
case "find_queries":
return Bookmark;
case "get_schema":
return Database;
default:
return Wrench;
}
}
function extractToolPreview(tool: string, inputJson: string): string | null {
try {
const parsed = JSON.parse(inputJson) as Record<string, unknown>;
switch (tool) {
case "run_query":
return typeof parsed.sql === "string" ? parsed.sql : null;
case "list_tables":
return typeof parsed.database === "string" ? parsed.database : null;
case "switch_database":
return typeof parsed.database === "string" ? parsed.database : null;
case "get_columns":
return Array.isArray(parsed.tables) ? parsed.tables.join(", ") : null;
case "remember":
return typeof parsed.note === "string" ? parsed.note : null;
case "save_query":
return typeof parsed.name === "string" ? parsed.name : null;
case "find_queries":
return typeof parsed.text === "string" ? parsed.text : null;
default:
return null;
}
} catch {
return null;
}
}
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useRef } from "react";
import { useChat } from "@/hooks/use-chat";
import { ChatComposer } from "./ChatComposer";
import { ChatMessageView } from "./ChatMessageView";
import { Button } from "@/components/ui/button";
import { Eraser, Sparkles } from "lucide-react";
import { useAppStore } from "@/stores/app-store";
import { useAiSettings } from "@/hooks/use-ai";
interface Props {
tabId: string;
connectionId: string;
}
export function ChatPanel({ tabId, connectionId }: Props) {
const { messages, pending, send, clear } = useChat(tabId, connectionId);
const dbFlavors = useAppStore((s) => s.dbFlavors);
const flavor = dbFlavors[connectionId];
const { data: aiSettings } = useAiSettings();
const aiReady = !!aiSettings?.model;
const scrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollerRef.current?.scrollTo({
top: scrollerRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages.length, pending]);
return (
<div className="flex h-full flex-col">
<div className="flex h-9 items-center justify-between border-b border-border/40 px-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-primary/70" />
<span className="font-medium">AI Assistant</span>
{flavor && <span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">· {flavor}</span>}
{aiSettings?.model && (
<span className="text-[10px] text-muted-foreground/60">· {aiSettings.model}</span>
)}
</div>
<Button
size="xs"
variant="ghost"
onClick={clear}
disabled={messages.length === 0 || pending}
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Eraser className="h-3 w-3" />
Clear
</Button>
</div>
<div ref={scrollerRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 && !pending ? (
<EmptyState aiReady={aiReady} flavor={flavor} />
) : (
<div className="flex flex-col gap-3 px-4 py-3">
{messages.map((m) => (
<ChatMessageView key={m.id} message={m} />
))}
{pending && <PendingIndicator />}
</div>
)}
</div>
<div className="border-t border-border/40 bg-background/40 p-2">
<ChatComposer
onSend={send}
disabled={pending || !aiReady}
placeholder={
aiReady
? "Ask in plain language. The agent will browse schema and run read-only queries."
: "Configure an AI model in Settings to enable chat."
}
/>
</div>
</div>
);
}
function PendingIndicator() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
<span className="inline-flex gap-0.5">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70" />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:120ms]" />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:240ms]" />
</span>
Thinking...
</div>
);
}
function EmptyState({ aiReady, flavor }: { aiReady: boolean; flavor: string | undefined }) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="max-w-md space-y-3 text-center">
<Sparkles className="mx-auto h-8 w-8 text-primary/50" />
<h3 className="text-sm font-medium">Ask anything about your data</h3>
<p className="text-xs text-muted-foreground">
{aiReady
? `Connected to ${flavor ?? "database"}. Try: "How many rows in each table?", "Top 10 customers by total spend", "Show me last week's orders".`
: "Open Settings → AI to choose an Ollama model. Tusk will then assist with natural-language queries."}
</p>
</div>
</div>
);
}

View File

@@ -17,8 +17,9 @@ import {
} from "@/components/ui/select";
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
import { toast } from "sonner";
import type { ConnectionConfig } from "@/types";
import type { ConnectionConfig, DbFlavor } from "@/types";
import { ENVIRONMENTS } from "@/lib/environment";
import { capsFor } from "@/lib/dbCapabilities";
import { Loader2, X } from "lucide-react";
type InputMode = "fields" | "dsn";
@@ -89,6 +90,8 @@ const emptyConfig: ConnectionConfig = {
ssl_mode: "prefer",
color: undefined,
environment: undefined,
db_flavor: "postgresql",
secure: false,
};
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
@@ -111,10 +114,29 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
}
}
const update = (field: keyof ConnectionConfig, value: string | number) => {
const flavor: DbFlavor = form.db_flavor ?? "postgresql";
const isClickHouse = flavor === "clickhouse";
const update = (field: keyof ConnectionConfig, value: string | number | boolean) => {
setForm((f) => ({ ...f, [field]: value }));
};
const handleFlavorChange = (next: DbFlavor) => {
setForm((f) => {
const caps = capsFor(next);
// If user is on a default port for current flavor, swap to the new flavor's default
const currentDefault = capsFor(f.db_flavor).defaultPort;
const port = f.port === currentDefault ? caps.defaultPort : f.port;
return {
...f,
db_flavor: next,
port,
secure: next === "clickhouse" ? f.secure ?? false : false,
ssl_mode: next === "clickhouse" ? undefined : f.ssl_mode ?? "prefer",
};
});
};
const handleTest = () => {
testMutation.mutate(form, {
onSuccess: (version) => {
@@ -164,6 +186,23 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Engine
</label>
<Select value={flavor} onValueChange={(v) => handleFlavorChange(v as DbFlavor)}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgresql">PostgreSQL</SelectItem>
<SelectItem value="greenplum">Greenplum</SelectItem>
<SelectItem value="clickhouse">ClickHouse</SelectItem>
</SelectContent>
</Select>
</div>
{!isClickHouse && (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Mode
@@ -206,8 +245,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
</button>
</div>
</div>
)}
{mode === "dsn" ? (
{!isClickHouse && mode === "dsn" ? (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-2">
DSN
@@ -261,7 +301,12 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
className="col-span-3"
type="number"
value={form.port}
onChange={(e) => update("port", parseInt(e.target.value) || 5432)}
onChange={(e) =>
update(
"port",
parseInt(e.target.value) || capsFor(flavor).defaultPort,
)
}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
@@ -295,24 +340,46 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
onChange={(e) => update("database", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
SSL Mode
</label>
<Select
value={form.ssl_mode ?? "prefer"}
onValueChange={(v) => update("ssl_mode", v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">Disable</SelectItem>
<SelectItem value="prefer">Prefer</SelectItem>
<SelectItem value="require">Require</SelectItem>
</SelectContent>
</Select>
</div>
{isClickHouse ? (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
HTTPS
</label>
<div className="col-span-3 flex items-center gap-2">
<Button
type="button"
size="sm"
variant={form.secure ? "default" : "outline"}
className="h-7 px-3 text-xs"
onClick={() => update("secure", !form.secure)}
>
{form.secure ? "On" : "Off"}
</Button>
<span className="text-xs text-muted-foreground">
Use HTTPS scheme for ClickHouse HTTP endpoint
</span>
</div>
</div>
) : (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
SSL Mode
</label>
<Select
value={form.ssl_mode ?? "prefer"}
onValueChange={(v) => update("ssl_mode", v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">Disable</SelectItem>
<SelectItem value="prefer">Prefer</SelectItem>
<SelectItem value="require">Require</SelectItem>
</SelectContent>
</Select>
</div>
)}
</>
)}
<div className="grid grid-cols-4 items-center gap-3">

View File

@@ -1,297 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useDataGenerator } from "@/hooks/use-data-generator";
import { toast } from "sonner";
import {
Loader2,
CheckCircle2,
XCircle,
Wand2,
Table2,
} from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
schema: string;
table: string;
}
type Step = "config" | "preview" | "done";
export function GenerateDataDialog({
open,
onOpenChange,
connectionId,
schema,
table,
}: Props) {
const [step, setStep] = useState<Step>("config");
const [rowCount, setRowCount] = useState(10);
const [includeRelated, setIncludeRelated] = useState(true);
const [customInstructions, setCustomInstructions] = useState("");
const {
generatePreview,
preview,
isGenerating,
generateError,
insertData,
insertedRows,
isInserting,
insertError,
progress,
reset,
} = useDataGenerator();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setStep("config");
setRowCount(10);
setIncludeRelated(true);
setCustomInstructions("");
reset();
}
}
const handleGenerate = () => {
const genId = crypto.randomUUID();
generatePreview(
{
params: {
connection_id: connectionId,
schema,
table,
row_count: rowCount,
include_related: includeRelated,
custom_instructions: customInstructions || undefined,
},
genId,
},
{
onSuccess: () => setStep("preview"),
onError: (err) => toast.error("Generation failed", { description: String(err) }),
}
);
};
const handleInsert = () => {
if (!preview) return;
insertData(
{ connectionId, preview },
{
onSuccess: (rows) => {
setStep("done");
toast.success(`Inserted ${rows} rows`);
},
onError: (err) => toast.error("Insert failed", { description: String(err) }),
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5" />
Generate Test Data
</DialogTitle>
</DialogHeader>
{step === "config" && (
<>
<div className="grid gap-3 py-2">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Table</label>
<div className="col-span-3">
<Badge variant="secondary">{schema}.{table}</Badge>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Row Count</label>
<Input
className="col-span-3"
type="number"
value={rowCount}
onChange={(e) => setRowCount(Math.min(1000, Math.max(1, parseInt(e.target.value) || 1)))}
min={1}
max={1000}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Related Tables</label>
<div className="col-span-3 flex items-center gap-2">
<input
type="checkbox"
checked={includeRelated}
onChange={(e) => setIncludeRelated(e.target.checked)}
className="rounded"
/>
<span className="text-sm text-muted-foreground">
Include parent tables (via foreign keys)
</span>
</div>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-2">Instructions</label>
<Input
className="col-span-3"
placeholder="Optional: specific data requirements..."
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
/>
</div>
</div>
{isGenerating && progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{progress.message}</span>
<span className="text-muted-foreground">{progress.percent}%</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<><Loader2 className="h-4 w-4 animate-spin mr-1" />Generating...</>
) : (
"Generate Preview"
)}
</Button>
</DialogFooter>
</>
)}
{step === "preview" && preview && (
<>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Preview:</span>
<Badge variant="secondary">{preview.total_rows} rows across {preview.tables.length} tables</Badge>
</div>
{preview.tables.map((tbl) => (
<div key={`${tbl.schema}.${tbl.table}`} className="rounded-md border">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 text-sm font-medium border-b">
<Table2 className="h-3.5 w-3.5" />
{tbl.schema}.{tbl.table}
<Badge variant="secondary" className="ml-auto text-[10px]">{tbl.row_count} rows</Badge>
</div>
<div className="overflow-x-auto max-h-48">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
{tbl.columns.map((col) => (
<th key={col} className="px-2 py-1 text-left font-medium text-muted-foreground whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{tbl.rows.slice(0, 5).map((row, i) => (
<tr key={i} className="border-b last:border-0">
{(row as unknown[]).map((val, j) => (
<td key={j} className="px-2 py-1 font-mono whitespace-nowrap">
{val === null ? (
<span className="text-muted-foreground">NULL</span>
) : (
String(val).substring(0, 50)
)}
</td>
))}
</tr>
))}
{tbl.rows.length > 5 && (
<tr>
<td colSpan={tbl.columns.length} className="px-2 py-1 text-center text-muted-foreground">
...and {tbl.rows.length - 5} more rows
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStep("config")}>Back</Button>
<Button onClick={handleInsert} disabled={isInserting}>
{isInserting ? (
<><Loader2 className="h-4 w-4 animate-spin mr-1" />Inserting...</>
) : (
`Insert ${preview.total_rows} Rows`
)}
</Button>
</DialogFooter>
</>
)}
{step === "done" && (
<div className="py-4 space-y-4">
{insertError ? (
<div className="flex items-start gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-4">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Insert Failed</p>
<p className="text-xs text-muted-foreground">{insertError}</p>
</div>
</div>
) : (
<div className="flex items-start gap-3 rounded-md border border-green-500/50 bg-green-500/10 p-4">
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Data Generated Successfully</p>
<p className="text-xs text-muted-foreground">
{insertedRows} rows inserted across {preview?.tables.length ?? 0} tables.
</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{insertError && (
<Button onClick={() => setStep("preview")}>Retry</Button>
)}
</DialogFooter>
</div>
)}
{generateError && step === "config" && (
<div className="flex items-start gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-4">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground">{generateError}</p>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,498 +0,0 @@
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useDockerStatus, useCloneToDocker } from "@/hooks/use-docker";
import { toast } from "sonner";
import {
Loader2,
CheckCircle2,
XCircle,
Container,
Copy,
ChevronDown,
ChevronRight,
} from "lucide-react";
import type { CloneMode, CloneProgress } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
database: string;
onConnect?: (connectionId: string) => void;
}
type Step = "config" | "progress" | "done";
function ProcessLog({
entries,
open: logOpen,
onToggle,
endRef,
}: {
entries: CloneProgress[];
open: boolean;
onToggle: () => void;
endRef: React.RefObject<HTMLDivElement | null>;
}) {
if (entries.length === 0) return null;
return (
<div>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onToggle}
>
{logOpen ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
Process Log ({entries.length})
</button>
{logOpen && (
<div className="mt-1.5 rounded-md bg-muted p-3 text-xs font-mono max-h-40 overflow-auto">
{entries.map((entry, i) => (
<div key={i} className="leading-5 min-w-0">
<span className="text-muted-foreground">
{entry.percent}%
</span>{" "}
<span>{entry.message}</span>
{entry.detail && (
<div className="text-muted-foreground break-all pl-6">
{entry.detail}
</div>
)}
</div>
))}
<div ref={endRef} />
</div>
)}
</div>
);
}
export function CloneDatabaseDialog({
open,
onOpenChange,
connectionId,
database,
onConnect,
}: Props) {
const [step, setStep] = useState<Step>("config");
const [containerName, setContainerName] = useState("");
const [pgVersion, setPgVersion] = useState("16");
const [portMode, setPortMode] = useState<"auto" | "manual">("auto");
const [manualPort, setManualPort] = useState(5433);
const [cloneMode, setCloneMode] = useState<CloneMode>("schema_only");
const [sampleRows, setSampleRows] = useState(1000);
const [logEntries, setLogEntries] = useState<CloneProgress[]>([]);
const [logOpen, setLogOpen] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const { data: dockerStatus } = useDockerStatus();
const { clone, result, error, isCloning, progress, reset } =
useCloneToDocker();
// Reset state when dialog opens
const [prevOpen, setPrevOpen] = useState<{ open: boolean; database: string }>({ open: false, database: "" });
if (open !== prevOpen.open || database !== prevOpen.database) {
setPrevOpen({ open, database });
if (open) {
setStep("config");
setContainerName(
`tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`
);
setPgVersion("16");
setPortMode("auto");
setManualPort(5433);
setCloneMode("schema_only");
setSampleRows(1000);
setLogEntries([]);
setLogOpen(false);
reset();
}
}
// Accumulate progress events into log
const [prevProgress, setPrevProgress] = useState(progress);
if (progress !== prevProgress) {
setPrevProgress(progress);
if (progress) {
setLogEntries((prev) => {
const last = prev[prev.length - 1];
if (last && last.stage === progress.stage && last.message === progress.message) {
return prev;
}
return [...prev, progress];
});
if (progress.stage === "done" || progress.stage === "error") {
setStep("done");
}
}
}
// Auto-scroll log to bottom
useEffect(() => {
if (logOpen && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logEntries, logOpen]);
const handleClone = () => {
if (!containerName.trim()) {
toast.error("Container name is required");
return;
}
setStep("progress");
const cloneId = crypto.randomUUID();
clone({
params: {
source_connection_id: connectionId,
source_database: database,
container_name: containerName.trim(),
pg_version: pgVersion,
host_port: portMode === "manual" ? manualPort : null,
clone_mode: cloneMode,
sample_rows: cloneMode === "sample_data" ? sampleRows : null,
postgres_password: null,
},
cloneId,
});
};
const handleConnect = () => {
if (result?.connection_id && onConnect) {
onConnect(result.connection_id);
}
onOpenChange(false);
};
const dockerReady =
dockerStatus?.installed && dockerStatus?.daemon_running;
const logSection = (
<ProcessLog
entries={logEntries}
open={logOpen}
onToggle={() => setLogOpen(!logOpen)}
endRef={logEndRef}
/>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Container className="h-5 w-5" />
Clone to Docker
</DialogTitle>
</DialogHeader>
{step === "config" && (
<>
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
{dockerStatus === undefined ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
Checking Docker...
</span>
</>
) : dockerReady ? (
<>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Docker {dockerStatus.version}</span>
</>
) : (
<>
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-destructive">
{dockerStatus?.error || "Docker not available"}
</span>
</>
)}
</div>
<div className="grid gap-3 py-2">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Database
</label>
<div className="col-span-3">
<Badge variant="secondary">{database}</Badge>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Container
</label>
<Input
className="col-span-3"
value={containerName}
onChange={(e) => setContainerName(e.target.value)}
placeholder="tusk-mydb-clone"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
PG Version
</label>
<Select value={pgVersion} onValueChange={setPgVersion}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="17">PostgreSQL 17</SelectItem>
<SelectItem value="16">PostgreSQL 16</SelectItem>
<SelectItem value="15">PostgreSQL 15</SelectItem>
<SelectItem value="14">PostgreSQL 14</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Port
</label>
<div className="col-span-3 flex items-center gap-2">
<Select
value={portMode}
onValueChange={(v) =>
setPortMode(v as "auto" | "manual")
}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
{portMode === "manual" && (
<Input
type="number"
className="flex-1"
value={manualPort}
onChange={(e) =>
setManualPort(parseInt(e.target.value) || 5433)
}
min={1024}
max={65535}
/>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Clone Mode
</label>
<Select
value={cloneMode}
onValueChange={(v) => setCloneMode(v as CloneMode)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="schema_only">
Schema Only
</SelectItem>
<SelectItem value="full_clone">Full Clone</SelectItem>
<SelectItem value="sample_data">
Sample Data
</SelectItem>
</SelectContent>
</Select>
</div>
{cloneMode === "sample_data" && (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Sample Rows
</label>
<Input
className="col-span-3"
type="number"
value={sampleRows}
onChange={(e) =>
setSampleRows(parseInt(e.target.value) || 1000)
}
min={1}
max={100000}
/>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleClone} disabled={!dockerReady}>
Clone
</Button>
</DialogFooter>
</>
)}
{step === "progress" && (
<div className="py-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{progress?.message || "Starting..."}</span>
<span className="text-muted-foreground">
{progress?.percent ?? 0}%
</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div>
</div>
{isCloning && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{progress?.stage || "Initializing..."}
</div>
)}
{logSection}
</div>
)}
{step === "done" && (
<div className="py-4 space-y-4">
{error ? (
<div className="flex items-start gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-4">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">
Clone Failed
</p>
<p className="text-xs text-muted-foreground">{error}</p>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-green-500/50 bg-green-500/10 p-4">
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">
Clone Completed
</p>
<p className="text-xs text-muted-foreground">
Database cloned to Docker container successfully.
</p>
</div>
</div>
{result && (
<div className="rounded-md border p-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
Container
</span>
<span className="font-mono">
{result.container.name}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Port</span>
<span className="font-mono">
{result.container.host_port}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">URL</span>
<div className="flex items-center gap-1">
<span className="font-mono text-xs truncate max-w-[250px]">
{result.connection_url}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
navigator.clipboard.writeText(
result.connection_url
);
toast.success("URL copied");
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
</div>
)}
{logSection}
<DialogFooter>
{error ? (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
onClick={() => setStep("config")}
>
Retry
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Close
</Button>
{onConnect && result && (
<Button onClick={handleConnect}>Connect</Button>
)}
</>
)}
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,179 +0,0 @@
import { useState } from "react";
import {
useTuskContainers,
useStartContainer,
useStopContainer,
useRemoveContainer,
useDockerStatus,
} from "@/hooks/use-docker";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
ChevronDown,
ChevronRight,
Container,
Play,
Square,
Trash2,
Loader2,
} from "lucide-react";
export function DockerContainersList() {
const [expanded, setExpanded] = useState(true);
const { data: dockerStatus } = useDockerStatus();
const { data: containers, isLoading } = useTuskContainers();
const startMutation = useStartContainer();
const stopMutation = useStopContainer();
const removeMutation = useRemoveContainer();
const dockerAvailable =
dockerStatus?.installed && dockerStatus?.daemon_running;
if (!dockerAvailable) {
return null;
}
const handleStart = (name: string) => {
startMutation.mutate(name, {
onSuccess: () => toast.success(`Container "${name}" started`),
onError: (err) =>
toast.error("Failed to start container", {
description: String(err),
}),
});
};
const handleStop = (name: string) => {
stopMutation.mutate(name, {
onSuccess: () => toast.success(`Container "${name}" stopped`),
onError: (err) =>
toast.error("Failed to stop container", {
description: String(err),
}),
});
};
const handleRemove = (name: string) => {
if (
!confirm(
`Remove container "${name}"? This will delete the container and all its data.`
)
) {
return;
}
removeMutation.mutate(name, {
onSuccess: () => toast.success(`Container "${name}" removed`),
onError: (err) =>
toast.error("Failed to remove container", {
description: String(err),
}),
});
};
const isRunning = (status: string) =>
status.toLowerCase().startsWith("up");
return (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<Container className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Docker Clones</span>
{containers && containers.length > 0 && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0"
>
{containers.length}
</Badge>
)}
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{containers && containers.length === 0 && (
<div className="px-6 pb-2 text-xs text-muted-foreground">
No Docker clones yet. Right-click a database to clone it.
</div>
)}
{containers?.map((container) => (
<div
key={container.container_id}
className="group flex items-center gap-1.5 px-6 py-1 text-xs hover:bg-accent/50"
>
<span className="truncate flex-1 font-medium">
{container.name}
</span>
{container.source_database && (
<span className="text-[10px] text-muted-foreground shrink-0">
{container.source_database}
</span>
)}
<span className="text-[10px] text-muted-foreground shrink-0">
:{container.host_port}
</span>
<Badge
variant={isRunning(container.status) ? "default" : "secondary"}
className={`text-[9px] px-1 py-0 shrink-0 ${
isRunning(container.status)
? "bg-green-600 hover:bg-green-600"
: ""
}`}
>
{isRunning(container.status) ? "running" : "stopped"}
</Badge>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
{isRunning(container.status) ? (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleStop(container.name)}
title="Stop"
disabled={stopMutation.isPending}
>
<Square className="h-3 w-3" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleStart(container.name)}
title="Start"
disabled={startMutation.isPending}
>
<Play className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
onClick={() => handleRemove(container.name)}
title="Remove"
disabled={removeMutation.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import CodeMirror from "@uiw/react-codemirror";
import { sql, PostgreSQL } from "@codemirror/lang-sql";
import { sql, PostgreSQL, StandardSQL } from "@codemirror/lang-sql";
import { keymap } from "@codemirror/view";
import { useCallback, useMemo } from "react";
import { useAppStore } from "@/stores/app-store";
interface Props {
value: string;
@@ -27,6 +28,10 @@ function buildSqlNamespace(
}
export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Props) {
const activeConnectionId = useAppStore((s) => s.activeConnectionId);
const dbFlavors = useAppStore((s) => s.dbFlavors);
const flavor = activeConnectionId ? dbFlavors[activeConnectionId] : undefined;
const handleChange = useCallback(
(val: string) => {
onChange(val);
@@ -36,11 +41,13 @@ export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Prop
const extensions = useMemo(() => {
const sqlNamespace = schema ? buildSqlNamespace(schema) : undefined;
const dialect = flavor === "clickhouse" ? StandardSQL : PostgreSQL;
const defaultSchema = flavor === "clickhouse" ? undefined : "public";
return [
sql({
dialect: PostgreSQL,
dialect,
schema: sqlNamespace,
defaultSchema: "public",
defaultSchema,
}),
keymap.of([
{
@@ -66,7 +73,7 @@ export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Prop
},
]),
];
}, [onExecute, onFormat, schema]);
}, [onExecute, onFormat, schema, flavor]);
return (
<CodeMirror

View File

@@ -1,186 +0,0 @@
import { useMemo, useCallback, useState } from "react";
import { useTheme } from "next-themes";
import {
ReactFlow,
Background,
Controls,
MiniMap,
MarkerType,
PanOnScrollMode,
applyNodeChanges,
applyEdgeChanges,
type Node,
type Edge,
type NodeTypes,
type NodeChange,
type EdgeChange,
} from "@xyflow/react";
import dagre from "dagre";
import "@xyflow/react/dist/style.css";
import { useSchemaErd } from "@/hooks/use-schema";
import { ErdTableNode, type ErdTableNodeData } from "./ErdTableNode";
import type { ErdData } from "@/types";
const nodeTypes: NodeTypes = {
erdTable: ErdTableNode,
};
const NODE_WIDTH = 250;
const NODE_ROW_HEIGHT = 24;
const NODE_HEADER_HEIGHT = 36;
function buildLayout(data: ErdData): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "LR", nodesep: 60, ranksep: 150 });
// Build list of FK column names per table for icon display
const fkColumnsPerTable = new Map<string, string[]>();
for (const rel of data.relationships) {
const key = `${rel.source_schema}.${rel.source_table}`;
if (!fkColumnsPerTable.has(key)) fkColumnsPerTable.set(key, []);
for (const col of rel.source_columns) {
const arr = fkColumnsPerTable.get(key)!;
if (!arr.includes(col)) arr.push(col);
}
}
for (const table of data.tables) {
const height = NODE_HEADER_HEIGHT + table.columns.length * NODE_ROW_HEIGHT;
g.setNode(table.name, { width: NODE_WIDTH, height });
}
for (const rel of data.relationships) {
g.setEdge(rel.source_table, rel.target_table);
}
dagre.layout(g);
const nodes: Node[] = data.tables.map((table) => {
const pos = g.node(table.name);
const tableKey = `${table.schema}.${table.name}`;
return {
id: table.name,
type: "erdTable",
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - pos.height / 2 },
data: {
label: table.name,
schema: table.schema,
columns: table.columns,
fkColumnNames: fkColumnsPerTable.get(tableKey) ?? [],
} satisfies ErdTableNodeData,
};
});
const edges: Edge[] = data.relationships.map((rel) => ({
id: rel.constraint_name,
source: rel.source_table,
target: rel.target_table,
type: "smoothstep",
label: rel.constraint_name,
labelStyle: { fontSize: 10, fill: "var(--muted-foreground)" },
labelBgStyle: { fill: "var(--card)", fillOpacity: 0.8 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: "var(--muted-foreground)",
},
style: { stroke: "var(--muted-foreground)", strokeWidth: 1.5 },
}));
return { nodes, edges };
}
interface Props {
connectionId: string;
schema: string;
}
export function ErdDiagram({ connectionId, schema }: Props) {
const { data: erdData, isLoading, error } = useSchemaErd(connectionId, schema);
const { resolvedTheme } = useTheme();
const layout = useMemo(() => {
if (!erdData) return null;
return buildLayout(erdData);
}, [erdData]);
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [prevLayout, setPrevLayout] = useState(layout);
if (layout !== prevLayout) {
setPrevLayout(layout);
if (layout) {
setNodes(layout.nodes);
setEdges(layout.edges);
}
}
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[],
);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading ER diagram...
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center text-sm text-destructive">
Error loading ER diagram: {String(error)}
</div>
);
}
if (!erdData || erdData.tables.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No tables found in schema &quot;{schema}&quot;.
</div>
);
}
return (
<div className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
minZoom={0.05}
maxZoom={3}
zoomOnScroll
zoomOnPinch
panOnScroll
panOnScrollMode={PanOnScrollMode.Free}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<Controls className="!bg-card !border !shadow-sm [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground" />
<MiniMap
className="!bg-card !border"
nodeColor="var(--muted)"
maskColor="rgba(0, 0, 0, 0.7)"
/>
</ReactFlow>
</div>
);
}

View File

@@ -1,54 +0,0 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { ErdColumn } from "@/types";
import { KeyRound, Link } from "lucide-react";
export interface ErdTableNodeData {
label: string;
schema: string;
columns: ErdColumn[];
fkColumnNames: string[];
[key: string]: unknown;
}
function ErdTableNodeComponent({ data }: NodeProps) {
const { label, columns, fkColumnNames } = data as unknown as ErdTableNodeData;
return (
<div className="min-w-[220px] rounded-lg border border-border bg-card text-card-foreground shadow-md">
<div className="rounded-t-lg border-b bg-primary/10 px-3 py-2 text-xs font-bold tracking-wide text-primary">
{label}
</div>
<div className="divide-y divide-border/50">
{(columns as ErdColumn[]).map((col, i) => (
<div key={i} className="flex items-center gap-1.5 px-3 py-1 text-[11px]">
{col.is_primary_key ? (
<KeyRound className="h-3 w-3 shrink-0 text-amber-500" />
) : (fkColumnNames as string[]).includes(col.name) ? (
<Link className="h-3 w-3 shrink-0 text-blue-400" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
<span className="font-medium">{col.name}</span>
<span className="ml-auto text-muted-foreground">{col.data_type}</span>
{col.is_nullable && (
<span className="text-muted-foreground/60">?</span>
)}
</div>
))}
</div>
<Handle
type="target"
position={Position.Left}
className="!w-1.5 !h-1.5 !bg-primary !opacity-0 hover:!opacity-100 !border-none !min-w-0 !min-h-0"
/>
<Handle
type="source"
position={Position.Right}
className="!w-1.5 !h-1.5 !bg-primary !opacity-0 hover:!opacity-100 !border-none !min-w-0 !min-h-0"
/>
</div>
);
}
export const ErdTableNode = memo(ErdTableNodeComponent);

View File

@@ -1,232 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useIndexAdvisorReport, useApplyIndexRecommendation } from "@/hooks/use-index-advisor";
import { RecommendationCard } from "./RecommendationCard";
import { toast } from "sonner";
import { Loader2, Gauge, Search, AlertTriangle } from "lucide-react";
import type { IndexAdvisorReport } from "@/types";
interface Props {
connectionId: string;
}
export function IndexAdvisorPanel({ connectionId }: Props) {
const [report, setReport] = useState<IndexAdvisorReport | null>(null);
const [appliedDdls, setAppliedDdls] = useState<Set<string>>(new Set());
const [applyingDdl, setApplyingDdl] = useState<string | null>(null);
const reportMutation = useIndexAdvisorReport();
const applyMutation = useApplyIndexRecommendation();
const handleAnalyze = () => {
reportMutation.mutate(connectionId, {
onSuccess: (data) => {
setReport(data);
setAppliedDdls(new Set());
},
onError: (err) => toast.error("Analysis failed", { description: String(err) }),
});
};
const handleApply = async (ddl: string) => {
if (!confirm("Apply this index change? This will modify the database schema.")) return;
setApplyingDdl(ddl);
try {
await applyMutation.mutateAsync({ connectionId, ddl });
setAppliedDdls((prev) => new Set(prev).add(ddl));
toast.success("Index change applied");
} catch (err) {
toast.error("Failed to apply", { description: String(err) });
} finally {
setApplyingDdl(null);
}
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-primary" />
<h2 className="text-sm font-medium">Index Advisor</h2>
</div>
<Button
size="sm"
onClick={handleAnalyze}
disabled={reportMutation.isPending}
>
{reportMutation.isPending ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Analyzing...</>
) : (
<><Search className="h-3.5 w-3.5 mr-1" />Analyze</>
)}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{!report ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Click Analyze to scan your database for index optimization opportunities.
</div>
) : (
<Tabs defaultValue="recommendations" className="h-full flex flex-col">
<div className="border-b px-4">
<TabsList className="h-9">
<TabsTrigger value="recommendations" className="text-xs">
Recommendations
{report.recommendations.length > 0 && (
<Badge variant="secondary" className="ml-1 text-[10px]">{report.recommendations.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="table-stats" className="text-xs">Table Stats</TabsTrigger>
<TabsTrigger value="index-stats" className="text-xs">Index Stats</TabsTrigger>
<TabsTrigger value="slow-queries" className="text-xs">
Slow Queries
{!report.has_pg_stat_statements && (
<AlertTriangle className="h-3 w-3 ml-1 text-yellow-500" />
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="recommendations" className="flex-1 overflow-auto p-4 space-y-2 mt-0">
{report.recommendations.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
No recommendations found. Your indexes look good!
</div>
) : (
report.recommendations.map((rec, i) => (
<RecommendationCard
key={rec.id || i}
recommendation={rec}
onApply={handleApply}
isApplying={applyingDdl === rec.ddl}
applied={appliedDdls.has(rec.ddl)}
/>
))
)}
</TabsContent>
<TabsContent value="table-stats" className="flex-1 overflow-auto mt-0">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Table</th>
<th className="px-3 py-2 text-right font-medium">Seq Scans</th>
<th className="px-3 py-2 text-right font-medium">Idx Scans</th>
<th className="px-3 py-2 text-right font-medium">Rows</th>
<th className="px-3 py-2 text-right font-medium">Table Size</th>
<th className="px-3 py-2 text-right font-medium">Index Size</th>
</tr>
</thead>
<tbody>
{report.table_stats.map((ts) => {
const ratio = ts.seq_scan + ts.idx_scan > 0
? ts.seq_scan / (ts.seq_scan + ts.idx_scan)
: 0;
return (
<tr key={`${ts.schema}.${ts.table}`} className="border-b">
<td className="px-3 py-2 font-mono">{ts.schema}.{ts.table}</td>
<td className={`px-3 py-2 text-right ${ratio > 0.8 && ts.n_live_tup > 1000 ? "text-destructive font-medium" : ""}`}>
{ts.seq_scan.toLocaleString()}
</td>
<td className="px-3 py-2 text-right">{ts.idx_scan.toLocaleString()}</td>
<td className="px-3 py-2 text-right">{ts.n_live_tup.toLocaleString()}</td>
<td className="px-3 py-2 text-right">{ts.table_size}</td>
<td className="px-3 py-2 text-right">{ts.index_size}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="index-stats" className="flex-1 overflow-auto mt-0">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Index</th>
<th className="px-3 py-2 text-left font-medium">Table</th>
<th className="px-3 py-2 text-right font-medium">Scans</th>
<th className="px-3 py-2 text-right font-medium">Size</th>
<th className="px-3 py-2 text-left font-medium">Definition</th>
</tr>
</thead>
<tbody>
{report.index_stats.map((is) => (
<tr key={`${is.schema}.${is.index_name}`} className="border-b">
<td className={`px-3 py-2 font-mono ${is.idx_scan === 0 ? "text-yellow-600" : ""}`}>
{is.index_name}
</td>
<td className="px-3 py-2">{is.schema}.{is.table}</td>
<td className={`px-3 py-2 text-right ${is.idx_scan === 0 ? "text-yellow-600 font-medium" : ""}`}>
{is.idx_scan.toLocaleString()}
</td>
<td className="px-3 py-2 text-right">{is.index_size}</td>
<td className="px-3 py-2 font-mono text-muted-foreground max-w-xs truncate">
{is.definition}
</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="slow-queries" className="flex-1 overflow-auto mt-0">
{!report.has_pg_stat_statements ? (
<div className="p-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
pg_stat_statements extension is not installed
</div>
<p className="text-xs">
Enable it with: CREATE EXTENSION pg_stat_statements;
</p>
</div>
) : report.slow_queries.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
No slow queries found.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">Query</th>
<th className="px-3 py-2 text-right font-medium">Calls</th>
<th className="px-3 py-2 text-right font-medium">Mean (ms)</th>
<th className="px-3 py-2 text-right font-medium">Total (ms)</th>
<th className="px-3 py-2 text-right font-medium">Rows</th>
</tr>
</thead>
<tbody>
{report.slow_queries.map((sq, i) => (
<tr key={i} className="border-b">
<td className="px-3 py-2 font-mono max-w-md truncate" title={sq.query}>
{sq.query.substring(0, 150)}
</td>
<td className="px-3 py-2 text-right">{sq.calls.toLocaleString()}</td>
<td className="px-3 py-2 text-right">{sq.mean_time_ms.toFixed(1)}</td>
<td className="px-3 py-2 text-right">{sq.total_time_ms.toFixed(0)}</td>
<td className="px-3 py-2 text-right">{sq.rows.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
</Tabs>
)}
</div>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, Play } from "lucide-react";
import type { IndexRecommendation } from "@/types";
interface Props {
recommendation: IndexRecommendation;
onApply: (ddl: string) => void;
isApplying: boolean;
applied: boolean;
}
function priorityBadge(priority: string) {
switch (priority.toLowerCase()) {
case "high":
return <Badge variant="destructive">{priority}</Badge>;
case "medium":
return <Badge className="bg-yellow-600 text-white">{priority}</Badge>;
default:
return <Badge variant="secondary">{priority}</Badge>;
}
}
function typeBadge(type: string) {
switch (type) {
case "create_index":
return <Badge className="bg-green-600 text-white">CREATE</Badge>;
case "drop_index":
return <Badge variant="destructive">DROP</Badge>;
case "replace_index":
return <Badge className="bg-blue-600 text-white">REPLACE</Badge>;
default:
return <Badge variant="secondary">{type}</Badge>;
}
}
export function RecommendationCard({ recommendation, onApply, isApplying, applied }: Props) {
const [showDdl] = useState(true);
return (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
{typeBadge(recommendation.recommendation_type)}
{priorityBadge(recommendation.priority)}
<span className="text-xs text-muted-foreground">
{recommendation.table_schema}.{recommendation.table_name}
</span>
{recommendation.index_name && (
<span className="text-xs font-mono text-muted-foreground">
{recommendation.index_name}
</span>
)}
</div>
<Button
size="sm"
variant={applied ? "outline" : "default"}
onClick={() => onApply(recommendation.ddl)}
disabled={isApplying || applied}
className="shrink-0"
>
{isApplying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : applied ? (
"Applied"
) : (
<><Play className="h-3.5 w-3.5 mr-1" />Apply</>
)}
</Button>
</div>
<p className="text-sm">{recommendation.rationale}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Impact: {recommendation.estimated_impact}</span>
</div>
{showDdl && (
<pre className="rounded bg-muted p-2 text-xs font-mono overflow-x-auto">
{recommendation.ddl}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Tusk render crash:", error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div className="flex h-screen items-center justify-center bg-background p-6">
<div className="max-w-xl space-y-3">
<h1 className="text-base font-semibold text-destructive">Something broke while rendering Tusk</h1>
<pre className="overflow-auto rounded-md border border-border/50 bg-muted/40 p-3 text-xs">
{this.state.error.message}
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ""}
</pre>
<p className="text-xs text-muted-foreground">
Open the developer console for the full stack. Click "Try again" to re-mount the UI.
</p>
<button
type="button"
className="rounded-md border border-border/60 px-3 py-1 text-xs hover:bg-accent/40"
onClick={this.reset}
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -10,10 +10,10 @@ import {
import { SchemaTree } from "@/components/schema/SchemaTree";
import { HistoryPanel } from "@/components/history/HistoryPanel";
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
import { AdminPanel } from "@/components/management/AdminPanel";
import { Search, RefreshCw, Layers, Clock, Bookmark, Shield } from "lucide-react";
import { MemoryPanel } from "@/components/memory/MemoryPanel";
import { Search, RefreshCw, Layers, Clock, Bookmark, Brain } from "lucide-react";
type SidebarView = "schema" | "history" | "saved" | "admin";
type SidebarView = "schema" | "history" | "saved" | "memory";
const SCHEMA_QUERY_KEYS = [
"databases", "schemas", "tables", "views",
@@ -24,7 +24,7 @@ const SIDEBAR_TABS: { id: SidebarView; label: string; icon: React.ReactNode }[]
{ id: "schema", label: "Schema", icon: <Layers className="h-3.5 w-3.5" /> },
{ id: "history", label: "History", icon: <Clock className="h-3.5 w-3.5" /> },
{ id: "saved", label: "Saved", icon: <Bookmark className="h-3.5 w-3.5" /> },
{ id: "admin", label: "Admin", icon: <Shield className="h-3.5 w-3.5" /> },
{ id: "memory", label: "Memory", icon: <Brain className="h-3.5 w-3.5" /> },
];
export function Sidebar() {
@@ -93,7 +93,7 @@ export function Sidebar() {
) : view === "saved" ? (
<SavedQueriesPanel />
) : (
<AdminPanel />
<MemoryPanel />
)}
</div>
);

View File

@@ -1,7 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns, Users, Activity, Search, GitFork, ShieldCheck, Gauge, Camera } from "lucide-react";
import { X, Table2, Code, Columns, Sparkles } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
@@ -13,13 +13,7 @@ export function TabBar() {
query: <Code className="h-3 w-3" />,
table: <Table2 className="h-3 w-3" />,
structure: <Columns className="h-3 w-3" />,
roles: <Users className="h-3 w-3" />,
sessions: <Activity className="h-3 w-3" />,
lookup: <Search className="h-3 w-3" />,
erd: <GitFork className="h-3 w-3" />,
validation: <ShieldCheck className="h-3 w-3" />,
"index-advisor": <Gauge className="h-3 w-3" />,
snapshots: <Camera className="h-3 w-3" />,
chat: <Sparkles className="h-3 w-3" />,
};
return (

View File

@@ -7,7 +7,7 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections, useReconnect } from "@/hooks/use-connections";
import { toast } from "sonner";
import { Database, Plus, RefreshCw, Search, Settings } from "lucide-react";
import { Database, Plus, RefreshCw, Settings, Sparkles } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet";
@@ -45,12 +45,12 @@ export function Toolbar() {
addTab(tab);
};
const handleNewLookup = () => {
const handleNewChat = () => {
if (!activeConnectionId) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "lookup",
title: "Entity Lookup",
type: "chat",
title: "Chat",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
@@ -101,22 +101,22 @@ export function Toolbar() {
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewQuery}
onClick={handleNewChat}
disabled={!activeConnectionId}
>
<Plus className="h-3.5 w-3.5" />
<span className="text-xs font-medium">New Query</span>
<Sparkles className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Ask AI</span>
</Button>
<Button
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewLookup}
onClick={handleNewQuery}
disabled={!activeConnectionId}
>
<Search className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Lookup</span>
<Plus className="h-3.5 w-3.5" />
<span className="text-xs font-medium">New Query</span>
</Button>
<div className="flex-1" />

View File

@@ -1,295 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Search, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { useEntityLookup } from "@/hooks/use-entity-lookup";
import { useConnections } from "@/hooks/use-connections";
import { useDatabases } from "@/hooks/use-schema";
import { LookupResultGroup } from "./LookupResultGroup";
import type { ConnectionConfig } from "@/types";
interface Props {
connectionId: string;
}
export function EntityLookupPanel({ connectionId }: Props) {
const [columnName, setColumnName] = useState("");
const [value, setValue] = useState("");
const [selectedDbs, setSelectedDbs] = useState<string[]>([]);
const [dbPickerOpen, setDbPickerOpen] = useState(false);
const { data: connections } = useConnections();
const { data: allDatabases } = useDatabases(connectionId);
const activeConn = connections?.find((c) => c.id === connectionId);
const { search, result, error, isSearching, progress } = useEntityLookup();
const handleSearch = useCallback(() => {
if (!columnName.trim() || !value.trim() || !activeConn) return;
const config: ConnectionConfig = { ...activeConn };
search({
config,
columnName: columnName.trim(),
value: value.trim(),
lookupId: crypto.randomUUID(),
databases: selectedDbs.length > 0 ? selectedDbs : undefined,
});
}, [columnName, value, activeConn, selectedDbs, search]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSearch();
}
},
[handleSearch]
);
const toggleDb = useCallback((db: string) => {
setSelectedDbs((prev) =>
prev.includes(db) ? prev.filter((d) => d !== db) : [...prev, db]
);
}, []);
const progressPercent = useMemo(() => {
if (!progress || progress.total === 0) return 0;
return Math.round((progress.completed / progress.total) * 100);
}, [progress]);
const matchedDbs = useMemo(
() => result?.databases.filter((d) => d.tables.length > 0) ?? [],
[result]
);
const errorDbs = useMemo(
() =>
result?.databases.filter(
(d) => d.error && d.tables.length === 0
) ?? [],
[result]
);
const emptyDbs = useMemo(
() =>
result?.databases.filter(
(d) => !d.error && d.tables.length === 0
) ?? [],
[result]
);
return (
<div className="flex h-full flex-col">
{/* Search form */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">
Column:
</label>
<Input
placeholder="carrier_id"
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 w-44"
disabled={isSearching}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">
Value:
</label>
<Input
placeholder="123"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 w-44"
disabled={isSearching}
/>
</div>
{/* Database picker */}
<Popover open={dbPickerOpen} onOpenChange={setDbPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
disabled={isSearching}
>
<ChevronsUpDown className="h-3 w-3" />
{selectedDbs.length === 0
? `All (${allDatabases?.length ?? "..."})`
: `${selectedDbs.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder="Filter databases..." />
<CommandList>
<CommandEmpty>No databases found.</CommandEmpty>
<CommandGroup>
{allDatabases?.map((db) => (
<CommandItem
key={db}
value={db}
onSelect={() => toggleDb(db)}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedDbs.includes(db)
? "opacity-100"
: "opacity-0"
}`}
/>
{db}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedDbs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => setSelectedDbs([])}
>
Clear
</Button>
)}
<Button
size="sm"
className="h-8 gap-1"
onClick={handleSearch}
disabled={isSearching || !columnName.trim() || !value.trim()}
>
{isSearching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5" />
)}
Search
</Button>
</div>
{/* Selected databases badges */}
{selectedDbs.length > 0 && (
<div className="flex flex-wrap gap-1 border-b px-4 py-1.5">
{selectedDbs.map((db) => (
<Badge
key={db}
variant="secondary"
className="cursor-pointer text-xs"
onClick={() => toggleDb(db)}
>
{db} &times;
</Badge>
))}
</div>
)}
{/* Progress */}
{isSearching && progress && (
<div className="border-b px-4 py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Searching... {progress.completed}/{progress.total} databases
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="border-b px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Results */}
{result && (
<div className="flex-1 overflow-hidden">
{/* Summary */}
<div className="border-b px-4 py-2 text-xs text-muted-foreground">
Found{" "}
<span className="font-medium text-foreground">
{result.total_rows_found}
</span>{" "}
row{result.total_rows_found !== 1 && "s"} in{" "}
<span className="font-medium text-foreground">
{result.total_tables_matched}
</span>{" "}
table{result.total_tables_matched !== 1 && "s"} across{" "}
<span className="font-medium text-foreground">
{result.total_databases_searched}
</span>{" "}
database{result.total_databases_searched !== 1 && "s"} in{" "}
{(result.total_time_ms / 1000).toFixed(1)}s
</div>
<div className="h-full overflow-auto">
<div className="flex flex-col gap-2 p-4">
{matchedDbs.map((dbResult) => (
<LookupResultGroup
key={dbResult.database}
dbResult={dbResult}
/>
))}
{errorDbs.map((dbResult) => (
<LookupResultGroup
key={dbResult.database}
dbResult={dbResult}
/>
))}
{emptyDbs.length > 0 && (
<div className="rounded-md border px-3 py-2 text-xs text-muted-foreground">
{emptyDbs.length} database{emptyDbs.length !== 1 && "s"} with
no matches:{" "}
{emptyDbs.map((d) => d.database).join(", ")}
</div>
)}
</div>
</div>
</div>
)}
{/* Empty state */}
{!result && !isSearching && !error && (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-sm text-muted-foreground">
<Search className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p>Search for a column value across all databases</p>
<p className="mt-1 text-xs">
Enter a column name and value, then press Search
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,116 +0,0 @@
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
AlertCircle,
Database,
} from "lucide-react";
import { ResultsTable } from "@/components/results/ResultsTable";
import type { LookupDatabaseResult } from "@/types";
interface Props {
dbResult: LookupDatabaseResult;
}
export function LookupResultGroup({ dbResult }: Props) {
const [expanded, setExpanded] = useState(dbResult.tables.length > 0);
const [expandedTables, setExpandedTables] = useState<Set<string>>(
() => new Set(dbResult.tables.map((t) => `${t.schema}.${t.table}`))
);
const totalRows = dbResult.tables.reduce((s, t) => s + t.row_count, 0);
const hasError = !!dbResult.error;
const hasMatches = dbResult.tables.length > 0;
const toggleTable = (key: string) => {
setExpandedTables((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="border rounded-md">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{dbResult.database}</span>
{hasMatches && (
<span className="text-xs text-muted-foreground">
{dbResult.tables.length} table{dbResult.tables.length !== 1 && "s"},{" "}
{totalRows} row{totalRows !== 1 && "s"}
</span>
)}
{hasError && (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{dbResult.error}
</span>
)}
{!hasError && !hasMatches && (
<span className="text-xs text-muted-foreground">no matches</span>
)}
<span className="ml-auto text-xs text-muted-foreground">
{dbResult.search_time_ms}ms
</span>
</button>
{expanded && hasMatches && (
<div className="border-t">
{dbResult.tables.map((table) => {
const key = `${table.schema}.${table.table}`;
const isOpen = expandedTables.has(key);
return (
<div key={key} className="border-b last:border-b-0">
<button
className="flex w-full items-center gap-2 px-5 py-1.5 text-left text-xs hover:bg-accent/50"
onClick={() => toggleTable(key)}
>
{isOpen ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
<span className="font-medium">
{table.schema}.{table.table}
</span>
<span className="text-muted-foreground">
({table.row_count} row{table.row_count !== 1 && "s"}
{table.total_count > table.row_count &&
`, ${table.total_count} total`}
)
</span>
<span className="text-muted-foreground">
[{table.column_type}]
</span>
</button>
{isOpen && table.columns.length > 0 && (
<div className="h-[200px] overflow-auto border-t">
<ResultsTable
columns={table.columns}
types={table.types}
rows={table.rows as unknown[][]}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,348 +0,0 @@
import { useState } from "react";
import {
useDatabaseInfo,
useRoles,
useDropDatabase,
useDropRole,
} from "@/hooks/use-management";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CreateDatabaseDialog } from "./CreateDatabaseDialog";
import { CreateRoleDialog } from "./CreateRoleDialog";
import { AlterRoleDialog } from "./AlterRoleDialog";
import { toast } from "sonner";
import {
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
HardDrive,
Users,
Activity,
Loader2,
} from "lucide-react";
import { DockerContainersList } from "@/components/docker/DockerContainersList";
import type { Tab, RoleInfo } from "@/types";
export function AdminPanel() {
const { activeConnectionId, currentDatabase, readOnlyMap, addTab } = useAppStore();
if (!activeConnectionId) {
return (
<div className="p-4 text-sm text-muted-foreground">
Connect to a database to manage it.
</div>
);
}
const isReadOnly = readOnlyMap[activeConnectionId] ?? true;
return (
<div className="flex h-full flex-col overflow-y-auto">
<DatabasesSection
connectionId={activeConnectionId}
currentDatabase={currentDatabase}
isReadOnly={isReadOnly}
/>
<RolesSection
connectionId={activeConnectionId}
isReadOnly={isReadOnly}
onOpenRoleManager={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "roles",
title: "Roles & Users",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
}}
/>
<SessionsSection
connectionId={activeConnectionId}
onOpenSessions={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "sessions",
title: "Active Sessions",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
}}
/>
<DockerContainersList />
</div>
);
}
function DatabasesSection({
connectionId,
currentDatabase,
isReadOnly,
}: {
connectionId: string;
currentDatabase: string | null;
isReadOnly: boolean;
}) {
const [expanded, setExpanded] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const { data: databases, isLoading } = useDatabaseInfo(connectionId);
const dropMutation = useDropDatabase();
const handleDrop = (name: string) => {
if (name === currentDatabase) {
toast.error("Cannot drop the active database");
return;
}
if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) {
return;
}
dropMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Database "${name}" dropped`),
onError: (err) => toast.error("Failed to drop database", { description: String(err) }),
}
);
};
return (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Databases</span>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
setCreateOpen(true);
}}
title="Create Database"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{databases?.map((db) => (
<div
key={db.name}
className={`group flex items-center gap-2 px-6 py-1 text-xs hover:bg-accent/50 ${
db.name === currentDatabase ? "text-primary" : ""
}`}
>
<span className="truncate flex-1 font-medium">{db.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{db.size}</span>
{db.name === currentDatabase && (
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
active
</Badge>
)}
{!isReadOnly && db.name !== currentDatabase && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive shrink-0"
onClick={() => handleDrop(db.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
<CreateDatabaseDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
</div>
);
}
function RolesSection({
connectionId,
isReadOnly,
onOpenRoleManager,
}: {
connectionId: string;
isReadOnly: boolean;
onOpenRoleManager: () => void;
}) {
const [expanded, setExpanded] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [alterRole, setAlterRole] = useState<RoleInfo | null>(null);
const { data: roles, isLoading } = useRoles(connectionId);
const dropMutation = useDropRole();
const handleDrop = (name: string) => {
if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return;
dropMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Role "${name}" dropped`),
onError: (err) => toast.error("Failed to drop role", { description: String(err) }),
}
);
};
return (
<div className="border-b">
<div
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Roles</span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenRoleManager}
title="Open Role Manager"
>
Manager
</Button>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setCreateOpen(true)}
title="Create Role"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
{expanded && (
<div className="pb-1">
{isLoading && (
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
)}
{roles?.map((role) => (
<div
key={role.name}
className="group flex items-center gap-1.5 px-6 py-1 text-xs hover:bg-accent/50"
>
<span className="truncate flex-1 font-medium">{role.name}</span>
<div className="flex gap-0.5 shrink-0">
{role.can_login && (
<Badge variant="secondary" className="text-[9px] px-1 py-0">
LOGIN
</Badge>
)}
{role.is_superuser && (
<Badge variant="default" className="text-[9px] px-1 py-0 bg-amber-600 hover:bg-amber-600">
SUPER
</Badge>
)}
</div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
{!isReadOnly && (
<>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => setAlterRole(role)}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
onClick={() => handleDrop(role.name)}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
))}
</div>
)}
<CreateRoleDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
<AlterRoleDialog
open={!!alterRole}
onOpenChange={(open) => !open && setAlterRole(null)}
connectionId={connectionId}
role={alterRole}
/>
</div>
);
}
function SessionsSection({
connectionId,
onOpenSessions,
}: {
connectionId: string;
onOpenSessions: () => void;
}) {
return (
<div className="border-b">
<div className="flex items-center gap-1 px-3 py-2">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Sessions</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenSessions}
title="View active sessions"
>
View Sessions
</Button>
</div>
<div className="px-6 pb-2 text-xs text-muted-foreground">
Monitor active database connections and running queries.
{/* The connectionId is used by parent to open the sessions tab */}
<span className="hidden">{connectionId}</span>
</div>
</div>
);
}

View File

@@ -1,185 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAlterRole } from "@/hooks/use-management";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import type { RoleInfo } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
role: RoleInfo | null;
}
export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Props) {
const [password, setPassword] = useState("");
const [login, setLogin] = useState(false);
const [superuser, setSuperuser] = useState(false);
const [createdb, setCreatedb] = useState(false);
const [createrole, setCreaterole] = useState(false);
const [inherit, setInherit] = useState(true);
const [replication, setReplication] = useState(false);
const [connectionLimit, setConnectionLimit] = useState(-1);
const [validUntil, setValidUntil] = useState("");
const [renameTo, setRenameTo] = useState("");
const alterMutation = useAlterRole();
const [prev, setPrev] = useState<{ open: boolean; role: typeof role }>({ open: false, role: null });
if (open !== prev.open || role !== prev.role) {
setPrev({ open, role });
if (open && role) {
setPassword("");
setLogin(role.can_login);
setSuperuser(role.is_superuser);
setCreatedb(role.can_create_db);
setCreaterole(role.can_create_role);
setInherit(role.inherit);
setReplication(role.is_replication);
setConnectionLimit(role.connection_limit);
setValidUntil(role.valid_until ?? "");
setRenameTo("");
}
}
if (!role) return null;
const handleAlter = () => {
const params: Record<string, unknown> = { name: role.name };
if (password) params.password = password;
if (login !== role.can_login) params.login = login;
if (superuser !== role.is_superuser) params.superuser = superuser;
if (createdb !== role.can_create_db) params.createdb = createdb;
if (createrole !== role.can_create_role) params.createrole = createrole;
if (inherit !== role.inherit) params.inherit = inherit;
if (replication !== role.is_replication) params.replication = replication;
if (connectionLimit !== role.connection_limit) params.connection_limit = connectionLimit;
if (validUntil !== (role.valid_until ?? "")) params.valid_until = validUntil || undefined;
if (renameTo.trim()) params.rename_to = renameTo.trim();
alterMutation.mutate(
{
connectionId,
params: params as {
name: string;
password?: string;
login?: boolean;
superuser?: boolean;
createdb?: boolean;
createrole?: boolean;
inherit?: boolean;
replication?: boolean;
connection_limit?: number;
valid_until?: string;
rename_to?: string;
},
},
{
onSuccess: () => {
toast.success(`Role "${role.name}" updated`);
onOpenChange(false);
},
onError: (err) => {
toast.error("Failed to alter role", { description: String(err) });
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Alter Role: {role.name}</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Rename To</label>
<Input
className="col-span-3"
value={renameTo}
onChange={(e) => setRenameTo(e.target.value)}
placeholder={role.name}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Password</label>
<Input
className="col-span-3"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep unchanged"
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 grid grid-cols-2 gap-2">
{([
["LOGIN", login, setLogin],
["SUPERUSER", superuser, setSuperuser],
["CREATEDB", createdb, setCreatedb],
["CREATEROLE", createrole, setCreaterole],
["INHERIT", inherit, setInherit],
["REPLICATION", replication, setReplication],
] as const).map(([label, value, setter]) => (
<label key={label} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value}
onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)}
className="rounded border-input"
/>
{label}
</label>
))}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Valid Until</label>
<Input
className="col-span-3"
type="datetime-local"
value={validUntil}
onChange={(e) => setValidUntil(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleAlter} disabled={alterMutation.isPending}>
{alterMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,162 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCreateDatabase, useRoles } from "@/hooks/use-management";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
}
export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props) {
const [name, setName] = useState("");
const [owner, setOwner] = useState("__default__");
const [template, setTemplate] = useState("__default__");
const [encoding, setEncoding] = useState("UTF8");
const [connectionLimit, setConnectionLimit] = useState(-1);
const { data: roles } = useRoles(open ? connectionId : null);
const createMutation = useCreateDatabase();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setName("");
setOwner("__default__");
setTemplate("__default__");
setEncoding("UTF8");
setConnectionLimit(-1);
}
}
const handleCreate = () => {
if (!name.trim()) {
toast.error("Database name is required");
return;
}
createMutation.mutate(
{
connectionId,
params: {
name: name.trim(),
owner: owner === "__default__" ? undefined : owner,
template: template === "__default__" ? undefined : template,
encoding,
connection_limit: connectionLimit,
},
},
{
onSuccess: () => {
toast.success(`Database "${name}" created`);
onOpenChange(false);
},
onError: (err) => {
toast.error("Failed to create database", { description: String(err) });
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Database</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my_database"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Owner</label>
<Select value={owner} onValueChange={setOwner}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
{roles?.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Template</label>
<Select value={template} onValueChange={setTemplate}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
<SelectItem value="template0">template0</SelectItem>
<SelectItem value="template1">template1</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Encoding</label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTF8">UTF8</SelectItem>
<SelectItem value="LATIN1">LATIN1</SelectItem>
<SelectItem value="SQL_ASCII">SQL_ASCII</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,205 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useCreateRole, useRoles } from "@/hooks/use-management";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
}
export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [login, setLogin] = useState(true);
const [superuser, setSuperuser] = useState(false);
const [createdb, setCreatedb] = useState(false);
const [createrole, setCreaterole] = useState(false);
const [inherit, setInherit] = useState(true);
const [replication, setReplication] = useState(false);
const [connectionLimit, setConnectionLimit] = useState(-1);
const [validUntil, setValidUntil] = useState("");
const [inRoles, setInRoles] = useState<string[]>([]);
const { data: roles } = useRoles(open ? connectionId : null);
const createMutation = useCreateRole();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setName("");
setPassword("");
setLogin(true);
setSuperuser(false);
setCreatedb(false);
setCreaterole(false);
setInherit(true);
setReplication(false);
setConnectionLimit(-1);
setValidUntil("");
setInRoles([]);
}
}
const handleCreate = () => {
if (!name.trim()) {
toast.error("Role name is required");
return;
}
createMutation.mutate(
{
connectionId,
params: {
name: name.trim(),
password: password || undefined,
login,
superuser,
createdb,
createrole,
inherit,
replication,
connection_limit: connectionLimit,
valid_until: validUntil || undefined,
in_roles: inRoles,
},
},
{
onSuccess: () => {
toast.success(`Role "${name}" created`);
onOpenChange(false);
},
onError: (err) => {
toast.error("Failed to create role", { description: String(err) });
},
}
);
};
const toggleInRole = (roleName: string) => {
setInRoles((prev) =>
prev.includes(roleName)
? prev.filter((r) => r !== roleName)
: [...prev, roleName]
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Role</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my_role"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Password</label>
<Input
className="col-span-3"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 grid grid-cols-2 gap-2">
{([
["LOGIN", login, setLogin],
["SUPERUSER", superuser, setSuperuser],
["CREATEDB", createdb, setCreatedb],
["CREATEROLE", createrole, setCreaterole],
["INHERIT", inherit, setInherit],
["REPLICATION", replication, setReplication],
] as const).map(([label, value, setter]) => (
<label key={label} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value}
onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)}
className="rounded border-input"
/>
{label}
</label>
))}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Conn Limit</label>
<Input
className="col-span-3"
type="number"
value={connectionLimit}
onChange={(e) => setConnectionLimit(parseInt(e.target.value) || -1)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Valid Until</label>
<Input
className="col-span-3"
type="datetime-local"
value={validUntil}
onChange={(e) => setValidUntil(e.target.value)}
/>
</div>
{roles && roles.length > 0 && (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Member Of</label>
<div className="col-span-3 flex flex-wrap gap-1.5">
{roles.map((r) => (
<button
key={r.name}
type="button"
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
inRoles.includes(r.name)
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
onClick={() => toggleInRole(r.name)}
>
{r.name}
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,245 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useRoles,
useGrantRevoke,
useTablePrivileges,
} from "@/hooks/use-management";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
const PRIVILEGE_OPTIONS: Record<string, string[]> = {
TABLE: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "ALL"],
SCHEMA: ["USAGE", "CREATE"],
DATABASE: ["CONNECT", "CREATE", "TEMPORARY"],
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
objectType: string;
objectName: string;
schema?: string;
table?: string;
}
export function GrantRevokeDialog({
open,
onOpenChange,
connectionId,
objectType,
objectName,
schema,
table,
}: Props) {
const [action, setAction] = useState("GRANT");
const [roleName, setRoleName] = useState("");
const [privileges, setPrivileges] = useState<string[]>([]);
const [withGrantOption, setWithGrantOption] = useState(false);
const { data: roles } = useRoles(open ? connectionId : null);
const { data: existingPrivileges } = useTablePrivileges(
open && objectType === "TABLE" ? connectionId : null,
schema ?? null,
table ?? null
);
const grantRevokeMutation = useGrantRevoke();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setAction("GRANT");
setRoleName("");
setPrivileges([]);
setWithGrantOption(false);
}
}
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
const togglePrivilege = (priv: string) => {
setPrivileges((prev) =>
prev.includes(priv)
? prev.filter((p) => p !== priv)
: [...prev, priv]
);
};
const handleSubmit = () => {
if (!roleName) {
toast.error("Please select a role");
return;
}
if (privileges.length === 0) {
toast.error("Please select at least one privilege");
return;
}
grantRevokeMutation.mutate(
{
connectionId,
params: {
action,
privileges,
object_type: objectType,
object_name: objectName,
role_name: roleName,
with_grant_option: withGrantOption,
},
},
{
onSuccess: () => {
toast.success(
`${action === "GRANT" ? "Granted" : "Revoked"} privileges on ${objectName}`
);
onOpenChange(false);
},
onError: (err) => {
toast.error("Operation failed", { description: String(err) });
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Manage Privileges</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Object</label>
<div className="col-span-3 text-sm">
<Badge variant="outline">{objectType}</Badge>{" "}
<span className="font-medium">{objectName}</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Action</label>
<div className="col-span-3 flex gap-2">
<Button
size="sm"
variant={action === "GRANT" ? "default" : "outline"}
onClick={() => setAction("GRANT")}
>
Grant
</Button>
<Button
size="sm"
variant={action === "REVOKE" ? "default" : "outline"}
onClick={() => setAction("REVOKE")}
>
Revoke
</Button>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Role</label>
<Select value={roleName} onValueChange={setRoleName}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select role..." />
</SelectTrigger>
<SelectContent>
{roles?.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Privileges</label>
<div className="col-span-3 flex flex-wrap gap-1.5">
{availablePrivileges.map((priv) => (
<button
key={priv}
type="button"
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
privileges.includes(priv)
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
onClick={() => togglePrivilege(priv)}
>
{priv}
</button>
))}
</div>
</div>
{action === "GRANT" && (
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Options</label>
<label className="col-span-3 flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withGrantOption}
onChange={(e) => setWithGrantOption(e.target.checked)}
className="rounded border-input"
/>
WITH GRANT OPTION
</label>
</div>
)}
{existingPrivileges && existingPrivileges.length > 0 && (
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Current</label>
<div className="col-span-3 max-h-32 overflow-y-auto rounded border p-2">
<div className="space-y-1">
{existingPrivileges.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="font-medium">{p.grantee}</span>
<Badge variant="secondary" className="text-[10px]">
{p.privilege_type}
</Badge>
{p.is_grantable && (
<Badge variant="outline" className="text-[10px]">
GRANTABLE
</Badge>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={grantRevokeMutation.isPending}>
{grantRevokeMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{action === "GRANT" ? "Grant" : "Revoke"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,306 +0,0 @@
import { useState } from "react";
import { useRoles, useDropRole, useManageRoleMembership } from "@/hooks/use-management";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CreateRoleDialog } from "./CreateRoleDialog";
import { AlterRoleDialog } from "./AlterRoleDialog";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, UserPlus, UserMinus, Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { RoleInfo } from "@/types";
interface Props {
connectionId: string;
}
export function RoleManagerView({ connectionId }: Props) {
const { data: roles, isLoading } = useRoles(connectionId);
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const dropMutation = useDropRole();
const membershipMutation = useManageRoleMembership();
const [createOpen, setCreateOpen] = useState(false);
const [alterRole, setAlterRole] = useState<RoleInfo | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [memberToAdd, setMemberToAdd] = useState("");
const handleDrop = (name: string) => {
if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return;
dropMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Role "${name}" dropped`),
onError: (err) => toast.error("Failed to drop role", { description: String(err) }),
}
);
};
const handleAddMember = (roleName: string, memberName: string) => {
membershipMutation.mutate(
{
connectionId,
params: { action: "GRANT", role_name: roleName, member_name: memberName },
},
{
onSuccess: () => {
toast.success(`Added "${memberName}" to "${roleName}"`);
setMemberToAdd("");
},
onError: (err) => toast.error("Failed", { description: String(err) }),
}
);
};
const handleRemoveMember = (roleName: string, memberName: string) => {
membershipMutation.mutate(
{
connectionId,
params: { action: "REVOKE", role_name: roleName, member_name: memberName },
},
{
onSuccess: () => toast.success(`Removed "${memberName}" from "${roleName}"`),
onError: (err) => toast.error("Failed", { description: String(err) }),
}
);
};
const selected = roles?.find((r) => r.name === selectedRole);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<h2 className="text-sm font-semibold">Roles & Users</h2>
<Button size="sm" onClick={() => setCreateOpen(true)} disabled={isReadOnly}>
<Plus className="mr-1 h-3.5 w-3.5" />
Create Role
</Button>
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Login</th>
<th className="px-4 py-2 font-medium">Superuser</th>
<th className="px-4 py-2 font-medium">CreateDB</th>
<th className="px-4 py-2 font-medium">CreateRole</th>
<th className="px-4 py-2 font-medium">Conn Limit</th>
<th className="px-4 py-2 font-medium">Member Of</th>
<th className="px-4 py-2 font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{roles?.map((role) => (
<tr
key={role.name}
className={`border-b hover:bg-accent/50 cursor-pointer ${
selectedRole === role.name ? "bg-accent/30" : ""
}`}
onClick={() => setSelectedRole(role.name)}
>
<td className="px-4 py-2 font-medium">{role.name}</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_login} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.is_superuser} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_create_db} />
</td>
<td className="px-4 py-2">
<BoolBadge value={role.can_create_role} />
</td>
<td className="px-4 py-2 text-muted-foreground">
{role.connection_limit === -1 ? "unlimited" : role.connection_limit}
</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{role.member_of.map((r) => (
<Badge key={r} variant="secondary" className="text-[10px]">
{r}
</Badge>
))}
</div>
</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setAlterRole(role);
}}
disabled={isReadOnly}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDrop(role.name);
}}
disabled={isReadOnly}
title="Drop"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selected && (
<div className="w-64 shrink-0 border-l overflow-auto">
<div className="p-3">
<h3 className="text-sm font-semibold mb-3">{selected.name}</h3>
<div className="space-y-3">
<div>
<p className="text-xs text-muted-foreground mb-1">Member Of</p>
<div className="flex flex-wrap gap-1">
{selected.member_of.length === 0 && (
<span className="text-xs text-muted-foreground">None</span>
)}
{selected.member_of.map((r) => (
<div key={r} className="flex items-center gap-0.5">
<Badge variant="secondary" className="text-[10px]">
{r}
</Badge>
{!isReadOnly && (
<button
className="text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(r, selected.name)}
title={`Remove from ${r}`}
>
<UserMinus className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Members</p>
<div className="flex flex-wrap gap-1">
{selected.members.length === 0 && (
<span className="text-xs text-muted-foreground">None</span>
)}
{selected.members.map((m) => (
<div key={m} className="flex items-center gap-0.5">
<Badge variant="secondary" className="text-[10px]">
{m}
</Badge>
{!isReadOnly && (
<button
className="text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(selected.name, m)}
title={`Remove ${m}`}
>
<UserMinus className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{!isReadOnly && (
<div>
<p className="text-xs text-muted-foreground mb-1">Add Member</p>
<div className="flex gap-1">
<Select value={memberToAdd} onValueChange={setMemberToAdd}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{roles
?.filter(
(r) =>
r.name !== selected.name &&
!selected.members.includes(r.name)
)
.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="h-7"
disabled={!memberToAdd || membershipMutation.isPending}
onClick={() => handleAddMember(selected.name, memberToAdd)}
>
<UserPlus className="h-3 w-3" />
</Button>
</div>
</div>
)}
{selected.description && (
<div>
<p className="text-xs text-muted-foreground mb-1">Description</p>
<p className="text-xs">{selected.description}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
<CreateRoleDialog
open={createOpen}
onOpenChange={setCreateOpen}
connectionId={connectionId}
/>
<AlterRoleDialog
open={!!alterRole}
onOpenChange={(open) => !open && setAlterRole(null)}
connectionId={connectionId}
role={alterRole}
/>
</div>
);
}
function BoolBadge({ value }: { value: boolean }) {
return (
<Badge
variant={value ? "default" : "secondary"}
className={`text-[10px] ${value ? "bg-green-600 hover:bg-green-600" : "text-muted-foreground"}`}
>
{value ? "Yes" : "No"}
</Badge>
);
}

View File

@@ -1,171 +0,0 @@
import {
useSessions,
useCancelQuery,
useTerminateBackend,
} from "@/hooks/use-management";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Loader2, XCircle, Skull, RefreshCw } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
interface Props {
connectionId: string;
}
function getStateBadge(state: string | null) {
if (!state) return null;
const colors: Record<string, string> = {
idle: "bg-green-500/15 text-green-600",
active: "bg-yellow-500/15 text-yellow-600",
"idle in transaction": "bg-orange-500/15 text-orange-600",
disabled: "bg-red-500/15 text-red-600",
};
return (
<Badge variant="outline" className={`text-[9px] px-1 py-0 ${colors[state] ?? ""}`}>
{state}
</Badge>
);
}
function formatDuration(queryStart: string | null): string {
if (!queryStart) return "-";
const start = new Date(queryStart).getTime();
const now = Date.now();
const diffSec = Math.floor((now - start) / 1000);
if (diffSec < 0) return "-";
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
}
function getDurationColor(queryStart: string | null, state: string | null): string {
if (state !== "active" || !queryStart) return "";
const diffSec = (Date.now() - new Date(queryStart).getTime()) / 1000;
if (diffSec > 30) return "text-red-500 font-semibold";
if (diffSec > 5) return "text-yellow-500 font-semibold";
return "";
}
export function SessionsView({ connectionId }: Props) {
const { data: sessions, isLoading } = useSessions(connectionId);
const cancelMutation = useCancelQuery();
const terminateMutation = useTerminateBackend();
const queryClient = useQueryClient();
const handleCancel = (pid: number) => {
cancelMutation.mutate(
{ connectionId, pid },
{
onSuccess: () => toast.success(`Cancel signal sent to PID ${pid}`),
onError: (err) => toast.error("Cancel failed", { description: String(err) }),
}
);
};
const handleTerminate = (pid: number) => {
if (!confirm(`Terminate backend PID ${pid}? This will kill the session.`)) return;
terminateMutation.mutate(
{ connectionId, pid },
{
onSuccess: () => toast.success(`Terminate signal sent to PID ${pid}`),
onError: (err) => toast.error("Terminate failed", { description: String(err) }),
}
);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading sessions...
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="text-xs font-semibold">Active Sessions</span>
<Badge variant="secondary" className="text-[10px]">
{sessions?.length ?? 0}
</Badge>
<span className="text-[10px] text-muted-foreground">Auto-refresh: 5s</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 text-xs"
onClick={() => queryClient.invalidateQueries({ queryKey: ["sessions"] })}
>
<RefreshCw className="h-3 w-3" />
Refresh
</Button>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card border-b">
<tr>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">PID</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">User</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Database</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">State</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Duration</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Wait</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Query</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Client</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{sessions?.map((s) => (
<tr key={s.pid} className="border-b hover:bg-accent/50">
<td className="px-2 py-1 font-mono">{s.pid}</td>
<td className="px-2 py-1">{s.usename ?? "-"}</td>
<td className="px-2 py-1">{s.datname ?? "-"}</td>
<td className="px-2 py-1">{getStateBadge(s.state)}</td>
<td className={`px-2 py-1 ${getDurationColor(s.query_start, s.state)}`}>
{formatDuration(s.query_start)}
</td>
<td className="px-2 py-1 text-muted-foreground">
{s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"}
</td>
<td className="px-2 py-1 max-w-xs truncate font-mono" title={s.query ?? ""}>
{s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"}
</td>
<td className="px-2 py-1 text-muted-foreground">{s.client_addr ?? "-"}</td>
<td className="px-2 py-1">
<div className="flex gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Cancel Query"
onClick={() => handleCancel(s.pid)}
>
<XCircle className="h-3 w-3 text-yellow-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Terminate Backend"
onClick={() => handleTerminate(s.pid)}
>
<Skull className="h-3 w-3 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{(!sessions || sessions.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No active sessions
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { Brain, RefreshCw, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { useMemory, useSaveMemory } from "@/hooks/use-memory";
export function MemoryPanel() {
const activeConnectionId = useAppStore((s) => s.activeConnectionId);
const { data: connections } = useConnections();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
const { data: serverContent, isFetching, refetch } = useMemory(activeConnectionId);
const saveMutation = useSaveMemory();
const [draft, setDraft] = useState<string>("");
const [dirty, setDirty] = useState(false);
// Sync local textarea when the active connection changes or server reloads.
// Only overwrite if the user hasn't edited.
useEffect(() => {
if (!dirty) {
setDraft(serverContent ?? "");
}
}, [serverContent, activeConnectionId, dirty]);
if (!activeConnectionId) {
return (
<div className="flex flex-col items-center gap-2 p-6 text-center">
<Brain className="h-8 w-8 text-muted-foreground/20" />
<p className="text-sm text-muted-foreground/60">
Connect to a database to view its memory.
</p>
</div>
);
}
const noteCount = (draft.match(/^## /gm) ?? []).length;
const isEmpty = draft.trim().length === 0;
const handleSave = () => {
saveMutation.mutate(
{ connectionId: activeConnectionId, content: draft },
{
onSuccess: () => {
setDirty(false);
toast.success("Memory saved");
},
onError: (err) => toast.error("Save failed", { description: String(err) }),
}
);
};
const handleReload = () => {
setDirty(false);
refetch();
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-1 border-b border-border/40 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3.5 w-3.5 text-primary/70" />
<span className="font-medium">Memory</span>
{activeConn && (
<span className="ml-1 truncate text-muted-foreground/60">· {activeConn.name}</span>
)}
{!isEmpty && (
<span className="ml-1 text-[10px] text-muted-foreground/50">
{noteCount} note{noteCount === 1 ? "" : "s"}
</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
onClick={handleReload}
disabled={isFetching}
title="Reload from disk"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-3.5 w-3.5 ${isFetching ? "animate-spin" : ""}`} />
</Button>
<Button
size="xs"
variant={dirty ? "default" : "ghost"}
onClick={handleSave}
disabled={!dirty || saveMutation.isPending}
title="Save"
className="gap-1"
>
<Save className="h-3 w-3" />
<span className="text-xs">Save</span>
</Button>
</div>
</div>
{isEmpty ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-8 text-center">
<Brain className="h-7 w-7 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground/70">
No notes yet for this connection.
</p>
<p className="text-[11px] text-muted-foreground/50 max-w-[260px]">
The agent will populate this as it learns about your database. You can also
edit notes here directly anything you type is loaded into the agent's
context on its next turn.
</p>
<textarea
className="mt-2 h-48 w-full max-w-[360px] resize-none rounded-md border border-border/40 bg-background/50 p-2 font-mono text-[11px] outline-none focus:border-primary/40"
placeholder="# Memory&#10;&#10;## (timestamp)&#10;your note..."
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
/>
</div>
) : (
<textarea
className="min-h-0 flex-1 resize-none overflow-y-auto bg-background/40 p-3 font-mono text-[11px] leading-relaxed outline-none"
spellCheck={false}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
/>
)}
{dirty && (
<div className="border-t border-border/40 px-3 py-1.5 text-[10px] text-amber-500/80">
Unsaved changes
</div>
)}
</div>
);
}

View File

@@ -9,7 +9,6 @@ import {
useSequences,
} from "@/hooks/use-schema";
import { useConnections } from "@/hooks/use-connections";
import { useDropDatabase } from "@/hooks/use-management";
import { useAppStore } from "@/stores/app-store";
import { toast } from "sonner";
import {
@@ -27,12 +26,8 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
import { CloneDatabaseDialog } from "@/components/docker/CloneDatabaseDialog";
import { GenerateDataDialog } from "@/components/data-generator/GenerateDataDialog";
import type { Tab, SchemaObject } from "@/types";
function formatSize(bytes: number): string {
@@ -67,7 +62,6 @@ export function SchemaTree() {
const { data: databases } = useDatabases(activeConnectionId);
const { data: connections } = useConnections();
const switchDbMutation = useSwitchDatabase();
const [cloneTarget, setCloneTarget] = useState<string | null>(null);
if (!activeConnectionId) {
return (
@@ -118,7 +112,6 @@ export function SchemaTree() {
connectionId={activeConnectionId}
onSwitch={() => handleSwitchDb(db)}
isSwitching={switchDbMutation.isPending}
onCloneToDocker={(dbName) => setCloneTarget(dbName)}
onOpenTable={(schema, table) => {
const tab: Tab = {
id: crypto.randomUUID(),
@@ -143,25 +136,8 @@ export function SchemaTree() {
};
addTab(tab);
}}
onViewErd={(schema) => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "erd",
title: `${schema} (ER Diagram)`,
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
schema,
};
addTab(tab);
}}
/>
))}
<CloneDatabaseDialog
open={cloneTarget !== null}
onOpenChange={(open) => { if (!open) setCloneTarget(null); }}
connectionId={activeConnectionId}
database={cloneTarget ?? ""}
/>
</div>
);
}
@@ -172,25 +148,18 @@ function DatabaseNode({
connectionId,
onSwitch,
isSwitching,
onCloneToDocker,
onOpenTable,
onViewStructure,
onViewErd,
}: {
name: string;
isActive: boolean;
connectionId: string;
onSwitch: () => void;
isSwitching: boolean;
onCloneToDocker: (dbName: string) => void;
onOpenTable: (schema: string, table: string) => void;
onViewStructure: (schema: string, table: string) => void;
onViewErd: (schema: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const dropDbMutation = useDropDatabase();
const handleClick = () => {
if (!isActive) {
@@ -199,23 +168,6 @@ function DatabaseNode({
setExpanded(!expanded);
};
const handleDropDb = () => {
if (isActive) {
toast.error("Cannot drop the active database");
return;
}
if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) {
return;
}
dropDbMutation.mutate(
{ connectionId, name },
{
onSuccess: () => toast.success(`Database "${name}" dropped`),
onError: (err) => toast.error("Failed to drop database", { description: String(err) }),
}
);
};
return (
<div>
<ContextMenu>
@@ -250,66 +202,6 @@ function DatabaseNode({
>
Properties
</ContextMenuItem>
<ContextMenuItem onClick={() => onCloneToDocker(name)}>
Clone to Docker
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "validation",
title: "Data Validation",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Data Validation
</ContextMenuItem>
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "index-advisor",
title: "Index Advisor",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Index Advisor
</ContextMenuItem>
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "snapshots",
title: "Data Snapshots",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Data Snapshots
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isActive || isReadOnly}
onClick={handleDropDb}
className="text-destructive focus:text-destructive"
>
Drop Database
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{expanded && isActive && (
@@ -318,7 +210,6 @@ function DatabaseNode({
connectionId={connectionId}
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
onViewErd={onViewErd}
/>
</div>
)}
@@ -335,12 +226,10 @@ function SchemasForCurrentDb({
connectionId,
onOpenTable,
onViewStructure,
onViewErd,
}: {
connectionId: string;
onOpenTable: (schema: string, table: string) => void;
onViewStructure: (schema: string, table: string) => void;
onViewErd: (schema: string) => void;
}) {
const { data: schemas } = useSchemas(connectionId);
@@ -359,7 +248,6 @@ function SchemasForCurrentDb({
connectionId={connectionId}
onOpenTable={(table) => onOpenTable(schema, table)}
onViewStructure={(table) => onViewStructure(schema, table)}
onViewErd={() => onViewErd(schema)}
/>
))}
</>
@@ -371,45 +259,34 @@ function SchemaNode({
connectionId,
onOpenTable,
onViewStructure,
onViewErd,
}: {
schema: string;
connectionId: string;
onOpenTable: (table: string) => void;
onViewStructure: (table: string) => void;
onViewErd: () => void;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<ContextMenu>
<ContextMenuTrigger>
<div
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium"
onClick={() => setExpanded(!expanded)}
>
<span className="text-muted-foreground/50">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
{expanded ? (
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
) : (
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
<span>{schema}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onViewErd}>
View ER Diagram
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium"
onClick={() => setExpanded(!expanded)}
>
<span className="text-muted-foreground/50">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
{expanded ? (
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
) : (
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
<span>{schema}</span>
</div>
{expanded && (
<div className="ml-4">
<CategoryNode
@@ -473,8 +350,6 @@ function CategoryNode({
onViewStructure: (table: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [privilegesTarget, setPrivilegesTarget] = useState<string | null>(null);
const [dataGenTarget, setDataGenTarget] = useState<string | null>(null);
const tablesQuery = useTables(
expanded && category === "tables" ? connectionId : null,
@@ -555,19 +430,6 @@ function CategoryNode({
>
View Structure
</ContextMenuItem>
{category === "tables" && (
<ContextMenuItem
onClick={() => setDataGenTarget(item.name)}
>
Generate Test Data
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => setPrivilegesTarget(item.name)}
>
View Privileges
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
@@ -586,26 +448,6 @@ function CategoryNode({
})}
</div>
)}
{privilegesTarget && (
<GrantRevokeDialog
open={!!privilegesTarget}
onOpenChange={(open) => !open && setPrivilegesTarget(null)}
connectionId={connectionId}
objectType="TABLE"
objectName={`${schema}.${privilegesTarget}`}
schema={schema}
table={privilegesTarget}
/>
)}
{dataGenTarget && (
<GenerateDataDialog
open={!!dataGenTarget}
onOpenChange={(open) => !open && setDataGenTarget(null)}
connectionId={connectionId}
schema={schema}
table={dataGenTarget}
/>
)}
</div>
);
}

View File

@@ -22,7 +22,7 @@ import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
import { AiSettingsFields } from "@/components/ai/AiSettingsFields";
import { Loader2, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import type { AppSettings, DockerHost } from "@/types";
import type { AppSettings } from "@/types";
interface Props {
open: boolean;
@@ -41,10 +41,6 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpPort, setMcpPort] = useState(9427);
// Docker state
const [dockerHost, setDockerHost] = useState<DockerHost>("local");
const [dockerRemoteUrl, setDockerRemoteUrl] = useState("");
// AI state
const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434");
const [aiModel, setAiModel] = useState("");
@@ -58,8 +54,6 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
if (appSettings) {
setMcpEnabled(appSettings.mcp.enabled);
setMcpPort(appSettings.mcp.port);
setDockerHost(appSettings.docker.host);
setDockerRemoteUrl(appSettings.docker.remote_url ?? "");
}
}
@@ -83,10 +77,6 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
const handleSave = () => {
const settings: AppSettings = {
mcp: { enabled: mcpEnabled, port: mcpPort },
docker: {
host: dockerHost,
remote_url: dockerHost === "remote" ? dockerRemoteUrl || undefined : undefined,
},
};
saveAppMutation.mutate(settings, {
@@ -183,38 +173,6 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
<Separator />
{/* Docker */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Docker</h3>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Docker Host</label>
<Select value={dockerHost} onValueChange={(v) => setDockerHost(v as DockerHost)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="remote">Remote</SelectItem>
</SelectContent>
</Select>
</div>
{dockerHost === "remote" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Remote URL</label>
<Input
value={dockerRemoteUrl}
onChange={(e) => setDockerRemoteUrl(e.target.value)}
placeholder="tcp://192.168.1.100:2375"
className="h-8 text-xs"
/>
</div>
)}
</section>
<Separator />
{/* AI */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">AI</h3>

View File

@@ -1,276 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useSchemas, useTables } from "@/hooks/use-schema";
import { useCreateSnapshot } from "@/hooks/use-snapshots";
import { toast } from "sonner";
import { save } from "@tauri-apps/plugin-dialog";
import {
Loader2,
CheckCircle2,
XCircle,
Camera,
} from "lucide-react";
import type { TableRef } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
}
type Step = "config" | "progress" | "done";
export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props) {
const [step, setStep] = useState<Step>("config");
const [name, setName] = useState("");
const [selectedSchema, setSelectedSchema] = useState<string>("");
const [selectedTables, setSelectedTables] = useState<Set<string>>(new Set());
const [includeDeps, setIncludeDeps] = useState(true);
const { data: schemas } = useSchemas(connectionId);
const { data: tables } = useTables(
selectedSchema ? connectionId : null,
selectedSchema
);
const { create, result, error, isCreating, progress, reset } = useCreateSnapshot();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setStep("config");
setName(`snapshot-${new Date().toISOString().slice(0, 10)}`);
setSelectedTables(new Set());
setIncludeDeps(true);
reset();
}
}
const [prevSchemas, setPrevSchemas] = useState(schemas);
if (schemas !== prevSchemas) {
setPrevSchemas(schemas);
if (schemas && schemas.length > 0 && !selectedSchema) {
setSelectedSchema(schemas.find((s) => s === "public") || schemas[0]);
}
}
const [prevProgress, setPrevProgress] = useState(progress);
if (progress !== prevProgress) {
setPrevProgress(progress);
if (progress?.stage === "done" || progress?.stage === "error") {
setStep("done");
}
}
const handleToggleTable = (tableName: string) => {
setSelectedTables((prev) => {
const next = new Set(prev);
if (next.has(tableName)) {
next.delete(tableName);
} else {
next.add(tableName);
}
return next;
});
};
const handleSelectAll = () => {
if (tables) {
if (selectedTables.size === tables.length) {
setSelectedTables(new Set());
} else {
setSelectedTables(new Set(tables.map((t) => t.name)));
}
}
};
const handleCreate = async () => {
if (!name.trim() || selectedTables.size === 0) {
toast.error("Please enter a name and select at least one table");
return;
}
const filePath = await save({
defaultPath: `${name}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!filePath) return;
setStep("progress");
const tableRefs: TableRef[] = Array.from(selectedTables).map((t) => ({
schema: selectedSchema,
table: t,
}));
const snapshotId = crypto.randomUUID();
create({
params: {
connection_id: connectionId,
tables: tableRefs,
name: name.trim(),
include_dependencies: includeDeps,
},
snapshotId,
filePath,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="h-5 w-5" />
Create Snapshot
</DialogTitle>
</DialogHeader>
{step === "config" && (
<>
<div className="grid gap-3 py-2">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="snapshot-name"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Schema</label>
<select
className="col-span-3 rounded-md border bg-background px-3 py-2 text-sm"
value={selectedSchema}
onChange={(e) => {
setSelectedSchema(e.target.value);
setSelectedTables(new Set());
}}
>
{schemas?.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">Tables</label>
<div className="col-span-3 space-y-1">
{tables && tables.length > 0 && (
<button
className="text-xs text-primary hover:underline"
onClick={handleSelectAll}
>
{selectedTables.size === tables.length ? "Deselect all" : "Select all"}
</button>
)}
<div className="max-h-48 overflow-y-auto rounded-md border p-2 space-y-1">
{tables?.map((t) => (
<label key={t.name} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-accent rounded px-1">
<input
type="checkbox"
checked={selectedTables.has(t.name)}
onChange={() => handleToggleTable(t.name)}
className="rounded"
/>
{t.name}
</label>
)) ?? (
<p className="text-xs text-muted-foreground">Select a schema first</p>
)}
</div>
<p className="text-xs text-muted-foreground">{selectedTables.size} tables selected</p>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Dependencies</label>
<div className="col-span-3 flex items-center gap-2">
<input
type="checkbox"
checked={includeDeps}
onChange={(e) => setIncludeDeps(e.target.checked)}
className="rounded"
/>
<span className="text-sm text-muted-foreground">
Include referenced tables (foreign keys)
</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={selectedTables.size === 0}>
Create Snapshot
</Button>
</DialogFooter>
</>
)}
{step === "progress" && (
<div className="py-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{progress?.message || "Starting..."}</span>
<span className="text-muted-foreground">{progress?.percent ?? 0}%</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div>
</div>
{isCreating && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{progress?.stage || "Initializing..."}
</div>
)}
</div>
)}
{step === "done" && (
<div className="py-4 space-y-4">
{error ? (
<div className="flex items-start gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-4">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Snapshot Failed</p>
<p className="text-xs text-muted-foreground">{error}</p>
</div>
</div>
) : (
<div className="flex items-start gap-3 rounded-md border border-green-500/50 bg-green-500/10 p-4">
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Snapshot Created</p>
<p className="text-xs text-muted-foreground">
{result?.total_rows} rows from {result?.tables.length} tables saved.
</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{error && <Button onClick={() => setStep("config")}>Retry</Button>}
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,248 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useRestoreSnapshot, useReadSnapshotMetadata } from "@/hooks/use-snapshots";
import { toast } from "sonner";
import { open as openFile } from "@tauri-apps/plugin-dialog";
import {
Loader2,
CheckCircle2,
XCircle,
Upload,
AlertTriangle,
FileJson,
} from "lucide-react";
import type { SnapshotMetadata } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
}
type Step = "select" | "confirm" | "progress" | "done";
export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Props) {
const [step, setStep] = useState<Step>("select");
const [filePath, setFilePath] = useState<string | null>(null);
const [metadata, setMetadata] = useState<SnapshotMetadata | null>(null);
const [truncate, setTruncate] = useState(false);
const readMeta = useReadSnapshotMetadata();
const { restore, rowsRestored, error, isRestoring, progress, reset } = useRestoreSnapshot();
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setStep("select");
setFilePath(null);
setMetadata(null);
setTruncate(false);
reset();
}
}
const [prevProgress, setPrevProgress] = useState(progress);
if (progress !== prevProgress) {
setPrevProgress(progress);
if (progress?.stage === "done" || progress?.stage === "error") {
setStep("done");
}
}
const handleSelectFile = async () => {
const selected = await openFile({
filters: [{ name: "JSON Snapshot", extensions: ["json"] }],
multiple: false,
});
if (!selected) return;
const path = typeof selected === "string" ? selected : (selected as { path: string }).path;
setFilePath(path);
readMeta.mutate(path, {
onSuccess: (meta) => {
setMetadata(meta);
setStep("confirm");
},
onError: (err) => toast.error("Invalid snapshot file", { description: String(err) }),
});
};
const handleRestore = () => {
if (!filePath) return;
setStep("progress");
const snapshotId = crypto.randomUUID();
restore({
params: {
connection_id: connectionId,
file_path: filePath,
truncate_before_restore: truncate,
},
snapshotId,
});
};
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Restore Snapshot
</DialogTitle>
</DialogHeader>
{step === "select" && (
<>
<div className="py-8 flex flex-col items-center gap-3">
<FileJson className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Select a snapshot file to restore</p>
<Button onClick={handleSelectFile} disabled={readMeta.isPending}>
{readMeta.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin mr-1" />Reading...</>
) : (
"Choose File"
)}
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
</DialogFooter>
</>
)}
{step === "confirm" && metadata && (
<>
<div className="space-y-3 py-2">
<div className="rounded-md border p-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Name</span>
<span className="font-medium">{metadata.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span>{new Date(metadata.created_at).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tables</span>
<span>{metadata.tables.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total Rows</span>
<span>{metadata.total_rows.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">File Size</span>
<span>{formatBytes(metadata.file_size_bytes)}</span>
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Tables included:</p>
<div className="flex flex-wrap gap-1">
{metadata.tables.map((t) => (
<Badge key={`${t.schema}.${t.table}`} variant="secondary" className="text-[10px]">
{t.schema}.{t.table} ({t.row_count})
</Badge>
))}
</div>
</div>
<div className="flex items-start gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-600 shrink-0 mt-0.5" />
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={truncate}
onChange={(e) => setTruncate(e.target.checked)}
className="rounded"
/>
Truncate existing data before restore
</label>
{truncate && (
<p className="text-xs text-yellow-700 dark:text-yellow-400">
This will DELETE all existing data in the affected tables before restoring.
</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStep("select")}>Back</Button>
<Button onClick={handleRestore}>Restore</Button>
</DialogFooter>
</>
)}
{step === "progress" && (
<div className="py-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{progress?.message || "Starting..."}</span>
<span className="text-muted-foreground">{progress?.percent ?? 0}%</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div>
</div>
{isRestoring && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{progress?.detail || progress?.stage || "Restoring..."}
</div>
)}
</div>
)}
{step === "done" && (
<div className="py-4 space-y-4">
{error ? (
<div className="flex items-start gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-4">
<XCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Restore Failed</p>
<p className="text-xs text-muted-foreground">{error}</p>
</div>
</div>
) : (
<div className="flex items-start gap-3 rounded-md border border-green-500/50 bg-green-500/10 p-4">
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Restore Completed</p>
<p className="text-xs text-muted-foreground">
{rowsRestored?.toLocaleString()} rows restored successfully.
</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{error && <Button onClick={() => setStep("confirm")}>Retry</Button>}
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,122 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useListSnapshots } from "@/hooks/use-snapshots";
import { CreateSnapshotDialog } from "./CreateSnapshotDialog";
import { RestoreSnapshotDialog } from "./RestoreSnapshotDialog";
import {
Camera,
Upload,
Plus,
FileJson,
Calendar,
Table2,
HardDrive,
} from "lucide-react";
import type { SnapshotMetadata } from "@/types";
interface Props {
connectionId: string;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function SnapshotCard({ snapshot }: { snapshot: SnapshotMetadata }) {
return (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<FileJson className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{snapshot.name}</span>
</div>
<Badge variant="secondary" className="text-[10px]">v{snapshot.version}</Badge>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(snapshot.created_at).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<Table2 className="h-3 w-3" />
{snapshot.tables.length} tables
</div>
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatBytes(snapshot.file_size_bytes)}
</div>
</div>
<div className="flex flex-wrap gap-1">
{snapshot.tables.map((t) => (
<Badge key={`${t.schema}.${t.table}`} variant="outline" className="text-[10px]">
{t.schema}.{t.table}
<span className="ml-1 text-muted-foreground">({t.row_count})</span>
</Badge>
))}
</div>
<div className="text-xs text-muted-foreground">
{snapshot.total_rows.toLocaleString()} total rows
</div>
</div>
);
}
export function SnapshotPanel({ connectionId }: Props) {
const [showCreate, setShowCreate] = useState(false);
const [showRestore, setShowRestore] = useState(false);
const { data: snapshots } = useListSnapshots();
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Camera className="h-5 w-5 text-primary" />
<h2 className="text-sm font-medium">Data Snapshots</h2>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowRestore(true)}>
<Upload className="h-3.5 w-3.5 mr-1" />
Restore
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Create
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-2">
{!snapshots || snapshots.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Camera className="h-12 w-12" />
<p className="text-sm">No snapshots yet</p>
<p className="text-xs">Create a snapshot to save table data for later restoration.</p>
</div>
) : (
snapshots.map((snap) => (
<SnapshotCard key={snap.id} snapshot={snap} />
))
)}
</div>
<CreateSnapshotDialog
open={showCreate}
onOpenChange={setShowCreate}
connectionId={connectionId}
/>
<RestoreSnapshotDialog
open={showRestore}
onOpenChange={setShowRestore}
connectionId={connectionId}
/>
</div>
);
}

View File

@@ -29,7 +29,11 @@ interface Props {
export function TableDataView({ connectionId, schema, table }: Props) {
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const dbFlavors = useAppStore((s) => s.dbFlavors);
// ClickHouse mutations are async and not transactional — surface the table viewer as
// read-only so existing edit/insert/delete affordances are hidden.
const isReadOnly =
(readOnlyMap[connectionId] ?? true) || dbFlavors[connectionId] === "clickhouse";
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);

View File

@@ -1,216 +0,0 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
useGenerateValidationSql,
useRunValidationRule,
useSuggestValidationRules,
} from "@/hooks/use-validation";
import { ValidationRuleCard } from "./ValidationRuleCard";
import { toast } from "sonner";
import { Plus, Sparkles, PlayCircle, Loader2, ShieldCheck } from "lucide-react";
import type { ValidationRule, ValidationStatus } from "@/types";
interface Props {
connectionId: string;
}
export function ValidationPanel({ connectionId }: Props) {
const [rules, setRules] = useState<ValidationRule[]>([]);
const [ruleInput, setRuleInput] = useState("");
const [runningIds, setRunningIds] = useState<Set<string>>(new Set());
const generateSql = useGenerateValidationSql();
const runRule = useRunValidationRule();
const suggestRules = useSuggestValidationRules();
const updateRule = useCallback(
(id: string, updates: Partial<ValidationRule>) => {
setRules((prev) =>
prev.map((r) => (r.id === id ? { ...r, ...updates } : r))
);
},
[]
);
const addRule = useCallback(
async (description: string) => {
const id = crypto.randomUUID();
const newRule: ValidationRule = {
id,
description,
generated_sql: "",
status: "generating" as ValidationStatus,
violation_count: 0,
sample_violations: [],
violation_columns: [],
error: null,
};
setRules((prev) => [...prev, newRule]);
try {
const sql = await generateSql.mutateAsync({
connectionId,
ruleDescription: description,
});
updateRule(id, { generated_sql: sql, status: "pending" });
} catch (err) {
updateRule(id, {
status: "error",
error: String(err),
});
}
},
[connectionId, generateSql, updateRule]
);
const handleAddRule = () => {
if (!ruleInput.trim()) return;
addRule(ruleInput.trim());
setRuleInput("");
};
const handleRunRule = useCallback(
async (id: string) => {
const rule = rules.find((r) => r.id === id);
if (!rule || !rule.generated_sql) return;
setRunningIds((prev) => new Set(prev).add(id));
updateRule(id, { status: "running" });
try {
const result = await runRule.mutateAsync({
connectionId,
sql: rule.generated_sql,
});
updateRule(id, {
status: result.status,
violation_count: result.violation_count,
sample_violations: result.sample_violations,
violation_columns: result.violation_columns,
error: result.error,
});
} catch (err) {
updateRule(id, { status: "error", error: String(err) });
} finally {
setRunningIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
},
[rules, connectionId, runRule, updateRule]
);
const handleRemoveRule = useCallback((id: string) => {
setRules((prev) => prev.filter((r) => r.id !== id));
}, []);
const handleRunAll = async () => {
const runnableRules = rules.filter(
(r) => r.generated_sql && r.status !== "generating"
);
for (const rule of runnableRules) {
await handleRunRule(rule.id);
}
};
const handleSuggest = async () => {
try {
const suggestions = await suggestRules.mutateAsync(connectionId);
for (const desc of suggestions) {
await addRule(desc);
}
toast.success(`Added ${suggestions.length} suggested rules`);
} catch (err) {
toast.error("Failed to suggest rules", { description: String(err) });
}
};
const passed = rules.filter((r) => r.status === "passed").length;
const failed = rules.filter((r) => r.status === "failed").length;
const errors = rules.filter((r) => r.status === "error").length;
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-primary" />
<h2 className="text-sm font-medium">Data Validation</h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSuggest}
disabled={suggestRules.isPending}
>
{suggestRules.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1" />
)}
Auto-suggest
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRunAll}
disabled={rules.length === 0 || runningIds.size > 0}
>
<PlayCircle className="h-3.5 w-3.5 mr-1" />
Run All
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Describe a data quality rule (e.g., 'All orders must have a positive total')"
value={ruleInput}
onChange={(e) => setRuleInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddRule()}
className="flex-1"
/>
<Button size="sm" onClick={handleAddRule} disabled={!ruleInput.trim()}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
{rules.length > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{rules.length} rules</span>
{passed > 0 && <Badge className="bg-green-600 text-white text-[10px]">{passed} passed</Badge>}
{failed > 0 && <Badge variant="destructive" className="text-[10px]">{failed} failed</Badge>}
{errors > 0 && <Badge variant="outline" className="text-[10px]">{errors} errors</Badge>}
</div>
)}
</div>
{/* Rules List */}
<div className="flex-1 overflow-auto p-4 space-y-2">
{rules.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Add a validation rule or click Auto-suggest to get started.
</div>
) : (
rules.map((rule) => (
<ValidationRuleCard
key={rule.id}
rule={rule}
onRun={() => handleRunRule(rule.id)}
onRemove={() => handleRemoveRule(rule.id)}
isRunning={runningIds.has(rule.id)}
/>
))
)}
</div>
</div>
);
}

View File

@@ -1,138 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ChevronDown,
ChevronRight,
Play,
Trash2,
Loader2,
} from "lucide-react";
import type { ValidationRule } from "@/types";
interface Props {
rule: ValidationRule;
onRun: () => void;
onRemove: () => void;
isRunning: boolean;
}
function statusBadge(status: string) {
switch (status) {
case "passed":
return <Badge className="bg-green-600 text-white">Passed</Badge>;
case "failed":
return <Badge variant="destructive">Failed</Badge>;
case "error":
return <Badge variant="outline" className="text-destructive border-destructive">Error</Badge>;
case "generating":
case "running":
return <Badge variant="secondary"><Loader2 className="h-3 w-3 animate-spin mr-1" />Running</Badge>;
default:
return <Badge variant="secondary">Pending</Badge>;
}
}
export function ValidationRuleCard({ rule, onRun, onRemove, isRunning }: Props) {
const [showSql, setShowSql] = useState(false);
const [showViolations, setShowViolations] = useState(false);
return (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm">{rule.description}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{statusBadge(rule.status)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onRun}
disabled={isRunning}
>
{isRunning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{rule.status === "failed" && (
<p className="text-xs text-destructive">
{rule.violation_count} violation{rule.violation_count !== 1 ? "s" : ""} found
</p>
)}
{rule.error && (
<p className="text-xs text-destructive">{rule.error}</p>
)}
{rule.generated_sql && (
<div>
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowSql(!showSql)}
>
{showSql ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
SQL
</button>
{showSql && (
<pre className="mt-1 rounded bg-muted p-2 text-xs font-mono overflow-x-auto max-h-32 overflow-y-auto">
{rule.generated_sql}
</pre>
)}
</div>
)}
{rule.status === "failed" && rule.sample_violations.length > 0 && (
<div>
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowViolations(!showViolations)}
>
{showViolations ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Sample Violations ({rule.sample_violations.length})
</button>
{showViolations && (
<div className="mt-1 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
{rule.violation_columns.map((col) => (
<th key={col} className="px-2 py-1 text-left font-medium text-muted-foreground">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rule.sample_violations.map((row, i) => (
<tr key={i} className="border-b last:border-0">
{(row as unknown[]).map((val, j) => (
<td key={j} className="px-2 py-1 font-mono">
{val === null ? <span className="text-muted-foreground">NULL</span> : String(val)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -2,13 +2,7 @@ import { useAppStore } from "@/stores/app-store";
import { WorkspacePanel } from "./WorkspacePanel";
import { TableDataView } from "@/components/table-viewer/TableDataView";
import { TableStructure } from "@/components/table-viewer/TableStructure";
import { RoleManagerView } from "@/components/management/RoleManagerView";
import { SessionsView } from "@/components/management/SessionsView";
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
import { ErdDiagram } from "@/components/erd/ErdDiagram";
import { ValidationPanel } from "@/components/validation/ValidationPanel";
import { IndexAdvisorPanel } from "@/components/index-advisor/IndexAdvisorPanel";
import { SnapshotPanel } from "@/components/snapshots/SnapshotPanel";
import { ChatPanel } from "@/components/chat/ChatPanel";
export function TabContent() {
const { tabs, activeTabId, updateTab } = useAppStore();
@@ -55,54 +49,9 @@ export function TabContent() {
/>
);
break;
case "roles":
case "chat":
content = (
<RoleManagerView
connectionId={tab.connectionId}
/>
);
break;
case "sessions":
content = (
<SessionsView
connectionId={tab.connectionId}
/>
);
break;
case "lookup":
content = (
<EntityLookupPanel
connectionId={tab.connectionId}
/>
);
break;
case "erd":
content = (
<ErdDiagram
connectionId={tab.connectionId}
schema={tab.schema!}
/>
);
break;
case "validation":
content = (
<ValidationPanel
connectionId={tab.connectionId}
/>
);
break;
case "index-advisor":
content = (
<IndexAdvisorPanel
connectionId={tab.connectionId}
/>
);
break;
case "snapshots":
content = (
<SnapshotPanel
connectionId={tab.connectionId}
/>
<ChatPanel tabId={tab.id} connectionId={tab.connectionId} />
);
break;
default:

56
src/hooks/use-chat.ts Normal file
View File

@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { chatSend } from "@/lib/tauri";
import { useAppStore } from "@/stores/app-store";
import type { ChatMessage } from "@/types";
const EMPTY_THREAD: ChatMessage[] = [];
function newId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function useChat(tabId: string, connectionId: string) {
const messages = useAppStore((s) => s.chatThreads[tabId] ?? EMPTY_THREAD);
const pending = useAppStore((s) => Boolean(s.chatPending[tabId]));
const appendChatMessages = useAppStore((s) => s.appendChatMessages);
const clearChatThread = useAppStore((s) => s.clearChatThread);
const setChatPending = useAppStore((s) => s.setChatPending);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const state = useAppStore.getState();
if (state.chatPending[tabId]) return;
const history = state.chatThreads[tabId] ?? [];
const userMsg: ChatMessage = {
id: newId("user"),
role: "user",
text: trimmed,
created_at: Date.now(),
};
appendChatMessages(tabId, [userMsg]);
setChatPending(tabId, true);
try {
const reply = await chatSend(connectionId, [...history, userMsg]);
appendChatMessages(tabId, reply);
} catch (err) {
appendChatMessages(tabId, [
{
id: newId("err"),
role: "assistant",
text: `Error: ${String(err)}`,
created_at: Date.now(),
},
]);
} finally {
setChatPending(tabId, false);
}
},
[tabId, connectionId, appendChatMessages, setChatPending]
);
const clear = useCallback(() => clearChatThread(tabId), [tabId, clearChatThread]);
return { messages, pending, send, clear };
}

View File

@@ -51,8 +51,15 @@ export function useTestConnection() {
}
export function useConnect() {
const { addConnectedId, setActiveConnectionId, setPgVersion, setDbFlavor, setCurrentDatabase } =
useAppStore();
const {
addConnectedId,
setActiveConnectionId,
setPgVersion,
setDbFlavor,
setCurrentDatabase,
addTab,
tabs,
} = useAppStore();
return useMutation({
mutationFn: async (config: ConnectionConfig) => {
@@ -65,6 +72,19 @@ export function useConnect() {
setPgVersion(version);
setDbFlavor(id, flavor);
setCurrentDatabase(database);
const hasChatForConnection = tabs.some(
(t) => t.type === "chat" && t.connectionId === id
);
if (!hasChatForConnection) {
addTab({
id: crypto.randomUUID(),
type: "chat",
title: "Chat",
connectionId: id,
database,
});
}
},
});
}

View File

@@ -1,84 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import {
generateTestDataPreview,
insertGeneratedData,
onDataGenProgress,
} from "@/lib/tauri";
import type { GenerateDataParams, DataGenProgress, GeneratedDataPreview } from "@/types";
export function useDataGenerator() {
const [progress, setProgress] = useState<DataGenProgress | null>(null);
const genIdRef = useRef<string>("");
const previewMutation = useMutation({
mutationFn: ({
params,
genId,
}: {
params: GenerateDataParams;
genId: string;
}) => {
genIdRef.current = genId;
setProgress(null);
return generateTestDataPreview(params, genId);
},
});
const insertMutation = useMutation({
mutationFn: ({
connectionId,
preview,
}: {
connectionId: string;
preview: GeneratedDataPreview;
}) => insertGeneratedData(connectionId, preview),
});
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
onDataGenProgress((p) => {
if (mounted && p.gen_id === genIdRef.current) {
setProgress(p);
}
}).then((fn) => {
if (mounted) {
unlisten = fn;
} else {
fn();
}
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const previewRef = useRef(previewMutation);
const insertRef = useRef(insertMutation);
useEffect(() => {
previewRef.current = previewMutation;
insertRef.current = insertMutation;
});
const reset = useCallback(() => {
previewRef.current.reset();
insertRef.current.reset();
setProgress(null);
genIdRef.current = "";
}, []);
return {
generatePreview: previewMutation.mutate,
preview: previewMutation.data as GeneratedDataPreview | undefined,
isGenerating: previewMutation.isPending,
generateError: previewMutation.error ? String(previewMutation.error) : null,
insertData: insertMutation.mutate,
insertedRows: insertMutation.data as number | undefined,
isInserting: insertMutation.isPending,
insertError: insertMutation.error ? String(insertMutation.error) : null,
progress,
reset,
};
}

View File

@@ -1,122 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
checkDocker,
listTuskContainers,
cloneToDocker,
startContainer,
stopContainer,
removeContainer,
onCloneProgress,
} from "@/lib/tauri";
import type { CloneToDockerParams, CloneProgress, CloneResult } from "@/types";
export function useDockerStatus() {
return useQuery({
queryKey: ["docker-status"],
queryFn: checkDocker,
staleTime: 30_000,
});
}
export function useTuskContainers() {
return useQuery({
queryKey: ["tusk-containers"],
queryFn: listTuskContainers,
refetchInterval: 10_000,
});
}
export function useCloneToDocker() {
const [progress, setProgress] = useState<CloneProgress | null>(null);
const cloneIdRef = useRef<string>("");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({
params,
cloneId,
}: {
params: CloneToDockerParams;
cloneId: string;
}) => {
cloneIdRef.current = cloneId;
setProgress(null);
return cloneToDocker(params, cloneId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["connections"] });
queryClient.invalidateQueries({ queryKey: ["tusk-containers"] });
},
});
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
onCloneProgress((p) => {
if (mounted && p.clone_id === cloneIdRef.current) {
setProgress(p);
}
}).then((fn) => {
if (mounted) {
unlisten = fn;
} else {
fn();
}
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const mutationRef = useRef(mutation);
useEffect(() => {
mutationRef.current = mutation;
});
const reset = useCallback(() => {
mutationRef.current.reset();
setProgress(null);
cloneIdRef.current = "";
}, []);
return {
clone: mutation.mutate,
result: mutation.data as CloneResult | undefined,
error: mutation.error ? String(mutation.error) : null,
isCloning: mutation.isPending,
progress,
reset,
};
}
export function useStartContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => startContainer(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tusk-containers"] });
},
});
}
export function useStopContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => stopContainer(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tusk-containers"] });
},
});
}
export function useRemoveContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => removeContainer(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tusk-containers"] });
},
});
}

View File

@@ -1,60 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { entityLookup, onLookupProgress } from "@/lib/tauri";
import type {
ConnectionConfig,
EntityLookupResult,
LookupProgress,
} from "@/types";
export function useEntityLookup() {
const [progress, setProgress] = useState<LookupProgress | null>(null);
const lookupIdRef = useRef<string>("");
const mutation = useMutation({
mutationFn: ({
config,
columnName,
value,
lookupId,
databases,
}: {
config: ConnectionConfig;
columnName: string;
value: string;
lookupId: string;
databases?: string[];
}) => {
lookupIdRef.current = lookupId;
setProgress(null);
return entityLookup(config, columnName, value, lookupId, databases);
},
});
useEffect(() => {
const unlistenPromise = onLookupProgress((p) => {
if (p.lookup_id === lookupIdRef.current) {
setProgress(p);
}
});
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
const reset = useCallback(() => {
mutation.reset();
setProgress(null);
lookupIdRef.current = "";
}, [mutation]);
return {
search: mutation.mutate,
result: mutation.data as EntityLookupResult | undefined,
error: mutation.error ? String(mutation.error) : null,
isSearching: mutation.isPending,
progress,
reset,
};
}

View File

@@ -1,20 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { getIndexAdvisorReport, applyIndexRecommendation } from "@/lib/tauri";
export function useIndexAdvisorReport() {
return useMutation({
mutationFn: (connectionId: string) => getIndexAdvisorReport(connectionId),
});
}
export function useApplyIndexRecommendation() {
return useMutation({
mutationFn: ({
connectionId,
ddl,
}: {
connectionId: string;
ddl: string;
}) => applyIndexRecommendation(connectionId, ddl),
});
}

View File

@@ -1,205 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getDatabaseInfo,
createDatabase,
dropDatabase,
listRoles,
createRole,
alterRole,
dropRole,
getTablePrivileges,
grantRevoke,
manageRoleMembership,
listSessions,
cancelQuery,
terminateBackend,
} from "@/lib/tauri";
import type {
CreateDatabaseParams,
CreateRoleParams,
AlterRoleParams,
GrantRevokeParams,
RoleMembershipParams,
} from "@/types";
// Queries
export function useDatabaseInfo(connectionId: string | null) {
return useQuery({
queryKey: ["databaseInfo", connectionId],
queryFn: () => getDatabaseInfo(connectionId!),
enabled: !!connectionId,
staleTime: 5 * 60 * 1000,
});
}
export function useRoles(connectionId: string | null) {
return useQuery({
queryKey: ["roles", connectionId],
queryFn: () => listRoles(connectionId!),
enabled: !!connectionId,
staleTime: 5 * 60 * 1000,
});
}
export function useTablePrivileges(
connectionId: string | null,
schema: string | null,
table: string | null
) {
return useQuery({
queryKey: ["tablePrivileges", connectionId, schema, table],
queryFn: () => getTablePrivileges(connectionId!, schema!, table!),
enabled: !!connectionId && !!schema && !!table,
staleTime: 5 * 60 * 1000,
});
}
// Mutations
export function useCreateDatabase() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
params,
}: {
connectionId: string;
params: CreateDatabaseParams;
}) => createDatabase(connectionId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["databaseInfo"] });
queryClient.invalidateQueries({ queryKey: ["databases"] });
},
});
}
export function useDropDatabase() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
name,
}: {
connectionId: string;
name: string;
}) => dropDatabase(connectionId, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["databaseInfo"] });
queryClient.invalidateQueries({ queryKey: ["databases"] });
},
});
}
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
params,
}: {
connectionId: string;
params: CreateRoleParams;
}) => createRole(connectionId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
});
}
export function useAlterRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
params,
}: {
connectionId: string;
params: AlterRoleParams;
}) => alterRole(connectionId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
});
}
export function useDropRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
name,
}: {
connectionId: string;
name: string;
}) => dropRole(connectionId, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
});
}
export function useGrantRevoke() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
params,
}: {
connectionId: string;
params: GrantRevokeParams;
}) => grantRevoke(connectionId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablePrivileges"] });
},
});
}
// Sessions
export function useSessions(connectionId: string | null) {
return useQuery({
queryKey: ["sessions", connectionId],
queryFn: () => listSessions(connectionId!),
enabled: !!connectionId,
refetchInterval: 5000,
});
}
export function useCancelQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
cancelQuery(connectionId, pid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useTerminateBackend() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
terminateBackend(connectionId, pid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useManageRoleMembership() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
connectionId,
params,
}: {
connectionId: string;
params: RoleMembershipParams;
}) => manageRoleMembership(connectionId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
});
}

22
src/hooks/use-memory.ts Normal file
View File

@@ -0,0 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getMemory, saveMemory } from "@/lib/tauri";
export function useMemory(connectionId: string | null) {
return useQuery({
queryKey: ["memory", connectionId],
queryFn: () => getMemory(connectionId!),
enabled: !!connectionId,
staleTime: 5_000,
});
}
export function useSaveMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ connectionId, content }: { connectionId: string; content: string }) =>
saveMemory(connectionId, content),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ["memory", vars.connectionId] });
},
});
}

View File

@@ -8,7 +8,6 @@ import {
listSequences,
switchDatabase,
getColumnDetails,
getSchemaErd,
} from "@/lib/tauri";
import type { ConnectionConfig } from "@/types";
@@ -89,12 +88,3 @@ export function useColumnDetails(connectionId: string | null, schema: string | n
staleTime: 5 * 60 * 1000,
});
}
export function useSchemaErd(connectionId: string | null, schema: string | null) {
return useQuery({
queryKey: ["schema-erd", connectionId, schema],
queryFn: () => getSchemaErd(connectionId!, schema!),
enabled: !!connectionId && !!schema,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -1,153 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
createSnapshot,
restoreSnapshot,
listSnapshots,
readSnapshotMetadata,
onSnapshotProgress,
} from "@/lib/tauri";
import type {
CreateSnapshotParams,
RestoreSnapshotParams,
SnapshotProgress,
SnapshotMetadata,
} from "@/types";
export function useListSnapshots() {
return useQuery({
queryKey: ["snapshots"],
queryFn: listSnapshots,
staleTime: 30_000,
});
}
export function useReadSnapshotMetadata() {
return useMutation({
mutationFn: (filePath: string) => readSnapshotMetadata(filePath),
});
}
export function useCreateSnapshot() {
const [progress, setProgress] = useState<SnapshotProgress | null>(null);
const snapshotIdRef = useRef<string>("");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({
params,
snapshotId,
filePath,
}: {
params: CreateSnapshotParams;
snapshotId: string;
filePath: string;
}) => {
snapshotIdRef.current = snapshotId;
setProgress(null);
return createSnapshot(params, snapshotId, filePath);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
});
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
onSnapshotProgress((p) => {
if (mounted && p.snapshot_id === snapshotIdRef.current) {
setProgress(p);
}
}).then((fn) => {
if (mounted) {
unlisten = fn;
} else {
fn();
}
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const mutationRef = useRef(mutation);
useEffect(() => {
mutationRef.current = mutation;
});
const reset = useCallback(() => {
mutationRef.current.reset();
setProgress(null);
snapshotIdRef.current = "";
}, []);
return {
create: mutation.mutate,
result: mutation.data as SnapshotMetadata | undefined,
error: mutation.error ? String(mutation.error) : null,
isCreating: mutation.isPending,
progress,
reset,
};
}
export function useRestoreSnapshot() {
const [progress, setProgress] = useState<SnapshotProgress | null>(null);
const snapshotIdRef = useRef<string>("");
const mutation = useMutation({
mutationFn: ({
params,
snapshotId,
}: {
params: RestoreSnapshotParams;
snapshotId: string;
}) => {
snapshotIdRef.current = snapshotId;
setProgress(null);
return restoreSnapshot(params, snapshotId);
},
});
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
onSnapshotProgress((p) => {
if (mounted && p.snapshot_id === snapshotIdRef.current) {
setProgress(p);
}
}).then((fn) => {
if (mounted) {
unlisten = fn;
} else {
fn();
}
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const mutationRef = useRef(mutation);
useEffect(() => {
mutationRef.current = mutation;
});
const reset = useCallback(() => {
mutationRef.current.reset();
setProgress(null);
snapshotIdRef.current = "";
}, []);
return {
restore: mutation.mutate,
rowsRestored: mutation.data as number | undefined,
error: mutation.error ? String(mutation.error) : null,
isRestoring: mutation.isPending,
progress,
reset,
};
}

View File

@@ -1,38 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import {
generateValidationSql,
runValidationRule,
suggestValidationRules,
} from "@/lib/tauri";
export function useGenerateValidationSql() {
return useMutation({
mutationFn: ({
connectionId,
ruleDescription,
}: {
connectionId: string;
ruleDescription: string;
}) => generateValidationSql(connectionId, ruleDescription),
});
}
export function useRunValidationRule() {
return useMutation({
mutationFn: ({
connectionId,
sql,
sampleLimit,
}: {
connectionId: string;
sql: string;
sampleLimit?: number;
}) => runValidationRule(connectionId, sql, sampleLimit),
});
}
export function useSuggestValidationRules() {
return useMutation({
mutationFn: (connectionId: string) => suggestValidationRules(connectionId),
});
}

42
src/lib/dbCapabilities.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { DbFlavor } from "@/types";
export interface DbCapabilities {
/** Direct row edit / insert / delete via the table viewer. */
rowEdit: boolean;
/** Multiple schemas inside one database. */
multipleSchemas: boolean;
/** Foreign-key constraints, triggers, sequences, indexes. */
pgObjects: boolean;
/** Default port for the engine when creating a new connection. */
defaultPort: number;
/** Display name for the engine. */
label: string;
}
const CAPABILITIES: Record<DbFlavor, DbCapabilities> = {
postgresql: {
rowEdit: true,
multipleSchemas: true,
pgObjects: true,
defaultPort: 5432,
label: "PostgreSQL",
},
greenplum: {
rowEdit: true,
multipleSchemas: true,
pgObjects: true,
defaultPort: 5432,
label: "Greenplum",
},
clickhouse: {
rowEdit: false,
multipleSchemas: false,
pgObjects: false,
defaultPort: 8123,
label: "ClickHouse",
},
};
export function capsFor(flavor: DbFlavor | undefined): DbCapabilities {
return CAPABILITIES[flavor ?? "postgresql"];
}

View File

@@ -1,5 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
ConnectResult,
@@ -12,38 +11,13 @@ import type {
ConstraintInfo,
IndexInfo,
TriggerInfo,
ErdData,
HistoryEntry,
SavedQuery,
SessionInfo,
DatabaseInfo,
CreateDatabaseParams,
RoleInfo,
CreateRoleParams,
AlterRoleParams,
TablePrivilege,
GrantRevokeParams,
RoleMembershipParams,
AiSettings,
OllamaModel,
EntityLookupResult,
LookupProgress,
DockerStatus,
CloneToDockerParams,
CloneProgress,
CloneResult,
TuskContainer,
AppSettings,
McpStatus,
ValidationRule,
GenerateDataParams,
GeneratedDataPreview,
DataGenProgress,
IndexAdvisorReport,
SnapshotMetadata,
CreateSnapshotParams,
RestoreSnapshotParams,
SnapshotProgress,
ChatMessage,
} from "@/types";
// Connections
@@ -139,9 +113,6 @@ export const getTableTriggers = (
table: string
) => invoke<TriggerInfo[]>("get_table_triggers", { connectionId, schema, table });
export const getSchemaErd = (connectionId: string, schema: string) =>
invoke<ErdData>("get_schema_erd", { connectionId, schema });
// Data
export const getTableData = (params: {
connectionId: string;
@@ -229,47 +200,6 @@ export const exportJson = (
rows: unknown[][]
) => invoke<void>("export_json", { path, columns, rows });
// Management
export const getDatabaseInfo = (connectionId: string) =>
invoke<DatabaseInfo[]>("get_database_info", { connectionId });
export const createDatabase = (connectionId: string, params: CreateDatabaseParams) =>
invoke<void>("create_database", { connectionId, params });
export const dropDatabase = (connectionId: string, name: string) =>
invoke<void>("drop_database", { connectionId, name });
export const listRoles = (connectionId: string) =>
invoke<RoleInfo[]>("list_roles", { connectionId });
export const createRole = (connectionId: string, params: CreateRoleParams) =>
invoke<void>("create_role", { connectionId, params });
export const alterRole = (connectionId: string, params: AlterRoleParams) =>
invoke<void>("alter_role", { connectionId, params });
export const dropRole = (connectionId: string, name: string) =>
invoke<void>("drop_role", { connectionId, name });
export const getTablePrivileges = (connectionId: string, schema: string, table: string) =>
invoke<TablePrivilege[]>("get_table_privileges", { connectionId, schema, table });
export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
invoke<void>("grant_revoke", { connectionId, params });
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
invoke<void>("manage_role_membership", { connectionId, params });
// Sessions
export const listSessions = (connectionId: string) =>
invoke<SessionInfo[]>("list_sessions", { connectionId });
export const cancelQuery = (connectionId: string, pid: number) =>
invoke<boolean>("cancel_query", { connectionId, pid });
export const terminateBackend = (connectionId: string, pid: number) =>
invoke<boolean>("terminate_backend", { connectionId, pid });
// AI
export const getAiSettings = () =>
invoke<AiSettings>("get_ai_settings");
@@ -289,50 +219,15 @@ export const explainSql = (connectionId: string, sql: string) =>
export const fixSqlError = (connectionId: string, sql: string, errorMessage: string) =>
invoke<string>("fix_sql_error", { connectionId, sql, errorMessage });
// Entity Lookup
export const entityLookup = (
config: ConnectionConfig,
columnName: string,
value: string,
lookupId: string,
databases?: string[]
) =>
invoke<EntityLookupResult>("entity_lookup", {
config,
columnName,
value,
lookupId,
databases,
});
export const chatSend = (connectionId: string, messages: ChatMessage[]) =>
invoke<ChatMessage[]>("chat_send", { connectionId, messages });
export const onLookupProgress = (
callback: (p: LookupProgress) => void
): Promise<UnlistenFn> =>
listen<LookupProgress>("lookup-progress", (e) => callback(e.payload));
// Memory (per-connection markdown notes for the chat agent)
export const getMemory = (connectionId: string) =>
invoke<string>("get_memory", { connectionId });
// Docker
export const checkDocker = () =>
invoke<DockerStatus>("check_docker");
export const listTuskContainers = () =>
invoke<TuskContainer[]>("list_tusk_containers");
export const cloneToDocker = (params: CloneToDockerParams, cloneId: string) =>
invoke<CloneResult>("clone_to_docker", { params, cloneId });
export const startContainer = (name: string) =>
invoke<void>("start_container", { name });
export const stopContainer = (name: string) =>
invoke<void>("stop_container", { name });
export const removeContainer = (name: string) =>
invoke<void>("remove_container", { name });
export const onCloneProgress = (
callback: (p: CloneProgress) => void
): Promise<UnlistenFn> =>
listen<CloneProgress>("clone-progress", (e) => callback(e.payload));
export const saveMemory = (connectionId: string, content: string) =>
invoke<void>("save_memory", { connectionId, content });
// App Settings
export const getAppSettings = () =>
@@ -343,50 +238,3 @@ export const saveAppSettings = (settings: AppSettings) =>
export const getMcpStatus = () =>
invoke<McpStatus>("get_mcp_status");
// Validation (Wave 1)
export const generateValidationSql = (connectionId: string, ruleDescription: string) =>
invoke<string>("generate_validation_sql", { connectionId, ruleDescription });
export const runValidationRule = (connectionId: string, sql: string, sampleLimit?: number) =>
invoke<ValidationRule>("run_validation_rule", { connectionId, sql, sampleLimit });
export const suggestValidationRules = (connectionId: string) =>
invoke<string[]>("suggest_validation_rules", { connectionId });
// Data Generator (Wave 2)
export const generateTestDataPreview = (params: GenerateDataParams, genId: string) =>
invoke<GeneratedDataPreview>("generate_test_data_preview", { params, genId });
export const insertGeneratedData = (connectionId: string, preview: GeneratedDataPreview) =>
invoke<number>("insert_generated_data", { connectionId, preview });
export const onDataGenProgress = (
callback: (p: DataGenProgress) => void
): Promise<UnlistenFn> =>
listen<DataGenProgress>("datagen-progress", (e) => callback(e.payload));
// Index Advisor (Wave 3A)
export const getIndexAdvisorReport = (connectionId: string) =>
invoke<IndexAdvisorReport>("get_index_advisor_report", { connectionId });
export const applyIndexRecommendation = (connectionId: string, ddl: string) =>
invoke<void>("apply_index_recommendation", { connectionId, ddl });
// Snapshots (Wave 3B)
export const createSnapshot = (params: CreateSnapshotParams, snapshotId: string, filePath: string) =>
invoke<SnapshotMetadata>("create_snapshot", { params, snapshotId, filePath });
export const restoreSnapshot = (params: RestoreSnapshotParams, snapshotId: string) =>
invoke<number>("restore_snapshot", { params, snapshotId });
export const listSnapshots = () =>
invoke<SnapshotMetadata[]>("list_snapshots");
export const readSnapshotMetadata = (filePath: string) =>
invoke<SnapshotMetadata>("read_snapshot_metadata", { filePath });
export const onSnapshotProgress = (
callback: (p: SnapshotProgress) => void
): Promise<UnlistenFn> =>
listen<SnapshotProgress>("snapshot-progress", (e) => callback(e.payload));

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import App from "./App";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import "./styles/globals.css";
const queryClient = new QueryClient({
@@ -17,11 +18,13 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<App />
<Toaster />
</TooltipProvider>
</QueryClientProvider>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<App />
<Toaster />
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -170,12 +170,12 @@ describe("AppStore", () => {
it("should handle different tab types", () => {
useAppStore.getState().addTab(makeTab("t1", "query"));
useAppStore.getState().addTab(makeTab("t2", "table"));
useAppStore.getState().addTab(makeTab("t3", "erd"));
useAppStore.getState().addTab(makeTab("t3", "structure"));
expect(useAppStore.getState().tabs.map((t) => t.type)).toEqual([
"query",
"table",
"erd",
"structure",
]);
});
});

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { ConnectionConfig, DbFlavor, Tab } from "@/types";
import type { ChatMessage, ConnectionConfig, DbFlavor, Tab } from "@/types";
interface AppState {
connections: ConnectionConfig[];
@@ -12,6 +12,8 @@ interface AppState {
activeTabId: string | null;
sidebarWidth: number;
pgVersion: string | null;
chatThreads: Record<string, ChatMessage[]>;
chatPending: Record<string, boolean>;
setConnections: (connections: ConnectionConfig[]) => void;
setActiveConnectionId: (id: string | null) => void;
@@ -27,6 +29,10 @@ interface AppState {
setActiveTabId: (id: string | null) => void;
updateTab: (id: string, updates: Partial<Tab>) => void;
setSidebarWidth: (width: number) => void;
appendChatMessages: (tabId: string, messages: ChatMessage[]) => void;
clearChatThread: (tabId: string) => void;
setChatPending: (tabId: string, pending: boolean) => void;
}
export const useAppStore = create<AppState>((set) => ({
@@ -40,6 +46,8 @@ export const useAppStore = create<AppState>((set) => ({
activeTabId: null,
sidebarWidth: 260,
pgVersion: null,
chatThreads: {},
chatPending: {},
setConnections: (connections) => set({ connections }),
setActiveConnectionId: (id) => set({ activeConnectionId: id }),
@@ -85,7 +93,11 @@ export const useAppStore = create<AppState>((set) => ({
? tabs[tabs.length - 1].id
: null
: state.activeTabId;
return { tabs, activeTabId };
const chatThreads = { ...state.chatThreads };
delete chatThreads[id];
const chatPending = { ...state.chatPending };
delete chatPending[id];
return { tabs, activeTabId, chatThreads, chatPending };
}),
setActiveTabId: (id) => set({ activeTabId: id }),
updateTab: (id, updates) =>
@@ -93,4 +105,20 @@ export const useAppStore = create<AppState>((set) => ({
tabs: state.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)),
})),
setSidebarWidth: (width) => set({ sidebarWidth: width }),
appendChatMessages: (tabId, messages) =>
set((state) => ({
chatThreads: {
...state.chatThreads,
[tabId]: [...(state.chatThreads[tabId] ?? []), ...messages],
},
})),
clearChatThread: (tabId) =>
set((state) => ({
chatThreads: { ...state.chatThreads, [tabId]: [] },
})),
setChatPending: (tabId, pending) =>
set((state) => ({
chatPending: { ...state.chatPending, [tabId]: pending },
})),
}));

View File

@@ -1,4 +1,4 @@
export type DbFlavor = "postgresql" | "greenplum";
export type DbFlavor = "postgresql" | "greenplum" | "clickhouse";
export interface ConnectResult {
version: string;
@@ -16,6 +16,10 @@ export interface ConnectionConfig {
ssl_mode?: string;
color?: string;
environment?: string;
/** DB engine selected by user. Older configs without this default to "postgresql". */
db_flavor?: DbFlavor;
/** HTTPS for ClickHouse. Defaults to false. */
secure?: boolean;
}
export interface QueryResult {
@@ -122,106 +126,6 @@ export interface ExplainResult {
"Execution Time": number;
}
export interface DatabaseInfo {
name: string;
owner: string;
encoding: string;
collation: string;
ctype: string;
tablespace: string;
connection_limit: number;
size: string;
description: string | null;
}
export interface CreateDatabaseParams {
name: string;
owner?: string;
template?: string;
encoding?: string;
tablespace?: string;
connection_limit?: number;
}
export interface RoleInfo {
name: string;
is_superuser: boolean;
can_login: boolean;
can_create_db: boolean;
can_create_role: boolean;
inherit: boolean;
is_replication: boolean;
connection_limit: number;
password_set: boolean;
valid_until: string | null;
member_of: string[];
members: string[];
description: string | null;
}
export interface CreateRoleParams {
name: string;
password?: string;
login: boolean;
superuser: boolean;
createdb: boolean;
createrole: boolean;
inherit: boolean;
replication: boolean;
connection_limit?: number;
valid_until?: string;
in_roles: string[];
}
export interface AlterRoleParams {
name: string;
password?: string;
login?: boolean;
superuser?: boolean;
createdb?: boolean;
createrole?: boolean;
inherit?: boolean;
replication?: boolean;
connection_limit?: number;
valid_until?: string;
rename_to?: string;
}
export interface TablePrivilege {
grantee: string;
table_schema: string;
table_name: string;
privilege_type: string;
is_grantable: boolean;
}
export interface GrantRevokeParams {
action: string;
privileges: string[];
object_type: string;
object_name: string;
role_name: string;
with_grant_option: boolean;
}
export interface RoleMembershipParams {
action: string;
role_name: string;
member_name: string;
}
export interface SessionInfo {
pid: number;
usename: string | null;
datname: string | null;
state: string | null;
query: string | null;
query_start: string | null;
wait_event_type: string | null;
wait_event: string | null;
client_addr: string | null;
}
export interface SavedQuery {
id: string;
name: string;
@@ -244,46 +148,6 @@ export interface OllamaModel {
name: string;
}
// Entity Lookup
export interface LookupTableMatch {
schema: string;
table: string;
column_type: string;
columns: string[];
types: string[];
rows: unknown[][];
row_count: number;
total_count: number;
}
export interface LookupDatabaseResult {
database: string;
tables: LookupTableMatch[];
error: string | null;
search_time_ms: number;
}
export interface EntityLookupResult {
column_name: string;
value: string;
databases: LookupDatabaseResult[];
total_databases_searched: number;
total_tables_matched: number;
total_rows_found: number;
total_time_ms: number;
}
export interface LookupProgress {
lookup_id: string;
database: string;
status: string;
tables_found: number;
rows_found: number;
error: string | null;
completed: number;
total: number;
}
export interface TriggerInfo {
name: string;
event: string;
@@ -294,52 +158,14 @@ export interface TriggerInfo {
definition: string;
}
export interface ErdColumn {
name: string;
data_type: string;
is_nullable: boolean;
is_primary_key: boolean;
}
export interface ErdTable {
schema: string;
name: string;
columns: ErdColumn[];
}
export interface ErdRelationship {
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}
export interface ErdData {
tables: ErdTable[];
relationships: ErdRelationship[];
}
// App Settings
export type DockerHost = "local" | "remote";
export interface McpSettings {
enabled: boolean;
port: number;
}
export interface DockerSettings {
host: DockerHost;
remote_url?: string;
}
export interface AppSettings {
mcp: McpSettings;
docker: DockerSettings;
}
export interface McpStatus {
@@ -348,53 +174,7 @@ export interface McpStatus {
running: boolean;
}
// Docker
export interface DockerStatus {
installed: boolean;
daemon_running: boolean;
version: string | null;
error: string | null;
}
export type CloneMode = "schema_only" | "full_clone" | "sample_data";
export interface CloneToDockerParams {
source_connection_id: string;
source_database: string;
container_name: string;
pg_version: string;
host_port: number | null;
clone_mode: CloneMode;
sample_rows: number | null;
postgres_password: string | null;
}
export interface CloneProgress {
clone_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
export interface TuskContainer {
container_id: string;
name: string;
status: string;
host_port: number;
pg_version: string;
source_database: string | null;
source_connection: string | null;
created_at: string | null;
}
export interface CloneResult {
container: TuskContainer;
connection_id: string;
connection_url: string;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup" | "erd" | "validation" | "index-advisor" | "snapshots";
export type TabType = "query" | "table" | "structure" | "chat";
export interface Tab {
id: string;
@@ -405,163 +185,23 @@ export interface Tab {
schema?: string;
table?: string;
sql?: string;
roleName?: string;
lookupColumn?: string;
lookupValue?: string;
}
// --- Wave 1: Validation ---
export type ChatRole = "user" | "assistant" | "tool_call" | "tool_result";
export type ValidationStatus = "pending" | "generating" | "running" | "passed" | "failed" | "error";
export interface ValidationRule {
interface ChatBase {
id: string;
description: string;
generated_sql: string;
status: ValidationStatus;
violation_count: number;
sample_violations: unknown[][];
violation_columns: string[];
error: string | null;
created_at: number;
}
export interface ValidationReport {
rules: ValidationRule[];
total_rules: number;
passed: number;
failed: number;
errors: number;
execution_time_ms: number;
}
// --- Wave 2: Data Generator ---
export interface GenerateDataParams {
connection_id: string;
schema: string;
table: string;
row_count: number;
include_related: boolean;
custom_instructions?: string;
}
export interface GeneratedDataPreview {
tables: GeneratedTableData[];
insert_order: string[];
total_rows: number;
}
export interface GeneratedTableData {
schema: string;
table: string;
columns: string[];
rows: unknown[][];
row_count: number;
}
export interface DataGenProgress {
gen_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
// --- Wave 3A: Index Advisor ---
export interface TableStats {
schema: string;
table: string;
seq_scan: number;
idx_scan: number;
n_live_tup: number;
table_size: string;
index_size: string;
}
export interface IndexStatsInfo {
schema: string;
table: string;
index_name: string;
idx_scan: number;
index_size: string;
definition: string;
}
export interface SlowQuery {
query: string;
calls: number;
total_time_ms: number;
mean_time_ms: number;
rows: number;
}
export type IndexRecommendationType = "create_index" | "drop_index" | "replace_index";
export interface IndexRecommendation {
id: string;
recommendation_type: IndexRecommendationType;
table_schema: string;
table_name: string;
index_name: string | null;
ddl: string;
rationale: string;
estimated_impact: string;
priority: string;
}
export interface IndexAdvisorReport {
table_stats: TableStats[];
index_stats: IndexStatsInfo[];
slow_queries: SlowQuery[];
recommendations: IndexRecommendation[];
has_pg_stat_statements: boolean;
}
// --- Wave 3B: Snapshots ---
export interface SnapshotMetadata {
id: string;
name: string;
created_at: string;
connection_name: string;
database: string;
tables: SnapshotTableMeta[];
total_rows: number;
file_size_bytes: number;
version: number;
}
export interface SnapshotTableMeta {
schema: string;
table: string;
row_count: number;
columns: string[];
column_types: string[];
}
export interface SnapshotProgress {
snapshot_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
export interface CreateSnapshotParams {
connection_id: string;
tables: TableRef[];
name: string;
include_dependencies: boolean;
}
export interface TableRef {
schema: string;
table: string;
}
export interface RestoreSnapshotParams {
connection_id: string;
file_path: string;
truncate_before_restore: boolean;
}
export type ChatMessage =
| (ChatBase & { role: "user"; text: string })
| (ChatBase & { role: "assistant"; text: string })
| (ChatBase & { role: "tool_call"; tool: string; input_json: string })
| (ChatBase & {
role: "tool_result";
tool: string;
is_error: boolean;
text?: string | null;
result?: QueryResult | null;
});