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:
64
src/components/chat/ChatComposer.tsx
Normal file
64
src/components/chat/ChatComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
src/components/chat/ChatMessageView.tsx
Normal file
385
src/components/chat/ChatMessageView.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
108
src/components/chat/ChatPanel.tsx
Normal file
108
src/components/chat/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "{schema}".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
50
src/components/layout/ErrorBoundary.tsx
Normal file
50
src/components/layout/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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} ×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
140
src/components/memory/MemoryPanel.tsx
Normal file
140
src/components/memory/MemoryPanel.tsx
Normal 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 ## (timestamp) 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
56
src/hooks/use-chat.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
22
src/hooks/use-memory.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
42
src/lib/dbCapabilities.ts
Normal 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"];
|
||||
}
|
||||
168
src/lib/tauri.ts
168
src/lib/tauri.ts
@@ -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));
|
||||
|
||||
15
src/main.tsx
15
src/main.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user