Compare commits
1 Commits
c03e887b71
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d431352816 |
14
CLAUDE.md
@@ -9,15 +9,13 @@ Tusk is a PostgreSQL database management GUI built with Tauri 2 (Rust backend) a
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run app:dev # Start full app in dev mode (Vite HMR + Rust backend) — alias for `tauri dev`
|
npm run tauri dev # Start full app in dev mode (Vite HMR + Rust backend)
|
||||||
npm run app:bundle # Build production desktop executable — alias for `tauri build`
|
|
||||||
npm run dev # Start only the Vite frontend dev server (port 5173)
|
npm run dev # Start only the Vite frontend dev server (port 5173)
|
||||||
npm run build # Build frontend (tsc + vite build)
|
npm run build # Build frontend (tsc + vite build)
|
||||||
|
npm run tauri build # Build production desktop executable
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run tauri <cmd>` still works for any raw Tauri CLI subcommand.
|
|
||||||
|
|
||||||
Rust backend builds automatically via Tauri when running `npm run tauri dev/build`. To check Rust compilation independently:
|
Rust backend builds automatically via Tauri when running `npm run tauri dev/build`. To check Rust compilation independently:
|
||||||
```bash
|
```bash
|
||||||
cd src-tauri && cargo check
|
cd src-tauri && cargo check
|
||||||
@@ -41,10 +39,10 @@ All frontend-backend communication goes through Tauri's typed IPC. The full pipe
|
|||||||
|
|
||||||
### Backend (src-tauri/src/)
|
### Backend (src-tauri/src/)
|
||||||
|
|
||||||
- **`state.rs`** — `AppState`: holds connection pools (`pools`, `ch_clients`), read-only flags, DB flavors (PostgreSQL/Greenplum/ClickHouse), config path, chat-agent caches (`overview_cache`, `tables_by_db_cache`), MCP signal channels, and `ai_settings`. Connections default to read-only.
|
- **`state.rs`** — `AppState`: holds `RwLock<HashMap<connection_id, PgPool>>`, read-only flags, config path. Connections default to read-only.
|
||||||
- **`error.rs`** — `TuskError` enum with `thiserror`, serialized as string for IPC. `TuskResult<T>` type alias.
|
- **`error.rs`** — `TuskError` enum with `thiserror`, serialized as string for IPC. `TuskResult<T>` type alias.
|
||||||
- **`utils.rs`** — `escape_ident()` for safe SQL identifier quoting.
|
- **`utils.rs`** — `escape_ident()` for safe SQL identifier quoting.
|
||||||
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries, ai, chat, chat_tools, memory, settings). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
|
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
|
||||||
- **`models/`** — serde-serializable structs matching TypeScript types.
|
- **`models/`** — serde-serializable structs matching TypeScript types.
|
||||||
|
|
||||||
SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterized queries. Read-only mode wraps queries in `SET TRANSACTION READ ONLY` + `ROLLBACK`.
|
SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterized queries. Read-only mode wraps queries in `SET TRANSACTION READ ONLY` + `ROLLBACK`.
|
||||||
@@ -53,8 +51,7 @@ SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterize
|
|||||||
|
|
||||||
- **State**: Zustand store (`stores/app-store.ts`) — connections, active connection/database, tabs, read-only flags, pg version.
|
- **State**: Zustand store (`stores/app-store.ts`) — connections, active connection/database, tabs, read-only flags, pg version.
|
||||||
- **Data fetching**: TanStack React Query hooks in `hooks/` — one hook per domain, wrapping `src/lib/tauri.ts` functions.
|
- **Data fetching**: TanStack React Query hooks in `hooks/` — one hook per domain, wrapping `src/lib/tauri.ts` functions.
|
||||||
- **AI chat**: tool-calling architecture — backend commands in `commands/chat.rs` / `commands/chat_tools.rs`, frontend tool definitions in `components/chat/tool-registry.ts`. Chat panel (`ChatPanel.tsx`) replaces the former inline `AiBar`, explain/fix, and chart-preview UIs.
|
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, dark mode via next-themes. SQL editor uses CodeMirror.
|
||||||
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, warm dark "Graphite & Honey" theme (IBM Plex Mono, honey accent). CodeMirror SQL editor with a custom theme from `src/lib/editor-theme.ts`.
|
|
||||||
- **Layout**: resizable panels (sidebar + main area with tab bar).
|
- **Layout**: resizable panels (sidebar + main area with tab bar).
|
||||||
|
|
||||||
### Stored Data
|
### Stored Data
|
||||||
@@ -67,4 +64,3 @@ Connections are persisted to `~/.config/tusk/connections.json`. History and save
|
|||||||
- **Path alias**: `@` maps to `./src`
|
- **Path alias**: `@` maps to `./src`
|
||||||
- **Rust errors**: always use `TuskError` variants, never `unwrap()` in commands
|
- **Rust errors**: always use `TuskError` variants, never `unwrap()` in commands
|
||||||
- **New PG types**: add conversion case in `pg_value_to_json()` in `commands/queries.rs`
|
- **New PG types**: add conversion case in `pg_value_to_json()` in `commands/queries.rs`
|
||||||
- **AI chat tools**: add Rust-side tool implementation in `commands/chat_tools.rs` and register it in both the backend dispatch (`ChatTool`) and the frontend `components/chat/tool-registry.ts` (schema, display component, card builder).
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
<title>Tusk</title>
|
<title>Tusk</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400;1,500&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
483
package-lock.json
generated
@@ -15,17 +15,19 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-resizable-panels": "^4.6.2",
|
"react-resizable-panels": "^4.6.2",
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sql-formatter": "^15.7.0",
|
"sql-formatter": "^15.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
@@ -3653,42 +3655,6 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
|
||||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^11.0.0",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
|
||||||
"version": "11.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz",
|
|
||||||
"integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -4070,12 +4036,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"dev": true,
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -4849,23 +4810,20 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-array": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-ease": {
|
"node_modules/@types/d3-drag": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-interpolate": {
|
"node_modules/@types/d3-interpolate": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
@@ -4876,40 +4834,35 @@
|
|||||||
"@types/d3-color": "*"
|
"@types/d3-color": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-path": {
|
"node_modules/@types/d3-selection": {
|
||||||
"version": "3.1.1",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-scale": {
|
"node_modules/@types/d3-transition": {
|
||||||
"version": "4.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-time": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
"node_modules/@types/d3-zoom": {
|
||||||
"version": "3.1.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-path": "*"
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-time": {
|
"node_modules/@types/dagre": {
|
||||||
"version": "3.0.4",
|
"version": "0.7.54",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
@@ -4970,12 +4923,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -5437,6 +5384,66 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||||
|
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.76",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
|
||||||
|
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -5870,6 +5877,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
@@ -6255,18 +6268,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
@@ -6276,6 +6277,28 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-ease": {
|
"node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
@@ -6285,15 +6308,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
"node_modules/d3-interpolate": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
@@ -6306,67 +6320,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-path": {
|
"node_modules/d3-selection": {
|
||||||
"version": "3.1.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -6376,6 +6338,51 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dagre": {
|
||||||
|
"version": "0.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
|
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphlib": "^2.1.8",
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
@@ -6425,12 +6432,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||||
@@ -6730,16 +6731,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.46.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
|
||||||
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -7030,12 +7021,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
@@ -7625,6 +7610,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/graphlib": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graphql": {
|
"node_modules/graphql": {
|
||||||
"version": "16.12.0",
|
"version": "16.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
||||||
@@ -7804,16 +7798,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -7858,15 +7842,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||||
@@ -8580,6 +8555,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -9769,32 +9750,10 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -9901,36 +9860,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
|
||||||
"version": "3.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
|
||||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"www"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"decimal.js-light": "^2.5.1",
|
|
||||||
"es-toolkit": "^1.39.3",
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"immer": "^10.1.1",
|
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
|
||||||
"reselect": "5.1.1",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -9945,21 +9874,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -9980,12 +9894,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -10685,6 +10593,7 @@
|
|||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
@@ -11117,28 +11026,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/victory-vendor": {
|
|
||||||
"version": "37.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
|
||||||
"license": "MIT AND ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "^3.0.3",
|
|
||||||
"@types/d3-ease": "^3.0.0",
|
|
||||||
"@types/d3-interpolate": "^3.0.1",
|
|
||||||
"@types/d3-scale": "^4.0.2",
|
|
||||||
"@types/d3-shape": "^3.1.0",
|
|
||||||
"@types/d3-time": "^3.0.0",
|
|
||||||
"@types/d3-timer": "^3.0.0",
|
|
||||||
"d3-array": "^3.1.6",
|
|
||||||
"d3-ease": "^3.0.1",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-shape": "^3.1.0",
|
|
||||||
"d3-time": "^3.0.0",
|
|
||||||
"d3-timer": "^3.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri"
|
||||||
"app:dev": "tauri dev",
|
|
||||||
"app:bundle": "tauri build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
@@ -22,17 +20,19 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-resizable-panels": "^4.6.2",
|
"react-resizable-panels": "^4.6.2",
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sql-formatter": "^15.7.0",
|
"sql-formatter": "^15.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
@@ -4705,6 +4705,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ tauri-plugin-shell = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros", "process", "io-util"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["serde"] }
|
uuid = { version = "1", features = ["serde"] }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#fff</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,39 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#27406e"/>
|
|
||||||
<stop offset="0.55" stop-color="#172f4f"/>
|
|
||||||
<stop offset="1" stop-color="#0d1730"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.6">
|
|
||||||
<stop offset="0" stop-color="#3a5fa0" stop-opacity="0.55"/>
|
|
||||||
<stop offset="1" stop-color="#3a5fa0" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient id="ivory" x1="0.2" y1="0.05" x2="0.85" y2="0.95">
|
|
||||||
<stop offset="0" stop-color="#fffdf6"/>
|
|
||||||
<stop offset="0.55" stop-color="#f3e8d2"/>
|
|
||||||
<stop offset="1" stop-color="#d8c39a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="ds" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.35"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#bg)"/>
|
|
||||||
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#glow)"/>
|
|
||||||
|
|
||||||
<g filter="url(#ds)">
|
|
||||||
<!-- single bold tusk: thick rounded root at upper-right, tapering along a
|
|
||||||
gentle curve to a sharp tip at lower-left -->
|
|
||||||
<path d="M 712 408
|
|
||||||
C 720 600, 560 720, 322 742
|
|
||||||
C 470 700, 600 470, 628 318
|
|
||||||
A 60 60 0 0 1 712 408 Z"
|
|
||||||
fill="url(#ivory)"/>
|
|
||||||
<!-- soft sheen along the belly (upper-left edge) for ivory depth -->
|
|
||||||
<path d="M 322 742
|
|
||||||
C 470 700, 600 470, 628 318
|
|
||||||
C 612 470, 470 660, 360 726 Z"
|
|
||||||
fill="#ffffff" opacity="0.18"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -1,4 +1,3 @@
|
|||||||
use crate::db::clickhouse::ChClient;
|
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::connection::ConnectionConfig;
|
use crate::models::connection::ConnectionConfig;
|
||||||
use crate::state::{AppState, DbFlavor};
|
use crate::state::{AppState, DbFlavor};
|
||||||
@@ -24,34 +23,6 @@ pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::Pat
|
|||||||
Ok(dir.join("connections.json"))
|
Ok(dir.join("connections.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all saved connection configs from disk.
|
|
||||||
pub(crate) fn load_all_connections(app: &AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
|
|
||||||
let path = get_connections_path(app)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let data = fs::read_to_string(&path)?;
|
|
||||||
let connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
|
|
||||||
Ok(connections)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a single saved connection by id. Used by tools that need credentials
|
|
||||||
/// (e.g. switch_database from inside the chat agent loop) but only have the id in scope.
|
|
||||||
pub(crate) fn load_connection_config(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<ConnectionConfig> {
|
|
||||||
load_all_connections(app)?
|
|
||||||
.into_iter()
|
|
||||||
.find(|c| c.id == connection_id)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
TuskError::Custom(format!(
|
|
||||||
"Connection '{}' not found in connections.json",
|
|
||||||
connection_id
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_connections(app: AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
|
pub async fn get_connections(app: AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
|
||||||
let path = get_connections_path(&app)?;
|
let path = get_connections_path(&app)?;
|
||||||
@@ -84,25 +55,6 @@ pub async fn save_connection(app: AppHandle, config: ConnectionConfig) -> TuskRe
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn close_connection(state: &AppState, id: &str) {
|
|
||||||
let mut pools = state.pools.write().await;
|
|
||||||
if let Some(pool) = pools.remove(id) {
|
|
||||||
pool.close().await;
|
|
||||||
}
|
|
||||||
drop(pools);
|
|
||||||
let mut clients = state.ch_clients.write().await;
|
|
||||||
clients.remove(id);
|
|
||||||
drop(clients);
|
|
||||||
let mut ro = state.read_only.write().await;
|
|
||||||
ro.remove(id);
|
|
||||||
drop(ro);
|
|
||||||
let mut flavors = state.db_flavors.write().await;
|
|
||||||
flavors.remove(id);
|
|
||||||
drop(flavors);
|
|
||||||
state.gp_majors.write().await.remove(id);
|
|
||||||
state.invalidate_chat_caches_for(id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_connection(
|
pub async fn delete_connection(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -117,47 +69,36 @@ pub async fn delete_connection(
|
|||||||
let data = serde_json::to_string_pretty(&connections)?;
|
let data = serde_json::to_string_pretty(&connections)?;
|
||||||
fs::write(&path, data)?;
|
fs::write(&path, data)?;
|
||||||
}
|
}
|
||||||
close_connection(&state, &id).await;
|
|
||||||
// Best-effort cleanup of per-connection persisted state; errors are logged
|
// Close pool if connected
|
||||||
// but don't block the deletion (the connections.json is the source of truth).
|
let mut pools = state.pools.write().await;
|
||||||
if let Err(e) = crate::commands::memory::delete_memory_core(&app, &id) {
|
if let Some(pool) = pools.remove(&id) {
|
||||||
log::warn!("failed to delete memory file for {}: {}", id, e);
|
pool.close().await;
|
||||||
}
|
|
||||||
if let Err(e) =
|
|
||||||
crate::commands::saved_queries::delete_by_connection_core(&app, &id).await
|
|
||||||
{
|
|
||||||
log::warn!("failed to clean saved queries for {}: {}", id, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut ro = state.read_only.write().await;
|
||||||
|
ro.remove(&id);
|
||||||
|
|
||||||
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.remove(&id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
||||||
match config.db_flavor {
|
let pool = PgPool::connect(&config.connection_url())
|
||||||
DbFlavor::ClickHouse => {
|
.await
|
||||||
let client = ChClient::new(
|
.map_err(TuskError::Database)?;
|
||||||
&config.host,
|
|
||||||
config.port,
|
let row = sqlx::query("SELECT version()")
|
||||||
config.secure,
|
.fetch_one(&pool)
|
||||||
&config.user,
|
.await
|
||||||
&config.password,
|
.map_err(TuskError::Database)?;
|
||||||
&config.database,
|
|
||||||
);
|
let version: String = row.get(0);
|
||||||
client.ping().await
|
pool.close().await;
|
||||||
}
|
Ok(version)
|
||||||
_ => {
|
|
||||||
let pool = PgPool::connect(&config.connection_url())
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
let row = sqlx::query("SELECT version()")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
let version: String = row.get(0);
|
|
||||||
pool.close().await;
|
|
||||||
Ok(version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -165,128 +106,39 @@ pub async fn connect(
|
|||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
) -> TuskResult<ConnectResult> {
|
) -> TuskResult<ConnectResult> {
|
||||||
match config.db_flavor {
|
let pool = PgPool::connect(&config.connection_url())
|
||||||
DbFlavor::ClickHouse => {
|
.await
|
||||||
let client = ChClient::new(
|
.map_err(TuskError::Database)?;
|
||||||
&config.host,
|
|
||||||
config.port,
|
|
||||||
config.secure,
|
|
||||||
&config.user,
|
|
||||||
&config.password,
|
|
||||||
&config.database,
|
|
||||||
);
|
|
||||||
let version = client.ping().await?;
|
|
||||||
let arc = Arc::new(client);
|
|
||||||
state.ch_clients.write().await.insert(config.id.clone(), arc);
|
|
||||||
state.read_only.write().await.insert(config.id.clone(), true);
|
|
||||||
state
|
|
||||||
.db_flavors
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(config.id.clone(), DbFlavor::ClickHouse);
|
|
||||||
Ok(ConnectResult {
|
|
||||||
version,
|
|
||||||
flavor: DbFlavor::ClickHouse,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let pool = PgPool::connect(&config.connection_url())
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
sqlx::query("SELECT 1")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
let row = sqlx::query("SELECT version()")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
let version: String = row.get(0);
|
|
||||||
let flavor = if version.to_lowercase().contains("greenplum") {
|
|
||||||
DbFlavor::Greenplum
|
|
||||||
} else {
|
|
||||||
DbFlavor::PostgreSQL
|
|
||||||
};
|
|
||||||
// Pull the GP major (6 or 7) from a string like
|
|
||||||
// "PostgreSQL 14.4 (Greenplum Database 7.1.0 ...)".
|
|
||||||
// GP6 and GP7 expose very different system catalogs, so downstream
|
|
||||||
// schema-introspection code branches on this.
|
|
||||||
let gp_major: Option<u8> = if flavor == DbFlavor::Greenplum {
|
|
||||||
version
|
|
||||||
.split("Greenplum Database ")
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|tail| tail.split('.').next())
|
|
||||||
.and_then(|major| major.parse::<u8>().ok())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
state.pools.write().await.insert(config.id.clone(), pool);
|
|
||||||
state.read_only.write().await.insert(config.id.clone(), true);
|
|
||||||
state
|
|
||||||
.db_flavors
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(config.id.clone(), flavor);
|
|
||||||
if let Some(m) = gp_major {
|
|
||||||
state.gp_majors.write().await.insert(config.id.clone(), m);
|
|
||||||
} else {
|
|
||||||
state.gp_majors.write().await.remove(&config.id);
|
|
||||||
}
|
|
||||||
Ok(ConnectResult { version, flavor })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core implementation of switching the active database for a connection.
|
// Verify connection
|
||||||
/// Reusable from both the Tauri command (frontend-driven) and the chat agent
|
sqlx::query("SELECT 1")
|
||||||
/// loop (model-driven via the switch_database tool).
|
.execute(&pool)
|
||||||
pub(crate) async fn switch_database_core(
|
.await
|
||||||
state: &AppState,
|
.map_err(TuskError::Database)?;
|
||||||
config: &ConnectionConfig,
|
|
||||||
database: &str,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
let mut switched = config.clone();
|
|
||||||
switched.database = database.to_string();
|
|
||||||
|
|
||||||
let result: TuskResult<()> = match config.db_flavor {
|
// Detect database flavor via version()
|
||||||
DbFlavor::ClickHouse => {
|
let row = sqlx::query("SELECT version()")
|
||||||
let client = ChClient::new(
|
.fetch_one(&pool)
|
||||||
&switched.host,
|
.await
|
||||||
switched.port,
|
.map_err(TuskError::Database)?;
|
||||||
switched.secure,
|
let version: String = row.get(0);
|
||||||
&switched.user,
|
|
||||||
&switched.password,
|
let flavor = if version.to_lowercase().contains("greenplum") {
|
||||||
&switched.database,
|
DbFlavor::Greenplum
|
||||||
);
|
} else {
|
||||||
client.ping().await?;
|
DbFlavor::PostgreSQL
|
||||||
state
|
|
||||||
.ch_clients
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(config.id.clone(), Arc::new(client));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let pool = PgPool::connect(&switched.connection_url())
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
sqlx::query("SELECT 1")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
let mut pools = state.pools.write().await;
|
|
||||||
if let Some(old_pool) = pools.remove(&config.id) {
|
|
||||||
old_pool.close().await;
|
|
||||||
}
|
|
||||||
pools.insert(config.id.clone(), pool);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drop every cache that's bound to this connection's previous database.
|
let mut pools = state.pools.write().await;
|
||||||
state.invalidate_chat_caches_for(&config.id).await;
|
pools.insert(config.id.clone(), pool);
|
||||||
|
|
||||||
result
|
let mut ro = state.read_only.write().await;
|
||||||
|
ro.insert(config.id.clone(), true);
|
||||||
|
|
||||||
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.insert(config.id.clone(), flavor);
|
||||||
|
|
||||||
|
Ok(ConnectResult { version, flavor })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -295,12 +147,40 @@ pub async fn switch_database(
|
|||||||
config: ConnectionConfig,
|
config: ConnectionConfig,
|
||||||
database: String,
|
database: String,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
switch_database_core(&state, &config, &database).await
|
let mut switched = config.clone();
|
||||||
|
switched.database = database;
|
||||||
|
|
||||||
|
let pool = PgPool::connect(&switched.connection_url())
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
sqlx::query("SELECT 1")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut pools = state.pools.write().await;
|
||||||
|
if let Some(old_pool) = pools.remove(&config.id) {
|
||||||
|
old_pool.close().await;
|
||||||
|
}
|
||||||
|
pools.insert(config.id.clone(), pool);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn disconnect(state: State<'_, Arc<AppState>>, id: String) -> TuskResult<()> {
|
pub async fn disconnect(state: State<'_, Arc<AppState>>, id: String) -> TuskResult<()> {
|
||||||
close_connection(&state, &id).await;
|
let mut pools = state.pools.write().await;
|
||||||
|
if let Some(pool) = pools.remove(&id) {
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ro = state.read_only.write().await;
|
||||||
|
ro.remove(&id);
|
||||||
|
|
||||||
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.remove(&id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::commands::queries::pg_value_to_json;
|
use crate::commands::queries::pg_value_to_json;
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::query_result::PaginatedQueryResult;
|
use crate::models::query_result::PaginatedQueryResult;
|
||||||
use crate::state::{AppState, DbFlavor};
|
use crate::state::AppState;
|
||||||
use crate::utils::escape_ident;
|
use crate::utils::escape_ident;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::{Column, Row, TypeInfo};
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
@@ -9,80 +9,6 @@ use std::sync::Arc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
async fn ch_get_table_data(
|
|
||||||
state: &AppState,
|
|
||||||
connection_id: &str,
|
|
||||||
schema: &str,
|
|
||||||
table: &str,
|
|
||||||
page: u32,
|
|
||||||
page_size: u32,
|
|
||||||
sort_column: Option<&str>,
|
|
||||||
sort_direction: Option<&str>,
|
|
||||||
filter: Option<&str>,
|
|
||||||
) -> TuskResult<PaginatedQueryResult> {
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
let qualified = format!(
|
|
||||||
"{}.{}",
|
|
||||||
ch_quote_ident(schema),
|
|
||||||
ch_quote_ident(table)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut where_clause = String::new();
|
|
||||||
if let Some(f) = filter {
|
|
||||||
if !f.trim().is_empty() {
|
|
||||||
crate::db::sql_guard::ensure_readonly_sql(&format!("SELECT 1 FROM x WHERE {}", f))?;
|
|
||||||
where_clause = format!(" WHERE {}", f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut order_clause = String::new();
|
|
||||||
if let Some(col) = sort_column {
|
|
||||||
if !col.trim().is_empty() {
|
|
||||||
let dir = match sort_direction {
|
|
||||||
Some("DESC") | Some("desc") => "DESC",
|
|
||||||
_ => "ASC",
|
|
||||||
};
|
|
||||||
order_clause = format!(" ORDER BY {} {}", ch_quote_ident(col), dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = (page.saturating_sub(1)) as i64 * page_size as i64;
|
|
||||||
let data_sql = format!(
|
|
||||||
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
|
|
||||||
qualified, where_clause, order_clause, page_size, offset
|
|
||||||
);
|
|
||||||
let count_sql = format!("SELECT count() AS c FROM {}{}", qualified, where_clause);
|
|
||||||
|
|
||||||
let result = client.execute_query(&data_sql, true).await?;
|
|
||||||
let count_rows = client.fetch_objects(&count_sql).await?;
|
|
||||||
let total_rows = count_rows
|
|
||||||
.first()
|
|
||||||
.and_then(|o| o.get("c"))
|
|
||||||
.and_then(|v| match v {
|
|
||||||
Value::Number(n) => n.as_i64(),
|
|
||||||
Value::String(s) => s.parse::<i64>().ok(),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
Ok(PaginatedQueryResult {
|
|
||||||
columns: result.columns,
|
|
||||||
types: result.types,
|
|
||||||
rows: result.rows,
|
|
||||||
row_count: result.row_count,
|
|
||||||
execution_time_ms: result.execution_time_ms,
|
|
||||||
total_rows,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
ctids: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_quote_ident(s: &str) -> String {
|
|
||||||
let escaped = s.replace('`', "``");
|
|
||||||
format!("`{}`", escaped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn get_table_data(
|
pub async fn get_table_data(
|
||||||
@@ -96,21 +22,6 @@ pub async fn get_table_data(
|
|||||||
sort_direction: Option<String>,
|
sort_direction: Option<String>,
|
||||||
filter: Option<String>,
|
filter: Option<String>,
|
||||||
) -> TuskResult<PaginatedQueryResult> {
|
) -> TuskResult<PaginatedQueryResult> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return ch_get_table_data(
|
|
||||||
&state,
|
|
||||||
&connection_id,
|
|
||||||
&schema,
|
|
||||||
&table,
|
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
sort_column.as_deref(),
|
|
||||||
sort_direction.as_deref(),
|
|
||||||
filter.as_deref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
@@ -136,19 +47,9 @@ pub async fn get_table_data(
|
|||||||
|
|
||||||
let offset = (page.saturating_sub(1)) * page_size;
|
let offset = (page.saturating_sub(1)) * page_size;
|
||||||
|
|
||||||
// Greenplum AO / AOCO tables don't expose `ctid` and even on heap tables
|
|
||||||
// it isn't a reliable per-row identifier across segments — so on GP we
|
|
||||||
// skip the ctid column entirely. Edits without a PK will be rejected by
|
|
||||||
// update_row/delete_rows below with a friendly error.
|
|
||||||
let include_ctid = !matches!(flavor, DbFlavor::Greenplum);
|
|
||||||
let select_clause = if include_ctid {
|
|
||||||
"*, ctid::text"
|
|
||||||
} else {
|
|
||||||
"*"
|
|
||||||
};
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT {} FROM {}{}{} LIMIT {} OFFSET {}",
|
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
|
||||||
select_clause, qualified, where_clause, order_clause, page_size, offset
|
qualified, where_clause, order_clause, page_size, offset
|
||||||
);
|
);
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
||||||
|
|
||||||
@@ -173,7 +74,7 @@ pub async fn get_table_data(
|
|||||||
|
|
||||||
tx.rollback().await.map_err(TuskError::Database)?;
|
tx.rollback().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
let execution_time_ms = start.elapsed().as_millis() as u64;
|
let execution_time_ms = start.elapsed().as_millis();
|
||||||
let total_rows: i64 = count_row.get(0);
|
let total_rows: i64 = count_row.get(0);
|
||||||
|
|
||||||
let mut all_columns = Vec::new();
|
let mut all_columns = Vec::new();
|
||||||
@@ -245,11 +146,6 @@ pub async fn update_row(
|
|||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Inline row edit is not supported for ClickHouse — use SQL ALTER … UPDATE.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
@@ -258,13 +154,6 @@ pub async fn update_row(
|
|||||||
let set_clause = format!("{} = $1", escape_ident(&column));
|
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
|
|
||||||
heap-table ctid isn't unique across segments). Inline edit requires \
|
|
||||||
a primary key — add one or use a SQL UPDATE.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Fallback: use ctid for row identification
|
// Fallback: use ctid for row identification
|
||||||
let ctid_val = ctid.ok_or_else(|| {
|
let ctid_val = ctid.ok_or_else(|| {
|
||||||
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
||||||
@@ -313,11 +202,6 @@ pub async fn insert_row(
|
|||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Inline row insert is not supported for ClickHouse — use SQL INSERT.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
@@ -356,11 +240,6 @@ pub async fn delete_rows(
|
|||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Inline row delete is not supported for ClickHouse — use SQL ALTER … DELETE.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
@@ -371,13 +250,6 @@ pub async fn delete_rows(
|
|||||||
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
|
|
||||||
heap-table ctid isn't unique across segments). Inline delete requires \
|
|
||||||
a primary key — add one or use a SQL DELETE.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Fallback: use ctids for row identification
|
// Fallback: use ctids for row identification
|
||||||
let ctid_list = ctids.ok_or_else(|| {
|
let ctid_list = ctids.ok_or_else(|| {
|
||||||
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
||||||
|
|||||||
1218
src-tauri/src/commands/docker.rs
Normal file
367
src-tauri/src/commands/lookup.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
use crate::commands::queries::pg_value_to_json;
|
||||||
|
use crate::error::TuskResult;
|
||||||
|
use crate::models::connection::ConnectionConfig;
|
||||||
|
use crate::models::lookup::{
|
||||||
|
EntityLookupResult, LookupDatabaseResult, LookupProgress, LookupTableMatch,
|
||||||
|
};
|
||||||
|
use crate::utils::escape_ident;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
struct TableCandidate {
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
data_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_database(
|
||||||
|
config: &ConnectionConfig,
|
||||||
|
database: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> LookupDatabaseResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let mut db_config = config.clone();
|
||||||
|
db_config.database = database.to_string();
|
||||||
|
let url = db_config.connection_url();
|
||||||
|
|
||||||
|
let pool = match PgPoolOptions::new()
|
||||||
|
.max_connections(2)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
return LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some(format!("Connection failed: {}", e)),
|
||||||
|
search_time_ms: start.elapsed().as_millis(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(120),
|
||||||
|
search_database_inner(&pool, database, column_name, value),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(db_result) => {
|
||||||
|
let mut db_result = db_result;
|
||||||
|
db_result.search_time_ms = start.elapsed().as_millis();
|
||||||
|
db_result
|
||||||
|
}
|
||||||
|
Err(_) => LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some("Timeout (120s)".to_string()),
|
||||||
|
search_time_ms: start.elapsed().as_millis(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_database_inner(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
database: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> LookupDatabaseResult {
|
||||||
|
// Find tables that have this column
|
||||||
|
let candidates = match sqlx::query_as::<_, (String, String, String)>(
|
||||||
|
"SELECT table_schema, table_name, data_type \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE column_name = $1 \
|
||||||
|
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit')",
|
||||||
|
)
|
||||||
|
.bind(column_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(schema, table, data_type)| TableCandidate {
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
data_type,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(e) => {
|
||||||
|
return LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some(format!("Schema query failed: {}", e)),
|
||||||
|
search_time_ms: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tables = Vec::new();
|
||||||
|
|
||||||
|
for candidate in &candidates {
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&candidate.schema),
|
||||||
|
escape_ident(&candidate.table)
|
||||||
|
);
|
||||||
|
let col_ident = escape_ident(column_name);
|
||||||
|
|
||||||
|
// Read-only transaction: SELECT rows + COUNT
|
||||||
|
let select_sql = format!(
|
||||||
|
"SELECT * FROM {} WHERE {}::text = $1 LIMIT 50",
|
||||||
|
qualified, col_ident
|
||||||
|
);
|
||||||
|
let count_sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM {} WHERE {}::text = $1",
|
||||||
|
qualified, col_ident
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tx = match pool.begin().await {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(e) => {
|
||||||
|
tables.push(LookupTableMatch {
|
||||||
|
schema: candidate.schema.clone(),
|
||||||
|
table: candidate.table.clone(),
|
||||||
|
column_type: candidate.data_type.clone(),
|
||||||
|
columns: vec![],
|
||||||
|
types: vec![],
|
||||||
|
rows: vec![],
|
||||||
|
row_count: 0,
|
||||||
|
total_count: 0,
|
||||||
|
});
|
||||||
|
log::warn!(
|
||||||
|
"Failed to begin tx for {}.{}: {}",
|
||||||
|
candidate.schema,
|
||||||
|
candidate.table,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
log::warn!("Failed SET TRANSACTION READ ONLY: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows_result = sqlx::query(&select_sql)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let count_result: Result<i64, _> = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
match rows_result {
|
||||||
|
Ok(rows) if !rows.is_empty() => {
|
||||||
|
let mut col_names = Vec::new();
|
||||||
|
let mut col_types = Vec::new();
|
||||||
|
if let Some(first) = rows.first() {
|
||||||
|
for col in first.columns() {
|
||||||
|
col_names.push(col.name().to_string());
|
||||||
|
col_types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_rows: Vec<Vec<serde_json::Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(0..col_names.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = result_rows.len();
|
||||||
|
let total_count = count_result.unwrap_or(row_count as i64);
|
||||||
|
|
||||||
|
tables.push(LookupTableMatch {
|
||||||
|
schema: candidate.schema.clone(),
|
||||||
|
table: candidate.table.clone(),
|
||||||
|
column_type: candidate.data_type.clone(),
|
||||||
|
columns: col_names,
|
||||||
|
types: col_types,
|
||||||
|
rows: result_rows,
|
||||||
|
row_count,
|
||||||
|
total_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// No rows matched — skip
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Query failed for {}.{}: {}",
|
||||||
|
candidate.schema,
|
||||||
|
candidate.table,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables,
|
||||||
|
error: None,
|
||||||
|
search_time_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn entity_lookup(
|
||||||
|
app: AppHandle,
|
||||||
|
config: ConnectionConfig,
|
||||||
|
column_name: String,
|
||||||
|
value: String,
|
||||||
|
databases: Option<Vec<String>>,
|
||||||
|
lookup_id: String,
|
||||||
|
) -> TuskResult<EntityLookupResult> {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// 1. Get list of databases
|
||||||
|
let url = config.connection_url();
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::TuskError::Database)?;
|
||||||
|
|
||||||
|
let db_names: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::TuskError::Database)?;
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
|
||||||
|
// Filter if specific databases requested
|
||||||
|
let db_names: Vec<String> = if let Some(ref filter) = databases {
|
||||||
|
db_names
|
||||||
|
.into_iter()
|
||||||
|
.filter(|d| filter.contains(d))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
db_names
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = db_names.len();
|
||||||
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
|
let semaphore = Arc::new(Semaphore::new(5));
|
||||||
|
|
||||||
|
// 2. Parallel search across databases
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for db_name in db_names {
|
||||||
|
let config = config.clone();
|
||||||
|
let column_name = column_name.clone();
|
||||||
|
let value = value.clone();
|
||||||
|
let lookup_id = lookup_id.clone();
|
||||||
|
let app = app.clone();
|
||||||
|
let semaphore = semaphore.clone();
|
||||||
|
let completed = completed.clone();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _permit = semaphore.acquire().await.unwrap();
|
||||||
|
|
||||||
|
// Emit "searching" progress
|
||||||
|
let _ = app.emit(
|
||||||
|
"lookup-progress",
|
||||||
|
LookupProgress {
|
||||||
|
lookup_id: lookup_id.clone(),
|
||||||
|
database: db_name.clone(),
|
||||||
|
status: "searching".to_string(),
|
||||||
|
tables_found: 0,
|
||||||
|
rows_found: 0,
|
||||||
|
error: None,
|
||||||
|
completed: completed.load(Ordering::Relaxed),
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = search_database(&config, &db_name, &column_name, &value).await;
|
||||||
|
|
||||||
|
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
|
||||||
|
let status = if result.error.is_some() {
|
||||||
|
"error"
|
||||||
|
} else {
|
||||||
|
"done"
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"lookup-progress",
|
||||||
|
LookupProgress {
|
||||||
|
lookup_id: lookup_id.clone(),
|
||||||
|
database: db_name.clone(),
|
||||||
|
status: status.to_string(),
|
||||||
|
tables_found: result.tables.len(),
|
||||||
|
rows_found: result.tables.iter().map(|t| t.row_count).sum(),
|
||||||
|
error: result.error.clone(),
|
||||||
|
completed: done,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Collect results
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
match handle.await {
|
||||||
|
Ok(result) => all_results.push(result),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Join error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: databases with matches first, then by name
|
||||||
|
all_results.sort_by(|a, b| {
|
||||||
|
let a_has = !a.tables.is_empty();
|
||||||
|
let b_has = !b.tables.is_empty();
|
||||||
|
b_has.cmp(&a_has).then(a.database.cmp(&b.database))
|
||||||
|
});
|
||||||
|
|
||||||
|
let total_databases_searched = all_results.len();
|
||||||
|
let total_tables_matched: usize = all_results.iter().map(|d| d.tables.len()).sum();
|
||||||
|
let total_rows_found: usize = all_results
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| d.tables.iter())
|
||||||
|
.map(|t| t.row_count)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
Ok(EntityLookupResult {
|
||||||
|
column_name,
|
||||||
|
value,
|
||||||
|
databases: all_results,
|
||||||
|
total_databases_searched,
|
||||||
|
total_tables_matched,
|
||||||
|
total_rows_found,
|
||||||
|
total_time_ms: start.elapsed().as_millis(),
|
||||||
|
})
|
||||||
|
}
|
||||||
594
src-tauri/src/commands/management.rs
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::management::*;
|
||||||
|
use crate::state::{AppState, DbFlavor};
|
||||||
|
use crate::utils::escape_ident;
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_database_info(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<DatabaseInfo>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT d.datname, \
|
||||||
|
pg_catalog.pg_get_userbyid(d.datdba) AS owner, \
|
||||||
|
pg_catalog.pg_encoding_to_char(d.encoding) AS encoding, \
|
||||||
|
d.datcollate, \
|
||||||
|
d.datctype, \
|
||||||
|
COALESCE(t.spcname, 'pg_default') AS tablespace, \
|
||||||
|
d.datconnlimit, \
|
||||||
|
pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.datname)) AS size, \
|
||||||
|
pg_catalog.shobj_description(d.oid, 'pg_database') AS description \
|
||||||
|
FROM pg_catalog.pg_database d \
|
||||||
|
LEFT JOIN pg_catalog.pg_tablespace t ON d.dattablespace = t.oid \
|
||||||
|
WHERE NOT d.datistemplate \
|
||||||
|
ORDER BY d.datname",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let databases = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| DatabaseInfo {
|
||||||
|
name: row.get("datname"),
|
||||||
|
owner: row.get("owner"),
|
||||||
|
encoding: row.get("encoding"),
|
||||||
|
collation: row.get("datcollate"),
|
||||||
|
ctype: row.get("datctype"),
|
||||||
|
tablespace: row.get("tablespace"),
|
||||||
|
connection_limit: row.get("datconnlimit"),
|
||||||
|
size: row.get("size"),
|
||||||
|
description: row.get("description"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(databases)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_database(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
params: CreateDatabaseParams,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let mut sql = format!("CREATE DATABASE {}", escape_ident(¶ms.name));
|
||||||
|
|
||||||
|
if let Some(ref owner) = params.owner {
|
||||||
|
sql.push_str(&format!(" OWNER {}", escape_ident(owner)));
|
||||||
|
}
|
||||||
|
if let Some(ref template) = params.template {
|
||||||
|
sql.push_str(&format!(" TEMPLATE {}", escape_ident(template)));
|
||||||
|
}
|
||||||
|
if let Some(ref encoding) = params.encoding {
|
||||||
|
sql.push_str(&format!(" ENCODING '{}'", encoding.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(ref tablespace) = params.tablespace {
|
||||||
|
sql.push_str(&format!(" TABLESPACE {}", escape_ident(tablespace)));
|
||||||
|
}
|
||||||
|
if let Some(limit) = params.connection_limit {
|
||||||
|
sql.push_str(&format!(" CONNECTION LIMIT {}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn drop_database(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
name: String,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
// Terminate active connections to the target database
|
||||||
|
sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1::name AND pid <> pg_backend_pid()")
|
||||||
|
.bind(&name)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let drop_sql = format!("DROP DATABASE {}", escape_ident(&name));
|
||||||
|
sqlx::query(&drop_sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_roles(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<RoleInfo>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT r.rolname, \
|
||||||
|
r.rolsuper, \
|
||||||
|
r.rolcanlogin, \
|
||||||
|
r.rolcreatedb, \
|
||||||
|
r.rolcreaterole, \
|
||||||
|
r.rolinherit, \
|
||||||
|
r.rolreplication, \
|
||||||
|
r.rolconnlimit, \
|
||||||
|
r.rolpassword IS NOT NULL AS password_set, \
|
||||||
|
r.rolvaliduntil::text, \
|
||||||
|
COALESCE(( \
|
||||||
|
SELECT array_agg(g.rolname ORDER BY g.rolname) \
|
||||||
|
FROM pg_catalog.pg_auth_members m \
|
||||||
|
JOIN pg_catalog.pg_roles g ON m.roleid = g.oid \
|
||||||
|
WHERE m.member = r.oid \
|
||||||
|
), ARRAY[]::text[]) AS member_of, \
|
||||||
|
COALESCE(( \
|
||||||
|
SELECT array_agg(m2.rolname ORDER BY m2.rolname) \
|
||||||
|
FROM pg_catalog.pg_auth_members am \
|
||||||
|
JOIN pg_catalog.pg_roles m2 ON am.member = m2.oid \
|
||||||
|
WHERE am.roleid = r.oid \
|
||||||
|
), ARRAY[]::text[]) AS members, \
|
||||||
|
pg_catalog.shobj_description(r.oid, 'pg_authid') AS description \
|
||||||
|
FROM pg_catalog.pg_roles r \
|
||||||
|
WHERE r.rolname !~ '^pg_' \
|
||||||
|
ORDER BY r.rolname",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let roles = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| RoleInfo {
|
||||||
|
name: row.get("rolname"),
|
||||||
|
is_superuser: row.get("rolsuper"),
|
||||||
|
can_login: row.get("rolcanlogin"),
|
||||||
|
can_create_db: row.get("rolcreatedb"),
|
||||||
|
can_create_role: row.get("rolcreaterole"),
|
||||||
|
inherit: row.get("rolinherit"),
|
||||||
|
is_replication: row.get("rolreplication"),
|
||||||
|
connection_limit: row.get("rolconnlimit"),
|
||||||
|
password_set: row.get("password_set"),
|
||||||
|
valid_until: row.get("rolvaliduntil"),
|
||||||
|
member_of: row.get("member_of"),
|
||||||
|
members: row.get("members"),
|
||||||
|
description: row.get("description"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_role(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
params: CreateRoleParams,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let mut sql = format!("CREATE ROLE {}", escape_ident(¶ms.name));
|
||||||
|
|
||||||
|
let mut options = Vec::new();
|
||||||
|
options.push(if params.login { "LOGIN" } else { "NOLOGIN" });
|
||||||
|
options.push(if params.superuser {
|
||||||
|
"SUPERUSER"
|
||||||
|
} else {
|
||||||
|
"NOSUPERUSER"
|
||||||
|
});
|
||||||
|
options.push(if params.createdb {
|
||||||
|
"CREATEDB"
|
||||||
|
} else {
|
||||||
|
"NOCREATEDB"
|
||||||
|
});
|
||||||
|
options.push(if params.createrole {
|
||||||
|
"CREATEROLE"
|
||||||
|
} else {
|
||||||
|
"NOCREATEROLE"
|
||||||
|
});
|
||||||
|
options.push(if params.inherit {
|
||||||
|
"INHERIT"
|
||||||
|
} else {
|
||||||
|
"NOINHERIT"
|
||||||
|
});
|
||||||
|
options.push(if params.replication {
|
||||||
|
"REPLICATION"
|
||||||
|
} else {
|
||||||
|
"NOREPLICATION"
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref password) = params.password {
|
||||||
|
options.push("PASSWORD");
|
||||||
|
// Will be appended separately
|
||||||
|
sql.push_str(&format!(" {}", options.join(" ")));
|
||||||
|
sql.push_str(&format!(" '{}'", password.replace('\'', "''")));
|
||||||
|
} else {
|
||||||
|
sql.push_str(&format!(" {}", options.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(limit) = params.connection_limit {
|
||||||
|
sql.push_str(&format!(" CONNECTION LIMIT {}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref valid_until) = params.valid_until {
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" VALID UNTIL '{}'",
|
||||||
|
valid_until.replace('\'', "''")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.in_roles.is_empty() {
|
||||||
|
let roles: Vec<String> = params.in_roles.iter().map(|r| escape_ident(r)).collect();
|
||||||
|
sql.push_str(&format!(" IN ROLE {}", roles.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn alter_role(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
params: AlterRoleParams,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let mut options = Vec::new();
|
||||||
|
|
||||||
|
if let Some(login) = params.login {
|
||||||
|
options.push(if login {
|
||||||
|
"LOGIN".to_string()
|
||||||
|
} else {
|
||||||
|
"NOLOGIN".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(superuser) = params.superuser {
|
||||||
|
options.push(if superuser {
|
||||||
|
"SUPERUSER".to_string()
|
||||||
|
} else {
|
||||||
|
"NOSUPERUSER".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(createdb) = params.createdb {
|
||||||
|
options.push(if createdb {
|
||||||
|
"CREATEDB".to_string()
|
||||||
|
} else {
|
||||||
|
"NOCREATEDB".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(createrole) = params.createrole {
|
||||||
|
options.push(if createrole {
|
||||||
|
"CREATEROLE".to_string()
|
||||||
|
} else {
|
||||||
|
"NOCREATEROLE".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(inherit) = params.inherit {
|
||||||
|
options.push(if inherit {
|
||||||
|
"INHERIT".to_string()
|
||||||
|
} else {
|
||||||
|
"NOINHERIT".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(replication) = params.replication {
|
||||||
|
options.push(if replication {
|
||||||
|
"REPLICATION".to_string()
|
||||||
|
} else {
|
||||||
|
"NOREPLICATION".to_string()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(ref password) = params.password {
|
||||||
|
options.push(format!("PASSWORD '{}'", password.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
if let Some(limit) = params.connection_limit {
|
||||||
|
options.push(format!("CONNECTION LIMIT {}", limit));
|
||||||
|
}
|
||||||
|
if let Some(ref valid_until) = params.valid_until {
|
||||||
|
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !options.is_empty() {
|
||||||
|
let sql = format!(
|
||||||
|
"ALTER ROLE {} {}",
|
||||||
|
escape_ident(¶ms.name),
|
||||||
|
options.join(" ")
|
||||||
|
);
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref new_name) = params.rename_to {
|
||||||
|
let sql = format!(
|
||||||
|
"ALTER ROLE {} RENAME TO {}",
|
||||||
|
escape_ident(¶ms.name),
|
||||||
|
escape_ident(new_name)
|
||||||
|
);
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn drop_role(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
name: String,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let sql = format!("DROP ROLE {}", escape_ident(&name));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_privileges(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
) -> TuskResult<Vec<TablePrivilege>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT grantee, table_schema, table_name, privilege_type, \
|
||||||
|
is_grantable = 'YES' AS is_grantable \
|
||||||
|
FROM information_schema.role_table_grants \
|
||||||
|
WHERE table_schema = $1 AND table_name = $2 \
|
||||||
|
ORDER BY grantee, privilege_type",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.bind(&table)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let privileges = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| TablePrivilege {
|
||||||
|
grantee: row.get("grantee"),
|
||||||
|
table_schema: row.get("table_schema"),
|
||||||
|
table_name: row.get("table_name"),
|
||||||
|
privilege_type: row.get("privilege_type"),
|
||||||
|
is_grantable: row.get("is_grantable"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(privileges)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn grant_revoke(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
params: GrantRevokeParams,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let privs = params.privileges.join(", ");
|
||||||
|
let object_type = params.object_type.to_uppercase();
|
||||||
|
let object_ref = escape_ident(¶ms.object_name);
|
||||||
|
let role_ref = escape_ident(¶ms.role_name);
|
||||||
|
|
||||||
|
let sql = if params.action.to_uppercase() == "GRANT" {
|
||||||
|
let grant_option = if params.with_grant_option {
|
||||||
|
" WITH GRANT OPTION"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"GRANT {} ON {} {} TO {}{}",
|
||||||
|
privs, object_type, object_ref, role_ref, grant_option
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"REVOKE {} ON {} {} FROM {}",
|
||||||
|
privs, object_type, object_ref, role_ref
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn manage_role_membership(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
params: RoleMembershipParams,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
if state.is_read_only(&connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let role_ref = escape_ident(¶ms.role_name);
|
||||||
|
let member_ref = escape_ident(¶ms.member_name);
|
||||||
|
|
||||||
|
let sql = if params.action.to_uppercase() == "GRANT" {
|
||||||
|
format!("GRANT {} TO {}", role_ref, member_ref)
|
||||||
|
} else {
|
||||||
|
format!("REVOKE {} FROM {}", role_ref, member_ref)
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_sessions(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<SessionInfo>> {
|
||||||
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
|
"SELECT pid, usename, datname, state, query, \
|
||||||
|
query_start::text, NULL::text as wait_event_type, NULL::text as wait_event, \
|
||||||
|
client_addr::text \
|
||||||
|
FROM pg_stat_activity \
|
||||||
|
WHERE datname IS NOT NULL \
|
||||||
|
ORDER BY query_start DESC NULLS LAST"
|
||||||
|
} else {
|
||||||
|
"SELECT pid, usename, datname, state, query, \
|
||||||
|
query_start::text, wait_event_type, wait_event, \
|
||||||
|
client_addr::text \
|
||||||
|
FROM pg_stat_activity \
|
||||||
|
WHERE datname IS NOT NULL \
|
||||||
|
ORDER BY query_start DESC NULLS LAST"
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = sqlx::query(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let sessions = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| SessionInfo {
|
||||||
|
pid: row.get("pid"),
|
||||||
|
usename: row.get("usename"),
|
||||||
|
datname: row.get("datname"),
|
||||||
|
state: row.get("state"),
|
||||||
|
query: row.get("query"),
|
||||||
|
query_start: row.get("query_start"),
|
||||||
|
wait_event_type: row.get("wait_event_type"),
|
||||||
|
wait_event: row.get("wait_event"),
|
||||||
|
client_addr: row.get("client_addr"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cancel_query(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
pid: i32,
|
||||||
|
) -> TuskResult<bool> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT pg_cancel_backend($1)")
|
||||||
|
.bind(pid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(row.get::<bool, _>(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn terminate_backend(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
pid: i32,
|
||||||
|
) -> TuskResult<bool> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT pg_terminate_backend($1)")
|
||||||
|
.bind(pid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(row.get::<bool, _>(0))
|
||||||
|
}
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
//! Per-connection long-term memory for the chat agent (F1).
|
|
||||||
//!
|
|
||||||
//! Stored as a markdown file at `<app_data_dir>/memory/<connection_id>.md`.
|
|
||||||
//! The agent appends notes via the `remember` tool; the user can view and edit
|
|
||||||
//! the file in the Memory sidebar tab. The same content is injected into the
|
|
||||||
//! LEARNED NOTES section of the system prompt every turn.
|
|
||||||
|
|
||||||
use crate::error::{TuskError, TuskResult};
|
|
||||||
use chrono::Utc;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
|
|
||||||
/// Soft cap on memory file size. Overflow drops oldest `## ts` blocks until fits.
|
|
||||||
pub const MEMORY_BYTE_CAP: usize = 16 * 1024;
|
|
||||||
|
|
||||||
pub(crate) fn get_memory_path(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<PathBuf> {
|
|
||||||
let dir = app
|
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|e| TuskError::Config(e.to_string()))?
|
|
||||||
.join("memory");
|
|
||||||
fs::create_dir_all(&dir)?;
|
|
||||||
let safe = sanitize_connection_id(connection_id);
|
|
||||||
Ok(dir.join(format!("{}.md", safe)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_connection_id(id: &str) -> String {
|
|
||||||
id.chars()
|
|
||||||
.map(|c| match c {
|
|
||||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
|
|
||||||
_ => '_',
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_memory_core(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<String> {
|
|
||||||
let path = get_memory_path(app, connection_id)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
Ok(fs::read_to_string(&path)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn write_memory_core(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
let path = get_memory_path(app, connection_id)?;
|
|
||||||
let trimmed = enforce_size_cap(content, MEMORY_BYTE_CAP);
|
|
||||||
fs::write(&path, trimmed)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_memory_core(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
note: &str,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
let trimmed_note = note.trim();
|
|
||||||
if trimmed_note.is_empty() {
|
|
||||||
return Err(TuskError::Custom("remember: note must not be empty".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing = read_memory_core(app, connection_id)?;
|
|
||||||
let mut buf = String::new();
|
|
||||||
if existing.is_empty() {
|
|
||||||
buf.push_str("# Memory\n\n");
|
|
||||||
} else {
|
|
||||||
buf.push_str(&existing);
|
|
||||||
if !buf.ends_with('\n') {
|
|
||||||
buf.push('\n');
|
|
||||||
}
|
|
||||||
if !buf.ends_with("\n\n") {
|
|
||||||
buf.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
|
||||||
buf.push_str(&format!("## {}\n{}\n", ts, trimmed_note));
|
|
||||||
|
|
||||||
let final_content = enforce_size_cap(&buf, MEMORY_BYTE_CAP);
|
|
||||||
let path = get_memory_path(app, connection_id)?;
|
|
||||||
fs::write(&path, final_content)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trim the file from the *oldest* note (top) until it fits within `cap` bytes.
|
|
||||||
/// Always preserves the trailing notes (the most recent observations). Keeps
|
|
||||||
/// the leading `# Memory\n\n` header if present.
|
|
||||||
pub(crate) fn enforce_size_cap(content: &str, cap: usize) -> String {
|
|
||||||
if content.len() <= cap {
|
|
||||||
return content.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let header = if content.starts_with("# Memory") {
|
|
||||||
match content.find("\n## ") {
|
|
||||||
Some(pos) => &content[..pos + 1],
|
|
||||||
None => "# Memory\n\n",
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
// Split into note blocks by "\n## " marker.
|
|
||||||
// First block (after header) might lack the leading "## " — handle uniformly.
|
|
||||||
let body_start = header.len();
|
|
||||||
let body = &content[body_start..];
|
|
||||||
|
|
||||||
let mut blocks: Vec<&str> = Vec::new();
|
|
||||||
let mut idx = 0;
|
|
||||||
while idx < body.len() {
|
|
||||||
// Find the next "\n## " starting at idx; if not found, the rest is one block.
|
|
||||||
let rel = body[idx..].find("\n## ");
|
|
||||||
match rel {
|
|
||||||
Some(r) => {
|
|
||||||
blocks.push(&body[idx..idx + r + 1]); // include trailing newline before next block
|
|
||||||
idx = idx + r + 1; // start of "## "
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
blocks.push(&body[idx..]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop blocks from the front until total fits.
|
|
||||||
let mut current_size: usize = header.len() + blocks.iter().map(|b| b.len()).sum::<usize>();
|
|
||||||
let mut start = 0usize;
|
|
||||||
while current_size > cap && start < blocks.len() {
|
|
||||||
current_size -= blocks[start].len();
|
|
||||||
start += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = String::with_capacity(current_size);
|
|
||||||
out.push_str(header);
|
|
||||||
for b in &blocks[start..] {
|
|
||||||
out.push_str(b);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort delete of a connection's memory file. Returns Ok(()) when the
|
|
||||||
/// file doesn't exist; only surfaces an error for actual filesystem failures.
|
|
||||||
/// Used by delete_connection to keep memory/ from filling up with orphan files.
|
|
||||||
pub(crate) fn delete_memory_core(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
let path = get_memory_path(app, connection_id)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
fs::remove_file(&path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
|
|
||||||
read_memory_core(&app, &connection_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_memory(
|
|
||||||
app: AppHandle,
|
|
||||||
connection_id: String,
|
|
||||||
content: String,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
write_memory_core(&app, &connection_id, &content)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cap_passthrough_under_limit() {
|
|
||||||
let small = "# Memory\n\n## 2026-01-01T00:00:00Z\nshort note\n";
|
|
||||||
assert_eq!(enforce_size_cap(small, MEMORY_BYTE_CAP), small);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cap_drops_oldest_blocks() {
|
|
||||||
// 3 blocks of ~6KB each -> 18KB total > 16KB cap
|
|
||||||
let block_body = "x".repeat(6000);
|
|
||||||
let content = format!(
|
|
||||||
"# Memory\n\n## 2026-01-01T00:00:00Z\n{body}\n## 2026-02-01T00:00:00Z\n{body}\n## 2026-03-01T00:00:00Z\n{body}\n",
|
|
||||||
body = block_body
|
|
||||||
);
|
|
||||||
assert!(content.len() > MEMORY_BYTE_CAP);
|
|
||||||
let trimmed = enforce_size_cap(&content, MEMORY_BYTE_CAP);
|
|
||||||
assert!(trimmed.len() <= MEMORY_BYTE_CAP);
|
|
||||||
// Most recent block must survive.
|
|
||||||
assert!(trimmed.contains("2026-03-01T00:00:00Z"));
|
|
||||||
// Oldest must be dropped.
|
|
||||||
assert!(!trimmed.contains("2026-01-01T00:00:00Z"));
|
|
||||||
// Header preserved.
|
|
||||||
assert!(trimmed.starts_with("# Memory"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cap_keeps_only_latest_when_single_block_huge() {
|
|
||||||
let block_body = "y".repeat(20_000);
|
|
||||||
let content = format!(
|
|
||||||
"# Memory\n\n## 2026-01-01T00:00:00Z\n{}\n",
|
|
||||||
block_body
|
|
||||||
);
|
|
||||||
let trimmed = enforce_size_cap(&content, MEMORY_BYTE_CAP);
|
|
||||||
// Even after dropping that single block we keep at least the header,
|
|
||||||
// so the result is just the header (or close to it).
|
|
||||||
assert!(trimmed.starts_with("# Memory"));
|
|
||||||
assert!(trimmed.len() <= MEMORY_BYTE_CAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_strips_path_chars() {
|
|
||||||
assert_eq!(sanitize_connection_id("abc/../etc"), "abc____etc");
|
|
||||||
assert_eq!(
|
|
||||||
sanitize_connection_id("cf9feefd-59ab-4a7c"),
|
|
||||||
"cf9feefd-59ab-4a7c"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod chat;
|
|
||||||
pub mod chat_tools;
|
|
||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
|
pub mod docker;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod memory;
|
pub mod lookup;
|
||||||
|
pub mod management;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::db::sql_guard::ensure_readonly_sql;
|
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::query_result::QueryResult;
|
use crate::models::query_result::QueryResult;
|
||||||
use crate::state::{AppState, DbFlavor};
|
use crate::state::AppState;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::postgres::PgRow;
|
use sqlx::postgres::PgRow;
|
||||||
use sqlx::{Column, Row, TypeInfo};
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
@@ -44,11 +43,6 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
|||||||
}
|
}
|
||||||
"DATE" => try_get!(chrono::NaiveDate),
|
"DATE" => try_get!(chrono::NaiveDate),
|
||||||
"TIME" => try_get!(chrono::NaiveTime),
|
"TIME" => try_get!(chrono::NaiveTime),
|
||||||
"INTERVAL" => match row.try_get::<Option<sqlx::postgres::types::PgInterval>, _>(index) {
|
|
||||||
Ok(Some(v)) => return Value::String(format_pg_interval(&v)),
|
|
||||||
Ok(None) => return Value::Null,
|
|
||||||
Err(_) => {}
|
|
||||||
},
|
|
||||||
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||||
Ok(None) => return Value::Null,
|
Ok(None) => return Value::Null,
|
||||||
@@ -81,60 +75,12 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a PostgreSQL INTERVAL the same way `psql` does:
|
|
||||||
/// "1 year 2 mons 3 days 04:05:06.789012"
|
|
||||||
/// Components are emitted only when non-zero; the time component fires
|
|
||||||
/// whenever microseconds != 0 OR everything else is zero.
|
|
||||||
fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String {
|
|
||||||
let years = iv.months / 12;
|
|
||||||
let months = iv.months % 12;
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
|
||||||
if years != 0 {
|
|
||||||
parts.push(format!("{} year{}", years, if years.abs() == 1 { "" } else { "s" }));
|
|
||||||
}
|
|
||||||
if months != 0 {
|
|
||||||
parts.push(format!("{} mon{}", months, if months.abs() == 1 { "" } else { "s" }));
|
|
||||||
}
|
|
||||||
if iv.days != 0 {
|
|
||||||
parts.push(format!("{} day{}", iv.days, if iv.days.abs() == 1 { "" } else { "s" }));
|
|
||||||
}
|
|
||||||
if iv.microseconds != 0 || parts.is_empty() {
|
|
||||||
let total_us = iv.microseconds.unsigned_abs();
|
|
||||||
let total_seconds = total_us / 1_000_000;
|
|
||||||
let micros = (total_us % 1_000_000) as u32;
|
|
||||||
let h = total_seconds / 3600;
|
|
||||||
let m = (total_seconds / 60) % 60;
|
|
||||||
let s = total_seconds % 60;
|
|
||||||
let sign = if iv.microseconds < 0 { "-" } else { "" };
|
|
||||||
let time_part = if micros == 0 {
|
|
||||||
format!("{}{:02}:{:02}:{:02}", sign, h, m, s)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}{:02}:{:02}:{:02}.{:06}",
|
|
||||||
sign, h, m, s, micros
|
|
||||||
)
|
|
||||||
};
|
|
||||||
parts.push(time_part);
|
|
||||||
}
|
|
||||||
parts.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute_query_core(
|
pub async fn execute_query_core(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
) -> TuskResult<QueryResult> {
|
) -> TuskResult<QueryResult> {
|
||||||
let read_only = state.is_read_only(connection_id).await;
|
let read_only = state.is_read_only(connection_id).await;
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
|
||||||
|
|
||||||
if read_only {
|
|
||||||
ensure_readonly_sql(sql)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
return client.execute_query(sql, read_only).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pools = state.pools.read().await;
|
let pools = state.pools.read().await;
|
||||||
let pool = pools
|
let pool = pools
|
||||||
@@ -160,7 +106,7 @@ pub async fn execute_query_core(
|
|||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?
|
.map_err(TuskError::Database)?
|
||||||
};
|
};
|
||||||
let execution_time_ms = start.elapsed().as_millis() as u64;
|
let execution_time_ms = start.elapsed().as_millis();
|
||||||
|
|
||||||
let mut columns = Vec::new();
|
let mut columns = Vec::new();
|
||||||
let mut types = Vec::new();
|
let mut types = Vec::new();
|
||||||
@@ -200,57 +146,3 @@ pub async fn execute_query(
|
|||||||
) -> TuskResult<QueryResult> {
|
) -> TuskResult<QueryResult> {
|
||||||
execute_query_core(&state, &connection_id, &sql).await
|
execute_query_core(&state, &connection_id, &sql).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::format_pg_interval;
|
|
||||||
use sqlx::postgres::types::PgInterval;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_zero_renders_as_zero_time() {
|
|
||||||
let iv = PgInterval { months: 0, days: 0, microseconds: 0 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "00:00:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_pure_time_micros() {
|
|
||||||
// 1h 30m
|
|
||||||
let iv = PgInterval { months: 0, days: 0, microseconds: 90 * 60 * 1_000_000 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "01:30:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_days_only() {
|
|
||||||
let iv = PgInterval { months: 0, days: 3, microseconds: 0 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "3 days");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_one_day() {
|
|
||||||
let iv = PgInterval { months: 0, days: 1, microseconds: 0 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "1 day");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_mixed_components() {
|
|
||||||
// 1 year 2 mons 3 days 04:05:06
|
|
||||||
let iv = PgInterval {
|
|
||||||
months: 14,
|
|
||||||
days: 3,
|
|
||||||
microseconds: ((4 * 3600) + (5 * 60) + 6) * 1_000_000,
|
|
||||||
};
|
|
||||||
assert_eq!(format_pg_interval(&iv), "1 year 2 mons 3 days 04:05:06");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_negative_time() {
|
|
||||||
let iv = PgInterval { months: 0, days: 0, microseconds: -3_600_000_000 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "-01:00:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interval_with_microseconds_fraction() {
|
|
||||||
let iv = PgInterval { months: 0, days: 0, microseconds: 1_500_000 };
|
|
||||||
assert_eq!(format_pg_interval(&iv), "00:00:01.500000");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
Ok(dir.join("saved_queries.json"))
|
Ok(dir.join("saved_queries.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_saved_queries_core(
|
#[tauri::command]
|
||||||
app: &AppHandle,
|
pub async fn list_saved_queries(
|
||||||
search: Option<&str>,
|
app: AppHandle,
|
||||||
|
search: Option<String>,
|
||||||
) -> TuskResult<Vec<SavedQuery>> {
|
) -> TuskResult<Vec<SavedQuery>> {
|
||||||
let path = get_saved_queries_path(app)?;
|
let path = get_saved_queries_path(&app)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,7 @@ pub(crate) async fn list_saved_queries_core(
|
|||||||
let filtered: Vec<SavedQuery> = entries
|
let filtered: Vec<SavedQuery> = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if let Some(s) = search {
|
if let Some(ref s) = search {
|
||||||
let lower = s.to_lowercase();
|
let lower = s.to_lowercase();
|
||||||
e.name.to_lowercase().contains(&lower) || e.sql.to_lowercase().contains(&lower)
|
e.name.to_lowercase().contains(&lower) || e.sql.to_lowercase().contains(&lower)
|
||||||
} else {
|
} else {
|
||||||
@@ -38,8 +39,9 @@ pub(crate) async fn list_saved_queries_core(
|
|||||||
Ok(filtered)
|
Ok(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskResult<()> {
|
#[tauri::command]
|
||||||
let path = get_saved_queries_path(app)?;
|
pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> {
|
||||||
|
let path = get_saved_queries_path(&app)?;
|
||||||
let mut entries = if path.exists() {
|
let mut entries = if path.exists() {
|
||||||
let data = fs::read_to_string(&path)?;
|
let data = fs::read_to_string(&path)?;
|
||||||
serde_json::from_str::<Vec<SavedQuery>>(&data).unwrap_or_default()
|
serde_json::from_str::<Vec<SavedQuery>>(&data).unwrap_or_default()
|
||||||
@@ -54,42 +56,6 @@ pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskR
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop every saved-query entry tied to a specific connection_id. Entries with
|
|
||||||
/// `connection_id == None` (older "global" saves) are deliberately preserved
|
|
||||||
/// so they remain visible across all connections.
|
|
||||||
pub(crate) async fn delete_by_connection_core(
|
|
||||||
app: &AppHandle,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<()> {
|
|
||||||
let path = get_saved_queries_path(app)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let data = fs::read_to_string(&path)?;
|
|
||||||
let mut entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
|
|
||||||
let before = entries.len();
|
|
||||||
entries.retain(|e| e.connection_id.as_deref() != Some(connection_id));
|
|
||||||
if entries.len() == before {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let data = serde_json::to_string_pretty(&entries)?;
|
|
||||||
fs::write(&path, data)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_saved_queries(
|
|
||||||
app: AppHandle,
|
|
||||||
search: Option<String>,
|
|
||||||
) -> TuskResult<Vec<SavedQuery>> {
|
|
||||||
list_saved_queries_core(&app, search.as_deref()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> {
|
|
||||||
save_query_core(&app, query).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_saved_query(app: AppHandle, id: String) -> TuskResult<()> {
|
pub async fn delete_saved_query(app: AppHandle, id: String) -> TuskResult<()> {
|
||||||
let path = get_saved_queries_path(&app)?;
|
let path = get_saved_queries_path(&app)?;
|
||||||
|
|||||||
@@ -1,53 +1,20 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::schema::{
|
use crate::models::schema::{
|
||||||
ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject, TriggerInfo,
|
ColumnDetail, ColumnInfo, ConstraintInfo, ErdColumn, ErdData, ErdRelationship, ErdTable,
|
||||||
|
IndexInfo, SchemaObject, TriggerInfo,
|
||||||
};
|
};
|
||||||
use crate::state::{AppState, DbFlavor};
|
use crate::state::{AppState, DbFlavor};
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
fn ch_string_literal(s: &str) -> String {
|
#[tauri::command]
|
||||||
let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
|
pub async fn list_databases(
|
||||||
format!("'{}'", escaped)
|
state: State<'_, Arc<AppState>>,
|
||||||
}
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<String>> {
|
||||||
fn ch_obj_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
obj.get(key).and_then(|v| match v {
|
|
||||||
Value::String(s) => Some(s.clone()),
|
|
||||||
Value::Number(n) => Some(n.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ch_obj_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
|
|
||||||
obj.get(key).and_then(|v| match v {
|
|
||||||
Value::Number(n) => n.as_i64(),
|
|
||||||
Value::String(s) => s.parse::<i64>().ok(),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
let rows = client
|
|
||||||
.fetch_objects(
|
|
||||||
"SELECT name FROM system.databases \
|
|
||||||
WHERE name NOT IN ('system','INFORMATION_SCHEMA','information_schema') \
|
|
||||||
ORDER BY name",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
return Ok(rows
|
|
||||||
.iter()
|
|
||||||
.filter_map(|o| ch_obj_string(o, "name"))
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT datname FROM pg_database \
|
"SELECT datname FROM pg_database \
|
||||||
@@ -61,24 +28,10 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskR
|
|||||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_databases(
|
|
||||||
state: State<'_, Arc<AppState>>,
|
|
||||||
connection_id: String,
|
|
||||||
) -> TuskResult<Vec<String>> {
|
|
||||||
list_databases_core(&state, &connection_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
// ClickHouse has no schema layer — surface the active database as a virtual schema.
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
return Ok(vec![client.database.clone()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
"SELECT schema_name FROM information_schema.schemata \
|
"SELECT schema_name FROM information_schema.schemata \
|
||||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
||||||
@@ -110,29 +63,6 @@ pub async fn list_tables_core(
|
|||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
schema: &str,
|
schema: &str,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
let escaped = ch_string_literal(schema);
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT name, total_rows, total_bytes FROM system.tables \
|
|
||||||
WHERE database = {} AND engine NOT LIKE '%View' \
|
|
||||||
ORDER BY name",
|
|
||||||
escaped
|
|
||||||
);
|
|
||||||
let rows = client.fetch_objects(&sql).await?;
|
|
||||||
return Ok(rows
|
|
||||||
.iter()
|
|
||||||
.map(|o| SchemaObject {
|
|
||||||
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
||||||
object_type: "table".to_string(),
|
|
||||||
schema: schema.to_string(),
|
|
||||||
row_count: ch_obj_i64(o, "total_rows"),
|
|
||||||
size_bytes: ch_obj_i64(o, "total_bytes"),
|
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -177,28 +107,6 @@ pub async fn list_views(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(&connection_id).await?;
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT name FROM system.tables \
|
|
||||||
WHERE database = {} AND engine LIKE '%View' \
|
|
||||||
ORDER BY name",
|
|
||||||
ch_string_literal(&schema)
|
|
||||||
);
|
|
||||||
let rows = client.fetch_objects(&sql).await?;
|
|
||||||
return Ok(rows
|
|
||||||
.iter()
|
|
||||||
.map(|o| SchemaObject {
|
|
||||||
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
||||||
object_type: "view".to_string(),
|
|
||||||
schema: schema.clone(),
|
|
||||||
row_count: None,
|
|
||||||
size_bytes: None,
|
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -229,11 +137,6 @@ pub async fn list_functions(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
// ClickHouse functions are global, not schema-scoped — surface empty here.
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -264,10 +167,6 @@ pub async fn list_indexes(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -298,10 +197,6 @@ pub async fn list_sequences(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -332,36 +227,6 @@ pub async fn get_table_columns_core(
|
|||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
) -> TuskResult<Vec<ColumnInfo>> {
|
) -> TuskResult<Vec<ColumnInfo>> {
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(connection_id).await?;
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT name, type, default_expression, is_in_primary_key, comment, position \
|
|
||||||
FROM system.columns WHERE database = {} AND table = {} \
|
|
||||||
ORDER BY position",
|
|
||||||
ch_string_literal(schema),
|
|
||||||
ch_string_literal(table)
|
|
||||||
);
|
|
||||||
let rows = client.fetch_objects(&sql).await?;
|
|
||||||
return Ok(rows
|
|
||||||
.iter()
|
|
||||||
.map(|o| {
|
|
||||||
let type_str = ch_obj_string(o, "type").unwrap_or_default();
|
|
||||||
let is_nullable = type_str.starts_with("Nullable(");
|
|
||||||
ColumnInfo {
|
|
||||||
name: ch_obj_string(o, "name").unwrap_or_default(),
|
|
||||||
data_type: type_str,
|
|
||||||
is_nullable,
|
|
||||||
column_default: ch_obj_string(o, "default_expression"),
|
|
||||||
ordinal_position: ch_obj_i64(o, "position").unwrap_or(0) as i32,
|
|
||||||
character_maximum_length: None,
|
|
||||||
is_primary_key: ch_obj_i64(o, "is_in_primary_key").unwrap_or(0) != 0,
|
|
||||||
comment: ch_obj_string(o, "comment"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -431,10 +296,6 @@ pub async fn get_table_constraints(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<ConstraintInfo>> {
|
) -> TuskResult<Vec<ConstraintInfo>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -511,10 +372,6 @@ pub async fn get_table_indexes(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<IndexInfo>> {
|
) -> TuskResult<Vec<IndexInfo>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -553,25 +410,6 @@ pub async fn get_completion_schema(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let client = state.get_ch_client(&connection_id).await?;
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT database, table, name FROM system.columns \
|
|
||||||
WHERE database = {} \
|
|
||||||
ORDER BY database, table, position",
|
|
||||||
ch_string_literal(&client.database)
|
|
||||||
);
|
|
||||||
let rows = client.fetch_objects(&sql).await?;
|
|
||||||
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
|
||||||
for row in rows {
|
|
||||||
let db = ch_obj_string(&row, "database").unwrap_or_default();
|
|
||||||
let table = ch_obj_string(&row, "table").unwrap_or_default();
|
|
||||||
let column = ch_obj_string(&row, "name").unwrap_or_default();
|
|
||||||
result.entry(db).or_default().entry(table).or_default().push(column);
|
|
||||||
}
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
@@ -616,19 +454,6 @@ pub async fn get_column_details(
|
|||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<ColumnDetail>> {
|
) -> TuskResult<Vec<ColumnDetail>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
let columns = get_table_columns_core(&state, &connection_id, &schema, &table).await?;
|
|
||||||
return Ok(columns
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| ColumnDetail {
|
|
||||||
column_name: c.name,
|
|
||||||
data_type: c.data_type,
|
|
||||||
is_nullable: c.is_nullable,
|
|
||||||
column_default: c.column_default,
|
|
||||||
is_identity: false,
|
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
@@ -675,10 +500,6 @@ pub async fn get_table_triggers(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<TriggerInfo>> {
|
) -> TuskResult<Vec<TriggerInfo>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
|
||||||
if matches!(flavor, DbFlavor::ClickHouse) {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -726,3 +547,127 @@ pub async fn get_table_triggers(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_schema_erd(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<ErdData> {
|
||||||
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
|
// Get all tables with columns
|
||||||
|
let col_rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
c.table_name, \
|
||||||
|
c.column_name, \
|
||||||
|
c.data_type, \
|
||||||
|
c.is_nullable = 'YES' AS is_nullable, \
|
||||||
|
COALESCE(( \
|
||||||
|
SELECT true FROM pg_constraint con \
|
||||||
|
JOIN pg_class cl ON cl.oid = con.conrelid \
|
||||||
|
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
|
||||||
|
WHERE con.contype = 'p' \
|
||||||
|
AND ns.nspname = $1 AND cl.relname = c.table_name \
|
||||||
|
AND EXISTS ( \
|
||||||
|
SELECT 1 FROM unnest(con.conkey) k \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k \
|
||||||
|
WHERE a.attname = c.column_name \
|
||||||
|
) \
|
||||||
|
LIMIT 1 \
|
||||||
|
), false) AS is_pk \
|
||||||
|
FROM information_schema.columns c \
|
||||||
|
JOIN information_schema.tables t \
|
||||||
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
|
||||||
|
WHERE c.table_schema = $1 AND t.table_type = 'BASE TABLE' \
|
||||||
|
ORDER BY c.table_name, c.ordinal_position",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// Build tables map
|
||||||
|
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
|
||||||
|
for row in &col_rows {
|
||||||
|
let table_name: String = row.get(0);
|
||||||
|
let entry = tables_map
|
||||||
|
.entry(table_name.clone())
|
||||||
|
.or_insert_with(|| ErdTable {
|
||||||
|
schema: schema.clone(),
|
||||||
|
name: table_name,
|
||||||
|
columns: Vec::new(),
|
||||||
|
});
|
||||||
|
entry.columns.push(ErdColumn {
|
||||||
|
name: row.get(1),
|
||||||
|
data_type: row.get(2),
|
||||||
|
is_nullable: row.get(3),
|
||||||
|
is_primary_key: row.get(4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let tables: Vec<ErdTable> = tables_map.into_values().collect();
|
||||||
|
|
||||||
|
// Get all FK relationships
|
||||||
|
let fk_rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
c.conname AS constraint_name, \
|
||||||
|
src_ns.nspname AS source_schema, \
|
||||||
|
src_cl.relname AS source_table, \
|
||||||
|
ARRAY( \
|
||||||
|
SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum \
|
||||||
|
ORDER BY k.ord \
|
||||||
|
)::text[] AS source_columns, \
|
||||||
|
ref_ns.nspname AS target_schema, \
|
||||||
|
ref_cl.relname AS target_table, \
|
||||||
|
ARRAY( \
|
||||||
|
SELECT a.attname FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum \
|
||||||
|
ORDER BY k.ord \
|
||||||
|
)::text[] AS target_columns, \
|
||||||
|
CASE c.confupdtype \
|
||||||
|
WHEN 'a' THEN 'NO ACTION' \
|
||||||
|
WHEN 'r' THEN 'RESTRICT' \
|
||||||
|
WHEN 'c' THEN 'CASCADE' \
|
||||||
|
WHEN 'n' THEN 'SET NULL' \
|
||||||
|
WHEN 'd' THEN 'SET DEFAULT' \
|
||||||
|
END AS update_rule, \
|
||||||
|
CASE c.confdeltype \
|
||||||
|
WHEN 'a' THEN 'NO ACTION' \
|
||||||
|
WHEN 'r' THEN 'RESTRICT' \
|
||||||
|
WHEN 'c' THEN 'CASCADE' \
|
||||||
|
WHEN 'n' THEN 'SET NULL' \
|
||||||
|
WHEN 'd' THEN 'SET DEFAULT' \
|
||||||
|
END AS delete_rule \
|
||||||
|
FROM pg_constraint c \
|
||||||
|
JOIN pg_class src_cl ON src_cl.oid = c.conrelid \
|
||||||
|
JOIN pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace \
|
||||||
|
JOIN pg_class ref_cl ON ref_cl.oid = c.confrelid \
|
||||||
|
JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace \
|
||||||
|
WHERE c.contype = 'f' AND src_ns.nspname = $1 \
|
||||||
|
ORDER BY c.conname",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let relationships: Vec<ErdRelationship> = fk_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| ErdRelationship {
|
||||||
|
constraint_name: r.get(0),
|
||||||
|
source_schema: r.get(1),
|
||||||
|
source_table: r.get(2),
|
||||||
|
source_columns: r.get(3),
|
||||||
|
target_schema: r.get(4),
|
||||||
|
target_table: r.get(5),
|
||||||
|
target_columns: r.get(6),
|
||||||
|
update_rule: r.get(7),
|
||||||
|
delete_rule: r.get(8),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ErdData {
|
||||||
|
tables,
|
||||||
|
relationships,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::mcp;
|
use crate::mcp;
|
||||||
use crate::models::settings::{AppSettings, McpStatus};
|
use crate::models::settings::{AppSettings, DockerHost, McpStatus};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -36,6 +36,15 @@ pub async fn save_app_settings(
|
|||||||
let data = serde_json::to_string_pretty(&settings)?;
|
let data = serde_json::to_string_pretty(&settings)?;
|
||||||
fs::write(&path, data)?;
|
fs::write(&path, data)?;
|
||||||
|
|
||||||
|
// Apply docker host setting
|
||||||
|
{
|
||||||
|
let mut docker_host = state.docker_host.write().await;
|
||||||
|
*docker_host = match settings.docker.host {
|
||||||
|
DockerHost::Remote => settings.docker.remote_url.clone(),
|
||||||
|
DockerHost::Local => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Apply MCP setting: restart or stop
|
// Apply MCP setting: restart or stop
|
||||||
let is_running = *state.mcp_running.read().await;
|
let is_running = *state.mcp_running.read().await;
|
||||||
|
|
||||||
|
|||||||
362
src-tauri/src/commands/snapshot.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
use crate::commands::ai::fetch_foreign_keys_raw;
|
||||||
|
use crate::commands::data::bind_json_value;
|
||||||
|
use crate::commands::queries::pg_value_to_json;
|
||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::snapshot::{
|
||||||
|
CreateSnapshotParams, RestoreSnapshotParams, Snapshot, SnapshotMetadata, SnapshotProgress,
|
||||||
|
SnapshotTableData, SnapshotTableMeta,
|
||||||
|
};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::utils::{escape_ident, topological_sort_tables};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_snapshot(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
params: CreateSnapshotParams,
|
||||||
|
snapshot_id: String,
|
||||||
|
file_path: String,
|
||||||
|
) -> TuskResult<SnapshotMetadata> {
|
||||||
|
let pool = state.get_pool(¶ms.connection_id).await?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "preparing".to_string(),
|
||||||
|
percent: 5,
|
||||||
|
message: "Preparing snapshot...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut target_tables: Vec<(String, String)> = params
|
||||||
|
.tables
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.schema.clone(), t.table.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Fetch FK info once — used for both dependency expansion and topological sort
|
||||||
|
let fk_rows = fetch_foreign_keys_raw(&pool).await?;
|
||||||
|
|
||||||
|
if params.include_dependencies {
|
||||||
|
for fk in &fk_rows {
|
||||||
|
if target_tables
|
||||||
|
.iter()
|
||||||
|
.any(|(s, t)| s == &fk.schema && t == &fk.table)
|
||||||
|
{
|
||||||
|
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
||||||
|
if !target_tables.contains(&parent) {
|
||||||
|
target_tables.push(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK-based topological sort
|
||||||
|
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
||||||
|
.iter()
|
||||||
|
.map(|fk| {
|
||||||
|
(
|
||||||
|
fk.schema.clone(),
|
||||||
|
fk.table.clone(),
|
||||||
|
fk.ref_schema.clone(),
|
||||||
|
fk.ref_table.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let total_tables = sorted_tables.len();
|
||||||
|
let mut snapshot_tables: Vec<SnapshotTableData> = Vec::new();
|
||||||
|
let mut table_metas: Vec<SnapshotTableMeta> = Vec::new();
|
||||||
|
let mut total_rows: u64 = 0;
|
||||||
|
|
||||||
|
for (i, (schema, table)) in sorted_tables.iter().enumerate() {
|
||||||
|
let percent = (10 + (i * 80 / total_tables.max(1))).min(90) as u8;
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "exporting".to_string(),
|
||||||
|
percent,
|
||||||
|
message: format!("Exporting {}.{}...", schema, table),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(schema), escape_ident(table));
|
||||||
|
let sql = format!("SELECT * FROM {}", qualified);
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut column_types = Vec::new();
|
||||||
|
|
||||||
|
if let Some(first) = rows.first() {
|
||||||
|
for col in first.columns() {
|
||||||
|
columns.push(col.name().to_string());
|
||||||
|
column_types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_rows: Vec<Vec<Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = data_rows.len() as u64;
|
||||||
|
total_rows += row_count;
|
||||||
|
|
||||||
|
table_metas.push(SnapshotTableMeta {
|
||||||
|
schema: schema.clone(),
|
||||||
|
table: table.clone(),
|
||||||
|
row_count,
|
||||||
|
columns: columns.clone(),
|
||||||
|
column_types: column_types.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
snapshot_tables.push(SnapshotTableData {
|
||||||
|
schema: schema.clone(),
|
||||||
|
table: table.clone(),
|
||||||
|
columns,
|
||||||
|
column_types,
|
||||||
|
rows: data_rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.rollback().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let metadata = SnapshotMetadata {
|
||||||
|
id: snapshot_id.clone(),
|
||||||
|
name: params.name.clone(),
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
connection_name: String::new(),
|
||||||
|
database: String::new(),
|
||||||
|
tables: table_metas,
|
||||||
|
total_rows,
|
||||||
|
file_size_bytes: 0,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = Snapshot {
|
||||||
|
metadata: metadata.clone(),
|
||||||
|
tables: snapshot_tables,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "saving".to_string(),
|
||||||
|
percent: 95,
|
||||||
|
message: "Saving snapshot file...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&snapshot)?;
|
||||||
|
let file_size = json.len() as u64;
|
||||||
|
fs::write(&file_path, json)?;
|
||||||
|
|
||||||
|
let mut final_metadata = metadata;
|
||||||
|
final_metadata.file_size_bytes = file_size;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "done".to_string(),
|
||||||
|
percent: 100,
|
||||||
|
message: "Snapshot created successfully".to_string(),
|
||||||
|
detail: Some(format!("{} rows, {} tables", total_rows, total_tables)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(final_metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_snapshot(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
params: RestoreSnapshotParams,
|
||||||
|
snapshot_id: String,
|
||||||
|
) -> TuskResult<u64> {
|
||||||
|
if state.is_read_only(¶ms.connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "reading".to_string(),
|
||||||
|
percent: 5,
|
||||||
|
message: "Reading snapshot file...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = fs::read_to_string(¶ms.file_path)?;
|
||||||
|
let snapshot: Snapshot = serde_json::from_str(&data)?;
|
||||||
|
|
||||||
|
let pool = state.get_pool(¶ms.connection_id).await?;
|
||||||
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// TRUNCATE in reverse order (children first)
|
||||||
|
if params.truncate_before_restore {
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "truncating".to_string(),
|
||||||
|
percent: 15,
|
||||||
|
message: "Truncating existing data...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for table_data in snapshot.tables.iter().rev() {
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&table_data.schema),
|
||||||
|
escape_ident(&table_data.table)
|
||||||
|
);
|
||||||
|
let truncate_sql = format!("TRUNCATE {} CASCADE", qualified);
|
||||||
|
sqlx::query(&truncate_sql)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT in forward order (parents first)
|
||||||
|
let total_tables = snapshot.tables.len();
|
||||||
|
let mut total_inserted: u64 = 0;
|
||||||
|
|
||||||
|
for (i, table_data) in snapshot.tables.iter().enumerate() {
|
||||||
|
if table_data.columns.is_empty() || table_data.rows.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let percent = (20 + (i * 75 / total_tables.max(1))).min(95) as u8;
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "inserting".to_string(),
|
||||||
|
percent,
|
||||||
|
message: format!("Restoring {}.{}...", table_data.schema, table_data.table),
|
||||||
|
detail: Some(format!("{} rows", table_data.rows.len())),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&table_data.schema),
|
||||||
|
escape_ident(&table_data.table)
|
||||||
|
);
|
||||||
|
let col_list: Vec<String> = table_data.columns.iter().map(|c| escape_ident(c)).collect();
|
||||||
|
let placeholders: Vec<String> = (1..=table_data.columns.len())
|
||||||
|
.map(|i| format!("${}", i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({})",
|
||||||
|
qualified,
|
||||||
|
col_list.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chunked insert
|
||||||
|
for row in &table_data.rows {
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for val in row {
|
||||||
|
query = bind_json_value(query, val);
|
||||||
|
}
|
||||||
|
query.execute(&mut *tx).await.map_err(TuskError::Database)?;
|
||||||
|
total_inserted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "done".to_string(),
|
||||||
|
percent: 100,
|
||||||
|
message: "Restore completed successfully".to_string(),
|
||||||
|
detail: Some(format!("{} rows restored", total_inserted)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
state.invalidate_schema_cache(¶ms.connection_id).await;
|
||||||
|
|
||||||
|
Ok(total_inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| TuskError::Config(e.to_string()))?
|
||||||
|
.join("snapshots");
|
||||||
|
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
if let Ok(data) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(snapshot) = serde_json::from_str::<Snapshot>(&data) {
|
||||||
|
let mut meta = snapshot.metadata;
|
||||||
|
meta.file_size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
snapshots.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
Ok(snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_snapshot_metadata(file_path: String) -> TuskResult<SnapshotMetadata> {
|
||||||
|
let data = fs::read_to_string(&file_path)?;
|
||||||
|
let snapshot: Snapshot = serde_json::from_str(&data)?;
|
||||||
|
let mut meta = snapshot.metadata;
|
||||||
|
meta.file_size_bytes = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
|
||||||
use crate::models::query_result::QueryResult;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
|
||||||
|
|
||||||
fn http_client() -> &'static reqwest::Client {
|
|
||||||
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(5))
|
|
||||||
.timeout(DEFAULT_TIMEOUT)
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default()
|
|
||||||
});
|
|
||||||
&CLIENT
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ChClient {
|
|
||||||
pub base_url: String,
|
|
||||||
pub user: String,
|
|
||||||
pub password: String,
|
|
||||||
pub database: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChClient {
|
|
||||||
pub fn new(host: &str, port: u16, secure: bool, user: &str, password: &str, database: &str) -> Self {
|
|
||||||
let scheme = if secure { "https" } else { "http" };
|
|
||||||
let base_url = format!("{}://{}:{}", scheme, host, port);
|
|
||||||
Self {
|
|
||||||
base_url,
|
|
||||||
user: user.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
database: database.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn endpoint(&self, database: Option<&str>, format: Option<&str>, read_only: bool) -> String {
|
|
||||||
let db = database.unwrap_or(&self.database);
|
|
||||||
let mut params = vec![
|
|
||||||
format!("database={}", urlencode(db)),
|
|
||||||
format!("user={}", urlencode(&self.user)),
|
|
||||||
];
|
|
||||||
if !self.password.is_empty() {
|
|
||||||
params.push(format!("password={}", urlencode(&self.password)));
|
|
||||||
}
|
|
||||||
if let Some(fmt) = format {
|
|
||||||
params.push(format!("default_format={}", urlencode(fmt)));
|
|
||||||
}
|
|
||||||
if read_only {
|
|
||||||
params.push("readonly=1".to_string());
|
|
||||||
}
|
|
||||||
format!("{}/?{}", self.base_url, params.join("&"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute SQL and return raw response body.
|
|
||||||
pub async fn execute_raw(&self, sql: &str, format: Option<&str>, read_only: bool) -> TuskResult<String> {
|
|
||||||
let url = self.endpoint(None, format, read_only);
|
|
||||||
let resp = http_client()
|
|
||||||
.post(&url)
|
|
||||||
.body(sql.to_string())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| TuskError::Custom(format!("ClickHouse request failed: {}", e)))?;
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| TuskError::Custom(format!("Failed to read ClickHouse response: {}", e)))?;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(TuskError::Custom(format!(
|
|
||||||
"ClickHouse error ({}): {}",
|
|
||||||
status,
|
|
||||||
body.trim()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test connection by running `SELECT 1` and return the server version.
|
|
||||||
pub async fn ping(&self) -> TuskResult<String> {
|
|
||||||
// Use raw FORMAT TabSeparated to fetch version
|
|
||||||
let body = self.execute_raw("SELECT version()", Some("TabSeparated"), false).await?;
|
|
||||||
Ok(body.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute SQL and parse rows via JSONCompact to preserve column metadata + types.
|
|
||||||
pub async fn execute_query(&self, sql: &str, read_only: bool) -> TuskResult<QueryResult> {
|
|
||||||
let start = Instant::now();
|
|
||||||
let body = self.execute_raw(sql, Some("JSONCompact"), read_only).await?;
|
|
||||||
let execution_time_ms = start.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
// Empty body for statements without result set (DDL etc.) — return zero rows
|
|
||||||
if body.trim().is_empty() {
|
|
||||||
return Ok(QueryResult {
|
|
||||||
columns: vec![],
|
|
||||||
types: vec![],
|
|
||||||
rows: vec![],
|
|
||||||
row_count: 0,
|
|
||||||
execution_time_ms,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: ChJsonCompactResponse = serde_json::from_str(&body).map_err(|e| {
|
|
||||||
TuskError::Custom(format!(
|
|
||||||
"Failed to parse ClickHouse JSONCompact response: {} (body head: {})",
|
|
||||||
e,
|
|
||||||
body.chars().take(200).collect::<String>()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let columns: Vec<String> = parsed.meta.iter().map(|m| m.name.clone()).collect();
|
|
||||||
let types: Vec<String> = parsed.meta.iter().map(|m| m.r#type.clone()).collect();
|
|
||||||
let row_count = parsed.data.len();
|
|
||||||
|
|
||||||
Ok(QueryResult {
|
|
||||||
columns,
|
|
||||||
types,
|
|
||||||
rows: parsed.data,
|
|
||||||
row_count,
|
|
||||||
execution_time_ms,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute SQL expecting result rows as objects (for schema introspection helpers).
|
|
||||||
pub async fn fetch_objects(&self, sql: &str) -> TuskResult<Vec<Map<String, Value>>> {
|
|
||||||
let body = self.execute_raw(sql, Some("JSONEachRow"), false).await?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for line in body.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let value: Value = serde_json::from_str(line).map_err(|e| {
|
|
||||||
TuskError::Custom(format!("Failed to parse JSONEachRow line: {}", e))
|
|
||||||
})?;
|
|
||||||
if let Value::Object(obj) = value {
|
|
||||||
out.push(obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ChJsonCompactResponse {
|
|
||||||
meta: Vec<ChMetaEntry>,
|
|
||||||
data: Vec<Vec<Value>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ChMetaEntry {
|
|
||||||
name: String,
|
|
||||||
r#type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn urlencode(s: &str) -> String {
|
|
||||||
s.chars()
|
|
||||||
.map(|c| match c {
|
|
||||||
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
|
|
||||||
| '+' | ',' | ';' | '=' | '%' | ' ' => format!("%{:02X}", c as u8),
|
|
||||||
_ => c.to_string(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod clickhouse;
|
|
||||||
pub mod sql_guard;
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
|
||||||
|
|
||||||
/// Cross-flavor whitelist guard for read-only SQL execution.
|
|
||||||
/// Allows: SELECT, WITH ... SELECT, SHOW, EXPLAIN, DESCRIBE.
|
|
||||||
/// Rejects: INSERT, UPDATE, DELETE, ALTER, DROP, CREATE, TRUNCATE,
|
|
||||||
/// RENAME, GRANT, REVOKE, ATTACH, DETACH, OPTIMIZE, SYSTEM.
|
|
||||||
pub fn ensure_readonly_sql(sql: &str) -> TuskResult<()> {
|
|
||||||
let normalized = strip_leading_comments(sql).to_ascii_uppercase();
|
|
||||||
let trimmed = normalized.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(TuskError::Validation("Empty SQL statement".into()));
|
|
||||||
}
|
|
||||||
let allowed_starts = ["SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC ", "DESC\n", "VALUES"];
|
|
||||||
let starts_ok = allowed_starts
|
|
||||||
.iter()
|
|
||||||
.any(|p| trimmed.starts_with(p) || trimmed == p.trim());
|
|
||||||
if !starts_ok {
|
|
||||||
return Err(TuskError::ReadOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if any forbidden keyword appears as a top-level token
|
|
||||||
let forbidden = [
|
|
||||||
"INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "CREATE", "TRUNCATE",
|
|
||||||
"RENAME", "GRANT", "REVOKE", "ATTACH", "DETACH", "OPTIMIZE", "SYSTEM",
|
|
||||||
"REPLACE", "MERGE",
|
|
||||||
];
|
|
||||||
for kw in forbidden {
|
|
||||||
if contains_keyword(&normalized, kw) {
|
|
||||||
return Err(TuskError::ReadOnly);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_leading_comments(sql: &str) -> &str {
|
|
||||||
let mut s = sql.trim_start();
|
|
||||||
loop {
|
|
||||||
if let Some(rest) = s.strip_prefix("--") {
|
|
||||||
// line comment — skip to newline
|
|
||||||
match rest.find('\n') {
|
|
||||||
Some(idx) => s = rest[idx + 1..].trim_start(),
|
|
||||||
None => return "",
|
|
||||||
}
|
|
||||||
} else if let Some(rest) = s.strip_prefix("/*") {
|
|
||||||
match rest.find("*/") {
|
|
||||||
Some(idx) => s = rest[idx + 2..].trim_start(),
|
|
||||||
None => return "",
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn contains_keyword(haystack: &str, kw: &str) -> bool {
|
|
||||||
let bytes = haystack.as_bytes();
|
|
||||||
let needle = kw.as_bytes();
|
|
||||||
let mut i = 0;
|
|
||||||
while i + needle.len() <= bytes.len() {
|
|
||||||
if &bytes[i..i + needle.len()] == needle {
|
|
||||||
let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
|
|
||||||
let after = i + needle.len();
|
|
||||||
let after_ok = after == bytes.len() || !is_word_char(bytes[after]);
|
|
||||||
if before_ok && after_ok {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_word_char(b: u8) -> bool {
|
|
||||||
b.is_ascii_alphanumeric() || b == b'_'
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allows_select() {
|
|
||||||
assert!(ensure_readonly_sql("SELECT 1").is_ok());
|
|
||||||
assert!(ensure_readonly_sql(" SELECT * FROM t").is_ok());
|
|
||||||
assert!(ensure_readonly_sql("select 1").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allows_with_select() {
|
|
||||||
assert!(ensure_readonly_sql("WITH x AS (SELECT 1) SELECT * FROM x").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allows_explain_show() {
|
|
||||||
assert!(ensure_readonly_sql("EXPLAIN SELECT 1").is_ok());
|
|
||||||
assert!(ensure_readonly_sql("SHOW TABLES").is_ok());
|
|
||||||
assert!(ensure_readonly_sql("DESCRIBE t").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_dml_ddl() {
|
|
||||||
assert!(ensure_readonly_sql("INSERT INTO t VALUES (1)").is_err());
|
|
||||||
assert!(ensure_readonly_sql("UPDATE t SET a=1").is_err());
|
|
||||||
assert!(ensure_readonly_sql("DELETE FROM t").is_err());
|
|
||||||
assert!(ensure_readonly_sql("DROP TABLE t").is_err());
|
|
||||||
assert!(ensure_readonly_sql("CREATE TABLE t(a int)").is_err());
|
|
||||||
assert!(ensure_readonly_sql("TRUNCATE TABLE t").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_writable_cte() {
|
|
||||||
// PG writable CTE — looks like WITH but contains INSERT
|
|
||||||
assert!(
|
|
||||||
ensure_readonly_sql("WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x")
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_select_with_drop_chain() {
|
|
||||||
assert!(ensure_readonly_sql("SELECT 1; DROP TABLE t").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allows_select_with_keyword_in_string() {
|
|
||||||
// Real-world: column names containing forbidden keywords should pass; string literals
|
|
||||||
// containing them should also pass. Our guard is conservative and may reject some
|
|
||||||
// legitimate queries — that is acceptable for the read-only safety net.
|
|
||||||
// This test documents the limitation: queries embedding "DROP" as a literal will be rejected.
|
|
||||||
// The user can disable read-only mode to run them.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strips_leading_comments() {
|
|
||||||
assert!(ensure_readonly_sql("-- comment\nSELECT 1").is_ok());
|
|
||||||
assert!(ensure_readonly_sql("/* block */ SELECT 1").is_ok());
|
|
||||||
assert!(ensure_readonly_sql("/* multi\nline */\n SELECT 1").is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,9 @@ pub enum TuskError {
|
|||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serde(#[from] serde_json::Error),
|
Serde(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Connection not found: {0}")]
|
||||||
|
ConnectionNotFound(String),
|
||||||
|
|
||||||
#[error("Not connected: {0}")]
|
#[error("Not connected: {0}")]
|
||||||
NotConnected(String),
|
NotConnected(String),
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ pub enum TuskError {
|
|||||||
#[error("AI error: {0}")]
|
#[error("AI error: {0}")]
|
||||||
Ai(String),
|
Ai(String),
|
||||||
|
|
||||||
|
#[error("Docker error: {0}")]
|
||||||
|
Docker(String),
|
||||||
|
|
||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
|
||||||
mod error;
|
mod error;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod models;
|
mod models;
|
||||||
mod state;
|
mod state;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use models::settings::AppSettings;
|
use models::settings::{AppSettings, DockerHost};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -38,9 +37,21 @@ pub fn run() {
|
|||||||
AppSettings::default()
|
AppSettings::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Apply docker host from settings
|
||||||
|
let docker_host = match settings.docker.host {
|
||||||
|
DockerHost::Remote => settings.docker.remote_url.clone(),
|
||||||
|
DockerHost::Local => None,
|
||||||
|
};
|
||||||
|
|
||||||
let mcp_enabled = settings.mcp.enabled;
|
let mcp_enabled = settings.mcp.enabled;
|
||||||
let mcp_port = settings.mcp.port;
|
let mcp_port = settings.mcp.port;
|
||||||
|
|
||||||
|
// Set docker host synchronously (state is fresh, no contention)
|
||||||
|
let state_for_setup = state.clone();
|
||||||
|
tauri::async_runtime::block_on(async {
|
||||||
|
*state_for_setup.docker_host.write().await = docker_host;
|
||||||
|
});
|
||||||
|
|
||||||
if mcp_enabled {
|
if mcp_enabled {
|
||||||
let shutdown_rx = state.mcp_shutdown_tx.subscribe();
|
let shutdown_rx = state.mcp_shutdown_tx.subscribe();
|
||||||
let mcp_state = state.clone();
|
let mcp_state = state.clone();
|
||||||
@@ -90,6 +101,7 @@ pub fn run() {
|
|||||||
commands::schema::get_completion_schema,
|
commands::schema::get_completion_schema,
|
||||||
commands::schema::get_column_details,
|
commands::schema::get_column_details,
|
||||||
commands::schema::get_table_triggers,
|
commands::schema::get_table_triggers,
|
||||||
|
commands::schema::get_schema_erd,
|
||||||
// data
|
// data
|
||||||
commands::data::get_table_data,
|
commands::data::get_table_data,
|
||||||
commands::data::update_row,
|
commands::data::update_row,
|
||||||
@@ -98,6 +110,20 @@ pub fn run() {
|
|||||||
// export
|
// export
|
||||||
commands::export::export_csv,
|
commands::export::export_csv,
|
||||||
commands::export::export_json,
|
commands::export::export_json,
|
||||||
|
// management
|
||||||
|
commands::management::get_database_info,
|
||||||
|
commands::management::create_database,
|
||||||
|
commands::management::drop_database,
|
||||||
|
commands::management::list_roles,
|
||||||
|
commands::management::create_role,
|
||||||
|
commands::management::alter_role,
|
||||||
|
commands::management::drop_role,
|
||||||
|
commands::management::get_table_privileges,
|
||||||
|
commands::management::grant_revoke,
|
||||||
|
commands::management::manage_role_membership,
|
||||||
|
commands::management::list_sessions,
|
||||||
|
commands::management::cancel_query,
|
||||||
|
commands::management::terminate_backend,
|
||||||
// history
|
// history
|
||||||
commands::history::add_history_entry,
|
commands::history::add_history_entry,
|
||||||
commands::history::get_history,
|
commands::history::get_history,
|
||||||
@@ -110,14 +136,30 @@ pub fn run() {
|
|||||||
commands::ai::get_ai_settings,
|
commands::ai::get_ai_settings,
|
||||||
commands::ai::save_ai_settings,
|
commands::ai::save_ai_settings,
|
||||||
commands::ai::list_ollama_models,
|
commands::ai::list_ollama_models,
|
||||||
commands::ai::list_fireworks_models,
|
commands::ai::generate_sql,
|
||||||
commands::ai::list_openrouter_models,
|
commands::ai::explain_sql,
|
||||||
// chat
|
commands::ai::fix_sql_error,
|
||||||
commands::chat::chat_send,
|
commands::ai::generate_validation_sql,
|
||||||
commands::chat::chat_compact,
|
commands::ai::run_validation_rule,
|
||||||
// memory
|
commands::ai::suggest_validation_rules,
|
||||||
commands::memory::get_memory,
|
commands::ai::generate_test_data_preview,
|
||||||
commands::memory::save_memory,
|
commands::ai::insert_generated_data,
|
||||||
|
commands::ai::get_index_advisor_report,
|
||||||
|
commands::ai::apply_index_recommendation,
|
||||||
|
// snapshot
|
||||||
|
commands::snapshot::create_snapshot,
|
||||||
|
commands::snapshot::restore_snapshot,
|
||||||
|
commands::snapshot::list_snapshots,
|
||||||
|
commands::snapshot::read_snapshot_metadata,
|
||||||
|
// lookup
|
||||||
|
commands::lookup::entity_lookup,
|
||||||
|
// docker
|
||||||
|
commands::docker::check_docker,
|
||||||
|
commands::docker::list_tusk_containers,
|
||||||
|
commands::docker::clone_to_docker,
|
||||||
|
commands::docker::start_container,
|
||||||
|
commands::docker::stop_container,
|
||||||
|
commands::docker::remove_container,
|
||||||
// settings
|
// settings
|
||||||
commands::settings::get_app_settings,
|
commands::settings::get_app_settings,
|
||||||
commands::settings::save_app_settings,
|
commands::settings::save_app_settings,
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AiProvider {
|
pub enum AiProvider {
|
||||||
#[default]
|
#[default]
|
||||||
Ollama,
|
Ollama,
|
||||||
Fireworks,
|
OpenAi,
|
||||||
OpenRouter,
|
Anthropic,
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize a provider string, coercing legacy `openai`/`anthropic` and any
|
|
||||||
/// unknown value to `Ollama`. Keeps existing config files loadable after the
|
|
||||||
/// stub providers were removed.
|
|
||||||
impl<'de> Deserialize<'de> for AiProvider {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
||||||
let s = String::deserialize(d)?;
|
|
||||||
Ok(match s.as_str() {
|
|
||||||
"fireworks" => AiProvider::Fireworks,
|
|
||||||
"openrouter" => AiProvider::OpenRouter,
|
|
||||||
_ => AiProvider::Ollama,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AiSettings {
|
pub struct AiSettings {
|
||||||
pub provider: AiProvider,
|
pub provider: AiProvider,
|
||||||
pub ollama_url: String,
|
pub ollama_url: String,
|
||||||
#[serde(default)]
|
pub openai_api_key: Option<String>,
|
||||||
pub fireworks_api_key: Option<String>,
|
pub anthropic_api_key: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
pub openrouter_api_key: Option<String>,
|
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +23,13 @@ impl Default for AiSettings {
|
|||||||
Self {
|
Self {
|
||||||
provider: AiProvider::Ollama,
|
provider: AiProvider::Ollama,
|
||||||
ollama_url: "http://localhost:11434".to_string(),
|
ollama_url: "http://localhost:11434".to_string(),
|
||||||
fireworks_api_key: None,
|
openai_api_key: None,
|
||||||
openrouter_api_key: None,
|
anthropic_api_key: None,
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic chat message used by all chat providers (Ollama, Fireworks, OpenAI-compatible).
|
|
||||||
/// `{role, content}` shape is identical across these APIs.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OllamaChatMessage {
|
pub struct OllamaChatMessage {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -59,8 +41,6 @@ pub struct OllamaChatRequest {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<OllamaChatMessage>,
|
pub messages: Vec<OllamaChatMessage>,
|
||||||
pub stream: bool,
|
pub stream: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub format: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -73,50 +53,134 @@ pub struct OllamaTagsResponse {
|
|||||||
pub models: Vec<OllamaModel>,
|
pub models: Vec<OllamaModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic chat-model descriptor exposed to the UI dropdown.
|
|
||||||
/// Reused as the return shape for both Ollama and Fireworks model listings.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OllamaModel {
|
pub struct OllamaModel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Wave 1: Validation ---
|
||||||
// OpenAI-compatible chat-completions (Fireworks, OpenRouter)
|
|
||||||
// These request/response shapes are shared by every OpenAI-compatible provider;
|
|
||||||
// the `Fireworks*` names are retained for historical reasons.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FireworksChatRequest {
|
#[serde(rename_all = "snake_case")]
|
||||||
pub model: String,
|
pub enum ValidationStatus {
|
||||||
pub messages: Vec<OllamaChatMessage>,
|
Pending,
|
||||||
pub temperature: f32,
|
Generating,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
Running,
|
||||||
pub response_format: Option<FireworksResponseFormat>,
|
Passed,
|
||||||
|
Failed,
|
||||||
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FireworksResponseFormat {
|
pub struct ValidationRule {
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct FireworksChatResponse {
|
|
||||||
pub choices: Vec<FireworksChoice>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct FireworksChoice {
|
|
||||||
pub message: OllamaChatMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct FireworksModelsResponse {
|
|
||||||
pub data: Vec<FireworksModelEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct FireworksModelEntry {
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub generated_sql: String,
|
||||||
|
pub status: ValidationStatus,
|
||||||
|
pub violation_count: u64,
|
||||||
|
pub sample_violations: Vec<Vec<serde_json::Value>>,
|
||||||
|
pub violation_columns: Vec<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wave 2: Data Generator ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GenerateDataParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub row_count: u32,
|
||||||
|
pub include_related: bool,
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeneratedDataPreview {
|
||||||
|
pub tables: Vec<GeneratedTableData>,
|
||||||
|
pub insert_order: Vec<String>,
|
||||||
|
pub total_rows: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeneratedTableData {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
pub row_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DataGenProgress {
|
||||||
|
pub gen_id: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wave 3A: Index Advisor ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TableStats {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub seq_scan: i64,
|
||||||
|
pub idx_scan: i64,
|
||||||
|
pub n_live_tup: i64,
|
||||||
|
pub table_size: String,
|
||||||
|
pub index_size: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexStats {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub index_name: String,
|
||||||
|
pub idx_scan: i64,
|
||||||
|
pub index_size: String,
|
||||||
|
pub definition: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SlowQuery {
|
||||||
|
pub query: String,
|
||||||
|
pub calls: i64,
|
||||||
|
pub total_time_ms: f64,
|
||||||
|
pub mean_time_ms: f64,
|
||||||
|
pub rows: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IndexRecommendationType {
|
||||||
|
#[serde(rename = "create_index")]
|
||||||
|
Create,
|
||||||
|
#[serde(rename = "drop_index")]
|
||||||
|
Drop,
|
||||||
|
#[serde(rename = "replace_index")]
|
||||||
|
Replace,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexRecommendation {
|
||||||
|
pub id: String,
|
||||||
|
pub recommendation_type: IndexRecommendationType,
|
||||||
|
pub table_schema: String,
|
||||||
|
pub table_name: String,
|
||||||
|
pub index_name: Option<String>,
|
||||||
|
pub ddl: String,
|
||||||
|
pub rationale: String,
|
||||||
|
pub estimated_impact: String,
|
||||||
|
pub priority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexAdvisorReport {
|
||||||
|
pub table_stats: Vec<TableStats>,
|
||||||
|
pub index_stats: Vec<IndexStats>,
|
||||||
|
pub slow_queries: Vec<SlowQuery>,
|
||||||
|
pub recommendations: Vec<IndexRecommendation>,
|
||||||
|
pub has_pg_stat_statements: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use crate::models::query_result::QueryResult;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "role", rename_all = "snake_case")]
|
|
||||||
pub enum ChatMessage {
|
|
||||||
User { id: String, text: String, created_at: i64 },
|
|
||||||
Assistant { id: String, text: String, created_at: i64 },
|
|
||||||
ToolCall { id: String, tool: String, input_json: String, created_at: i64 },
|
|
||||||
ToolResult {
|
|
||||||
id: String,
|
|
||||||
tool: String,
|
|
||||||
is_error: bool,
|
|
||||||
text: Option<String>,
|
|
||||||
result: Option<QueryResult>,
|
|
||||||
created_at: i64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approximate model-context budget usage for the current chat thread.
|
|
||||||
/// Measured in characters of the serialized history that we send to the LLM.
|
|
||||||
/// Token estimate ≈ used_chars / 3 for mixed Cyrillic/ASCII content.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ContextUsage {
|
|
||||||
pub used_chars: u64,
|
|
||||||
pub budget_chars: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatTurnResult {
|
|
||||||
pub messages: Vec<ChatMessage>,
|
|
||||||
pub usage: ContextUsage,
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::state::DbFlavor;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -13,17 +12,6 @@ pub struct ConnectionConfig {
|
|||||||
pub ssl_mode: Option<String>,
|
pub ssl_mode: Option<String>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub environment: Option<String>,
|
pub environment: Option<String>,
|
||||||
/// Database flavor selected by the user. Defaults to PostgreSQL for backwards
|
|
||||||
/// compatibility with older `connections.json` files written before multi-DB support.
|
|
||||||
#[serde(default = "default_flavor")]
|
|
||||||
pub db_flavor: DbFlavor,
|
|
||||||
/// HTTPS for ClickHouse. Defaults to false.
|
|
||||||
#[serde(default)]
|
|
||||||
pub secure: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_flavor() -> DbFlavor {
|
|
||||||
DbFlavor::PostgreSQL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionConfig {
|
impl ConnectionConfig {
|
||||||
|
|||||||
57
src-tauri/src/models/docker.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DockerStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub daemon_running: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CloneMode {
|
||||||
|
SchemaOnly,
|
||||||
|
FullClone,
|
||||||
|
SampleData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CloneToDockerParams {
|
||||||
|
pub source_connection_id: String,
|
||||||
|
pub source_database: String,
|
||||||
|
pub container_name: String,
|
||||||
|
pub pg_version: String,
|
||||||
|
pub host_port: Option<u16>,
|
||||||
|
pub clone_mode: CloneMode,
|
||||||
|
pub sample_rows: Option<u32>,
|
||||||
|
pub postgres_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CloneProgress {
|
||||||
|
pub clone_id: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TuskContainer {
|
||||||
|
pub container_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub host_port: u16,
|
||||||
|
pub pg_version: String,
|
||||||
|
pub source_database: Option<String>,
|
||||||
|
pub source_connection: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CloneResult {
|
||||||
|
pub container: TuskContainer,
|
||||||
|
pub connection_id: String,
|
||||||
|
pub connection_url: String,
|
||||||
|
}
|
||||||
44
src-tauri/src/models/lookup.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupTableMatch {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub column_type: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub total_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupDatabaseResult {
|
||||||
|
pub database: String,
|
||||||
|
pub tables: Vec<LookupTableMatch>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub search_time_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EntityLookupResult {
|
||||||
|
pub column_name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub databases: Vec<LookupDatabaseResult>,
|
||||||
|
pub total_databases_searched: usize,
|
||||||
|
pub total_tables_matched: usize,
|
||||||
|
pub total_rows_found: usize,
|
||||||
|
pub total_time_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupProgress {
|
||||||
|
pub lookup_id: String,
|
||||||
|
pub database: String,
|
||||||
|
pub status: String,
|
||||||
|
pub tables_found: usize,
|
||||||
|
pub rows_found: usize,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub completed: usize,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
110
src-tauri/src/models/management.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DatabaseInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub encoding: String,
|
||||||
|
pub collation: String,
|
||||||
|
pub ctype: String,
|
||||||
|
pub tablespace: String,
|
||||||
|
pub connection_limit: i32,
|
||||||
|
pub size: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDatabaseParams {
|
||||||
|
pub name: String,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub template: Option<String>,
|
||||||
|
pub encoding: Option<String>,
|
||||||
|
pub tablespace: Option<String>,
|
||||||
|
pub connection_limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RoleInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub is_superuser: bool,
|
||||||
|
pub can_login: bool,
|
||||||
|
pub can_create_db: bool,
|
||||||
|
pub can_create_role: bool,
|
||||||
|
pub inherit: bool,
|
||||||
|
pub is_replication: bool,
|
||||||
|
pub connection_limit: i32,
|
||||||
|
pub password_set: bool,
|
||||||
|
pub valid_until: Option<String>,
|
||||||
|
pub member_of: Vec<String>,
|
||||||
|
pub members: Vec<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateRoleParams {
|
||||||
|
pub name: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub login: bool,
|
||||||
|
pub superuser: bool,
|
||||||
|
pub createdb: bool,
|
||||||
|
pub createrole: bool,
|
||||||
|
pub inherit: bool,
|
||||||
|
pub replication: bool,
|
||||||
|
pub connection_limit: Option<i32>,
|
||||||
|
pub valid_until: Option<String>,
|
||||||
|
pub in_roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlterRoleParams {
|
||||||
|
pub name: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub login: Option<bool>,
|
||||||
|
pub superuser: Option<bool>,
|
||||||
|
pub createdb: Option<bool>,
|
||||||
|
pub createrole: Option<bool>,
|
||||||
|
pub inherit: Option<bool>,
|
||||||
|
pub replication: Option<bool>,
|
||||||
|
pub connection_limit: Option<i32>,
|
||||||
|
pub valid_until: Option<String>,
|
||||||
|
pub rename_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TablePrivilege {
|
||||||
|
pub grantee: String,
|
||||||
|
pub table_schema: String,
|
||||||
|
pub table_name: String,
|
||||||
|
pub privilege_type: String,
|
||||||
|
pub is_grantable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub pid: i32,
|
||||||
|
pub usename: Option<String>,
|
||||||
|
pub datname: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub query_start: Option<String>,
|
||||||
|
pub wait_event_type: Option<String>,
|
||||||
|
pub wait_event: Option<String>,
|
||||||
|
pub client_addr: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GrantRevokeParams {
|
||||||
|
pub action: String,
|
||||||
|
pub privileges: Vec<String>,
|
||||||
|
pub object_type: String,
|
||||||
|
pub object_name: String,
|
||||||
|
pub role_name: String,
|
||||||
|
pub with_grant_option: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RoleMembershipParams {
|
||||||
|
pub action: String,
|
||||||
|
pub role_name: String,
|
||||||
|
pub member_name: String,
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod chat;
|
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
pub mod docker;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod lookup;
|
||||||
|
pub mod management;
|
||||||
pub mod query_result;
|
pub mod query_result;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
// Tauri's IPC layer does not support u128/i128 in command arguments,
|
|
||||||
// so timings round-trip through frontend → backend as u64 ms.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct QueryResult {
|
pub struct QueryResult {
|
||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
pub types: Vec<String>,
|
pub types: Vec<String>,
|
||||||
pub rows: Vec<Vec<Value>>,
|
pub rows: Vec<Vec<Value>>,
|
||||||
pub row_count: usize,
|
pub row_count: usize,
|
||||||
pub execution_time_ms: u64,
|
pub execution_time_ms: u128,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -18,7 +16,7 @@ pub struct PaginatedQueryResult {
|
|||||||
pub types: Vec<String>,
|
pub types: Vec<String>,
|
||||||
pub rows: Vec<Vec<Value>>,
|
pub rows: Vec<Vec<Value>>,
|
||||||
pub row_count: usize,
|
pub row_count: usize,
|
||||||
pub execution_time_ms: u64,
|
pub execution_time_ms: u128,
|
||||||
pub total_rows: i64,
|
pub total_rows: i64,
|
||||||
pub page: u32,
|
pub page: u32,
|
||||||
pub page_size: u32,
|
pub page_size: u32,
|
||||||
|
|||||||
@@ -60,3 +60,37 @@ pub struct TriggerInfo {
|
|||||||
pub is_enabled: bool,
|
pub is_enabled: bool,
|
||||||
pub definition: String,
|
pub definition: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErdColumn {
|
||||||
|
pub name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
pub is_nullable: bool,
|
||||||
|
pub is_primary_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErdTable {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<ErdColumn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErdRelationship {
|
||||||
|
pub constraint_name: String,
|
||||||
|
pub source_schema: String,
|
||||||
|
pub source_table: String,
|
||||||
|
pub source_columns: Vec<String>,
|
||||||
|
pub target_schema: String,
|
||||||
|
pub target_table: String,
|
||||||
|
pub target_columns: Vec<String>,
|
||||||
|
pub update_rule: String,
|
||||||
|
pub delete_rule: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErdData {
|
||||||
|
pub tables: Vec<ErdTable>,
|
||||||
|
pub relationships: Vec<ErdRelationship>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub mcp: McpSettings,
|
pub mcp: McpSettings,
|
||||||
|
pub docker: DockerSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -20,6 +21,28 @@ impl Default for McpSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DockerSettings {
|
||||||
|
pub host: DockerHost,
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DockerSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: DockerHost::Local,
|
||||||
|
remote_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DockerHost {
|
||||||
|
Local,
|
||||||
|
Remote,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct McpStatus {
|
pub struct McpStatus {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|||||||
68
src-tauri/src/models/snapshot.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotMetadata {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub connection_name: String,
|
||||||
|
pub database: String,
|
||||||
|
pub tables: Vec<SnapshotTableMeta>,
|
||||||
|
pub total_rows: u64,
|
||||||
|
pub file_size_bytes: u64,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotTableMeta {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub row_count: u64,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub column_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub metadata: SnapshotMetadata,
|
||||||
|
pub tables: Vec<SnapshotTableData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotTableData {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub column_types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotProgress {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateSnapshotParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub tables: Vec<TableRef>,
|
||||||
|
pub name: String,
|
||||||
|
pub include_dependencies: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TableRef {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RestoreSnapshotParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub truncate_before_restore: bool,
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
use crate::db::clickhouse::ChClient;
|
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::ai::AiSettings;
|
use crate::models::ai::AiSettings;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::time::{Duration, Instant};
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::{watch, RwLock};
|
use tokio::sync::{watch, RwLock};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -13,67 +11,43 @@ use tokio::sync::{watch, RwLock};
|
|||||||
pub enum DbFlavor {
|
pub enum DbFlavor {
|
||||||
PostgreSQL,
|
PostgreSQL,
|
||||||
Greenplum,
|
Greenplum,
|
||||||
ClickHouse,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CachedString {
|
|
||||||
pub value: String,
|
|
||||||
pub cached_at: Instant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CachedVec<T: Clone> {
|
pub struct SchemaCacheEntry {
|
||||||
pub value: Vec<T>,
|
pub schema_text: String,
|
||||||
pub cached_at: Instant,
|
pub cached_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||||
pub ch_clients: RwLock<HashMap<String, Arc<ChClient>>>,
|
|
||||||
pub read_only: RwLock<HashMap<String, bool>>,
|
pub read_only: RwLock<HashMap<String, bool>>,
|
||||||
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
||||||
/// Greenplum major version (6 or 7), tracked separately because GP6 and GP7
|
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
|
||||||
/// expose very different system catalogs (GP6 = PG9.4 base, GP7 = PG14 base).
|
|
||||||
pub gp_majors: RwLock<HashMap<String, u8>>,
|
|
||||||
/// Chat agent caches: lite overview per connection.
|
|
||||||
pub overview_cache: RwLock<HashMap<String, CachedString>>,
|
|
||||||
/// Chat agent caches: list of tables per (connection_id, db_name) — used for
|
|
||||||
/// list_tables on a non-active PG database via temporary pool.
|
|
||||||
pub tables_by_db_cache: RwLock<HashMap<(String, String), CachedVec<String>>>,
|
|
||||||
pub mcp_shutdown_tx: watch::Sender<bool>,
|
pub mcp_shutdown_tx: watch::Sender<bool>,
|
||||||
pub mcp_running: RwLock<bool>,
|
pub mcp_running: RwLock<bool>,
|
||||||
|
pub docker_host: RwLock<Option<String>>,
|
||||||
pub ai_settings: RwLock<Option<AiSettings>>,
|
pub ai_settings: RwLock<Option<AiSettings>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
||||||
|
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (mcp_shutdown_tx, _) = watch::channel(false);
|
let (mcp_shutdown_tx, _) = watch::channel(false);
|
||||||
Self {
|
Self {
|
||||||
pools: RwLock::new(HashMap::new()),
|
pools: RwLock::new(HashMap::new()),
|
||||||
ch_clients: RwLock::new(HashMap::new()),
|
|
||||||
read_only: RwLock::new(HashMap::new()),
|
read_only: RwLock::new(HashMap::new()),
|
||||||
db_flavors: RwLock::new(HashMap::new()),
|
db_flavors: RwLock::new(HashMap::new()),
|
||||||
gp_majors: RwLock::new(HashMap::new()),
|
schema_cache: RwLock::new(HashMap::new()),
|
||||||
overview_cache: RwLock::new(HashMap::new()),
|
|
||||||
tables_by_db_cache: RwLock::new(HashMap::new()),
|
|
||||||
mcp_shutdown_tx,
|
mcp_shutdown_tx,
|
||||||
mcp_running: RwLock::new(false),
|
mcp_running: RwLock::new(false),
|
||||||
|
docker_host: RwLock::new(None),
|
||||||
ai_settings: RwLock::new(None),
|
ai_settings: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop every chat-agent cache entry tied to this connection.
|
|
||||||
/// Called by switch_database_core, disconnect, and on connection delete.
|
|
||||||
pub async fn invalidate_chat_caches_for(&self, connection_id: &str) {
|
|
||||||
self.overview_cache.write().await.remove(connection_id);
|
|
||||||
self.tables_by_db_cache
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.retain(|(cid, _), _| cid != connection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
|
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
|
||||||
let pools = self.pools.read().await;
|
let pools = self.pools.read().await;
|
||||||
pools
|
pools
|
||||||
@@ -82,14 +56,6 @@ impl AppState {
|
|||||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
|
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_ch_client(&self, connection_id: &str) -> TuskResult<Arc<ChClient>> {
|
|
||||||
let clients = self.ch_clients.read().await;
|
|
||||||
clients
|
|
||||||
.get(connection_id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_read_only(&self, id: &str) -> bool {
|
pub async fn is_read_only(&self, id: &str) -> bool {
|
||||||
let map = self.read_only.read().await;
|
let map = self.read_only.read().await;
|
||||||
map.get(id).copied().unwrap_or(true)
|
map.get(id).copied().unwrap_or(true)
|
||||||
@@ -100,9 +66,42 @@ impl AppState {
|
|||||||
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the Greenplum major version (6 or 7) for a connection, or None
|
pub async fn get_schema_cache(&self, connection_id: &str) -> Option<String> {
|
||||||
/// for non-GP connections / when the version string couldn't be parsed.
|
let cache = self.schema_cache.read().await;
|
||||||
pub async fn get_gp_major(&self, id: &str) -> Option<u8> {
|
cache.get(connection_id).and_then(|entry| {
|
||||||
self.gp_majors.read().await.get(id).copied()
|
if entry.cached_at.elapsed() < SCHEMA_CACHE_TTL {
|
||||||
|
Some(entry.schema_text.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) {
|
||||||
|
let mut cache = self.schema_cache.write().await;
|
||||||
|
// Evict stale entries to prevent unbounded memory growth
|
||||||
|
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
|
||||||
|
// If still at capacity, remove the oldest entry
|
||||||
|
if cache.len() >= SCHEMA_CACHE_MAX_SIZE {
|
||||||
|
if let Some(oldest_key) = cache
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, e)| e.cached_at)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
{
|
||||||
|
cache.remove(&oldest_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.insert(
|
||||||
|
connection_id,
|
||||||
|
SchemaCacheEntry {
|
||||||
|
schema_text,
|
||||||
|
cached_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate_schema_cache(&self, connection_id: &str) {
|
||||||
|
let mut cache = self.schema_cache.write().await;
|
||||||
|
cache.remove(connection_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,95 @@
|
|||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
pub fn escape_ident(name: &str) -> String {
|
pub fn escape_ident(name: &str) -> String {
|
||||||
format!("\"{}\"", name.replace('"', "\"\""))
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Topological sort of tables based on foreign key dependencies.
|
||||||
|
/// Returns tables in insertion order: parents before children.
|
||||||
|
pub fn topological_sort_tables(
|
||||||
|
fk_edges: &[(String, String, String, String)], // (schema, table, ref_schema, ref_table)
|
||||||
|
target_tables: &[(String, String)],
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut graph: HashMap<(String, String), HashSet<(String, String)>> = HashMap::new();
|
||||||
|
let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
|
||||||
|
|
||||||
|
// Initialize all target tables
|
||||||
|
for t in target_tables {
|
||||||
|
graph.entry(t.clone()).or_default();
|
||||||
|
in_degree.entry(t.clone()).or_insert(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_set: HashSet<(String, String)> = target_tables.iter().cloned().collect();
|
||||||
|
|
||||||
|
// Build edges: parent -> child (child depends on parent)
|
||||||
|
for (schema, table, ref_schema, ref_table) in fk_edges {
|
||||||
|
let child = (schema.clone(), table.clone());
|
||||||
|
let parent = (ref_schema.clone(), ref_table.clone());
|
||||||
|
|
||||||
|
if child == parent {
|
||||||
|
continue; // self-referencing
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_set.contains(&child) || !target_set.contains(&parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(child.clone())
|
||||||
|
{
|
||||||
|
*in_degree.entry(child).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm
|
||||||
|
let mut initial: Vec<(String, String)> = in_degree
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, °)| deg == 0)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
initial.sort(); // deterministic order
|
||||||
|
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
result.push(node.clone());
|
||||||
|
if let Some(neighbors) = graph.get(&node) {
|
||||||
|
let mut new_ready: Vec<(String, String)> = neighbors
|
||||||
|
.iter()
|
||||||
|
.filter(|neighbor| {
|
||||||
|
if let Some(deg) = in_degree.get_mut(*neighbor) {
|
||||||
|
*deg -= 1;
|
||||||
|
*deg == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
new_ready.sort();
|
||||||
|
queue.extend(new_ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining tables (cycles) at the end
|
||||||
|
for t in target_tables {
|
||||||
|
if !result.contains(t) {
|
||||||
|
result.push(t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ── escape_ident ──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_ident_simple_name() {
|
fn escape_ident_simple_name() {
|
||||||
assert_eq!(escape_ident("users"), "\"users\"");
|
assert_eq!(escape_ident("users"), "\"users\"");
|
||||||
@@ -65,4 +149,70 @@ mod tests {
|
|||||||
fn escape_ident_newline() {
|
fn escape_ident_newline() {
|
||||||
assert_eq!(escape_ident("a\nb"), "\"a\nb\"");
|
assert_eq!(escape_ident("a\nb"), "\"a\nb\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── topological_sort_tables ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_no_edges() {
|
||||||
|
let tables = vec![("public".into(), "b".into()), ("public".into(), "a".into())];
|
||||||
|
let result = topological_sort_tables(&[], &tables);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert!(result.contains(&("public".into(), "a".into())));
|
||||||
|
assert!(result.contains(&("public".into(), "b".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_simple_dependency() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"orders".into(),
|
||||||
|
"public".into(),
|
||||||
|
"users".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![
|
||||||
|
("public".into(), "orders".into()),
|
||||||
|
("public".into(), "users".into()),
|
||||||
|
];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
let user_pos = result.iter().position(|t| t.1 == "users").unwrap();
|
||||||
|
let order_pos = result.iter().position(|t| t.1 == "orders").unwrap();
|
||||||
|
assert!(user_pos < order_pos, "users must come before orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_self_reference() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"employees".into(),
|
||||||
|
"public".into(),
|
||||||
|
"employees".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![("public".into(), "employees".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_cycle() {
|
||||||
|
let edges = vec![
|
||||||
|
("public".into(), "a".into(), "public".into(), "b".into()),
|
||||||
|
("public".into(), "b".into(), "public".into(), "a".into()),
|
||||||
|
];
|
||||||
|
let tables = vec![("public".into(), "a".into()), ("public".into(), "b".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_edge_outside_target_set_ignored() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"orders".into(),
|
||||||
|
"public".into(),
|
||||||
|
"external".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![("public".into(), "orders".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"identifier": "com.tusk.dbm",
|
"identifier": "com.tusk.dbm",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5174",
|
"devUrl": "http://localhost:5173",
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"beforeBuildCommand": "npm run build"
|
"beforeBuildCommand": "npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
103
src/components/ai/AiBar.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AiSettingsPopover } from "./AiSettingsPopover";
|
||||||
|
import { useGenerateSql } from "@/hooks/use-ai";
|
||||||
|
import { Sparkles, Loader2, X, Eraser } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
onSqlGenerated: (sql: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onExecute?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Props) {
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const generateMutation = useGenerateSql();
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (!prompt.trim() || generateMutation.isPending) return;
|
||||||
|
generateMutation.mutate(
|
||||||
|
{ connectionId, prompt },
|
||||||
|
{
|
||||||
|
onSuccess: (sql) => {
|
||||||
|
onSqlGenerated(sql);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error("AI generation failed", { description: String(err) });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onExecute?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGenerate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tusk-ai-bar flex items-center gap-2 px-2 py-1.5 tusk-fade-in">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 shrink-0 tusk-ai-icon" />
|
||||||
|
<Input
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Describe the query you want..."
|
||||||
|
className="h-7 min-w-0 flex-1 border-tusk-purple/20 bg-tusk-purple/5 text-xs placeholder:text-muted-foreground/40 focus:border-tusk-purple/40 focus:ring-tusk-purple/20"
|
||||||
|
autoFocus
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-1 text-[11px] text-tusk-purple hover:bg-tusk-purple/10 hover:text-tusk-purple"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generateMutation.isPending || !prompt.trim()}
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Generate"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{prompt.trim() && (
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPrompt("")}
|
||||||
|
title="Clear prompt"
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Eraser className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AiSettingsPopover />
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close AI bar"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,85 +7,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import { useOllamaModels } from "@/hooks/use-ai";
|
||||||
useFireworksModels,
|
|
||||||
useOllamaModels,
|
|
||||||
useOpenRouterModels,
|
|
||||||
} from "@/hooks/use-ai";
|
|
||||||
import { RefreshCw, Loader2 } from "lucide-react";
|
import { RefreshCw, Loader2 } from "lucide-react";
|
||||||
import type { AiProvider, OllamaModel } from "@/types";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
provider: AiProvider;
|
|
||||||
ollamaUrl: string;
|
ollamaUrl: string;
|
||||||
onOllamaUrlChange: (url: string) => void;
|
onOllamaUrlChange: (url: string) => void;
|
||||||
fireworksApiKey: string;
|
|
||||||
onFireworksApiKeyChange: (key: string) => void;
|
|
||||||
openrouterApiKey: string;
|
|
||||||
onOpenRouterApiKeyChange: (key: string) => void;
|
|
||||||
model: string;
|
model: string;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiSettingsFields({
|
export function AiSettingsFields({
|
||||||
provider,
|
|
||||||
ollamaUrl,
|
ollamaUrl,
|
||||||
onOllamaUrlChange,
|
onOllamaUrlChange,
|
||||||
fireworksApiKey,
|
|
||||||
onFireworksApiKeyChange,
|
|
||||||
openrouterApiKey,
|
|
||||||
onOpenRouterApiKeyChange,
|
|
||||||
model,
|
model,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (provider === "fireworks") {
|
|
||||||
return (
|
|
||||||
<FireworksFields
|
|
||||||
apiKey={fireworksApiKey}
|
|
||||||
onApiKeyChange={onFireworksApiKeyChange}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "openrouter") {
|
|
||||||
return (
|
|
||||||
<OpenRouterFields
|
|
||||||
apiKey={openrouterApiKey}
|
|
||||||
onApiKeyChange={onOpenRouterApiKeyChange}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OllamaFields
|
|
||||||
ollamaUrl={ollamaUrl}
|
|
||||||
onOllamaUrlChange={onOllamaUrlChange}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OllamaFields({
|
|
||||||
ollamaUrl,
|
|
||||||
onOllamaUrlChange,
|
|
||||||
model,
|
|
||||||
onModelChange,
|
|
||||||
}: {
|
|
||||||
ollamaUrl: string;
|
|
||||||
onOllamaUrlChange: (url: string) => void;
|
|
||||||
model: string;
|
|
||||||
onModelChange: (model: string) => void;
|
|
||||||
}) {
|
|
||||||
const {
|
const {
|
||||||
data: models,
|
data: models,
|
||||||
isLoading,
|
isLoading: modelsLoading,
|
||||||
isError,
|
isError: modelsError,
|
||||||
refetch,
|
refetch: refetchModels,
|
||||||
} = useOllamaModels(ollamaUrl);
|
} = useOllamaModels(ollamaUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,171 +42,41 @@ function OllamaFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModelDropdown
|
|
||||||
models={models}
|
|
||||||
loading={isLoading}
|
|
||||||
errored={isError}
|
|
||||||
errorText="Cannot connect to Ollama"
|
|
||||||
onRefresh={() => refetch()}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FireworksFields({
|
|
||||||
apiKey,
|
|
||||||
onApiKeyChange,
|
|
||||||
model,
|
|
||||||
onModelChange,
|
|
||||||
}: {
|
|
||||||
apiKey: string;
|
|
||||||
onApiKeyChange: (key: string) => void;
|
|
||||||
model: string;
|
|
||||||
onModelChange: (model: string) => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
data: models,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useFireworksModels(apiKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Fireworks API key</label>
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Model</label>
|
||||||
type="password"
|
<Button
|
||||||
value={apiKey}
|
size="sm"
|
||||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
variant="ghost"
|
||||||
placeholder="fw_..."
|
className="h-5 w-5 p-0"
|
||||||
className="h-8 text-xs"
|
onClick={() => refetchModels()}
|
||||||
autoComplete="off"
|
disabled={modelsLoading}
|
||||||
/>
|
title="Refresh models"
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
>
|
||||||
Stored locally; sent only to api.fireworks.ai.
|
{modelsLoading ? (
|
||||||
</p>
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{modelsError ? (
|
||||||
|
<p className="text-xs text-destructive">Cannot connect to Ollama</p>
|
||||||
|
) : (
|
||||||
|
<Select value={model} onValueChange={onModelChange}>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models?.map((m) => (
|
||||||
|
<SelectItem key={m.name} value={m.name}>
|
||||||
|
{m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModelDropdown
|
|
||||||
models={models}
|
|
||||||
loading={isLoading}
|
|
||||||
errored={isError}
|
|
||||||
errorText="Cannot reach Fireworks (check API key)"
|
|
||||||
onRefresh={() => refetch()}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
emptyHint={apiKey.trim() ? "Click ↻ to load models" : "Enter API key first"}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenRouterFields({
|
|
||||||
apiKey,
|
|
||||||
onApiKeyChange,
|
|
||||||
model,
|
|
||||||
onModelChange,
|
|
||||||
}: {
|
|
||||||
apiKey: string;
|
|
||||||
onApiKeyChange: (key: string) => void;
|
|
||||||
model: string;
|
|
||||||
onModelChange: (model: string) => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
data: models,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useOpenRouterModels(apiKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">OpenRouter API key</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
|
||||||
placeholder="sk-or-..."
|
|
||||||
className="h-8 text-xs"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
|
||||||
Stored locally; sent only to openrouter.ai.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelDropdown
|
|
||||||
models={models}
|
|
||||||
loading={isLoading}
|
|
||||||
errored={isError}
|
|
||||||
errorText="Cannot reach OpenRouter (check API key)"
|
|
||||||
onRefresh={() => refetch()}
|
|
||||||
model={model}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
emptyHint={apiKey.trim() ? "Click ↻ to load models" : "Enter API key first"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelDropdown({
|
|
||||||
models,
|
|
||||||
loading,
|
|
||||||
errored,
|
|
||||||
errorText,
|
|
||||||
onRefresh,
|
|
||||||
model,
|
|
||||||
onModelChange,
|
|
||||||
emptyHint,
|
|
||||||
}: {
|
|
||||||
models: OllamaModel[] | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
errored: boolean;
|
|
||||||
errorText: string;
|
|
||||||
onRefresh: () => void;
|
|
||||||
model: string;
|
|
||||||
onModelChange: (model: string) => void;
|
|
||||||
emptyHint?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs text-muted-foreground">Model</label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-5 w-5 p-0"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
title="Refresh models"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{errored ? (
|
|
||||||
<p className="text-xs text-destructive">{errorText}</p>
|
|
||||||
) : (
|
|
||||||
<Select value={model} onValueChange={onModelChange}>
|
|
||||||
<SelectTrigger className="h-8 w-full text-xs">
|
|
||||||
<SelectValue placeholder={emptyHint ?? "Select a model"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{models?.map((m) => (
|
|
||||||
<SelectItem key={m.name} value={m.name}>
|
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,67 +4,25 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
|
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AiSettingsFields } from "./AiSettingsFields";
|
import { AiSettingsFields } from "./AiSettingsFields";
|
||||||
import type { AiProvider } from "@/types";
|
|
||||||
|
|
||||||
const SUPPORTED_PROVIDERS: { value: AiProvider; label: string }[] = [
|
|
||||||
{ value: "ollama", label: "Ollama (local)" },
|
|
||||||
{ value: "fireworks", label: "Fireworks AI" },
|
|
||||||
{ value: "openrouter", label: "OpenRouter" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AiSettingsPopover() {
|
export function AiSettingsPopover() {
|
||||||
const { data: settings } = useAiSettings();
|
const { data: settings } = useAiSettings();
|
||||||
const saveMutation = useSaveAiSettings();
|
const saveMutation = useSaveAiSettings();
|
||||||
|
|
||||||
const [provider, setProvider] = useState<AiProvider | null>(null);
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [fireworksKey, setFireworksKey] = useState<string | null>(null);
|
|
||||||
const [openrouterKey, setOpenrouterKey] = useState<string | null>(null);
|
|
||||||
const [model, setModel] = useState<string | null>(null);
|
const [model, setModel] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentProvider: AiProvider =
|
|
||||||
provider ?? settings?.provider ?? "ollama";
|
|
||||||
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
||||||
const currentFireworksKey =
|
|
||||||
fireworksKey ?? settings?.fireworks_api_key ?? "";
|
|
||||||
const currentOpenrouterKey =
|
|
||||||
openrouterKey ?? settings?.openrouter_api_key ?? "";
|
|
||||||
const currentModel = model ?? settings?.model ?? "";
|
const currentModel = model ?? settings?.model ?? "";
|
||||||
|
|
||||||
const handleProviderChange = (next: AiProvider) => {
|
|
||||||
if (next === currentProvider) return;
|
|
||||||
setProvider(next);
|
|
||||||
// Model lists differ between providers — drop the previous selection.
|
|
||||||
setModel("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
saveMutation.mutate(
|
saveMutation.mutate(
|
||||||
{
|
{ provider: "ollama", ollama_url: currentUrl, model: currentModel },
|
||||||
provider: currentProvider,
|
|
||||||
ollama_url: currentUrl,
|
|
||||||
fireworks_api_key:
|
|
||||||
currentProvider === "fireworks"
|
|
||||||
? currentFireworksKey.trim() || undefined
|
|
||||||
: settings?.fireworks_api_key,
|
|
||||||
openrouter_api_key:
|
|
||||||
currentProvider === "openrouter"
|
|
||||||
? currentOpenrouterKey.trim() || undefined
|
|
||||||
: settings?.openrouter_api_key,
|
|
||||||
model: currentModel,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success("AI settings saved"),
|
onSuccess: () => toast.success("AI settings saved"),
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
@@ -89,35 +47,11 @@ export function AiSettingsPopover() {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80" align="end">
|
<PopoverContent className="w-80" align="end">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h4 className="text-sm font-medium">AI Settings</h4>
|
<h4 className="text-sm font-medium">Ollama Settings</h4>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">Provider</label>
|
|
||||||
<Select
|
|
||||||
value={currentProvider}
|
|
||||||
onValueChange={(v) => handleProviderChange(v as AiProvider)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-full text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SUPPORTED_PROVIDERS.map((p) => (
|
|
||||||
<SelectItem key={p.value} value={p.value}>
|
|
||||||
{p.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AiSettingsFields
|
<AiSettingsFields
|
||||||
provider={currentProvider}
|
|
||||||
ollamaUrl={currentUrl}
|
ollamaUrl={currentUrl}
|
||||||
onOllamaUrlChange={setUrl}
|
onOllamaUrlChange={setUrl}
|
||||||
fireworksApiKey={currentFireworksKey}
|
|
||||||
onFireworksApiKeyChange={setFireworksKey}
|
|
||||||
openrouterApiKey={currentOpenrouterKey}
|
|
||||||
onOpenRouterApiKeyChange={setOpenrouterKey}
|
|
||||||
model={currentModel}
|
model={currentModel}
|
||||||
onModelChange={setModel}
|
onModelChange={setModel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
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,
|
|
||||||
Database,
|
|
||||||
Maximize2,
|
|
||||||
Download,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ChatMessage } from "@/types";
|
|
||||||
import { getToolMeta, isQueryResultTool } from "./tool-registry";
|
|
||||||
|
|
||||||
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 meta = getToolMeta(tool);
|
|
||||||
const preview = previewFromJson(tool, inputJson);
|
|
||||||
const Icon = meta.icon;
|
|
||||||
const showSqlPreview = (tool === "run_query" || tool === "explain_query") && preview;
|
|
||||||
|
|
||||||
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">{meta.label}</span>
|
|
||||||
{preview && (
|
|
||||||
<span className="ml-1 truncate text-muted-foreground/70">{preview}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-border/30 p-2">
|
|
||||||
{showSqlPreview ? (
|
|
||||||
<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 previewFromJson(tool: string, inputJson: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(inputJson) as Record<string, unknown>;
|
|
||||||
return getToolMeta(tool).preview(parsed);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">{getToolMeta(tool).label} failed</div>
|
|
||||||
{text && <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{text}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools that produce a QueryResult (rendered as a table): run_query, sample_data.
|
|
||||||
if (isQueryResultTool(tool) && result) {
|
|
||||||
return <RunQueryResultBlock result={result} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else falls back to a collapsible text block.
|
|
||||||
return <TextToolResult tool={tool} text={text} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) {
|
|
||||||
// Lazy preview: switch_database is short; everything else collapses by default.
|
|
||||||
const [expanded, setExpanded] = useState(tool === "switch_database");
|
|
||||||
const meta = getToolMeta(tool);
|
|
||||||
const Icon = meta.icon;
|
|
||||||
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">{meta.label}</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 prettyJson(s: string): string {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(s), null, 2);
|
|
||||||
} catch {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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 {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Eraser, Sparkles, Layers } from "lucide-react";
|
|
||||||
import { useAppStore } from "@/stores/app-store";
|
|
||||||
import { useAiSettings } from "@/hooks/use-ai";
|
|
||||||
import type { ContextUsage } from "@/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tabId: string;
|
|
||||||
connectionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPanel({ tabId, connectionId }: Props) {
|
|
||||||
const { messages, pending, usage, send, clear, compact } = 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>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UsageBadge usage={usage} />
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => compact()}
|
|
||||||
disabled={messages.length === 0 || pending}
|
|
||||||
title="Summarize older messages to free context (also: type /compact)"
|
|
||||||
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Layers className="h-3 w-3" />
|
|
||||||
Compact
|
|
||||||
</Button>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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. /compact to summarise, /clear to wipe."
|
|
||||||
: "Configure an AI model in Settings to enable chat."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBadge({ usage }: { usage: ContextUsage | undefined }) {
|
|
||||||
if (!usage || usage.budget_chars === 0) return null;
|
|
||||||
const ratio = Math.min(usage.used_chars / usage.budget_chars, 1.5);
|
|
||||||
const usedTok = Math.round(usage.used_chars / 3 / 100) / 10; // ~k-tokens with 1 decimal
|
|
||||||
const budgetTok = Math.round(usage.budget_chars / 3 / 100) / 10;
|
|
||||||
const percent = Math.round(ratio * 100);
|
|
||||||
|
|
||||||
let toneClass = "text-muted-foreground/70";
|
|
||||||
if (ratio >= 0.85) toneClass = "text-destructive";
|
|
||||||
else if (ratio >= 0.6) toneClass = "text-warning";
|
|
||||||
else if (ratio >= 0.3) toneClass = "text-success/80";
|
|
||||||
|
|
||||||
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
|
|
||||||
let fillClass = "bg-success/70";
|
|
||||||
if (ratio >= 0.85) fillClass = "bg-destructive";
|
|
||||||
else if (ratio >= 0.6) fillClass = "bg-warning";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
|
||||||
<div className={trackClass}>
|
|
||||||
<div
|
|
||||||
className={fillClass}
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${Math.min(ratio, 1) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className={toneClass}>
|
|
||||||
{usedTok}k / {budgetTok}k tok · {percent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
|
|
||||||
Approximate context usage. {usage.used_chars.toLocaleString()} chars sent to the model
|
|
||||||
last turn out of {usage.budget_chars.toLocaleString()} budget.
|
|
||||||
{ratio >= 0.6 && " Type /compact (or click Compact) to summarise older history."}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
{aiReady && (
|
|
||||||
<p className="text-[11px] text-muted-foreground/60">
|
|
||||||
Slash commands: <code>/compact</code> · <code>/clear</code>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||