Compare commits

...

19 Commits

Author SHA1 Message Date
33b07a31da ci: add workflow_dispatch trigger to CI workflow
Some checks failed
CI / lint-and-build (push) Failing after 2s
2026-04-08 10:09:30 +03:00
2d2dcdc4a8 fix: resolve all 25 ESLint react-hooks and react-refresh violations
Replace useEffect-based state resets in dialogs with React's render-time
state adjustment pattern. Wrap ref assignments in hooks with useEffect.
Suppress known third-party library warnings (shadcn CVA exports,
TanStack Table). Remove warn downgrades from eslint config.
2026-04-08 07:41:34 +03:00
9237c7dd8e perf: optimize HTTP client, DB queries, and clean up dead code
- Make reqwest::Client a LazyLock singleton instead of per-call allocation
- Parallelize 3 independent DB queries in get_index_advisor_report with tokio::join!
- Eliminate per-iteration Vec allocation in snapshot FK dependency loop
- Hoist try_local_pg_dump() call in SampleData clone mode to avoid double execution
- Evict stale schema cache entries on write to prevent unbounded memory growth
- Remove unused ValidationReport struct and config_path field
- Rename IndexRecommendationType variants to remove redundant suffix
2026-04-06 13:13:03 +03:00
6b925d6260 style: apply rustfmt, fix clippy warnings, and minor code cleanup
Reformat Rust code with rustfmt, suppress clippy::too_many_arguments
for Tauri IPC commands, derive Default for AppSettings, fix unused
variable pattern in TableDataView, and add unit tests for utils.
2026-04-06 13:12:52 +03:00
1e002d801a chore: update build config, linting, and add test infrastructure
Replace install -D with mkdir -p + install for macOS portability,
add vitest with jsdom and testing-library, configure eslint for
react-hooks v7 warnings, and add tokio test deps for Rust.
2026-04-06 13:12:43 +03:00
f8dd94a6c7 chore: remove node_modules prerequisite from make dev 2026-04-06 10:27:24 +03:00
4e5714b291 feat: redesign UI with Twilight design system
Outfit + JetBrains Mono typography, soft dark palette with blue
undertones, electric teal primary, purple-branded AI features,
noise texture, glow effects, glassmorphism, and refined grid/tree.
2026-04-06 10:27:20 +03:00
64e27f79a4 ci: configure Gitea Actions for Docker-based runner
Some checks failed
CI / lint-and-build (push) Failing after 2s
Use explicit ubuntu:22.04 container instead of marketplace actions
that may not work on self-hosted Gitea runners. Install Node.js and
Rust toolchain directly via curl/rustup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:38:00 +03:00
ab898262dd ci: add Gitea Actions workflows for Linux-only CI and release
Some checks failed
CI / lint-and-test (push) Failing after 1m20s
CI / build (push) Has been skipped
Adapt GitHub Actions workflows for Gitea with Docker runner:
- Linux-only builds (no macOS/Windows matrix)
- Manual `npm run tauri build` instead of tauri-action
- Release creation via Gitea API with asset upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:30:40 +03:00
e984bf233e ci: add cross-platform builds for Linux, macOS (Intel + ARM), and Windows
Some checks failed
CI / lint-and-test (push) Failing after 27s
CI / build (, tusk-linux-x64, ubuntu-22.04, ) (push) Has been skipped
CI / build (, tusk-windows-x64, windows-latest, ) (push) Has been skipped
CI / build (--target aarch64-apple-darwin, tusk-macos-arm64, macos-latest, aarch64-apple-darwin) (push) Has been skipped
CI / build (--target x86_64-apple-darwin, tusk-macos-x64, macos-13, x86_64-apple-darwin) (push) Has been skipped
- Add macOS Intel build (macos-13 / x86_64-apple-darwin) to CI matrix
- Add artifact upload step to CI build job
- Add release workflow triggered by v* tags with draft GitHub Release
- Add AppImage to Linux bundle targets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:21:09 +03:00
34c80809f1 fix: prevent snapshot hang caused by u8 overflow in progress calculation
When creating/restoring snapshots with 4+ tables, the progress percentage
calculation overflowed u8 (e.g. 4*80=320 > 255), causing a panic that left
the IPC call unresolved. Also deduplicate fetch_foreign_keys_raw call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:45:58 +03:00
a3b05b0328 feat: add AI data validation, test data generator, index advisor, and snapshots
Four new killer features leveraging AI (Ollama) and PostgreSQL internals:

- Data Validation: describe quality rules in natural language, AI generates
  SQL to find violations, run with pass/fail results and sample violations
- Test Data Generator: right-click table to generate realistic FK-aware test
  data with AI, preview before inserting in a transaction
- Index Advisor: analyze pg_stat tables + AI recommendations for CREATE/DROP
  INDEX with one-click apply
- Data Snapshots: export selected tables to JSON (FK-ordered), restore from
  file with optional truncate in a transaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:27:41 +03:00
d507162377 fix: harden security, reduce duplication, and improve robustness
- Fix SQL injection in data.rs by wrapping get_table_data in READ ONLY transaction
- Fix SQL injection in docker.rs CREATE DATABASE via escape_ident
- Fix command injection in docker.rs by validating pg_version/container_name
  and escaping shell-interpolated values
- Fix UTF-8 panic on stderr truncation with char_indices
- Wrap delete_rows in a transaction for atomicity
- Replace .expect() with proper error propagation in lib.rs
- Cache AI settings in AppState to avoid repeated disk reads
- Cap JSONB column discovery at 50 to prevent unbounded queries
- Fix ERD colorMode to respect system theme via useTheme()
- Extract AppState::get_pool() replacing ~19 inline pool patterns
- Extract shared AiSettingsFields component (DRY popover + sheet)
- Make get_connections_path pub(crate) and reuse from docker.rs
- Deduplicate check_docker by delegating to check_docker_internal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:41:14 +03:00
baa794b66a feat: fallback to ctid for editing tables without primary key
When a table has no PRIMARY KEY, use PostgreSQL's ctid (physical row ID)
to identify rows for UPDATE/DELETE operations instead of blocking edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:14:26 +03:00
e76a96deb8 feat: add unified Settings sheet, MCP indicator, and Docker host config
- Add AppSettingsSheet (gear icon in Toolbar) with MCP, Docker, and AI sections
- MCP Server: toggle on/off, port config, status badge, endpoint URL with copy
- Docker: local/remote daemon selector with remote URL input
- AI: moved Ollama settings into the unified sheet
- MCP status probes actual TCP port for reliable running detection
- Docker commands respect configurable docker host (-H flag) for remote daemons
- MCP server supports graceful shutdown via tokio watch channel
- Settings persisted to app_settings.json alongside existing config files
- StatusBar shows MCP indicator (green/gray dot) with tooltip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:04:12 +03:00
20b00e55b0 fix: improve Docker clone reliability and log display
- Use bash with pipefail instead of sh to detect pg_dump failures in pipes
- Switch full clone from binary format (pg_dump -Fc | pg_restore) to plain
  text (pg_dump | psql) for reliable transfer through docker exec
- Add --no-owner --no-acl flags to avoid errors from missing roles
- Extract shared run_pipe_cmd helper with proper error handling
- Remove shell commands from progress events to prevent credential leaks
- Fix process log layout overflow with break-all and block-level details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:41:59 +03:00
1ce5f78de8 feat: add Clone Database to Docker functionality
Clone any database to a local Docker PostgreSQL container with schema
and/or data transfer via pg_dump. Supports three modes: schema only,
full clone, and sample data. Includes container lifecycle management
(start/stop/remove) in the Admin panel, progress tracking with
collapsible process log, and automatic connection creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:27:16 +03:00
f68057beef fix: remove duplicate app name from toolbar
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 16:38:57 +03:00
94df94db7c feat: add ER diagram and enhance TableStructure with FK details, triggers, comments
- Add interactive ER diagram with ReactFlow + dagre auto-layout, accessible
  via right-click context menu on schema nodes in the sidebar
- Enhance TableStructure: column comments, FK referenced table/columns,
  ON UPDATE/DELETE rules, new Triggers tab
- Backend: rewrite get_table_constraints using pg_constraint for proper
  composite FK support, add get_table_triggers and get_schema_erd commands

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 16:37:38 +03:00
88 changed files with 11271 additions and 987 deletions

View File

@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
lint-and-build:
runs-on: ubuntu-latest
container:
image: ubuntu:22.04
env:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
apt-get install -y \
build-essential curl wget pkg-config \
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libssl-dev git ca-certificates
- name: Install Node.js 22
run: |
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --component clippy,rustfmt
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install frontend dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Rust fmt check
run: |
. "$HOME/.cargo/env"
cd src-tauri && cargo fmt --check
- name: Rust clippy
run: |
. "$HOME/.cargo/env"
cd src-tauri && cargo clippy -- -D warnings
- name: Rust tests
run: |
. "$HOME/.cargo/env"
cd src-tauri && cargo test
- name: Build Tauri app
run: |
. "$HOME/.cargo/env"
npm run tauri build

View File

@@ -0,0 +1,97 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
container:
image: ubuntu:22.04
env:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
apt-get install -y \
build-essential curl wget pkg-config jq file \
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libssl-dev git ca-certificates
- name: Install Node.js 22
run: |
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install frontend dependencies
run: npm ci
- name: Build Tauri app
run: |
. "$HOME/.cargo/env"
npm run tauri build
- name: Create release and upload assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
TAG_NAME="${GITHUB_REF_NAME}"
REPO="${GITHUB_REPOSITORY}"
SERVER_URL="${GITHUB_SERVER_URL}"
API_URL="${SERVER_URL}/api/v1"
# Create release
RELEASE_RESPONSE=$(curl -s -X POST \
"${API_URL}/repos/${REPO}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"Tusk ${TAG_NAME}\",
\"body\": \"See the assets below to download Tusk for your platform.\",
\"draft\": true,
\"prerelease\": false
}")
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | jq -r '.id')
if [ "${RELEASE_ID}" = "null" ] || [ -z "${RELEASE_ID}" ]; then
echo "Failed to create release:"
echo "${RELEASE_RESPONSE}"
exit 1
fi
echo "Created release with ID: ${RELEASE_ID}"
# Upload assets
upload_asset() {
local file="$1"
local filename=$(basename "${file}")
echo "Uploading ${filename}..."
curl -s -X POST \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}"
}
for pattern in \
"src-tauri/target/release/bundle/deb/*.deb" \
"src-tauri/target/release/bundle/rpm/*.rpm" \
"src-tauri/target/release/bundle/appimage/*.AppImage"; do
for file in ${pattern}; do
[ -f "${file}" ] && upload_asset "${file}"
done
done
echo "Release created successfully"

123
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Rust fmt check
run: cd src-tauri && cargo fmt --check
- name: Rust clippy
run: cd src-tauri && cargo clippy -- -D warnings
- name: Rust tests
run: cd src-tauri && cargo test
- name: Frontend tests
run: npm test
build:
needs: lint-and-test
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
args: --target aarch64-apple-darwin
rust-target: aarch64-apple-darwin
artifact-name: tusk-macos-arm64
- platform: macos-13
args: --target x86_64-apple-darwin
rust-target: x86_64-apple-darwin
artifact-name: tusk-macos-x64
- platform: windows-latest
args: ""
rust-target: ""
artifact-name: tusk-windows-x64
- platform: ubuntu-22.04
args: ""
rust-target: ""
artifact-name: tusk-linux-x64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: ${{ matrix.args }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: |
src-tauri/target/**/release/bundle/deb/*.deb
src-tauri/target/**/release/bundle/rpm/*.rpm
src-tauri/target/**/release/bundle/appimage/*.AppImage
src-tauri/target/**/release/bundle/dmg/*.dmg
src-tauri/target/**/release/bundle/nsis/*.exe
src-tauri/target/**/release/bundle/msi/*.msi
if-no-files-found: ignore

67
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
args: --target aarch64-apple-darwin
rust-target: aarch64-apple-darwin
- platform: macos-13
args: --target x86_64-apple-darwin
rust-target: x86_64-apple-darwin
- platform: windows-latest
args: ""
rust-target: ""
- platform: ubuntu-22.04
args: ""
rust-target: ""
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install frontend dependencies
run: npm ci
- name: Build and release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Tusk ${{ github.ref_name }}"
releaseBody: "See the assets below to download Tusk for your platform."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}

View File

@@ -15,7 +15,7 @@ TARGET_DIR := $(if $(TARGET),src-tauri/target/$(TARGET)/release,src-tauri/targe
# ──────────────────────────────────────────────
.PHONY: dev
dev: node_modules ## Run app in dev mode (Vite HMR + Rust backend)
dev: ## Run app in dev mode (Vite HMR + Rust backend)
npm run tauri dev
.PHONY: dev-frontend
@@ -98,12 +98,14 @@ export DESKTOP_ENTRY
.PHONY: install
install: build ## Build release and install to system (PREFIX=/usr/local)
install -Dm755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME)
@mkdir -p $(DESTDIR)$(BINDIR)
install -m755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME)
@mkdir -p $(DESTDIR)$(DATADIR)/applications
@echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
@for size in 32x32 128x128; do \
if [ -f src-tauri/icons/$$size.png ]; then \
install -Dm644 src-tauri/icons/$$size.png \
mkdir -p $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps; \
install -m644 src-tauri/icons/$$size.png \
$(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \
fi; \
done

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'src-tauri']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -19,5 +19,8 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
])

View File

@@ -1,9 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tusk</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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" />
</head>
<body>
<div id="root"></div>

1359
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"tauri": "tauri"
},
"dependencies": {
@@ -18,10 +20,13 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/dagre": "^0.7.54",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -37,6 +42,8 @@
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -45,11 +52,13 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"shadcn": "^3.8.4",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

345
src-tauri/Cargo.lock generated
View File

@@ -383,6 +383,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
@@ -438,16 +444,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -471,9 +467,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -484,7 +480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -930,12 +926,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -994,15 +984,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1010,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -1024,12 +1005,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1291,8 +1266,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1302,9 +1279,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1455,25 +1434,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1618,7 +1578,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1645,22 +1604,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
@@ -1681,11 +1625,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2079,12 +2021,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -2106,6 +2042,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2222,23 +2164,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdede44f9a69cab2899a2049e2c3bd49bf911a157f6a3353d4a91c61abbce44"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2595,50 +2520,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3042,6 +2923,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.44"
@@ -3259,29 +3195,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@@ -3289,6 +3222,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
@@ -3428,6 +3362,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3437,19 +3377,6 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
@@ -3470,6 +3397,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
@@ -3505,15 +3433,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3585,29 +3504,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4329,27 +4225,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4371,7 +4246,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4713,19 +4588,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
@@ -4861,16 +4723,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5440,6 +5292,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@@ -5697,17 +5559,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -30,8 +30,11 @@ csv = "1"
log = "0.4"
hex = "0.4"
bigdecimal = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable-http-server"] }
axum = "0.8"
schemars = "1"
tokio-util = "0.7"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ pub struct ConnectResult {
pub flavor: DbFlavor,
}
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app
.path()
.app_data_dir()

View File

@@ -10,6 +10,7 @@ use std::time::Instant;
use tauri::State;
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn get_table_data(
state: State<'_, Arc<AppState>>,
connection_id: String,
@@ -21,10 +22,7 @@ pub async fn get_table_data(
sort_direction: Option<String>,
filter: Option<String>,
) -> TuskResult<PaginatedQueryResult> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
@@ -49,35 +47,71 @@ pub async fn get_table_data(
let offset = (page.saturating_sub(1)) * page_size;
let data_sql = format!(
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
qualified, where_clause, order_clause, page_size, offset
);
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
let start = Instant::now();
let (rows, count_row) = tokio::try_join!(
sqlx::query(&data_sql).fetch_all(pool),
sqlx::query(&count_sql).fetch_one(pool),
)
// Always run table data queries in a read-only transaction to prevent
// writable CTEs or other mutation via the raw filter parameter.
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 rows = sqlx::query(&data_sql)
.fetch_all(&mut *tx)
.await
.map_err(TuskError::Database)?;
let count_row = sqlx::query(&count_sql)
.fetch_one(&mut *tx)
.await
.map_err(TuskError::Database)?;
tx.rollback().await.map_err(TuskError::Database)?;
let execution_time_ms = start.elapsed().as_millis();
let total_rows: i64 = count_row.get(0);
let mut columns = Vec::new();
let mut types = Vec::new();
let mut all_columns = Vec::new();
let mut all_types = Vec::new();
if let Some(first_row) = rows.first() {
for col in first_row.columns() {
columns.push(col.name().to_string());
types.push(col.type_info().name().to_string());
all_columns.push(col.name().to_string());
all_types.push(col.type_info().name().to_string());
}
}
// Find and strip the trailing ctid column
let ctid_idx = all_columns.iter().rposition(|c| c == "ctid");
let mut ctids: Vec<String> = Vec::new();
let (columns, types) = if let Some(idx) = ctid_idx {
let mut cols = all_columns.clone();
let mut tps = all_types.clone();
cols.remove(idx);
tps.remove(idx);
(cols, tps)
} else {
(all_columns.clone(), all_types.clone())
};
let result_rows: Vec<Vec<Value>> = rows
.iter()
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
.map(|row| {
if let Some(idx) = ctid_idx {
let ctid_val: String = row.get(idx);
ctids.push(ctid_val);
}
(0..all_columns.len())
.filter(|i| Some(*i) != ctid_idx)
.map(|i| pg_value_to_json(row, i))
.collect()
})
.collect();
let row_count = result_rows.len();
@@ -91,10 +125,12 @@ pub async fn get_table_data(
total_rows,
page,
page_size,
ctids,
})
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn update_row(
state: State<'_, Arc<AppState>>,
connection_id: String,
@@ -104,20 +140,32 @@ pub async fn update_row(
pk_values: Vec<Value>,
column: String,
value: Value,
ctid: Option<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 pool = state.get_pool(&connection_id).await?;
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
let set_clause = format!("{} = $1", escape_ident(&column));
if pk_columns.is_empty() {
// Fallback: use ctid for row identification
let ctid_val = ctid.ok_or_else(|| {
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
})?;
let sql = format!(
"UPDATE {} SET {} WHERE ctid = $2::tid",
qualified, set_clause
);
let mut query = sqlx::query(&sql);
query = bind_json_value(query, &value);
query = query.bind(ctid_val);
query.execute(&pool).await.map_err(TuskError::Database)?;
} else {
let where_parts: Vec<String> = pk_columns
.iter()
.enumerate()
@@ -135,8 +183,8 @@ pub async fn update_row(
for pk_val in &pk_values {
query = bind_json_value(query, pk_val);
}
query.execute(pool).await.map_err(TuskError::Database)?;
query.execute(&pool).await.map_err(TuskError::Database)?;
}
Ok(())
}
@@ -154,10 +202,7 @@ pub async fn insert_row(
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
@@ -176,7 +221,7 @@ pub async fn insert_row(
query = bind_json_value(query, val);
}
query.execute(pool).await.map_err(TuskError::Database)?;
query.execute(&pool).await.map_err(TuskError::Database)?;
Ok(())
}
@@ -189,19 +234,32 @@ pub async fn delete_rows(
table: String,
pk_columns: Vec<String>,
pk_values_list: Vec<Vec<Value>>,
ctids: Option<Vec<String>>,
) -> TuskResult<u64> {
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 pool = state.get_pool(&connection_id).await?;
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
let mut total_affected: u64 = 0;
// Wrap all deletes in a transaction for atomicity
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
if pk_columns.is_empty() {
// Fallback: use ctids for row identification
let ctid_list = ctids.ok_or_else(|| {
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
})?;
for ctid_val in &ctid_list {
let sql = format!("DELETE FROM {} WHERE ctid = $1::tid", qualified);
let query = sqlx::query(&sql).bind(ctid_val);
let result = query.execute(&mut *tx).await.map_err(TuskError::Database)?;
total_affected += result.rows_affected();
}
} else {
for pk_values in &pk_values_list {
let where_parts: Vec<String> = pk_columns
.iter()
@@ -217,14 +275,17 @@ pub async fn delete_rows(
query = bind_json_value(query, val);
}
let result = query.execute(pool).await.map_err(TuskError::Database)?;
let result = query.execute(&mut *tx).await.map_err(TuskError::Database)?;
total_affected += result.rows_affected();
}
}
tx.commit().await.map_err(TuskError::Database)?;
Ok(total_affected)
}
fn bind_json_value<'q>(
pub(crate) fn bind_json_value<'q>(
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
value: &'q Value,
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {

File diff suppressed because it is too large Load Diff

View File

@@ -336,10 +336,7 @@ pub async fn alter_role(
options.push(format!("CONNECTION LIMIT {}", limit));
}
if let Some(ref valid_until) = params.valid_until {
options.push(format!(
"VALID UNTIL '{}'",
valid_until.replace('\'', "''")
));
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
}
if !options.is_empty() {

View File

@@ -1,6 +1,7 @@
pub mod ai;
pub mod connections;
pub mod data;
pub mod docker;
pub mod export;
pub mod history;
pub mod lookup;
@@ -8,3 +9,5 @@ pub mod management;
pub mod queries;
pub mod saved_queries;
pub mod schema;
pub mod settings;
pub mod snapshot;

View File

@@ -43,20 +43,16 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
}
"DATE" => try_get!(chrono::NaiveDate),
"TIME" => try_get!(chrono::NaiveTime),
"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(None) => return Value::Null,
Err(_) => {}
}
}
"OID" => {
match row.try_get::<Option<i32>, _>(index) {
},
"OID" => match row.try_get::<Option<i32>, _>(index) {
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
Ok(None) => return Value::Null,
Err(_) => {}
}
}
},
"VOID" => return Value::Null,
// Array types (PG prefixes array type names with underscore)
"_BOOL" => try_get!(Vec<bool>),
@@ -124,7 +120,11 @@ pub async fn execute_query_core(
let result_rows: Vec<Vec<Value>> = rows
.iter()
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
.map(|row| {
(0..columns.len())
.map(|i| pg_value_to_json(row, i))
.collect()
})
.collect();
let row_count = result_rows.len();

View File

@@ -1,5 +1,8 @@
use crate::error::{TuskError, TuskResult};
use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
use crate::models::schema::{
ColumnDetail, ColumnInfo, ConstraintInfo, ErdColumn, ErdData, ErdRelationship, ErdTable,
IndexInfo, SchemaObject, TriggerInfo,
};
use crate::state::{AppState, DbFlavor};
use sqlx::Row;
use std::collections::HashMap;
@@ -11,31 +14,22 @@ pub async fn list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<String>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT datname FROM pg_database \
WHERE datistemplate = false \
ORDER BY datname",
)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
}
pub async fn list_schemas_core(
state: &AppState,
connection_id: &str,
) -> TuskResult<Vec<String>> {
let pools = state.pools.read().await;
let pool = pools
.get(connection_id)
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
let pool = state.get_pool(connection_id).await?;
let flavor = state.get_flavor(connection_id).await;
let sql = if flavor == DbFlavor::Greenplum {
@@ -49,7 +43,7 @@ pub async fn list_schemas_core(
};
let rows = sqlx::query(sql)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -69,10 +63,7 @@ pub async fn list_tables_core(
connection_id: &str,
schema: &str,
) -> TuskResult<Vec<SchemaObject>> {
let pools = state.pools.read().await;
let pool = pools
.get(connection_id)
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
let pool = state.get_pool(connection_id).await?;
let rows = sqlx::query(
"SELECT t.table_name, \
@@ -85,7 +76,7 @@ pub async fn list_tables_core(
ORDER BY t.table_name",
)
.bind(schema)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -116,10 +107,7 @@ pub async fn list_views(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT table_name FROM information_schema.views \
@@ -127,7 +115,7 @@ pub async fn list_views(
ORDER BY table_name",
)
.bind(&schema)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -149,10 +137,7 @@ pub async fn list_functions(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT routine_name FROM information_schema.routines \
@@ -160,7 +145,7 @@ pub async fn list_functions(
ORDER BY routine_name",
)
.bind(&schema)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -182,10 +167,7 @@ pub async fn list_indexes(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT indexname FROM pg_indexes \
@@ -193,7 +175,7 @@ pub async fn list_indexes(
ORDER BY indexname",
)
.bind(&schema)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -215,10 +197,7 @@ pub async fn list_sequences(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT sequence_name FROM information_schema.sequences \
@@ -226,7 +205,7 @@ pub async fn list_sequences(
ORDER BY sequence_name",
)
.bind(&schema)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -248,10 +227,7 @@ pub async fn get_table_columns_core(
schema: &str,
table: &str,
) -> TuskResult<Vec<ColumnInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(connection_id)
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
let pool = state.get_pool(connection_id).await?;
let rows = sqlx::query(
"SELECT \
@@ -271,14 +247,20 @@ pub async fn get_table_columns_core(
AND tc.table_name = $2 \
AND kcu.column_name = c.column_name \
LIMIT 1 \
), false) as is_pk \
), false) as is_pk, \
col_description( \
(SELECT oid FROM pg_class \
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace \
WHERE relname = $2 AND nspname = $1), \
c.ordinal_position \
) as col_comment \
FROM information_schema.columns c \
WHERE c.table_schema = $1 AND c.table_name = $2 \
ORDER BY c.ordinal_position",
)
.bind(schema)
.bind(table)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -292,6 +274,7 @@ pub async fn get_table_columns_core(
ordinal_position: r.get::<i32, _>(4),
character_maximum_length: r.get::<Option<i32>, _>(5),
is_primary_key: r.get::<bool, _>(6),
comment: r.get::<Option<String>, _>(7),
})
.collect())
}
@@ -313,27 +296,57 @@ pub async fn get_table_constraints(
schema: String,
table: String,
) -> TuskResult<Vec<ConstraintInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT \
tc.constraint_name, \
tc.constraint_type, \
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage kcu \
ON tc.constraint_name = kcu.constraint_name \
AND tc.table_schema = kcu.table_schema \
WHERE tc.table_schema = $1 AND tc.table_name = $2 \
GROUP BY tc.constraint_name, tc.constraint_type \
ORDER BY tc.constraint_type, tc.constraint_name",
c.conname AS constraint_name, \
CASE c.contype \
WHEN 'p' THEN 'PRIMARY KEY' \
WHEN 'f' THEN 'FOREIGN KEY' \
WHEN 'u' THEN 'UNIQUE' \
WHEN 'c' THEN 'CHECK' \
WHEN 'x' THEN 'EXCLUDE' \
END AS constraint_type, \
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 columns, \
ref_ns.nspname AS referenced_schema, \
ref_cl.relname AS referenced_table, \
CASE WHEN c.confrelid > 0 THEN 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[] ELSE NULL END AS referenced_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' \
ELSE NULL \
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' \
ELSE NULL \
END AS delete_rule \
FROM pg_constraint c \
JOIN pg_class cl ON cl.oid = c.conrelid \
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
LEFT JOIN pg_class ref_cl ON ref_cl.oid = c.confrelid \
LEFT JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace \
WHERE ns.nspname = $1 AND cl.relname = $2 \
ORDER BY c.contype, c.conname",
)
.bind(&schema)
.bind(&table)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -343,6 +356,11 @@ pub async fn get_table_constraints(
name: r.get::<String, _>(0),
constraint_type: r.get::<String, _>(1),
columns: r.get::<Vec<String>, _>(2),
referenced_schema: r.get::<Option<String>, _>(3),
referenced_table: r.get::<Option<String>, _>(4),
referenced_columns: r.get::<Option<Vec<String>>, _>(5),
update_rule: r.get::<Option<String>, _>(6),
delete_rule: r.get::<Option<String>, _>(7),
})
.collect())
}
@@ -354,10 +372,7 @@ pub async fn get_table_indexes(
schema: String,
table: String,
) -> TuskResult<Vec<IndexInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT \
@@ -374,7 +389,7 @@ pub async fn get_table_indexes(
)
.bind(&schema)
.bind(&table)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -395,10 +410,7 @@ pub async fn get_completion_schema(
connection_id: String,
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
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 pool = state.get_pool(&connection_id).await?;
let sql = if flavor == DbFlavor::Greenplum {
"SELECT table_schema, table_name, column_name \
@@ -413,7 +425,7 @@ pub async fn get_completion_schema(
};
let rows = sqlx::query(sql)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -442,10 +454,7 @@ pub async fn get_column_details(
table: String,
) -> TuskResult<Vec<ColumnDetail>> {
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 pool = state.get_pool(&connection_id).await?;
let sql = if flavor == DbFlavor::Greenplum {
"SELECT c.column_name, c.data_type, \
@@ -468,7 +477,7 @@ pub async fn get_column_details(
let rows = sqlx::query(sql)
.bind(&schema)
.bind(&table)
.fetch_all(pool)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
@@ -483,3 +492,182 @@ pub async fn get_column_details(
})
.collect())
}
#[tauri::command]
pub async fn get_table_triggers(
state: State<'_, Arc<AppState>>,
connection_id: String,
schema: String,
table: String,
) -> TuskResult<Vec<TriggerInfo>> {
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT \
t.tgname AS trigger_name, \
CASE \
WHEN t.tgtype::int & 2 = 2 THEN 'BEFORE' \
WHEN t.tgtype::int & 2 = 0 AND t.tgtype::int & 64 = 64 THEN 'INSTEAD OF' \
ELSE 'AFTER' \
END AS timing, \
array_to_string(ARRAY[ \
CASE WHEN t.tgtype::int & 4 = 4 THEN 'INSERT' ELSE NULL END, \
CASE WHEN t.tgtype::int & 8 = 8 THEN 'DELETE' ELSE NULL END, \
CASE WHEN t.tgtype::int & 16 = 16 THEN 'UPDATE' ELSE NULL END, \
CASE WHEN t.tgtype::int & 32 = 32 THEN 'TRUNCATE' ELSE NULL END \
], ' OR ') AS event, \
CASE WHEN t.tgtype::int & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, \
p.proname AS function_name, \
t.tgenabled != 'D' AS is_enabled, \
pg_get_triggerdef(t.oid) AS definition \
FROM pg_trigger t \
JOIN pg_class c ON c.oid = t.tgrelid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_proc p ON p.oid = t.tgfoid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal \
ORDER BY t.tgname",
)
.bind(&schema)
.bind(&table)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
Ok(rows
.iter()
.map(|r| TriggerInfo {
name: r.get::<String, _>(0),
timing: r.get::<String, _>(1),
event: r.get::<String, _>(2),
orientation: r.get::<String, _>(3),
function_name: r.get::<String, _>(4),
is_enabled: r.get::<bool, _>(5),
definition: r.get::<String, _>(6),
})
.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,
})
}

View File

@@ -0,0 +1,116 @@
use crate::error::{TuskError, TuskResult};
use crate::mcp;
use crate::models::settings::{AppSettings, DockerHost, McpStatus};
use crate::state::AppState;
use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
fn get_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?;
fs::create_dir_all(&dir)?;
Ok(dir.join("app_settings.json"))
}
#[tauri::command]
pub async fn get_app_settings(app: AppHandle) -> TuskResult<AppSettings> {
let path = get_settings_path(&app)?;
if !path.exists() {
return Ok(AppSettings::default());
}
let data = fs::read_to_string(&path)?;
let settings: AppSettings = serde_json::from_str(&data)?;
Ok(settings)
}
#[tauri::command]
pub async fn save_app_settings(
app: AppHandle,
state: State<'_, Arc<AppState>>,
settings: AppSettings,
) -> TuskResult<()> {
let path = get_settings_path(&app)?;
let data = serde_json::to_string_pretty(&settings)?;
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
let is_running = *state.mcp_running.read().await;
if settings.mcp.enabled {
if is_running {
// Stop existing MCP server first
let _ = state.mcp_shutdown_tx.send(true);
// Give it a moment to shut down
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
*state.mcp_running.write().await = false;
}
// Start new MCP server
let connections_path = app
.path()
.app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?
.join("connections.json");
let mcp_state = state.inner().clone();
let port = settings.mcp.port;
let shutdown_rx = state.mcp_shutdown_tx.subscribe();
tokio::spawn(async move {
*mcp_state.mcp_running.write().await = true;
if let Err(e) =
mcp::start_mcp_server(mcp_state.clone(), connections_path, port, shutdown_rx).await
{
log::error!("MCP server error: {}", e);
}
*mcp_state.mcp_running.write().await = false;
});
} else if is_running {
// Stop MCP server
let _ = state.mcp_shutdown_tx.send(true);
*state.mcp_running.write().await = false;
}
Ok(())
}
#[tauri::command]
pub async fn get_mcp_status(app: AppHandle) -> TuskResult<McpStatus> {
// Read settings from file for enabled/port
let settings = {
let path = get_settings_path(&app)?;
if path.exists() {
let data = fs::read_to_string(&path)?;
serde_json::from_str::<AppSettings>(&data).unwrap_or_default()
} else {
AppSettings::default()
}
};
// Probe the actual port to determine if MCP is running
let running = tokio::time::timeout(
std::time::Duration::from_millis(500),
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", settings.mcp.port)),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
Ok(McpStatus {
enabled: settings.mcp.enabled,
port: settings.mcp.port,
running,
})
}

View File

@@ -0,0 +1,359 @@
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(&params.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(&params.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(&params.file_path)?;
let snapshot: Snapshot = serde_json::from_str(&data)?;
let pool = state.get_pool(&params.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(&params.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::Custom(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)
}

View File

@@ -23,6 +23,9 @@ pub enum TuskError {
#[error("AI error: {0}")]
Ai(String),
#[error("Docker error: {0}")]
Docker(String),
#[error("{0}")]
Custom(String),
}

View File

@@ -5,6 +5,7 @@ mod models;
mod state;
mod utils;
use models::settings::{AppSettings, DockerHost};
use state::AppState;
use std::sync::Arc;
use tauri::Manager;
@@ -12,23 +13,63 @@ use tauri::Manager;
pub fn run() {
let shared_state = Arc::new(AppState::new());
tauri::Builder::default()
let _ = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(shared_state)
.setup(|app| {
let state = app.state::<Arc<AppState>>().inner().clone();
let connections_path = app
let data_dir = app
.path()
.app_data_dir()
.expect("failed to resolve app data dir")
.join("connections.json");
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let connections_path = data_dir.join("connections.json");
// Read app settings
let settings_path = data_dir.join("app_settings.json");
let settings = if settings_path.exists() {
std::fs::read_to_string(&settings_path)
.ok()
.and_then(|data| serde_json::from_str::<AppSettings>(&data).ok())
.unwrap_or_default()
} else {
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_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 {
let shutdown_rx = state.mcp_shutdown_tx.subscribe();
let mcp_state = state.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = mcp::start_mcp_server(state, connections_path, 9427).await {
*mcp_state.mcp_running.write().await = true;
if let Err(e) = mcp::start_mcp_server(
mcp_state.clone(),
connections_path,
mcp_port,
shutdown_rx,
)
.await
{
log::error!("MCP server error: {}", e);
}
*mcp_state.mcp_running.write().await = false;
});
}
Ok(())
})
@@ -59,6 +100,8 @@ pub fn run() {
commands::schema::get_table_indexes,
commands::schema::get_completion_schema,
commands::schema::get_column_details,
commands::schema::get_table_triggers,
commands::schema::get_schema_erd,
// data
commands::data::get_table_data,
commands::data::update_row,
@@ -96,9 +139,34 @@ pub fn run() {
commands::ai::generate_sql,
commands::ai::explain_sql,
commands::ai::fix_sql_error,
commands::ai::generate_validation_sql,
commands::ai::run_validation_rule,
commands::ai::suggest_validation_rules,
commands::ai::generate_test_data_preview,
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
commands::settings::get_app_settings,
commands::settings::save_app_settings,
commands::settings::get_mcp_status,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.inspect_err(|e| {
log::error!("Tauri application error: {}", e);
});
}

View File

@@ -13,6 +13,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::watch;
// --- Tool parameter types ---
@@ -78,7 +79,9 @@ impl TuskMcpServer {
}
}
#[tool(description = "List all configured database connections with their active/read-only status")]
#[tool(
description = "List all configured database connections with their active/read-only status"
)]
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
@@ -109,9 +112,8 @@ impl TuskMcpServer {
})
.collect();
let json = serde_json::to_string_pretty(&statuses).map_err(|e| {
McpError::internal_error(format!("Serialization error: {}", e), None)
})?;
let json = serde_json::to_string_pretty(&statuses)
.map_err(|e| McpError::internal_error(format!("Serialization error: {}", e), None))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
@@ -164,7 +166,9 @@ impl TuskMcpServer {
}
}
#[tool(description = "Describe table columns: name, type, nullable, primary key, default value")]
#[tool(
description = "Describe table columns: name, type, nullable, primary key, default value"
)]
async fn describe_table(
&self,
Parameters(params): Parameters<DescribeTableParam>,
@@ -217,6 +221,7 @@ pub async fn start_mcp_server(
state: Arc<AppState>,
connections_path: PathBuf,
port: u16,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let service = StreamableHttpService::new(
move || Ok(TuskMcpServer::new(state.clone(), connections_path.clone())),
@@ -230,7 +235,14 @@ pub async fn start_mcp_server(
log::info!("MCP server listening on http://{}/mcp", addr);
axum::serve(listener, router).await?;
tokio::select! {
res = axum::serve(listener, router) => {
res?;
}
_ = shutdown_rx.changed() => {
log::info!("MCP server stopped by shutdown signal");
}
}
Ok(())
}

View File

@@ -1,27 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AiProvider {
#[default]
Ollama,
OpenAi,
Anthropic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiSettings {
pub provider: AiProvider,
pub ollama_url: String,
pub openai_api_key: Option<String>,
pub anthropic_api_key: Option<String>,
pub model: String,
}
impl Default for AiSettings {
fn default() -> Self {
Self {
provider: AiProvider::Ollama,
ollama_url: "http://localhost:11434".to_string(),
openai_api_key: None,
anthropic_api_key: None,
model: String::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct OllamaChatRequest {
pub model: String,
pub messages: Vec<OllamaChatMessage>,
@@ -42,3 +57,130 @@ pub struct OllamaTagsResponse {
pub struct OllamaModel {
pub name: String,
}
// --- Wave 1: Validation ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationStatus {
Pending,
Generating,
Running,
Passed,
Failed,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationRule {
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,
}

View File

@@ -16,6 +16,10 @@ pub struct ConnectionConfig {
impl ConnectionConfig {
pub fn connection_url(&self) -> String {
self.connection_url_for_db(&self.database)
}
pub fn connection_url_for_db(&self, database: &str) -> String {
let ssl = self.ssl_mode.as_deref().unwrap_or("prefer");
format!(
"postgres://{}:{}@{}:{}/{}?sslmode={}",
@@ -23,7 +27,7 @@ impl ConnectionConfig {
urlencoded(&self.password),
self.host,
self.port,
urlencoded(&self.database),
urlencoded(database),
ssl
)
}
@@ -32,8 +36,8 @@ impl ConnectionConfig {
fn urlencoded(s: &str) -> String {
s.chars()
.map(|c| match c {
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
| '+' | ',' | ';' | '=' | '%' | ' ' => {
format!("%{:02X}", c as u8)
}
_ => c.to_string(),

View 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,
}

View File

@@ -1,8 +1,11 @@
pub mod ai;
pub mod connection;
pub mod docker;
pub mod history;
pub mod lookup;
pub mod management;
pub mod query_result;
pub mod saved_queries;
pub mod schema;
pub mod settings;
pub mod snapshot;

View File

@@ -20,4 +20,5 @@ pub struct PaginatedQueryResult {
pub total_rows: i64,
pub page: u32,
pub page_size: u32,
pub ctids: Vec<String>,
}

View File

@@ -18,6 +18,7 @@ pub struct ColumnInfo {
pub ordinal_position: i32,
pub character_maximum_length: Option<i32>,
pub is_primary_key: bool,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -34,6 +35,11 @@ pub struct ConstraintInfo {
pub name: String,
pub constraint_type: String,
pub columns: Vec<String>,
pub referenced_schema: Option<String>,
pub referenced_table: Option<String>,
pub referenced_columns: Option<Vec<String>>,
pub update_rule: Option<String>,
pub delete_rule: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -43,3 +49,48 @@ pub struct IndexInfo {
pub is_unique: bool,
pub is_primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub event: String,
pub timing: String,
pub orientation: String,
pub function_name: String,
pub is_enabled: bool,
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>,
}

View File

@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppSettings {
pub mcp: McpSettings,
pub docker: DockerSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpSettings {
pub enabled: bool,
pub port: u16,
}
impl Default for McpSettings {
fn default() -> Self {
Self {
enabled: true,
port: 9427,
}
}
}
#[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)]
pub struct McpStatus {
pub enabled: bool,
pub port: u16,
pub running: bool,
}

View 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,
}

View File

@@ -1,8 +1,10 @@
use crate::error::{TuskError, TuskResult};
use crate::models::ai::AiSettings;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::sync::{watch, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -11,23 +13,48 @@ pub enum DbFlavor {
Greenplum,
}
#[derive(Clone)]
pub struct SchemaCacheEntry {
pub schema_text: String,
pub cached_at: Instant,
}
pub struct AppState {
pub pools: RwLock<HashMap<String, PgPool>>,
pub config_path: RwLock<Option<PathBuf>>,
pub read_only: RwLock<HashMap<String, bool>>,
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
pub mcp_shutdown_tx: watch::Sender<bool>,
pub mcp_running: RwLock<bool>,
pub docker_host: RwLock<Option<String>>,
pub ai_settings: RwLock<Option<AiSettings>>,
}
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
impl AppState {
pub fn new() -> Self {
let (mcp_shutdown_tx, _) = watch::channel(false);
Self {
pools: RwLock::new(HashMap::new()),
config_path: RwLock::new(None),
read_only: RwLock::new(HashMap::new()),
db_flavors: RwLock::new(HashMap::new()),
schema_cache: RwLock::new(HashMap::new()),
mcp_shutdown_tx,
mcp_running: RwLock::new(false),
docker_host: RwLock::new(None),
ai_settings: RwLock::new(None),
}
}
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
let pools = self.pools.read().await;
pools
.get(connection_id)
.cloned()
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
}
pub async fn is_read_only(&self, id: &str) -> bool {
let map = self.read_only.read().await;
map.get(id).copied().unwrap_or(true)
@@ -37,4 +64,33 @@ impl AppState {
let map = self.db_flavors.read().await;
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
}
pub async fn get_schema_cache(&self, connection_id: &str) -> Option<String> {
let cache = self.schema_cache.read().await;
cache.get(connection_id).and_then(|entry| {
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);
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);
}
}

View File

@@ -1,3 +1,212 @@
use std::collections::{HashMap, HashSet};
pub fn escape_ident(name: &str) -> String {
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 queue: Vec<(String, String)> = in_degree
.iter()
.filter(|(_, &deg)| deg == 0)
.map(|(k, _)| k.clone())
.collect();
queue.sort(); // deterministic order
let mut result = Vec::new();
while let Some(node) = queue.pop() {
result.push(node.clone());
if let Some(neighbors) = graph.get(&node) {
for neighbor in neighbors {
if let Some(deg) = in_degree.get_mut(neighbor) {
*deg -= 1;
if *deg == 0 {
queue.push(neighbor.clone());
queue.sort();
}
}
}
}
}
// Add any remaining tables (cycles) at the end
for t in target_tables {
if !result.contains(t) {
result.push(t.clone());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// ── escape_ident ──────────────────────────────────────────
#[test]
fn escape_ident_simple_name() {
assert_eq!(escape_ident("users"), "\"users\"");
}
#[test]
fn escape_ident_with_double_quotes() {
assert_eq!(escape_ident(r#"my"table"#), r#""my""table""#);
}
#[test]
fn escape_ident_empty_string() {
assert_eq!(escape_ident(""), r#""""#);
}
#[test]
fn escape_ident_with_spaces() {
assert_eq!(escape_ident("my table"), "\"my table\"");
}
#[test]
fn escape_ident_with_semicolon() {
assert_eq!(escape_ident("users; DROP TABLE"), "\"users; DROP TABLE\"");
}
#[test]
fn escape_ident_with_single_quotes() {
assert_eq!(escape_ident("it's"), "\"it's\"");
}
#[test]
fn escape_ident_with_backslash() {
assert_eq!(escape_ident(r"back\slash"), r#""back\slash""#);
}
#[test]
fn escape_ident_unicode() {
assert_eq!(escape_ident("таблица"), "\"таблица\"");
}
#[test]
fn escape_ident_multiple_double_quotes() {
assert_eq!(escape_ident(r#"a""b"#), r#""a""""b""#);
}
#[test]
fn escape_ident_reserved_word() {
assert_eq!(escape_ident("select"), "\"select\"");
}
#[test]
fn escape_ident_null_byte() {
assert_eq!(escape_ident("a\0b"), "\"a\0b\"");
}
#[test]
fn escape_ident_newline() {
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);
}
}

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tusk",
"version": "0.1.0",
"identifier": "com.tusk.app",
"identifier": "com.tusk.dbm",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
@@ -27,7 +27,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["deb", "rpm", "appimage", "dmg", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -49,7 +49,7 @@ export default function App() {
}, [handleNewQuery, handleCloseTab]);
return (
<div className="flex h-screen flex-col">
<div className="tusk-noise flex h-screen flex-col">
<Toolbar />
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup orientation="horizontal">

View File

@@ -52,21 +52,21 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
};
return (
<div className="flex items-center gap-2 border-b bg-muted/50 px-2 py-1">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-purple-500" />
<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 text-xs"
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="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px] text-tusk-purple hover:bg-tusk-purple/10 hover:text-tusk-purple"
onClick={handleGenerate}
disabled={generateMutation.isPending || !prompt.trim()}
>
@@ -78,23 +78,23 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
</Button>
{prompt.trim() && (
<Button
size="sm"
size="icon-xs"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => setPrompt("")}
title="Clear prompt"
disabled={generateMutation.isPending}
className="text-muted-foreground"
>
<Eraser className="h-3 w-3" />
</Button>
)}
<AiSettingsPopover />
<Button
size="sm"
size="icon-xs"
variant="ghost"
className="h-6 w-6 p-0"
onClick={onClose}
title="Close AI bar"
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</Button>

View File

@@ -0,0 +1,82 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOllamaModels } from "@/hooks/use-ai";
import { RefreshCw, Loader2 } from "lucide-react";
interface Props {
ollamaUrl: string;
onOllamaUrlChange: (url: string) => void;
model: string;
onModelChange: (model: string) => void;
}
export function AiSettingsFields({
ollamaUrl,
onOllamaUrlChange,
model,
onModelChange,
}: Props) {
const {
data: models,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useOllamaModels(ollamaUrl);
return (
<>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Ollama URL</label>
<Input
value={ollamaUrl}
onChange={(e) => onOllamaUrlChange(e.target.value)}
placeholder="http://localhost:11434"
className="h-8 text-xs"
/>
</div>
<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={() => refetchModels()}
disabled={modelsLoading}
title="Refresh models"
>
{modelsLoading ? (
<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>
</>
);
}

View File

@@ -5,17 +5,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAiSettings, useSaveAiSettings, useOllamaModels } from "@/hooks/use-ai";
import { Settings, RefreshCw, Loader2 } from "lucide-react";
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
import { Settings } from "lucide-react";
import { toast } from "sonner";
import { AiSettingsFields } from "./AiSettingsFields";
export function AiSettingsPopover() {
const { data: settings } = useAiSettings();
@@ -27,16 +20,9 @@ export function AiSettingsPopover() {
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
const currentModel = model ?? settings?.model ?? "";
const {
data: models,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useOllamaModels(currentUrl);
const handleSave = () => {
saveMutation.mutate(
{ ollama_url: currentUrl, model: currentModel },
{ provider: "ollama", ollama_url: currentUrl, model: currentModel },
{
onSuccess: () => toast.success("AI settings saved"),
onError: (err) =>
@@ -63,53 +49,12 @@ export function AiSettingsPopover() {
<div className="flex flex-col gap-3">
<h4 className="text-sm font-medium">Ollama Settings</h4>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Ollama URL</label>
<Input
value={currentUrl}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:11434"
className="h-8 text-xs"
<AiSettingsFields
ollamaUrl={currentUrl}
onOllamaUrlChange={setUrl}
model={currentModel}
onModelChange={setModel}
/>
</div>
<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={() => refetchModels()}
disabled={modelsLoading}
title="Refresh models"
>
{modelsLoading ? (
<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={currentModel} onValueChange={setModel}>
<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>
<Button size="sm" className="h-7 text-xs" onClick={handleSave}>
Save

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -99,7 +99,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
const saveMutation = useSaveConnection();
const testMutation = useTestConnection();
useEffect(() => {
const [prev, setPrev] = useState<{ open: boolean; connection: typeof connection }>({ open: false, connection: null });
if (open !== prev.open || connection !== prev.connection) {
setPrev({ open, connection });
if (open) {
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
setForm(config);
@@ -107,7 +109,7 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
setDsn(connection ? buildDsn(config) : "");
setDsnError(null);
}
}, [open, connection]);
}
const update = (field: keyof ConnectionConfig, value: string | number) => {
setForm((f) => ({ ...f, [field]: value }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,24 +36,24 @@ export function ReadOnlyToggle() {
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`h-7 gap-1.5 text-xs font-medium ${
size="xs"
className={`gap-1.5 font-medium ${
isReadOnly
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
? "text-amber-500 hover:bg-amber-500/10 hover:text-amber-500"
: "text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-500"
}`}
onClick={handleToggle}
disabled={toggleMutation.isPending}
>
{isReadOnly ? (
<>
<Lock className="h-3.5 w-3.5" />
Read-Only
<Lock className="h-3 w-3" />
<span className="text-[11px] tracking-wide">Read-Only</span>
</>
) : (
<>
<LockOpen className="h-3.5 w-3.5" />
Read-Write
<LockOpen className="h-3 w-3" />
<span className="text-[11px] tracking-wide">Read-Write</span>
</>
)}
</Button>

View File

@@ -11,7 +11,7 @@ import { SchemaTree } from "@/components/schema/SchemaTree";
import { HistoryPanel } from "@/components/history/HistoryPanel";
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
import { AdminPanel } from "@/components/management/AdminPanel";
import { Search, RefreshCw } from "lucide-react";
import { Search, RefreshCw, Layers, Clock, Bookmark, Shield } from "lucide-react";
type SidebarView = "schema" | "history" | "saved" | "admin";
@@ -20,6 +20,13 @@ const SCHEMA_QUERY_KEYS = [
"functions", "sequences", "completionSchema", "column-details",
];
const SIDEBAR_TABS: { id: SidebarView; label: string; icon: React.ReactNode }[] = [
{ id: "schema", label: "Schema", icon: <Layers className="h-3.5 w-3.5" /> },
{ id: "history", label: "History", icon: <Clock className="h-3.5 w-3.5" /> },
{ id: "saved", label: "Saved", icon: <Bookmark className="h-3.5 w-3.5" /> },
{ id: "admin", label: "Admin", icon: <Shield className="h-3.5 w-3.5" /> },
];
export function Sidebar() {
const [view, setView] = useState<SidebarView>("schema");
const [search, setSearch] = useState("");
@@ -32,58 +39,33 @@ export function Sidebar() {
};
return (
<div className="flex h-full flex-col bg-card">
<div className="flex border-b text-xs">
<div className="flex h-full flex-col" style={{ background: "var(--sidebar)" }}>
{/* Sidebar navigation tabs */}
<div className="flex border-b border-border/50">
{SIDEBAR_TABS.map((tab) => (
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "schema"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
key={tab.id}
className={`relative flex flex-1 items-center justify-center gap-1.5 px-2 py-2 text-[11px] font-medium tracking-wide transition-colors ${
view === tab.id
? "text-foreground tusk-sidebar-tab-active"
: "text-muted-foreground hover:text-foreground/70"
}`}
onClick={() => setView("schema")}
onClick={() => setView(tab.id)}
>
Schema
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "history"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("history")}
>
History
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "saved"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("saved")}
>
Saved
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "admin"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("admin")}
>
Admin
{tab.icon}
<span className="hidden min-[220px]:inline">{tab.label}</span>
</button>
))}
</div>
{view === "schema" ? (
<>
<div className="flex items-center gap-1 p-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
placeholder="Search objects..."
className="h-7 pl-7 text-xs"
className="h-7 border-border/40 bg-background/50 pl-7 text-xs placeholder:text-muted-foreground/40 focus:border-primary/40 focus:ring-primary/20"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -94,6 +76,7 @@ export function Sidebar() {
variant="ghost"
size="icon-xs"
onClick={handleRefreshSchema}
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>

View File

@@ -1,7 +1,8 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { Circle } from "lucide-react";
import { useMcpStatus } from "@/hooks/use-settings";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
function formatDbVersion(version: string): string {
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
@@ -21,6 +22,7 @@ interface Props {
export function StatusBar({ rowCount, executionTime }: Props) {
const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore();
const { data: connections } = useConnections();
const { data: mcpStatus } = useMcpStatus();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
const isConnected = activeConnectionId
@@ -28,40 +30,75 @@ export function StatusBar({ rowCount, executionTime }: Props) {
: false;
return (
<div className="flex h-6 items-center justify-between border-t bg-card px-3 text-[11px] text-muted-foreground">
<div className="tusk-status-bar flex h-6 items-center justify-between px-3 text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<span className="flex items-center gap-1.5">
{activeConn?.color ? (
<span
className="inline-block h-2 w-2 rounded-full"
className="inline-block h-2 w-2 rounded-full ring-1 ring-white/10"
style={{ backgroundColor: activeConn.color }}
/>
) : (
<Circle
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
<span
className={`inline-block h-2 w-2 rounded-full ${
isConnected
? "bg-emerald-500 shadow-[0_0_6px_theme(--color-emerald-500/40)]"
: "bg-muted-foreground/30"
}`}
/>
)}
<span className="font-medium">
{activeConn ? activeConn.name : "No connection"}
</span>
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
</span>
{isConnected && activeConnectionId && (
<span
className={`font-semibold ${
className={`rounded px-1 py-px text-[10px] font-semibold tracking-wide ${
(readOnlyMap[activeConnectionId] ?? true)
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
? "bg-amber-500/10 text-amber-500"
: "bg-emerald-500/10 text-emerald-500"
}`}
>
{(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"}
{(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"}
</span>
)}
{pgVersion && (
<span className="hidden sm:inline">{formatDbVersion(pgVersion)}</span>
<span className="hidden text-muted-foreground/60 sm:inline font-mono text-[10px]">
{formatDbVersion(pgVersion)}
</span>
)}
</div>
<div className="flex items-center gap-3">
{rowCount != null && <span>{rowCount.toLocaleString()} rows</span>}
{executionTime != null && <span>{executionTime} ms</span>}
{rowCount != null && (
<span className="font-mono">
{rowCount.toLocaleString()} <span className="text-muted-foreground/50">rows</span>
</span>
)}
{executionTime != null && (
<span className="font-mono">
{executionTime} <span className="text-muted-foreground/50">ms</span>
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1.5 cursor-default">
<span
className={`inline-block h-1.5 w-1.5 rounded-full transition-colors ${
mcpStatus?.running
? "bg-emerald-500 shadow-[0_0_4px_theme(--color-emerald-500/40)]"
: "bg-muted-foreground/20"
}`}
/>
<span className="tracking-wide">MCP</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">
MCP Server {mcpStatus?.running ? `running on :${mcpStatus.port}` : "stopped"}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns, Users, Activity, Search } from "lucide-react";
import { X, Table2, Code, Columns, Users, Activity, Search, GitFork, ShieldCheck, Gauge, Camera } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
@@ -16,42 +16,47 @@ export function TabBar() {
roles: <Users className="h-3 w-3" />,
sessions: <Activity className="h-3 w-3" />,
lookup: <Search className="h-3 w-3" />,
erd: <GitFork className="h-3 w-3" />,
validation: <ShieldCheck className="h-3 w-3" />,
"index-advisor": <Gauge className="h-3 w-3" />,
snapshots: <Camera className="h-3 w-3" />,
};
return (
<div className="border-b bg-card">
<div className="border-b border-border/40" style={{ background: "var(--card)" }}>
<ScrollArea>
<div className="flex">
{tabs.map((tab) => (
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
return (
<div
key={tab.id}
className={`group flex h-8 cursor-pointer items-center gap-1.5 border-r px-3 text-xs ${
activeTabId === tab.id
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
className={`group relative flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs transition-colors ${
isActive
? "bg-background text-foreground tusk-tab-active"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground/80"
}`}
onClick={() => setActiveTabId(tab.id)}
>
{(() => {
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
return tabColor ? (
{tabColor && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: tabColor }}
/>
) : null;
})()}
{iconMap[tab.type]}
<span className="max-w-[150px] truncate">
)}
<span className="opacity-60">{iconMap[tab.type]}</span>
<span className="max-w-[150px] truncate font-medium">
{tab.title}
{tab.database && (
<span className="ml-1 text-[10px] text-muted-foreground">
<span className="ml-1 text-[10px] font-normal text-muted-foreground/60">
{tab.database}
</span>
)}
</span>
<button
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
className="ml-1 rounded-sm p-0.5 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-60 hover:!opacity-100"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
@@ -59,8 +64,14 @@ export function TabBar() {
>
<X className="h-3 w-3" />
</button>
{/* Right separator between tabs */}
{!isActive && (
<div className="absolute right-0 top-1.5 bottom-1.5 w-px bg-border/30" />
)}
</div>
))}
);
})}
</div>
</ScrollArea>
</div>

View File

@@ -1,6 +1,5 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
import { ConnectionList } from "@/components/connections/ConnectionList";
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
@@ -8,13 +7,15 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections, useReconnect } from "@/hooks/use-connections";
import { toast } from "sonner";
import { Database, Plus, RefreshCw, Search } from "lucide-react";
import { Database, Plus, RefreshCw, Search, Settings } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet";
export function Toolbar() {
const [listOpen, setListOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
const { data: connections } = useConnections();
@@ -59,67 +60,76 @@ export function Toolbar() {
return (
<>
<div
className="flex h-10 items-center gap-2 border-b px-3 bg-card"
style={{ borderLeftWidth: activeColor ? 3 : 0, borderLeftColor: activeColor }}
className="tusk-toolbar tusk-conn-strip flex h-10 items-center gap-1.5 px-3"
style={{
"--strip-width": activeColor ? "3px" : "0px",
"--strip-color": activeColor ?? "transparent",
} as React.CSSProperties}
>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setListOpen(true)}
>
<Database className="h-3.5 w-3.5" />
Connections
<span className="text-xs font-medium">Connections</span>
</Button>
<Separator orientation="vertical" className="h-5" />
<div className="mx-1 h-4 w-px bg-border" />
<ConnectionSelector />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
size="icon-xs"
onClick={handleReconnect}
disabled={!activeConnectionId || reconnectMutation.isPending}
title="Reconnect"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
</Button>
<Separator orientation="vertical" className="h-5" />
<div className="mx-1 h-4 w-px bg-border" />
<ReadOnlyToggle />
<Separator orientation="vertical" className="h-5" />
<div className="mx-1 h-4 w-px bg-border" />
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewQuery}
disabled={!activeConnectionId}
>
<Plus className="h-3.5 w-3.5" />
New Query
<span className="text-xs font-medium">New Query</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewLookup}
disabled={!activeConnectionId}
>
<Search className="h-3.5 w-3.5" />
Entity Lookup
<span className="text-xs font-medium">Lookup</span>
</Button>
<div className="flex-1" />
<span className="text-xs font-semibold text-muted-foreground tracking-wide">
TUSK
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setSettingsOpen(true)}
title="Settings"
className="text-muted-foreground hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<ConnectionList
@@ -140,6 +150,11 @@ export function Toolbar() {
onOpenChange={setDialogOpen}
connection={editingConn}
/>
<AppSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
</>
);
}

View File

@@ -23,6 +23,7 @@ import {
Activity,
Loader2,
} from "lucide-react";
import { DockerContainersList } from "@/components/docker/DockerContainersList";
import type { Tab, RoleInfo } from "@/types";
export function AdminPanel() {
@@ -72,6 +73,7 @@ export function AdminPanel() {
addTab(tab);
}}
/>
<DockerContainersList />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -34,7 +34,9 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
const alterMutation = useAlterRole();
useEffect(() => {
const [prev, setPrev] = useState<{ open: boolean; role: typeof role }>({ open: false, role: null });
if (open !== prev.open || role !== prev.role) {
setPrev({ open, role });
if (open && role) {
setPassword("");
setLogin(role.can_login);
@@ -47,7 +49,7 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
setValidUntil(role.valid_until ?? "");
setRenameTo("");
}
}, [open, role]);
}
if (!role) return null;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -35,7 +35,9 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
const { data: roles } = useRoles(open ? connectionId : null);
const createMutation = useCreateDatabase();
useEffect(() => {
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setName("");
setOwner("__default__");
@@ -43,7 +45,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
setEncoding("UTF8");
setConnectionLimit(-1);
}
}, [open]);
}
const handleCreate = () => {
if (!name.trim()) {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -34,7 +34,9 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
const { data: roles } = useRoles(open ? connectionId : null);
const createMutation = useCreateRole();
useEffect(() => {
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setName("");
setPassword("");
@@ -48,7 +50,7 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
setValidUntil("");
setInRoles([]);
}
}, [open]);
}
const handleCreate = () => {
if (!name.trim()) {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -61,14 +61,16 @@ export function GrantRevokeDialog({
);
const grantRevokeMutation = useGrantRevoke();
useEffect(() => {
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setAction("GRANT");
setRoleName("");
setPrivileges([]);
setWithGrantOption(false);
}
}, [open]);
}
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;

View File

@@ -55,11 +55,11 @@ export function ResultsTable({
return (
<div
className={`truncate px-2 py-1 ${
className={`truncate px-2 py-1 font-mono text-[12px] ${
value === null
? "italic text-muted-foreground"
? "tusk-grid-cell-null"
: isHighlighted
? "bg-yellow-900/30"
? "tusk-grid-cell-highlight"
: ""
}`}
onDoubleClick={() =>
@@ -77,6 +77,7 @@ export function ResultsTable({
[colNames, onCellDoubleClick, highlightedCells]
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: rows,
columns,
@@ -108,7 +109,6 @@ export function ResultsTable({
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
(e: unknown) => {
if (externalSort) {
// Cycle: none → ASC → DESC → none
if (externalSort.column !== colName) {
externalSort.onSort(colName, "ASC");
} else if (externalSort.direction === "ASC") {
@@ -141,16 +141,16 @@ export function ResultsTable({
return (
<div ref={parentRef} className="h-full select-text overflow-auto">
{/* Header */}
<div className="sticky top-0 z-10 flex bg-card border-b">
<div className="tusk-grid-header sticky top-0 z-10 flex">
{table.getHeaderGroups().map((headerGroup) =>
headerGroup.headers.map((header) => (
<div
key={header.id}
className="relative shrink-0 select-none border-r px-2 py-1.5 text-left text-xs font-medium text-muted-foreground"
className="relative shrink-0 select-none border-r border-border/30 px-2 py-1.5 text-left text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
style={{ width: header.getSize(), minWidth: header.getSize() }}
>
<div
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
className="flex cursor-pointer items-center gap-1 transition-colors hover:text-foreground"
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
>
{flexRender(
@@ -158,10 +158,10 @@ export function ResultsTable({
header.getContext()
)}
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
<ArrowUp className="h-3 w-3" />
<ArrowUp className="h-3 w-3 text-primary" />
)}
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
<ArrowDown className="h-3 w-3" />
<ArrowDown className="h-3 w-3 text-primary" />
)}
</div>
{/* Resize handle */}
@@ -169,7 +169,7 @@ export function ResultsTable({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => header.column.resetSize()}
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary ${
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none transition-colors hover:bg-primary/60 ${
header.column.getIsResizing() ? "bg-primary" : ""
}`}
/>
@@ -190,7 +190,7 @@ export function ResultsTable({
return (
<div
key={row.id}
className="absolute left-0 flex hover:bg-accent/50"
className="tusk-grid-row absolute left-0 flex transition-colors"
style={{
top: `${virtualRow.start}px`,
height: `${virtualRow.size}px`,
@@ -201,7 +201,7 @@ export function ResultsTable({
return (
<div
key={cell.id}
className="shrink-0 border-b border-r text-xs"
className="shrink-0 border-b border-r border-border/20 text-xs"
style={{ width: w, minWidth: w }}
>
{flexRender(

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -23,9 +23,11 @@ export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props
const [name, setName] = useState("");
const saveMutation = useSaveQuery();
useEffect(() => {
const [prevOpen, setPrevOpen] = useState(false);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setName("");
}, [open]);
}
const handleSave = () => {
if (!name.trim()) {

View File

@@ -31,6 +31,8 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
import { CloneDatabaseDialog } from "@/components/docker/CloneDatabaseDialog";
import { GenerateDataDialog } from "@/components/data-generator/GenerateDataDialog";
import type { Tab, SchemaObject } from "@/types";
function formatSize(bytes: number): string {
@@ -53,7 +55,7 @@ function TableSizeInfo({ item }: { item: SchemaObject }) {
if (item.row_count != null) parts.push(formatCount(item.row_count));
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
return (
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/50">
{parts.join(", ")}
</span>
);
@@ -65,18 +67,22 @@ export function SchemaTree() {
const { data: databases } = useDatabases(activeConnectionId);
const { data: connections } = useConnections();
const switchDbMutation = useSwitchDatabase();
const [cloneTarget, setCloneTarget] = useState<string | null>(null);
if (!activeConnectionId) {
return (
<div className="p-4 text-sm text-muted-foreground">
Connect to a database to browse schema.
<div className="flex flex-col items-center gap-2 p-6 text-center">
<Database className="h-8 w-8 text-muted-foreground/20" />
<p className="text-sm text-muted-foreground/60">
Connect to a database to browse schema
</p>
</div>
);
}
if (!databases || databases.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground">
<div className="p-4 text-sm text-muted-foreground/60">
No databases found.
</div>
);
@@ -112,6 +118,7 @@ export function SchemaTree() {
connectionId={activeConnectionId}
onSwitch={() => handleSwitchDb(db)}
isSwitching={switchDbMutation.isPending}
onCloneToDocker={(dbName) => setCloneTarget(dbName)}
onOpenTable={(schema, table) => {
const tab: Tab = {
id: crypto.randomUUID(),
@@ -136,8 +143,25 @@ export function SchemaTree() {
};
addTab(tab);
}}
onViewErd={(schema) => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "erd",
title: `${schema} (ER Diagram)`,
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
schema,
};
addTab(tab);
}}
/>
))}
<CloneDatabaseDialog
open={cloneTarget !== null}
onOpenChange={(open) => { if (!open) setCloneTarget(null); }}
connectionId={activeConnectionId}
database={cloneTarget ?? ""}
/>
</div>
);
}
@@ -148,16 +172,20 @@ function DatabaseNode({
connectionId,
onSwitch,
isSwitching,
onCloneToDocker,
onOpenTable,
onViewStructure,
onViewErd,
}: {
name: string;
isActive: boolean;
connectionId: string;
onSwitch: () => void;
isSwitching: boolean;
onCloneToDocker: (dbName: string) => void;
onOpenTable: (schema: string, table: string) => void;
onViewStructure: (schema: string, table: string) => void;
onViewErd: (schema: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
@@ -193,22 +221,26 @@ function DatabaseNode({
<ContextMenu>
<ContextMenuTrigger>
<div
className={`flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium ${
isActive ? "text-primary" : "text-muted-foreground"
className={`flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium ${
isActive ? "text-foreground" : "text-muted-foreground"
}`}
onClick={handleClick}
>
<span className="text-muted-foreground/50">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
<HardDrive
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`}
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground/50"}`}
/>
<span className="truncate">{name}</span>
{isActive && (
<span className="ml-auto text-[10px] text-primary">active</span>
<span className="ml-auto rounded-sm bg-primary/10 px-1 py-px text-[9px] font-semibold tracking-wider text-primary uppercase">
active
</span>
)}
</div>
</ContextMenuTrigger>
@@ -218,6 +250,58 @@ function DatabaseNode({
>
Properties
</ContextMenuItem>
<ContextMenuItem onClick={() => onCloneToDocker(name)}>
Clone to Docker
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "validation",
title: "Data Validation",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Data Validation
</ContextMenuItem>
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "index-advisor",
title: "Index Advisor",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Index Advisor
</ContextMenuItem>
<ContextMenuItem
disabled={!isActive}
onClick={() => {
if (!isActive) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "snapshots",
title: "Data Snapshots",
connectionId,
database: name,
};
useAppStore.getState().addTab(tab);
}}
>
Data Snapshots
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isActive || isReadOnly}
@@ -234,11 +318,12 @@ function DatabaseNode({
connectionId={connectionId}
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
onViewErd={onViewErd}
/>
</div>
)}
{expanded && !isActive && (
<div className="ml-6 py-1 text-xs text-muted-foreground">
<div className="ml-6 py-1 text-xs text-muted-foreground/50">
{isSwitching ? "Switching..." : "Click to switch to this database"}
</div>
)}
@@ -250,16 +335,18 @@ function SchemasForCurrentDb({
connectionId,
onOpenTable,
onViewStructure,
onViewErd,
}: {
connectionId: string;
onOpenTable: (schema: string, table: string) => void;
onViewStructure: (schema: string, table: string) => void;
onViewErd: (schema: string) => void;
}) {
const { data: schemas } = useSchemas(connectionId);
if (!schemas || schemas.length === 0) {
return (
<div className="py-1 text-xs text-muted-foreground">No schemas found.</div>
<div className="py-1 text-xs text-muted-foreground/50">No schemas found.</div>
);
}
@@ -272,6 +359,7 @@ function SchemasForCurrentDb({
connectionId={connectionId}
onOpenTable={(table) => onOpenTable(schema, table)}
onViewStructure={(table) => onViewStructure(schema, table)}
onViewErd={() => onViewErd(schema)}
/>
))}
</>
@@ -283,32 +371,45 @@ function SchemaNode({
connectionId,
onOpenTable,
onViewStructure,
onViewErd,
}: {
schema: string;
connectionId: string;
onOpenTable: (table: string) => void;
onViewStructure: (table: string) => void;
onViewErd: () => void;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<ContextMenu>
<ContextMenuTrigger>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium"
onClick={() => setExpanded(!expanded)}
>
<span className="text-muted-foreground/50">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
{expanded ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
) : (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
<span>{schema}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onViewErd}>
View ER Diagram
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{expanded && (
<div className="ml-4">
<CategoryNode
@@ -350,10 +451,10 @@ function SchemaNode({
}
const categoryIcons = {
tables: <Table2 className="h-3.5 w-3.5 text-blue-400" />,
views: <Eye className="h-3.5 w-3.5 text-green-400" />,
functions: <FunctionSquare className="h-3.5 w-3.5 text-purple-400" />,
sequences: <Hash className="h-3.5 w-3.5 text-orange-400" />,
tables: <Table2 className="h-3.5 w-3.5 text-sky-400/80" />,
views: <Eye className="h-3.5 w-3.5 text-emerald-400/80" />,
functions: <FunctionSquare className="h-3.5 w-3.5 text-violet-400/80" />,
sequences: <Hash className="h-3.5 w-3.5 text-amber-400/80" />,
};
function CategoryNode({
@@ -373,6 +474,7 @@ function CategoryNode({
}) {
const [expanded, setExpanded] = useState(false);
const [privilegesTarget, setPrivilegesTarget] = useState<string | null>(null);
const [dataGenTarget, setDataGenTarget] = useState<string | null>(null);
const tablesQuery = useTables(
expanded && category === "tables" ? connectionId : null,
@@ -405,18 +507,24 @@ function CategoryNode({
return (
<div>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
<span className="text-muted-foreground/50">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
{icon}
<span className="truncate">
<span className="truncate font-medium">
{label}
{items ? ` (${items.length})` : ""}
{items && (
<span className="ml-1 font-normal text-muted-foreground/40">
{items.length}
</span>
)}
</span>
</div>
{expanded && (
@@ -429,12 +537,12 @@ function CategoryNode({
<ContextMenu key={item.name}>
<ContextMenuTrigger>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
onDoubleClick={() => onOpenTable(item.name)}
>
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
<span className="truncate text-foreground/80">{item.name}</span>
{category === "tables" && <TableSizeInfo item={item} />}
</div>
</ContextMenuTrigger>
@@ -447,6 +555,13 @@ function CategoryNode({
>
View Structure
</ContextMenuItem>
{category === "tables" && (
<ContextMenuItem
onClick={() => setDataGenTarget(item.name)}
>
Generate Test Data
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => setPrivilegesTarget(item.name)}
@@ -461,11 +576,11 @@ function CategoryNode({
return (
<div
key={item.name}
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
>
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
<span className="truncate text-foreground/80">{item.name}</span>
</div>
);
})}
@@ -482,6 +597,15 @@ function CategoryNode({
table={privilegesTarget}
/>
)}
{dataGenTarget && (
<GenerateDataDialog
open={!!dataGenTarget}
onOpenChange={(open) => !open && setDataGenTarget(null)}
connectionId={connectionId}
schema={schema}
table={dataGenTarget}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useAppSettings, useSaveAppSettings, useMcpStatus } from "@/hooks/use-settings";
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
import { AiSettingsFields } from "@/components/ai/AiSettingsFields";
import { Loader2, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import type { AppSettings, DockerHost } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AppSettingsSheet({ open, onOpenChange }: Props) {
const { data: appSettings } = useAppSettings();
const { data: mcpStatus } = useMcpStatus();
const saveAppMutation = useSaveAppSettings();
const { data: aiSettings } = useAiSettings();
const saveAiMutation = useSaveAiSettings();
// MCP state
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpPort, setMcpPort] = useState(9427);
// Docker state
const [dockerHost, setDockerHost] = useState<DockerHost>("local");
const [dockerRemoteUrl, setDockerRemoteUrl] = useState("");
// AI state
const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434");
const [aiModel, setAiModel] = useState("");
const [copied, setCopied] = useState(false);
// Sync form with loaded settings
const [prevAppSettings, setPrevAppSettings] = useState(appSettings);
if (appSettings !== prevAppSettings) {
setPrevAppSettings(appSettings);
if (appSettings) {
setMcpEnabled(appSettings.mcp.enabled);
setMcpPort(appSettings.mcp.port);
setDockerHost(appSettings.docker.host);
setDockerRemoteUrl(appSettings.docker.remote_url ?? "");
}
}
const [prevAiSettings, setPrevAiSettings] = useState(aiSettings);
if (aiSettings !== prevAiSettings) {
setPrevAiSettings(aiSettings);
if (aiSettings) {
setOllamaUrl(aiSettings.ollama_url);
setAiModel(aiSettings.model);
}
}
const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`;
const handleCopy = async () => {
await navigator.clipboard.writeText(mcpEndpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSave = () => {
const settings: AppSettings = {
mcp: { enabled: mcpEnabled, port: mcpPort },
docker: {
host: dockerHost,
remote_url: dockerHost === "remote" ? dockerRemoteUrl || undefined : undefined,
},
};
saveAppMutation.mutate(settings, {
onSuccess: () => {
toast.success("Settings saved");
},
onError: (err) =>
toast.error("Failed to save settings", { description: String(err) }),
});
// Save AI settings separately
saveAiMutation.mutate(
{ provider: "ollama", ollama_url: ollamaUrl, model: aiModel },
{
onError: (err) =>
toast.error("Failed to save AI settings", { description: String(err) }),
}
);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[400px] sm:max-w-[400px] overflow-y-auto">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Application configuration</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-6 px-4">
{/* MCP Server */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">MCP Server</h3>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Enabled</span>
<Button
size="sm"
variant={mcpEnabled ? "default" : "outline"}
className="h-6 text-xs px-3"
onClick={() => setMcpEnabled(!mcpEnabled)}
>
{mcpEnabled ? "On" : "Off"}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Port</label>
<Input
type="number"
value={mcpPort}
onChange={(e) => setMcpPort(Number(e.target.value))}
className="h-8 text-xs"
min={1}
max={65535}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Status:</span>
<span className="flex items-center gap-1.5 text-xs">
<span
className={`inline-block h-2 w-2 rounded-full ${
mcpStatus?.running
? "bg-green-500"
: "bg-muted-foreground/30"
}`}
/>
{mcpStatus?.running ? "Running" : "Stopped"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Endpoint</label>
<div className="flex items-center gap-1">
<code className="flex-1 rounded bg-muted px-2 py-1 text-xs font-mono truncate">
{mcpEndpoint}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleCopy}
title="Copy endpoint URL"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
</section>
<Separator />
{/* Docker */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Docker</h3>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Docker Host</label>
<Select value={dockerHost} onValueChange={(v) => setDockerHost(v as DockerHost)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="remote">Remote</SelectItem>
</SelectContent>
</Select>
</div>
{dockerHost === "remote" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Remote URL</label>
<Input
value={dockerRemoteUrl}
onChange={(e) => setDockerRemoteUrl(e.target.value)}
placeholder="tcp://192.168.1.100:2375"
className="h-8 text-xs"
/>
</div>
)}
</section>
<Separator />
{/* AI */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">AI</h3>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Provider</label>
<Select value="ollama" disabled>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ollama">Ollama</SelectItem>
</SelectContent>
</Select>
</div>
<AiSettingsFields
ollamaUrl={ollamaUrl}
onOllamaUrlChange={setOllamaUrl}
model={aiModel}
onModelChange={setAiModel}
/>
</section>
</div>
<SheetFooter>
<Button
className="w-full"
onClick={handleSave}
disabled={saveAppMutation.isPending}
>
{saveAppMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Save
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

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

View File

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

View File

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

View File

@@ -94,21 +94,36 @@ export function TableDataView({ connectionId, schema, table }: Props) {
[pendingChanges, isReadOnly]
);
const usesCtid = pkColumns.length === 0;
const handleCommit = async () => {
if (!data || pkColumns.length === 0) {
toast.error("Cannot save: no primary key detected");
if (!data) return;
if (pkColumns.length === 0 && (!data.ctids || data.ctids.length === 0)) {
toast.error("Cannot save: no primary key and no ctid available");
return;
}
setIsSaving(true);
try {
for (const [_key, change] of pendingChanges) {
for (const [, change] of pendingChanges) {
const row = data.rows[change.rowIndex];
const colName = data.columns[change.colIndex];
if (usesCtid) {
await updateRowApi({
connectionId,
schema,
table,
pkColumns: [],
pkValues: [],
column: colName,
value: change.value,
ctid: data.ctids[change.rowIndex],
});
} else {
const pkValues = pkColumns.map((pkCol) => {
const idx = data.columns.indexOf(pkCol);
return row[idx];
});
const colName = data.columns[change.colIndex];
await updateRowApi({
connectionId,
schema,
@@ -119,6 +134,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
value: change.value,
});
}
}
setPendingChanges(new Map());
queryClient.invalidateQueries({
queryKey: ["table-data", connectionId],
@@ -182,6 +198,14 @@ export function TableDataView({ connectionId, schema, table }: Props) {
Read-Only
</span>
)}
{!isReadOnly && usesCtid && (
<span
className="rounded bg-orange-500/10 px-1.5 py-0.5 text-[10px] font-medium text-orange-600 dark:text-orange-400"
title="This table has no primary key. Edits use physical row ID (ctid), which may change after VACUUM or concurrent writes."
>
No PK using ctid
</span>
)}
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="WHERE clause (e.g. id > 10)"

View File

@@ -3,6 +3,7 @@ import {
getTableColumns,
getTableConstraints,
getTableIndexes,
getTableTriggers,
} from "@/lib/tauri";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -38,6 +39,11 @@ export function TableStructure({ connectionId, schema, table }: Props) {
queryFn: () => getTableIndexes(connectionId, schema, table),
});
const { data: triggers } = useQuery({
queryKey: ["table-triggers", connectionId, schema, table],
queryFn: () => getTableTriggers(connectionId, schema, table),
});
return (
<Tabs defaultValue="columns" className="flex h-full flex-col">
<TabsList className="mx-2 mt-2 w-fit">
@@ -50,6 +56,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
<TabsTrigger value="indexes" className="text-xs">
Indexes{indexes ? ` (${indexes.length})` : ""}
</TabsTrigger>
<TabsTrigger value="triggers" className="text-xs">
Triggers{triggers ? ` (${triggers.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="columns" className="flex-1 overflow-hidden mt-0">
@@ -63,6 +72,7 @@ export function TableStructure({ connectionId, schema, table }: Props) {
<TableHead className="text-xs">Nullable</TableHead>
<TableHead className="text-xs">Default</TableHead>
<TableHead className="text-xs">Key</TableHead>
<TableHead className="text-xs">Comment</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -84,7 +94,7 @@ export function TableStructure({ connectionId, schema, table }: Props) {
{col.is_nullable ? "YES" : "NO"}
</TableCell>
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
{col.column_default ?? ""}
{col.column_default ?? "\u2014"}
</TableCell>
<TableCell>
{col.is_primary_key && (
@@ -93,6 +103,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
</Badge>
)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
{col.comment ?? "\u2014"}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -108,6 +121,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
<TableHead className="text-xs">Name</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs">Columns</TableHead>
<TableHead className="text-xs">References</TableHead>
<TableHead className="text-xs">On Update</TableHead>
<TableHead className="text-xs">On Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -124,6 +140,17 @@ export function TableStructure({ connectionId, schema, table }: Props) {
<TableCell className="text-xs">
{c.columns.join(", ")}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.referenced_table
? `${c.referenced_schema}.${c.referenced_table}(${c.referenced_columns?.join(", ")})`
: "\u2014"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.update_rule ?? "\u2014"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.delete_rule ?? "\u2014"}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -163,6 +190,49 @@ export function TableStructure({ connectionId, schema, table }: Props) {
</Table>
</ScrollArea>
</TabsContent>
<TabsContent value="triggers" className="flex-1 overflow-hidden mt-0">
<ScrollArea className="h-full">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Name</TableHead>
<TableHead className="text-xs">Timing</TableHead>
<TableHead className="text-xs">Event</TableHead>
<TableHead className="text-xs">Level</TableHead>
<TableHead className="text-xs">Function</TableHead>
<TableHead className="text-xs">Enabled</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{triggers?.map((t) => (
<TableRow key={t.name}>
<TableCell className="text-xs font-medium">
{t.name}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{t.timing}
</Badge>
</TableCell>
<TableCell className="text-xs">
{t.event}
</TableCell>
<TableCell className="text-xs">
{t.orientation}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{t.function_name}
</TableCell>
<TableCell className="text-xs">
{t.is_enabled ? "YES" : "NO"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
@@ -5,19 +6,19 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-default",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white shadow-sm shadow-destructive/20 hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border border-border/60 bg-transparent shadow-xs hover:bg-accent/50 hover:text-accent-foreground hover:border-border",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"hover:bg-accent/50 hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary/20 selection:text-foreground border-border/50 h-9 w-full min-w-0 rounded-md border bg-background/50 px-3 py-1 text-base shadow-xs transition-all duration-150 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40 md:text-sm",
"focus-visible:border-primary/40 focus-visible:ring-primary/15 focus-visible:ring-[3px] focus-visible:bg-background/80",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"

View File

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

View File

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

View File

@@ -5,6 +5,10 @@ import { TableStructure } from "@/components/table-viewer/TableStructure";
import { RoleManagerView } from "@/components/management/RoleManagerView";
import { SessionsView } from "@/components/management/SessionsView";
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
import { ErdDiagram } from "@/components/erd/ErdDiagram";
import { ValidationPanel } from "@/components/validation/ValidationPanel";
import { IndexAdvisorPanel } from "@/components/index-advisor/IndexAdvisorPanel";
import { SnapshotPanel } from "@/components/snapshots/SnapshotPanel";
export function TabContent() {
const { tabs, activeTabId, updateTab } = useAppStore();
@@ -72,6 +76,35 @@ export function TabContent() {
/>
);
break;
case "erd":
content = (
<ErdDiagram
connectionId={tab.connectionId}
schema={tab.schema!}
/>
);
break;
case "validation":
content = (
<ValidationPanel
connectionId={tab.connectionId}
/>
);
break;
case "index-advisor":
content = (
<IndexAdvisorPanel
connectionId={tab.connectionId}
/>
);
break;
case "snapshots":
content = (
<SnapshotPanel
connectionId={tab.connectionId}
/>
);
break;
default:
content = null;
}

View File

@@ -255,11 +255,12 @@ export function WorkspacePanel({
<ResizablePanelGroup orientation="vertical">
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-2 py-1">
{/* Editor action bar */}
<div className="flex items-center gap-1 border-b border-border/40 px-2 py-1">
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px] text-primary hover:bg-primary/10 hover:text-primary"
onClick={handleExecute}
disabled={queryMutation.isPending || !sqlValue.trim()}
>
@@ -271,9 +272,9 @@ export function WorkspacePanel({
Run
</Button>
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px]"
onClick={handleExplain}
disabled={queryMutation.isPending || !sqlValue.trim()}
>
@@ -285,9 +286,9 @@ export function WorkspacePanel({
Explain
</Button>
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px]"
onClick={handleFormat}
disabled={!sqlValue.trim()}
title="Format SQL (Shift+Alt+F)"
@@ -296,9 +297,9 @@ export function WorkspacePanel({
Format
</Button>
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px]"
onClick={() => setSaveDialogOpen(true)}
disabled={!sqlValue.trim()}
title="Save query"
@@ -306,20 +307,24 @@ export function WorkspacePanel({
<Bookmark className="h-3 w-3" />
Save
</Button>
<div className="mx-1 h-3.5 w-px bg-border/40" />
{/* AI actions group — purple-branded */}
<Button
size="sm"
size="xs"
variant={aiBarOpen ? "secondary" : "ghost"}
className="h-6 gap-1 text-xs"
className={`gap-1 text-[11px] ${aiBarOpen ? "text-tusk-purple" : ""}`}
onClick={() => setAiBarOpen(!aiBarOpen)}
title="AI SQL Generator"
>
<Sparkles className="h-3 w-3" />
<Sparkles className={`h-3 w-3 ${aiBarOpen ? "tusk-ai-icon" : ""}`} />
AI
</Button>
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px]"
onClick={handleAiExplain}
disabled={isAiLoading || !sqlValue.trim()}
title="Explain query with AI"
@@ -331,13 +336,16 @@ export function WorkspacePanel({
)}
AI Explain
</Button>
{result && result.columns.length > 0 && (
<>
<div className="mx-1 h-3.5 w-px bg-border/40" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
size="xs"
variant="ghost"
className="h-6 gap-1 text-xs"
className="gap-1 text-[11px]"
>
<Download className="h-3 w-3" />
Export
@@ -352,14 +360,18 @@ export function WorkspacePanel({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<span className="text-[11px] text-muted-foreground">
Ctrl+Enter to execute
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/50 font-mono">
{"\u2318"}Enter
</span>
{isReadOnly && (
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
<Lock className="h-3 w-3" />
Read-Only
<span className="ml-2 flex items-center gap-1 rounded-sm bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-500">
<Lock className="h-2.5 w-2.5" />
READ
</span>
)}
</div>
@@ -389,35 +401,41 @@ export function WorkspacePanel({
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
<div className="flex h-full flex-col overflow-hidden">
{(explainData || result || error || aiExplanation) && (
<div className="flex shrink-0 items-center border-b text-xs">
<div className="flex shrink-0 items-center border-b border-border/40 text-xs">
<button
className={`px-3 py-1 font-medium ${
className={`relative px-3 py-1.5 font-medium transition-colors ${
resultView === "results"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
? "text-foreground"
: "text-muted-foreground hover:text-foreground/70"
}`}
onClick={() => setResultView("results")}
>
Results
{resultView === "results" && (
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
)}
</button>
{explainData && (
<button
className={`px-3 py-1 font-medium ${
className={`relative px-3 py-1.5 font-medium transition-colors ${
resultView === "explain"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
? "text-foreground"
: "text-muted-foreground hover:text-foreground/70"
}`}
onClick={() => setResultView("explain")}
>
Explain
{resultView === "explain" && (
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
)}
</button>
)}
{resultView === "results" && result && result.columns.length > 0 && (
<div className="ml-auto mr-2 flex items-center rounded-md border">
<div className="ml-auto mr-2 flex items-center overflow-hidden rounded border border-border/40">
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium transition-colors ${
resultViewMode === "table"
? "bg-muted text-foreground"
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("table")}
@@ -427,9 +445,9 @@ export function WorkspacePanel({
Table
</button>
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium transition-colors ${
resultViewMode === "json"
? "bg-muted text-foreground"
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("json")}

View File

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

122
src/hooks/use-docker.ts Normal file
View File

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

View File

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

View File

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

30
src/hooks/use-settings.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getAppSettings, saveAppSettings, getMcpStatus } from "@/lib/tauri";
import type { AppSettings } from "@/types";
export function useAppSettings() {
return useQuery({
queryKey: ["app-settings"],
queryFn: getAppSettings,
staleTime: Infinity,
});
}
export function useSaveAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: AppSettings) => saveAppSettings(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["app-settings"] });
queryClient.invalidateQueries({ queryKey: ["mcp-status"] });
},
});
}
export function useMcpStatus() {
return useQuery({
queryKey: ["mcp-status"],
queryFn: getMcpStatus,
refetchInterval: 5000,
});
}

153
src/hooks/use-snapshots.ts Normal file
View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import type {
ColumnInfo,
ConstraintInfo,
IndexInfo,
TriggerInfo,
ErdData,
HistoryEntry,
SavedQuery,
SessionInfo,
@@ -26,6 +28,22 @@ import type {
OllamaModel,
EntityLookupResult,
LookupProgress,
DockerStatus,
CloneToDockerParams,
CloneProgress,
CloneResult,
TuskContainer,
AppSettings,
McpStatus,
ValidationRule,
GenerateDataParams,
GeneratedDataPreview,
DataGenProgress,
IndexAdvisorReport,
SnapshotMetadata,
CreateSnapshotParams,
RestoreSnapshotParams,
SnapshotProgress,
} from "@/types";
// Connections
@@ -115,6 +133,15 @@ export const getTableIndexes = (
table: string
) => invoke<IndexInfo[]>("get_table_indexes", { connectionId, schema, table });
export const getTableTriggers = (
connectionId: string,
schema: string,
table: string
) => invoke<TriggerInfo[]>("get_table_triggers", { connectionId, schema, table });
export const getSchemaErd = (connectionId: string, schema: string) =>
invoke<ErdData>("get_schema_erd", { connectionId, schema });
// Data
export const getTableData = (params: {
connectionId: string;
@@ -135,6 +162,7 @@ export const updateRow = (params: {
pkValues: unknown[];
column: string;
value: unknown;
ctid?: string;
}) => invoke<void>("update_row", params);
export const insertRow = (params: {
@@ -151,6 +179,7 @@ export const deleteRows = (params: {
table: string;
pkColumns: string[];
pkValuesList: unknown[][];
ctids?: string[];
}) => invoke<number>("delete_rows", params);
// History
@@ -280,3 +309,84 @@ export const onLookupProgress = (
callback: (p: LookupProgress) => void
): Promise<UnlistenFn> =>
listen<LookupProgress>("lookup-progress", (e) => callback(e.payload));
// Docker
export const checkDocker = () =>
invoke<DockerStatus>("check_docker");
export const listTuskContainers = () =>
invoke<TuskContainer[]>("list_tusk_containers");
export const cloneToDocker = (params: CloneToDockerParams, cloneId: string) =>
invoke<CloneResult>("clone_to_docker", { params, cloneId });
export const startContainer = (name: string) =>
invoke<void>("start_container", { name });
export const stopContainer = (name: string) =>
invoke<void>("stop_container", { name });
export const removeContainer = (name: string) =>
invoke<void>("remove_container", { name });
export const onCloneProgress = (
callback: (p: CloneProgress) => void
): Promise<UnlistenFn> =>
listen<CloneProgress>("clone-progress", (e) => callback(e.payload));
// App Settings
export const getAppSettings = () =>
invoke<AppSettings>("get_app_settings");
export const saveAppSettings = (settings: AppSettings) =>
invoke<void>("save_app_settings", { settings });
export const getMcpStatus = () =>
invoke<McpStatus>("get_mcp_status");
// Validation (Wave 1)
export const generateValidationSql = (connectionId: string, ruleDescription: string) =>
invoke<string>("generate_validation_sql", { connectionId, ruleDescription });
export const runValidationRule = (connectionId: string, sql: string, sampleLimit?: number) =>
invoke<ValidationRule>("run_validation_rule", { connectionId, sql, sampleLimit });
export const suggestValidationRules = (connectionId: string) =>
invoke<string[]>("suggest_validation_rules", { connectionId });
// Data Generator (Wave 2)
export const generateTestDataPreview = (params: GenerateDataParams, genId: string) =>
invoke<GeneratedDataPreview>("generate_test_data_preview", { params, genId });
export const insertGeneratedData = (connectionId: string, preview: GeneratedDataPreview) =>
invoke<number>("insert_generated_data", { connectionId, preview });
export const onDataGenProgress = (
callback: (p: DataGenProgress) => void
): Promise<UnlistenFn> =>
listen<DataGenProgress>("datagen-progress", (e) => callback(e.payload));
// Index Advisor (Wave 3A)
export const getIndexAdvisorReport = (connectionId: string) =>
invoke<IndexAdvisorReport>("get_index_advisor_report", { connectionId });
export const applyIndexRecommendation = (connectionId: string, ddl: string) =>
invoke<void>("apply_index_recommendation", { connectionId, ddl });
// Snapshots (Wave 3B)
export const createSnapshot = (params: CreateSnapshotParams, snapshotId: string, filePath: string) =>
invoke<SnapshotMetadata>("create_snapshot", { params, snapshotId, filePath });
export const restoreSnapshot = (params: RestoreSnapshotParams, snapshotId: string) =>
invoke<number>("restore_snapshot", { params, snapshotId });
export const listSnapshots = () =>
invoke<SnapshotMetadata[]>("list_snapshots");
export const readSnapshotMetadata = (filePath: string) =>
invoke<SnapshotMetadata>("read_snapshot_metadata", { filePath });
export const onSnapshotProgress = (
callback: (p: SnapshotProgress) => void
): Promise<UnlistenFn> =>
listen<SnapshotProgress>("snapshot-progress", (e) => callback(e.payload));

View File

@@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useAppStore } from "../app-store";
import type { Tab } from "@/types";
function resetStore() {
useAppStore.setState({
connections: [],
activeConnectionId: null,
currentDatabase: null,
connectedIds: new Set(),
readOnlyMap: {},
dbFlavors: {},
tabs: [],
activeTabId: null,
sidebarWidth: 260,
pgVersion: null,
});
}
const makeTab = (id: string, type: Tab["type"] = "query"): Tab => ({
id,
type,
title: `Tab ${id}`,
connectionId: "conn-1",
});
describe("AppStore", () => {
beforeEach(() => {
resetStore();
});
// ── Connections ────────────────────────────────────────────
describe("connections", () => {
it("should set active connection id", () => {
useAppStore.getState().setActiveConnectionId("conn-1");
expect(useAppStore.getState().activeConnectionId).toBe("conn-1");
});
it("should clear active connection id", () => {
useAppStore.getState().setActiveConnectionId("conn-1");
useAppStore.getState().setActiveConnectionId(null);
expect(useAppStore.getState().activeConnectionId).toBeNull();
});
it("should add connected id and default to read-only", () => {
useAppStore.getState().addConnectedId("conn-1");
const state = useAppStore.getState();
expect(state.connectedIds.has("conn-1")).toBe(true);
expect(state.readOnlyMap["conn-1"]).toBe(true);
});
it("should remove connected id and clean up maps", () => {
const s = useAppStore.getState();
s.addConnectedId("conn-1");
s.setDbFlavor("conn-1", "postgresql");
s.setReadOnly("conn-1", false);
useAppStore.getState().removeConnectedId("conn-1");
const state = useAppStore.getState();
expect(state.connectedIds.has("conn-1")).toBe(false);
expect(state.readOnlyMap["conn-1"]).toBeUndefined();
expect(state.dbFlavors["conn-1"]).toBeUndefined();
});
it("should not affect other connections on remove", () => {
const s = useAppStore.getState();
s.addConnectedId("conn-1");
s.addConnectedId("conn-2");
s.setDbFlavor("conn-1", "postgresql");
s.setDbFlavor("conn-2", "greenplum");
useAppStore.getState().removeConnectedId("conn-1");
const state = useAppStore.getState();
expect(state.connectedIds.has("conn-2")).toBe(true);
expect(state.readOnlyMap["conn-2"]).toBe(true);
expect(state.dbFlavors["conn-2"]).toBe("greenplum");
});
it("should toggle read-only mode", () => {
useAppStore.getState().addConnectedId("conn-1");
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
useAppStore.getState().setReadOnly("conn-1", false);
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(false);
useAppStore.getState().setReadOnly("conn-1", true);
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
});
it("addConnectedId forces read-only true on reconnect", () => {
useAppStore.getState().addConnectedId("conn-1");
useAppStore.getState().setReadOnly("conn-1", false);
// Reconnect resets to read-only
useAppStore.getState().addConnectedId("conn-1");
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
});
});
// ── Tabs ──────────────────────────────────────────────────
describe("tabs", () => {
it("should add tab and set it as active", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
const state = useAppStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0].id).toBe("tab-1");
expect(state.activeTabId).toBe("tab-1");
});
it("should activate the most recently added tab", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().addTab(makeTab("tab-2"));
expect(useAppStore.getState().activeTabId).toBe("tab-2");
});
it("should close tab and activate last remaining", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().addTab(makeTab("tab-2"));
useAppStore.getState().addTab(makeTab("tab-3"));
useAppStore.getState().setActiveTabId("tab-2");
useAppStore.getState().closeTab("tab-2");
const state = useAppStore.getState();
expect(state.tabs).toHaveLength(2);
// When closing the active tab, last remaining tab becomes active
expect(state.activeTabId).toBe("tab-3");
});
it("should set activeTabId to null when closing the only tab", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().closeTab("tab-1");
expect(useAppStore.getState().tabs).toHaveLength(0);
expect(useAppStore.getState().activeTabId).toBeNull();
});
it("should preserve activeTabId when closing a non-active tab", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().addTab(makeTab("tab-2"));
useAppStore.getState().setActiveTabId("tab-1");
useAppStore.getState().closeTab("tab-2");
expect(useAppStore.getState().activeTabId).toBe("tab-1");
});
it("should update tab fields without affecting others", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().addTab(makeTab("tab-2"));
useAppStore.getState().updateTab("tab-1", { title: "Updated" });
expect(useAppStore.getState().tabs[0].title).toBe("Updated");
expect(useAppStore.getState().tabs[1].title).toBe("Tab tab-2");
});
it("should handle closing non-existent tab gracefully", () => {
useAppStore.getState().addTab(makeTab("tab-1"));
useAppStore.getState().closeTab("non-existent");
expect(useAppStore.getState().tabs).toHaveLength(1);
expect(useAppStore.getState().activeTabId).toBe("tab-1");
});
it("should handle different tab types", () => {
useAppStore.getState().addTab(makeTab("t1", "query"));
useAppStore.getState().addTab(makeTab("t2", "table"));
useAppStore.getState().addTab(makeTab("t3", "erd"));
expect(useAppStore.getState().tabs.map((t) => t.type)).toEqual([
"query",
"table",
"erd",
]);
});
});
// ── Database state ────────────────────────────────────────
describe("database state", () => {
it("should set and clear current database", () => {
useAppStore.getState().setCurrentDatabase("mydb");
expect(useAppStore.getState().currentDatabase).toBe("mydb");
useAppStore.getState().setCurrentDatabase(null);
expect(useAppStore.getState().currentDatabase).toBeNull();
});
it("should set pg version", () => {
useAppStore.getState().setPgVersion("16.2");
expect(useAppStore.getState().pgVersion).toBe("16.2");
});
it("should set db flavor", () => {
useAppStore.getState().setDbFlavor("conn-1", "greenplum");
expect(useAppStore.getState().dbFlavors["conn-1"]).toBe("greenplum");
});
});
// ── Sidebar ───────────────────────────────────────────────
describe("sidebar", () => {
it("should set sidebar width", () => {
useAppStore.getState().setSidebarWidth(400);
expect(useAppStore.getState().sidebarWidth).toBe(400);
});
it("should have default sidebar width of 260", () => {
expect(useAppStore.getState().sidebarWidth).toBe(260);
});
});
});

View File

@@ -4,6 +4,11 @@
@custom-variant dark (&:is(.dark *));
/* ═══════════════════════════════════════════════════════
TUSK — "Twilight" Design System
Soft dark with blue undertones and teal accents
═══════════════════════════════════════════════════════ */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -43,50 +48,399 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Custom semantic tokens */
--color-tusk-teal: var(--tusk-teal);
--color-tusk-purple: var(--tusk-purple);
--color-tusk-amber: var(--tusk-amber);
--color-tusk-rose: var(--tusk-rose);
--color-tusk-surface: var(--tusk-surface);
/* Font families */
--font-sans: "Outfit", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
}
:root {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--radius: 0.5rem;
/* Soft twilight palette — comfortable, not eye-straining */
--background: oklch(0.2 0.012 250);
--foreground: oklch(0.9 0.005 250);
--card: oklch(0.23 0.012 250);
--card-foreground: oklch(0.9 0.005 250);
--popover: oklch(0.25 0.014 250);
--popover-foreground: oklch(0.9 0.005 250);
/* Teal primary — slightly softer for the lighter background */
--primary: oklch(0.72 0.14 170);
--primary-foreground: oklch(0.18 0.015 250);
/* Surfaces — gentle stepping */
--secondary: oklch(0.27 0.012 250);
--secondary-foreground: oklch(0.85 0.008 250);
--muted: oklch(0.27 0.012 250);
--muted-foreground: oklch(0.62 0.015 250);
--accent: oklch(0.28 0.014 250);
--accent-foreground: oklch(0.9 0.005 250);
/* Status */
--destructive: oklch(0.65 0.2 15);
/* Borders & inputs — more visible, less transparent */
--border: oklch(0.34 0.015 250 / 70%);
--input: oklch(0.36 0.015 250 / 60%);
--ring: oklch(0.72 0.14 170 / 40%);
/* Chart palette */
--chart-1: oklch(0.72 0.14 170);
--chart-2: oklch(0.68 0.14 200);
--chart-3: oklch(0.78 0.14 85);
--chart-4: oklch(0.62 0.18 290);
--chart-5: oklch(0.68 0.16 30);
/* Sidebar <20><> same family, slightly offset */
--sidebar: oklch(0.215 0.012 250);
--sidebar-foreground: oklch(0.9 0.005 250);
--sidebar-primary: oklch(0.72 0.14 170);
--sidebar-primary-foreground: oklch(0.9 0.005 250);
--sidebar-accent: oklch(0.28 0.014 250);
--sidebar-accent-foreground: oklch(0.9 0.005 250);
--sidebar-border: oklch(0.34 0.015 250 / 70%);
--sidebar-ring: oklch(0.72 0.14 170 / 40%);
/* Tusk semantic tokens */
--tusk-teal: oklch(0.72 0.14 170);
--tusk-purple: oklch(0.62 0.2 290);
--tusk-amber: oklch(0.78 0.14 85);
--tusk-rose: oklch(0.65 0.2 15);
--tusk-surface: oklch(0.26 0.012 250);
}
/* ═══════════════════════════════════════════════════════
Base layer
═══════════════════════════════════════════════════════ */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Monospace for code and data */
code, pre, .font-mono,
[data-slot="sql-editor"],
.cm-editor {
font-family: var(--font-mono);
}
/* Smoother scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.42 0.015 250 / 45%);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.015 250 / 60%);
}
::-webkit-scrollbar-corner {
background: transparent;
}
}
/* ═══════════════════════════════════════════════════════
Noise texture overlay — very subtle depth
═══════════════════════════════════════════════════════ */
.tusk-noise::before {
content: "";
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.018;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* ═══════════════════════════════════════════════════════
Glow effects — softer for lighter background
═══════════════════════════════════════════════════════ */
.tusk-glow-teal {
box-shadow: 0 0 10px oklch(0.72 0.14 170 / 12%),
0 0 3px oklch(0.72 0.14 170 / 8%);
}
.tusk-glow-purple {
box-shadow: 0 0 10px oklch(0.62 0.2 290 / 15%),
0 0 3px oklch(0.62 0.2 290 / 10%);
}
.tusk-glow-teal-subtle {
box-shadow: 0 0 6px oklch(0.72 0.14 170 / 6%);
}
/* ═══════════════════════════════════════════════<E29590><E29590>═══════
Active tab indicator — top glow bar
═══════════════════════════════════════════════════════ */
.tusk-tab-active {
position: relative;
}
.tusk-tab-active::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, oklch(0.72 0.14 170), oklch(0.68 0.14 200));
border-radius: 0 0 2px 2px;
}
/* ═══════════════════════════════════════════════════════
AI feature branding — purple glow language
═══════════════════════════════════════════════════════ */
.tusk-ai-bar {
background: linear-gradient(
135deg,
oklch(0.62 0.2 290 / 5%) 0%,
oklch(0.62 0.2 290 / 2%) 50%,
oklch(0.72 0.14 170 / 3%) 100%
);
border-bottom: 1px solid oklch(0.62 0.2 290 / 12%);
}
.tusk-ai-icon {
color: oklch(0.68 0.18 290);
filter: drop-shadow(0 0 3px oklch(0.62 0.2 290 / 30%));
}
/* ═══════════════════════════════════════════════════════
Transitions — smooth everything
═══════════════════════════════════════════════════════ */
button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ═══════════════════════════════════════════════════════
Glassmorphism for floating elements
═══════════════════════════════════════════════════════ */
[data-radix-popper-content-wrapper] [role="dialog"],
[data-radix-popper-content-wrapper] [role="listbox"],
[data-radix-popper-content-wrapper] [role="menu"],
[data-state="open"][data-side] {
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
}
/* ═══════════════════════════════════════════════════════
Sidebar tab active indicator
═══════════════════════════════════════════════════════ */
.tusk-sidebar-tab-active {
position: relative;
}
.tusk-sidebar-tab-active::after {
content: "";
position: absolute;
bottom: 0;
left: 25%;
right: 25%;
height: 2px;
background: oklch(0.72 0.14 170);
border-radius: 2px 2px 0 0;
}
/* ═══════════════════════════════════════════════════════
Data grid refinements
═══════════════════════════════════════════════════════ */
.tusk-grid-header {
background: oklch(0.23 0.012 250);
border-bottom: 1px solid oklch(0.34 0.015 250 / 80%);
}
.tusk-grid-row:hover {
background: oklch(0.72 0.14 170 / 5%);
}
.tusk-grid-cell-null {
color: oklch(0.5 0.015 250);
font-style: italic;
}
.tusk-grid-cell-highlight {
background: oklch(0.78 0.14 85 / 10%);
}
/* ═══════════════════════════════════════════════════════
Status bar
═══════════════════════════════════════════════════════ */
.tusk-status-bar {
background: oklch(0.215 0.012 250);
border-top: 1px solid oklch(0.34 0.015 250 / 50%);
}
/* ═══════════════════════════════════════════════════════
Connection color accent strip
═══════════════════════════════════════════════════════ */
.tusk-conn-strip {
position: relative;
}
.tusk-conn-strip::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--strip-width, 3px);
background: var(--strip-color, transparent);
border-radius: 0 2px 2px 0;
}
/* ═══════════════════════════════════════════════════════
Toolbar
═══════════════════════════════════════════════════════ */
.tusk-toolbar {
background: oklch(0.23 0.012 250);
border-bottom: 1px solid oklch(0.34 0.015 250 / 60%);
}
/* ═══════════════════════════════════════════════════════
Resizable handle
═══════════════════════════════════════════════════════ */
[data-panel-group-direction="horizontal"] > [data-resize-handle] {
width: 1px !important;
background: oklch(0.34 0.015 250 / 50%);
transition: background 200ms, width 200ms;
}
[data-panel-group-direction="horizontal"] > [data-resize-handle]:hover,
[data-panel-group-direction="horizontal"] > [data-resize-handle][data-resize-handle-active] {
width: 3px !important;
background: oklch(0.72 0.14 170 / 60%);
}
[data-panel-group-direction="vertical"] > [data-resize-handle] {
height: 1px !important;
background: oklch(0.34 0.015 250 / 50%);
transition: background 200ms, height 200ms;
}
[data-panel-group-direction="vertical"] > [data-resize-handle]:hover,
[data-panel-group-direction="vertical"] > [data-resize-handle][data-resize-handle-active] {
height: 3px !important;
background: oklch(0.72 0.14 170 / 60%);
}
/* ═══════════════════════════════════════════════════════
CodeMirror theme overrides
═══════════════════════════════════════════════════════ */
.cm-editor {
font-size: 13px;
line-height: 1.6;
}
.cm-editor .cm-gutters {
background: oklch(0.215 0.012 250);
border-right: 1px solid oklch(0.34 0.015 250 / 50%);
color: oklch(0.48 0.012 250);
}
.cm-editor .cm-activeLineGutter {
background: oklch(0.72 0.14 170 / 8%);
color: oklch(0.65 0.015 250);
}
.cm-editor .cm-activeLine {
background: oklch(0.72 0.14 170 / 4%);
}
.cm-editor .cm-cursor {
border-left-color: oklch(0.72 0.14 170);
}
.cm-editor .cm-selectionBackground {
background: oklch(0.72 0.14 170 / 15%) !important;
}
.cm-editor .cm-tooltip-autocomplete {
backdrop-filter: blur(16px);
background: oklch(0.25 0.014 250 / 95%);
border: 1px solid oklch(0.34 0.015 250 / 70%);
border-radius: 6px;
box-shadow: 0 8px 32px oklch(0 0 0 / 30%);
}
/* ═══════════════════════════════════════════════════════
Sonner toast styling
═══════════════════════════════════════════════════════ */
[data-sonner-toaster] [data-sonner-toast] {
font-family: var(--font-sans);
backdrop-filter: blur(12px);
}
/* ═══════════════════════════════════════════════════════
Utility animations
═══════════════════════════════════════════════════════ */
@keyframes tusk-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes tusk-pulse-glow {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.tusk-fade-in {
animation: tusk-fade-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tusk-pulse-glow {
animation: tusk-pulse-glow 2s ease-in-out infinite;
}
/* ═══════════════════════════════════════════════════════
Selection styling
═══════════════════════════════════════════════════════ */
::selection {
background: oklch(0.72 0.14 170 / 25%);
color: oklch(0.95 0.005 250);
}

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -30,6 +30,7 @@ export interface PaginatedQueryResult extends QueryResult {
total_rows: number;
page: number;
page_size: number;
ctids: string[];
}
export interface SchemaObject {
@@ -56,12 +57,18 @@ export interface ColumnInfo {
ordinal_position: number;
character_maximum_length: number | null;
is_primary_key: boolean;
comment: string | null;
}
export interface ConstraintInfo {
name: string;
constraint_type: string;
columns: string[];
referenced_schema: string | null;
referenced_table: string | null;
referenced_columns: string[] | null;
update_rule: string | null;
delete_rule: string | null;
}
export interface IndexInfo {
@@ -223,8 +230,13 @@ export interface SavedQuery {
created_at: string;
}
export type AiProvider = "ollama" | "openai" | "anthropic";
export interface AiSettings {
provider: AiProvider;
ollama_url: string;
openai_api_key?: string;
anthropic_api_key?: string;
model: string;
}
@@ -272,7 +284,117 @@ export interface LookupProgress {
total: number;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup";
export interface TriggerInfo {
name: string;
event: string;
timing: string;
orientation: string;
function_name: string;
is_enabled: boolean;
definition: string;
}
export interface ErdColumn {
name: string;
data_type: string;
is_nullable: boolean;
is_primary_key: boolean;
}
export interface ErdTable {
schema: string;
name: string;
columns: ErdColumn[];
}
export interface ErdRelationship {
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}
export interface ErdData {
tables: ErdTable[];
relationships: ErdRelationship[];
}
// App Settings
export type DockerHost = "local" | "remote";
export interface McpSettings {
enabled: boolean;
port: number;
}
export interface DockerSettings {
host: DockerHost;
remote_url?: string;
}
export interface AppSettings {
mcp: McpSettings;
docker: DockerSettings;
}
export interface McpStatus {
enabled: boolean;
port: number;
running: boolean;
}
// Docker
export interface DockerStatus {
installed: boolean;
daemon_running: boolean;
version: string | null;
error: string | null;
}
export type CloneMode = "schema_only" | "full_clone" | "sample_data";
export interface CloneToDockerParams {
source_connection_id: string;
source_database: string;
container_name: string;
pg_version: string;
host_port: number | null;
clone_mode: CloneMode;
sample_rows: number | null;
postgres_password: string | null;
}
export interface CloneProgress {
clone_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
export interface TuskContainer {
container_id: string;
name: string;
status: string;
host_port: number;
pg_version: string;
source_database: string | null;
source_connection: string | null;
created_at: string | null;
}
export interface CloneResult {
container: TuskContainer;
connection_id: string;
connection_url: string;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup" | "erd" | "validation" | "index-advisor" | "snapshots";
export interface Tab {
id: string;
@@ -287,3 +409,159 @@ export interface Tab {
lookupColumn?: string;
lookupValue?: string;
}
// --- Wave 1: Validation ---
export type ValidationStatus = "pending" | "generating" | "running" | "passed" | "failed" | "error";
export interface ValidationRule {
id: string;
description: string;
generated_sql: string;
status: ValidationStatus;
violation_count: number;
sample_violations: unknown[][];
violation_columns: string[];
error: string | null;
}
export interface ValidationReport {
rules: ValidationRule[];
total_rules: number;
passed: number;
failed: number;
errors: number;
execution_time_ms: number;
}
// --- Wave 2: Data Generator ---
export interface GenerateDataParams {
connection_id: string;
schema: string;
table: string;
row_count: number;
include_related: boolean;
custom_instructions?: string;
}
export interface GeneratedDataPreview {
tables: GeneratedTableData[];
insert_order: string[];
total_rows: number;
}
export interface GeneratedTableData {
schema: string;
table: string;
columns: string[];
rows: unknown[][];
row_count: number;
}
export interface DataGenProgress {
gen_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
// --- Wave 3A: Index Advisor ---
export interface TableStats {
schema: string;
table: string;
seq_scan: number;
idx_scan: number;
n_live_tup: number;
table_size: string;
index_size: string;
}
export interface IndexStatsInfo {
schema: string;
table: string;
index_name: string;
idx_scan: number;
index_size: string;
definition: string;
}
export interface SlowQuery {
query: string;
calls: number;
total_time_ms: number;
mean_time_ms: number;
rows: number;
}
export type IndexRecommendationType = "create_index" | "drop_index" | "replace_index";
export interface IndexRecommendation {
id: string;
recommendation_type: IndexRecommendationType;
table_schema: string;
table_name: string;
index_name: string | null;
ddl: string;
rationale: string;
estimated_impact: string;
priority: string;
}
export interface IndexAdvisorReport {
table_stats: TableStats[];
index_stats: IndexStatsInfo[];
slow_queries: SlowQuery[];
recommendations: IndexRecommendation[];
has_pg_stat_statements: boolean;
}
// --- Wave 3B: Snapshots ---
export interface SnapshotMetadata {
id: string;
name: string;
created_at: string;
connection_name: string;
database: string;
tables: SnapshotTableMeta[];
total_rows: number;
file_size_bytes: number;
version: number;
}
export interface SnapshotTableMeta {
schema: string;
table: string;
row_count: number;
columns: string[];
column_types: string[];
}
export interface SnapshotProgress {
snapshot_id: string;
stage: string;
percent: number;
message: string;
detail: string | null;
}
export interface CreateSnapshotParams {
connection_id: string;
tables: TableRef[];
name: string;
include_dependencies: boolean;
}
export interface TableRef {
schema: string;
table: string;
}
export interface RestoreSnapshotParams {
connection_id: string;
file_path: string;
truncate_before_restore: boolean;
}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});