Compare commits
19 Commits
b44254bb29
...
fix/eslint
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b07a31da | |||
| 2d2dcdc4a8 | |||
| 9237c7dd8e | |||
| 6b925d6260 | |||
| 1e002d801a | |||
| f8dd94a6c7 | |||
| 4e5714b291 | |||
| 64e27f79a4 | |||
| ab898262dd | |||
| e984bf233e | |||
| 34c80809f1 | |||
| a3b05b0328 | |||
| d507162377 | |||
| baa794b66a | |||
| e76a96deb8 | |||
| 20b00e55b0 | |||
| 1ce5f78de8 | |||
| f68057beef | |||
| 94df94db7c |
62
.gitea/workflows/build.yml
Normal file
62
.gitea/workflows/build.yml
Normal 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
|
||||||
97
.gitea/workflows/release.yml
Normal file
97
.gitea/workflows/release.yml
Normal 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
123
.github/workflows/build.yml
vendored
Normal 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
67
.github/workflows/release.yml
vendored
Normal 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 }}
|
||||||
8
Makefile
8
Makefile
@@ -15,7 +15,7 @@ TARGET_DIR := $(if $(TARGET),src-tauri/target/$(TARGET)/release,src-tauri/targe
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
.PHONY: dev
|
.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
|
npm run tauri dev
|
||||||
|
|
||||||
.PHONY: dev-frontend
|
.PHONY: dev-frontend
|
||||||
@@ -98,12 +98,14 @@ export DESKTOP_ENTRY
|
|||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build ## Build release and install to system (PREFIX=/usr/local)
|
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
|
@mkdir -p $(DESTDIR)$(DATADIR)/applications
|
||||||
@echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
@echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||||
@for size in 32x32 128x128; do \
|
@for size in 32x32 128x128; do \
|
||||||
if [ -f src-tauri/icons/$$size.png ]; then \
|
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; \
|
$(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \
|
||||||
fi; \
|
fi; \
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'src-tauri']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -19,5 +19,8 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tusk</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1359
package-lock.json
generated
1359
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -8,6 +8,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,10 +20,13 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -37,6 +42,8 @@
|
|||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2.10.0",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -45,11 +52,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"shadcn": "^3.8.4",
|
"shadcn": "^3.8.4",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
345
src-tauri/Cargo.lock
generated
345
src-tauri/Cargo.lock
generated
@@ -383,6 +383,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
@@ -438,16 +444,6 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -471,9 +467,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -484,7 +480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -930,12 +926,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrand"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -994,15 +984,6 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1010,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared 0.3.1",
|
"foreign-types-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1024,12 +1005,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1291,8 +1266,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1302,9 +1279,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1455,25 +1434,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1618,7 +1578,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1645,22 +1604,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
"webpki-roots 1.0.6",
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1681,11 +1625,9 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2079,12 +2021,6 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2106,6 +2042,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2222,23 +2164,6 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2595,50 +2520,6 @@ dependencies = [
|
|||||||
"pathdiff",
|
"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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3042,6 +2923,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -3259,29 +3195,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
|
||||||
"native-tls",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3289,6 +3222,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3428,6 +3362,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3437,19 +3377,6 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.36"
|
version = "0.23.36"
|
||||||
@@ -3470,6 +3397,7 @@ version = "1.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3505,15 +3433,6 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3585,29 +3504,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4329,27 +4225,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -4371,7 +4246,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -4713,19 +4588,6 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"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]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4861,16 +4723,6 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -5440,6 +5292,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -5697,17 +5559,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ csv = "1"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bigdecimal = { version = "0.4", features = ["serde"] }
|
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"] }
|
rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable-http-server"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ pub struct ConnectResult {
|
|||||||
pub flavor: DbFlavor,
|
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
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::time::Instant;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn get_table_data(
|
pub async fn get_table_data(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -21,10 +22,7 @@ pub async fn get_table_data(
|
|||||||
sort_direction: Option<String>,
|
sort_direction: Option<String>,
|
||||||
filter: Option<String>,
|
filter: Option<String>,
|
||||||
) -> TuskResult<PaginatedQueryResult> {
|
) -> TuskResult<PaginatedQueryResult> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
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 offset = (page.saturating_sub(1)) * page_size;
|
||||||
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
|
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
|
||||||
qualified, where_clause, order_clause, page_size, offset
|
qualified, where_clause, order_clause, page_size, offset
|
||||||
);
|
);
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let (rows, count_row) = tokio::try_join!(
|
// Always run table data queries in a read-only transaction to prevent
|
||||||
sqlx::query(&data_sql).fetch_all(pool),
|
// writable CTEs or other mutation via the raw filter parameter.
|
||||||
sqlx::query(&count_sql).fetch_one(pool),
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
)
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
.map_err(TuskError::Database)?;
|
.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 execution_time_ms = start.elapsed().as_millis();
|
||||||
let total_rows: i64 = count_row.get(0);
|
let total_rows: i64 = count_row.get(0);
|
||||||
|
|
||||||
let mut columns = Vec::new();
|
let mut all_columns = Vec::new();
|
||||||
let mut types = Vec::new();
|
let mut all_types = Vec::new();
|
||||||
|
|
||||||
if let Some(first_row) = rows.first() {
|
if let Some(first_row) = rows.first() {
|
||||||
for col in first_row.columns() {
|
for col in first_row.columns() {
|
||||||
columns.push(col.name().to_string());
|
all_columns.push(col.name().to_string());
|
||||||
types.push(col.type_info().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
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
let row_count = result_rows.len();
|
let row_count = result_rows.len();
|
||||||
@@ -91,10 +125,12 @@ pub async fn get_table_data(
|
|||||||
total_rows,
|
total_rows,
|
||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
|
ctids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn update_row(
|
pub async fn update_row(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -104,40 +140,52 @@ pub async fn update_row(
|
|||||||
pk_values: Vec<Value>,
|
pk_values: Vec<Value>,
|
||||||
column: String,
|
column: String,
|
||||||
value: Value,
|
value: Value,
|
||||||
|
ctid: Option<String>,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
|
|
||||||
let set_clause = format!("{} = $1", escape_ident(&column));
|
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||||
|
|
||||||
let where_parts: Vec<String> = pk_columns
|
if pk_columns.is_empty() {
|
||||||
.iter()
|
// Fallback: use ctid for row identification
|
||||||
.enumerate()
|
let ctid_val = ctid.ok_or_else(|| {
|
||||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2))
|
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
||||||
.collect();
|
})?;
|
||||||
let where_clause = where_parts.join(" AND ");
|
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()
|
||||||
|
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2))
|
||||||
|
.collect();
|
||||||
|
let where_clause = where_parts.join(" AND ");
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"UPDATE {} SET {} WHERE {}",
|
"UPDATE {} SET {} WHERE {}",
|
||||||
qualified, set_clause, where_clause
|
qualified, set_clause, where_clause
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut query = sqlx::query(&sql);
|
let mut query = sqlx::query(&sql);
|
||||||
query = bind_json_value(query, &value);
|
query = bind_json_value(query, &value);
|
||||||
for pk_val in &pk_values {
|
for pk_val in &pk_values {
|
||||||
query = bind_json_value(query, pk_val);
|
query = bind_json_value(query, pk_val);
|
||||||
|
}
|
||||||
|
query.execute(&pool).await.map_err(TuskError::Database)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
query.execute(pool).await.map_err(TuskError::Database)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,10 +202,7 @@ pub async fn insert_row(
|
|||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
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 = bind_json_value(query, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
query.execute(pool).await.map_err(TuskError::Database)?;
|
query.execute(&pool).await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -189,42 +234,58 @@ pub async fn delete_rows(
|
|||||||
table: String,
|
table: String,
|
||||||
pk_columns: Vec<String>,
|
pk_columns: Vec<String>,
|
||||||
pk_values_list: Vec<Vec<Value>>,
|
pk_values_list: Vec<Vec<Value>>,
|
||||||
|
ctids: Option<Vec<String>>,
|
||||||
) -> TuskResult<u64> {
|
) -> TuskResult<u64> {
|
||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
let mut total_affected: u64 = 0;
|
let mut total_affected: u64 = 0;
|
||||||
|
|
||||||
for pk_values in &pk_values_list {
|
// Wrap all deletes in a transaction for atomicity
|
||||||
let where_parts: Vec<String> = pk_columns
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1))
|
|
||||||
.collect();
|
|
||||||
let where_clause = where_parts.join(" AND ");
|
|
||||||
|
|
||||||
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
if pk_columns.is_empty() {
|
||||||
|
// Fallback: use ctids for row identification
|
||||||
let mut query = sqlx::query(&sql);
|
let ctid_list = ctids.ok_or_else(|| {
|
||||||
for val in pk_values {
|
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
||||||
query = bind_json_value(query, val);
|
})?;
|
||||||
|
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()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1))
|
||||||
|
.collect();
|
||||||
|
let where_clause = where_parts.join(" AND ");
|
||||||
|
|
||||||
let result = query.execute(pool).await.map_err(TuskError::Database)?;
|
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
||||||
total_affected += result.rows_affected();
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for val in pk_values {
|
||||||
|
query = bind_json_value(query, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
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>,
|
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||||
value: &'q Value,
|
value: &'q Value,
|
||||||
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||||
|
|||||||
1208
src-tauri/src/commands/docker.rs
Normal file
1208
src-tauri/src/commands/docker.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -336,10 +336,7 @@ pub async fn alter_role(
|
|||||||
options.push(format!("CONNECTION LIMIT {}", limit));
|
options.push(format!("CONNECTION LIMIT {}", limit));
|
||||||
}
|
}
|
||||||
if let Some(ref valid_until) = params.valid_until {
|
if let Some(ref valid_until) = params.valid_until {
|
||||||
options.push(format!(
|
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
|
||||||
"VALID UNTIL '{}'",
|
|
||||||
valid_until.replace('\'', "''")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !options.is_empty() {
|
if !options.is_empty() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
|
pub mod docker;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
@@ -8,3 +9,5 @@ pub mod management;
|
|||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -43,20 +43,16 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
|||||||
}
|
}
|
||||||
"DATE" => try_get!(chrono::NaiveDate),
|
"DATE" => try_get!(chrono::NaiveDate),
|
||||||
"TIME" => try_get!(chrono::NaiveTime),
|
"TIME" => try_get!(chrono::NaiveTime),
|
||||||
"BYTEA" => {
|
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||||
match row.try_get::<Option<Vec<u8>>, _>(index) {
|
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
Ok(None) => return Value::Null,
|
||||||
Ok(None) => return Value::Null,
|
Err(_) => {}
|
||||||
Err(_) => {}
|
},
|
||||||
}
|
"OID" => match row.try_get::<Option<i32>, _>(index) {
|
||||||
}
|
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
||||||
"OID" => {
|
Ok(None) => return Value::Null,
|
||||||
match row.try_get::<Option<i32>, _>(index) {
|
Err(_) => {}
|
||||||
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
},
|
||||||
Ok(None) => return Value::Null,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"VOID" => return Value::Null,
|
"VOID" => return Value::Null,
|
||||||
// Array types (PG prefixes array type names with underscore)
|
// Array types (PG prefixes array type names with underscore)
|
||||||
"_BOOL" => try_get!(Vec<bool>),
|
"_BOOL" => try_get!(Vec<bool>),
|
||||||
@@ -124,7 +120,11 @@ pub async fn execute_query_core(
|
|||||||
|
|
||||||
let result_rows: Vec<Vec<Value>> = rows
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
let row_count = result_rows.len();
|
let row_count = result_rows.len();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
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 crate::state::{AppState, DbFlavor};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -11,31 +14,22 @@ pub async fn list_databases(
|
|||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> TuskResult<Vec<String>> {
|
) -> TuskResult<Vec<String>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT datname FROM pg_database \
|
"SELECT datname FROM pg_database \
|
||||||
WHERE datistemplate = false \
|
WHERE datistemplate = false \
|
||||||
ORDER BY datname",
|
ORDER BY datname",
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_schemas_core(
|
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
||||||
state: &AppState,
|
let pool = state.get_pool(connection_id).await?;
|
||||||
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()))?;
|
|
||||||
|
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
@@ -49,7 +43,7 @@ pub async fn list_schemas_core(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rows = sqlx::query(sql)
|
let rows = sqlx::query(sql)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -69,10 +63,7 @@ pub async fn list_tables_core(
|
|||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
schema: &str,
|
schema: &str,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(connection_id)
|
|
||||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT t.table_name, \
|
"SELECT t.table_name, \
|
||||||
@@ -85,7 +76,7 @@ pub async fn list_tables_core(
|
|||||||
ORDER BY t.table_name",
|
ORDER BY t.table_name",
|
||||||
)
|
)
|
||||||
.bind(schema)
|
.bind(schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -116,10 +107,7 @@ pub async fn list_views(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT table_name FROM information_schema.views \
|
"SELECT table_name FROM information_schema.views \
|
||||||
@@ -127,7 +115,7 @@ pub async fn list_views(
|
|||||||
ORDER BY table_name",
|
ORDER BY table_name",
|
||||||
)
|
)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -149,10 +137,7 @@ pub async fn list_functions(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT routine_name FROM information_schema.routines \
|
"SELECT routine_name FROM information_schema.routines \
|
||||||
@@ -160,7 +145,7 @@ pub async fn list_functions(
|
|||||||
ORDER BY routine_name",
|
ORDER BY routine_name",
|
||||||
)
|
)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -182,10 +167,7 @@ pub async fn list_indexes(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT indexname FROM pg_indexes \
|
"SELECT indexname FROM pg_indexes \
|
||||||
@@ -193,7 +175,7 @@ pub async fn list_indexes(
|
|||||||
ORDER BY indexname",
|
ORDER BY indexname",
|
||||||
)
|
)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -215,10 +197,7 @@ pub async fn list_sequences(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
schema: String,
|
schema: String,
|
||||||
) -> TuskResult<Vec<SchemaObject>> {
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT sequence_name FROM information_schema.sequences \
|
"SELECT sequence_name FROM information_schema.sequences \
|
||||||
@@ -226,7 +205,7 @@ pub async fn list_sequences(
|
|||||||
ORDER BY sequence_name",
|
ORDER BY sequence_name",
|
||||||
)
|
)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -248,10 +227,7 @@ pub async fn get_table_columns_core(
|
|||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
) -> TuskResult<Vec<ColumnInfo>> {
|
) -> TuskResult<Vec<ColumnInfo>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(connection_id)
|
|
||||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
@@ -271,14 +247,20 @@ pub async fn get_table_columns_core(
|
|||||||
AND tc.table_name = $2 \
|
AND tc.table_name = $2 \
|
||||||
AND kcu.column_name = c.column_name \
|
AND kcu.column_name = c.column_name \
|
||||||
LIMIT 1 \
|
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 \
|
FROM information_schema.columns c \
|
||||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||||
ORDER BY c.ordinal_position",
|
ORDER BY c.ordinal_position",
|
||||||
)
|
)
|
||||||
.bind(schema)
|
.bind(schema)
|
||||||
.bind(table)
|
.bind(table)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -292,6 +274,7 @@ pub async fn get_table_columns_core(
|
|||||||
ordinal_position: r.get::<i32, _>(4),
|
ordinal_position: r.get::<i32, _>(4),
|
||||||
character_maximum_length: r.get::<Option<i32>, _>(5),
|
character_maximum_length: r.get::<Option<i32>, _>(5),
|
||||||
is_primary_key: r.get::<bool, _>(6),
|
is_primary_key: r.get::<bool, _>(6),
|
||||||
|
comment: r.get::<Option<String>, _>(7),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -313,27 +296,57 @@ pub async fn get_table_constraints(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<ConstraintInfo>> {
|
) -> TuskResult<Vec<ConstraintInfo>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
tc.constraint_name, \
|
c.conname AS constraint_name, \
|
||||||
tc.constraint_type, \
|
CASE c.contype \
|
||||||
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
|
WHEN 'p' THEN 'PRIMARY KEY' \
|
||||||
FROM information_schema.table_constraints tc \
|
WHEN 'f' THEN 'FOREIGN KEY' \
|
||||||
JOIN information_schema.key_column_usage kcu \
|
WHEN 'u' THEN 'UNIQUE' \
|
||||||
ON tc.constraint_name = kcu.constraint_name \
|
WHEN 'c' THEN 'CHECK' \
|
||||||
AND tc.table_schema = kcu.table_schema \
|
WHEN 'x' THEN 'EXCLUDE' \
|
||||||
WHERE tc.table_schema = $1 AND tc.table_name = $2 \
|
END AS constraint_type, \
|
||||||
GROUP BY tc.constraint_name, tc.constraint_type \
|
ARRAY( \
|
||||||
ORDER BY tc.constraint_type, tc.constraint_name",
|
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(&schema)
|
||||||
.bind(&table)
|
.bind(&table)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -343,6 +356,11 @@ pub async fn get_table_constraints(
|
|||||||
name: r.get::<String, _>(0),
|
name: r.get::<String, _>(0),
|
||||||
constraint_type: r.get::<String, _>(1),
|
constraint_type: r.get::<String, _>(1),
|
||||||
columns: r.get::<Vec<String>, _>(2),
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -354,10 +372,7 @@ pub async fn get_table_indexes(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<IndexInfo>> {
|
) -> TuskResult<Vec<IndexInfo>> {
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
@@ -374,7 +389,7 @@ pub async fn get_table_indexes(
|
|||||||
)
|
)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.bind(&table)
|
.bind(&table)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -395,10 +410,7 @@ pub async fn get_completion_schema(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
"SELECT table_schema, table_name, column_name \
|
"SELECT table_schema, table_name, column_name \
|
||||||
@@ -413,7 +425,7 @@ pub async fn get_completion_schema(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rows = sqlx::query(sql)
|
let rows = sqlx::query(sql)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -442,10 +454,7 @@ pub async fn get_column_details(
|
|||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<ColumnDetail>> {
|
) -> TuskResult<Vec<ColumnDetail>> {
|
||||||
let flavor = state.get_flavor(&connection_id).await;
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
let pools = state.pools.read().await;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let pool = pools
|
|
||||||
.get(&connection_id)
|
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
|
||||||
|
|
||||||
let sql = if flavor == DbFlavor::Greenplum {
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
"SELECT c.column_name, c.data_type, \
|
"SELECT c.column_name, c.data_type, \
|
||||||
@@ -468,7 +477,7 @@ pub async fn get_column_details(
|
|||||||
let rows = sqlx::query(sql)
|
let rows = sqlx::query(sql)
|
||||||
.bind(&schema)
|
.bind(&schema)
|
||||||
.bind(&table)
|
.bind(&table)
|
||||||
.fetch_all(pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
@@ -483,3 +492,182 @@ pub async fn get_column_details(
|
|||||||
})
|
})
|
||||||
.collect())
|
.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
116
src-tauri/src/commands/settings.rs
Normal file
116
src-tauri/src/commands/settings.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
359
src-tauri/src/commands/snapshot.rs
Normal file
359
src-tauri/src/commands/snapshot.rs
Normal 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(¶ms.connection_id).await?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "preparing".to_string(),
|
||||||
|
percent: 5,
|
||||||
|
message: "Preparing snapshot...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut target_tables: Vec<(String, String)> = params
|
||||||
|
.tables
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.schema.clone(), t.table.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Fetch FK info once — used for both dependency expansion and topological sort
|
||||||
|
let fk_rows = fetch_foreign_keys_raw(&pool).await?;
|
||||||
|
|
||||||
|
if params.include_dependencies {
|
||||||
|
for fk in &fk_rows {
|
||||||
|
if target_tables.iter().any(|(s, t)| s == &fk.schema && t == &fk.table) {
|
||||||
|
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
||||||
|
if !target_tables.contains(&parent) {
|
||||||
|
target_tables.push(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK-based topological sort
|
||||||
|
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
||||||
|
.iter()
|
||||||
|
.map(|fk| {
|
||||||
|
(
|
||||||
|
fk.schema.clone(),
|
||||||
|
fk.table.clone(),
|
||||||
|
fk.ref_schema.clone(),
|
||||||
|
fk.ref_table.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let total_tables = sorted_tables.len();
|
||||||
|
let mut snapshot_tables: Vec<SnapshotTableData> = Vec::new();
|
||||||
|
let mut table_metas: Vec<SnapshotTableMeta> = Vec::new();
|
||||||
|
let mut total_rows: u64 = 0;
|
||||||
|
|
||||||
|
for (i, (schema, table)) in sorted_tables.iter().enumerate() {
|
||||||
|
let percent = (10 + (i * 80 / total_tables.max(1))).min(90) as u8;
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "exporting".to_string(),
|
||||||
|
percent,
|
||||||
|
message: format!("Exporting {}.{}...", schema, table),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(schema), escape_ident(table));
|
||||||
|
let sql = format!("SELECT * FROM {}", qualified);
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut column_types = Vec::new();
|
||||||
|
|
||||||
|
if let Some(first) = rows.first() {
|
||||||
|
for col in first.columns() {
|
||||||
|
columns.push(col.name().to_string());
|
||||||
|
column_types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_rows: Vec<Vec<Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = data_rows.len() as u64;
|
||||||
|
total_rows += row_count;
|
||||||
|
|
||||||
|
table_metas.push(SnapshotTableMeta {
|
||||||
|
schema: schema.clone(),
|
||||||
|
table: table.clone(),
|
||||||
|
row_count,
|
||||||
|
columns: columns.clone(),
|
||||||
|
column_types: column_types.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
snapshot_tables.push(SnapshotTableData {
|
||||||
|
schema: schema.clone(),
|
||||||
|
table: table.clone(),
|
||||||
|
columns,
|
||||||
|
column_types,
|
||||||
|
rows: data_rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.rollback().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let metadata = SnapshotMetadata {
|
||||||
|
id: snapshot_id.clone(),
|
||||||
|
name: params.name.clone(),
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
connection_name: String::new(),
|
||||||
|
database: String::new(),
|
||||||
|
tables: table_metas,
|
||||||
|
total_rows,
|
||||||
|
file_size_bytes: 0,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = Snapshot {
|
||||||
|
metadata: metadata.clone(),
|
||||||
|
tables: snapshot_tables,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "saving".to_string(),
|
||||||
|
percent: 95,
|
||||||
|
message: "Saving snapshot file...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&snapshot)?;
|
||||||
|
let file_size = json.len() as u64;
|
||||||
|
fs::write(&file_path, json)?;
|
||||||
|
|
||||||
|
let mut final_metadata = metadata;
|
||||||
|
final_metadata.file_size_bytes = file_size;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "done".to_string(),
|
||||||
|
percent: 100,
|
||||||
|
message: "Snapshot created successfully".to_string(),
|
||||||
|
detail: Some(format!("{} rows, {} tables", total_rows, total_tables)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(final_metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_snapshot(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
params: RestoreSnapshotParams,
|
||||||
|
snapshot_id: String,
|
||||||
|
) -> TuskResult<u64> {
|
||||||
|
if state.is_read_only(¶ms.connection_id).await {
|
||||||
|
return Err(TuskError::ReadOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "reading".to_string(),
|
||||||
|
percent: 5,
|
||||||
|
message: "Reading snapshot file...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = fs::read_to_string(¶ms.file_path)?;
|
||||||
|
let snapshot: Snapshot = serde_json::from_str(&data)?;
|
||||||
|
|
||||||
|
let pool = state.get_pool(¶ms.connection_id).await?;
|
||||||
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// TRUNCATE in reverse order (children first)
|
||||||
|
if params.truncate_before_restore {
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "truncating".to_string(),
|
||||||
|
percent: 15,
|
||||||
|
message: "Truncating existing data...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for table_data in snapshot.tables.iter().rev() {
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&table_data.schema),
|
||||||
|
escape_ident(&table_data.table)
|
||||||
|
);
|
||||||
|
let truncate_sql = format!("TRUNCATE {} CASCADE", qualified);
|
||||||
|
sqlx::query(&truncate_sql)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT in forward order (parents first)
|
||||||
|
let total_tables = snapshot.tables.len();
|
||||||
|
let mut total_inserted: u64 = 0;
|
||||||
|
|
||||||
|
for (i, table_data) in snapshot.tables.iter().enumerate() {
|
||||||
|
if table_data.columns.is_empty() || table_data.rows.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let percent = (20 + (i * 75 / total_tables.max(1))).min(95) as u8;
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "inserting".to_string(),
|
||||||
|
percent,
|
||||||
|
message: format!("Restoring {}.{}...", table_data.schema, table_data.table),
|
||||||
|
detail: Some(format!("{} rows", table_data.rows.len())),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&table_data.schema),
|
||||||
|
escape_ident(&table_data.table)
|
||||||
|
);
|
||||||
|
let col_list: Vec<String> = table_data.columns.iter().map(|c| escape_ident(c)).collect();
|
||||||
|
let placeholders: Vec<String> = (1..=table_data.columns.len())
|
||||||
|
.map(|i| format!("${}", i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({})",
|
||||||
|
qualified,
|
||||||
|
col_list.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chunked insert
|
||||||
|
for row in &table_data.rows {
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for val in row {
|
||||||
|
query = bind_json_value(query, val);
|
||||||
|
}
|
||||||
|
query.execute(&mut *tx).await.map_err(TuskError::Database)?;
|
||||||
|
total_inserted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"snapshot-progress",
|
||||||
|
SnapshotProgress {
|
||||||
|
snapshot_id: snapshot_id.clone(),
|
||||||
|
stage: "done".to_string(),
|
||||||
|
percent: 100,
|
||||||
|
message: "Restore completed successfully".to_string(),
|
||||||
|
detail: Some(format!("{} rows restored", total_inserted)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
state.invalidate_schema_cache(¶ms.connection_id).await;
|
||||||
|
|
||||||
|
Ok(total_inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| TuskError::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)
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ pub enum TuskError {
|
|||||||
#[error("AI error: {0}")]
|
#[error("AI error: {0}")]
|
||||||
Ai(String),
|
Ai(String),
|
||||||
|
|
||||||
|
#[error("Docker error: {0}")]
|
||||||
|
Docker(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod models;
|
|||||||
mod state;
|
mod state;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
use models::settings::{AppSettings, DockerHost};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -12,24 +13,64 @@ use tauri::Manager;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let shared_state = Arc::new(AppState::new());
|
let shared_state = Arc::new(AppState::new());
|
||||||
|
|
||||||
tauri::Builder::default()
|
let _ = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(shared_state)
|
.manage(shared_state)
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let state = app.state::<Arc<AppState>>().inner().clone();
|
let state = app.state::<Arc<AppState>>().inner().clone();
|
||||||
let connections_path = app
|
let data_dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.expect("failed to resolve app data dir")
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
.join("connections.json");
|
let connections_path = data_dir.join("connections.json");
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
// Read app settings
|
||||||
if let Err(e) = mcp::start_mcp_server(state, connections_path, 9427).await {
|
let settings_path = data_dir.join("app_settings.json");
|
||||||
log::error!("MCP server error: {}", e);
|
|
||||||
}
|
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 {
|
||||||
|
*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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -59,6 +100,8 @@ pub fn run() {
|
|||||||
commands::schema::get_table_indexes,
|
commands::schema::get_table_indexes,
|
||||||
commands::schema::get_completion_schema,
|
commands::schema::get_completion_schema,
|
||||||
commands::schema::get_column_details,
|
commands::schema::get_column_details,
|
||||||
|
commands::schema::get_table_triggers,
|
||||||
|
commands::schema::get_schema_erd,
|
||||||
// data
|
// data
|
||||||
commands::data::get_table_data,
|
commands::data::get_table_data,
|
||||||
commands::data::update_row,
|
commands::data::update_row,
|
||||||
@@ -96,9 +139,34 @@ pub fn run() {
|
|||||||
commands::ai::generate_sql,
|
commands::ai::generate_sql,
|
||||||
commands::ai::explain_sql,
|
commands::ai::explain_sql,
|
||||||
commands::ai::fix_sql_error,
|
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
|
// lookup
|
||||||
commands::lookup::entity_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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.inspect_err(|e| {
|
||||||
|
log::error!("Tauri application error: {}", e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use schemars::JsonSchema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
// --- Tool parameter types ---
|
// --- 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> {
|
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
||||||
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
||||||
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
||||||
@@ -109,9 +112,8 @@ impl TuskMcpServer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&statuses).map_err(|e| {
|
let json = serde_json::to_string_pretty(&statuses)
|
||||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
.map_err(|e| McpError::internal_error(format!("Serialization error: {}", e), None))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
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(
|
async fn describe_table(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<DescribeTableParam>,
|
Parameters(params): Parameters<DescribeTableParam>,
|
||||||
@@ -217,6 +221,7 @@ pub async fn start_mcp_server(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
connections_path: PathBuf,
|
connections_path: PathBuf,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || Ok(TuskMcpServer::new(state.clone(), connections_path.clone())),
|
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);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AiSettings {
|
pub struct AiSettings {
|
||||||
|
pub provider: AiProvider,
|
||||||
pub ollama_url: String,
|
pub ollama_url: String,
|
||||||
|
pub openai_api_key: Option<String>,
|
||||||
|
pub anthropic_api_key: Option<String>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AiSettings {
|
impl Default for AiSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
provider: AiProvider::Ollama,
|
||||||
ollama_url: "http://localhost:11434".to_string(),
|
ollama_url: "http://localhost:11434".to_string(),
|
||||||
|
openai_api_key: None,
|
||||||
|
anthropic_api_key: None,
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OllamaChatMessage {
|
pub struct OllamaChatMessage {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct OllamaChatRequest {
|
pub struct OllamaChatRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<OllamaChatMessage>,
|
pub messages: Vec<OllamaChatMessage>,
|
||||||
@@ -42,3 +57,130 @@ pub struct OllamaTagsResponse {
|
|||||||
pub struct OllamaModel {
|
pub struct OllamaModel {
|
||||||
pub name: String,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ pub struct ConnectionConfig {
|
|||||||
|
|
||||||
impl ConnectionConfig {
|
impl ConnectionConfig {
|
||||||
pub fn connection_url(&self) -> String {
|
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");
|
let ssl = self.ssl_mode.as_deref().unwrap_or("prefer");
|
||||||
format!(
|
format!(
|
||||||
"postgres://{}:{}@{}:{}/{}?sslmode={}",
|
"postgres://{}:{}@{}:{}/{}?sslmode={}",
|
||||||
@@ -23,7 +27,7 @@ impl ConnectionConfig {
|
|||||||
urlencoded(&self.password),
|
urlencoded(&self.password),
|
||||||
self.host,
|
self.host,
|
||||||
self.port,
|
self.port,
|
||||||
urlencoded(&self.database),
|
urlencoded(database),
|
||||||
ssl
|
ssl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,8 +36,8 @@ impl ConnectionConfig {
|
|||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
|
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
|
||||||
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
|
| '+' | ',' | ';' | '=' | '%' | ' ' => {
|
||||||
format!("%{:02X}", c as u8)
|
format!("%{:02X}", c as u8)
|
||||||
}
|
}
|
||||||
_ => c.to_string(),
|
_ => c.to_string(),
|
||||||
|
|||||||
57
src-tauri/src/models/docker.rs
Normal file
57
src-tauri/src/models/docker.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DockerStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub daemon_running: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CloneMode {
|
||||||
|
SchemaOnly,
|
||||||
|
FullClone,
|
||||||
|
SampleData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CloneToDockerParams {
|
||||||
|
pub source_connection_id: String,
|
||||||
|
pub source_database: String,
|
||||||
|
pub container_name: String,
|
||||||
|
pub pg_version: String,
|
||||||
|
pub host_port: Option<u16>,
|
||||||
|
pub clone_mode: CloneMode,
|
||||||
|
pub sample_rows: Option<u32>,
|
||||||
|
pub postgres_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CloneProgress {
|
||||||
|
pub clone_id: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TuskContainer {
|
||||||
|
pub container_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub host_port: u16,
|
||||||
|
pub pg_version: String,
|
||||||
|
pub source_database: Option<String>,
|
||||||
|
pub source_connection: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CloneResult {
|
||||||
|
pub container: TuskContainer,
|
||||||
|
pub connection_id: String,
|
||||||
|
pub connection_url: String,
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
pub mod docker;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod query_result;
|
pub mod query_result;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ pub struct PaginatedQueryResult {
|
|||||||
pub total_rows: i64,
|
pub total_rows: i64,
|
||||||
pub page: u32,
|
pub page: u32,
|
||||||
pub page_size: u32,
|
pub page_size: u32,
|
||||||
|
pub ctids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct ColumnInfo {
|
|||||||
pub ordinal_position: i32,
|
pub ordinal_position: i32,
|
||||||
pub character_maximum_length: Option<i32>,
|
pub character_maximum_length: Option<i32>,
|
||||||
pub is_primary_key: bool,
|
pub is_primary_key: bool,
|
||||||
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -34,6 +35,11 @@ pub struct ConstraintInfo {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub constraint_type: String,
|
pub constraint_type: String,
|
||||||
pub columns: Vec<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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -43,3 +49,48 @@ pub struct IndexInfo {
|
|||||||
pub is_unique: bool,
|
pub is_unique: bool,
|
||||||
pub is_primary: 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>,
|
||||||
|
}
|
||||||
|
|||||||
51
src-tauri/src/models/settings.rs
Normal file
51
src-tauri/src/models/settings.rs
Normal 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,
|
||||||
|
}
|
||||||
68
src-tauri/src/models/snapshot.rs
Normal file
68
src-tauri/src/models/snapshot.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotMetadata {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub connection_name: String,
|
||||||
|
pub database: String,
|
||||||
|
pub tables: Vec<SnapshotTableMeta>,
|
||||||
|
pub total_rows: u64,
|
||||||
|
pub file_size_bytes: u64,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotTableMeta {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub row_count: u64,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub column_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub metadata: SnapshotMetadata,
|
||||||
|
pub tables: Vec<SnapshotTableData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotTableData {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub column_types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SnapshotProgress {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateSnapshotParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub tables: Vec<TableRef>,
|
||||||
|
pub name: String,
|
||||||
|
pub include_dependencies: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TableRef {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RestoreSnapshotParams {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub truncate_before_restore: bool,
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::ai::AiSettings;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{watch, RwLock};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -11,23 +13,48 @@ pub enum DbFlavor {
|
|||||||
Greenplum,
|
Greenplum,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SchemaCacheEntry {
|
||||||
|
pub schema_text: String,
|
||||||
|
pub cached_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||||
pub config_path: RwLock<Option<PathBuf>>,
|
|
||||||
pub read_only: RwLock<HashMap<String, bool>>,
|
pub read_only: RwLock<HashMap<String, bool>>,
|
||||||
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
||||||
|
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 {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let (mcp_shutdown_tx, _) = watch::channel(false);
|
||||||
Self {
|
Self {
|
||||||
pools: RwLock::new(HashMap::new()),
|
pools: RwLock::new(HashMap::new()),
|
||||||
config_path: RwLock::new(None),
|
|
||||||
read_only: RwLock::new(HashMap::new()),
|
read_only: RwLock::new(HashMap::new()),
|
||||||
db_flavors: 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 {
|
pub async fn is_read_only(&self, id: &str) -> bool {
|
||||||
let map = self.read_only.read().await;
|
let map = self.read_only.read().await;
|
||||||
map.get(id).copied().unwrap_or(true)
|
map.get(id).copied().unwrap_or(true)
|
||||||
@@ -37,4 +64,33 @@ impl AppState {
|
|||||||
let map = self.db_flavors.read().await;
|
let map = self.db_flavors.read().await;
|
||||||
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,212 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
pub fn escape_ident(name: &str) -> String {
|
pub fn escape_ident(name: &str) -> String {
|
||||||
format!("\"{}\"", name.replace('"', "\"\""))
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Topological sort of tables based on foreign key dependencies.
|
||||||
|
/// Returns tables in insertion order: parents before children.
|
||||||
|
pub fn topological_sort_tables(
|
||||||
|
fk_edges: &[(String, String, String, String)], // (schema, table, ref_schema, ref_table)
|
||||||
|
target_tables: &[(String, String)],
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut graph: HashMap<(String, String), HashSet<(String, String)>> = HashMap::new();
|
||||||
|
let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
|
||||||
|
|
||||||
|
// Initialize all target tables
|
||||||
|
for t in target_tables {
|
||||||
|
graph.entry(t.clone()).or_default();
|
||||||
|
in_degree.entry(t.clone()).or_insert(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_set: HashSet<(String, String)> = target_tables.iter().cloned().collect();
|
||||||
|
|
||||||
|
// Build edges: parent -> child (child depends on parent)
|
||||||
|
for (schema, table, ref_schema, ref_table) in fk_edges {
|
||||||
|
let child = (schema.clone(), table.clone());
|
||||||
|
let parent = (ref_schema.clone(), ref_table.clone());
|
||||||
|
|
||||||
|
if child == parent {
|
||||||
|
continue; // self-referencing
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_set.contains(&child) || !target_set.contains(&parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(child.clone())
|
||||||
|
{
|
||||||
|
*in_degree.entry(child).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm
|
||||||
|
let mut queue: Vec<(String, String)> = in_degree
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, °)| 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Tusk",
|
"productName": "Tusk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.tusk.app",
|
"identifier": "com.tusk.dbm",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["deb", "rpm", "appimage", "dmg", "nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function App() {
|
|||||||
}, [handleNewQuery, handleCloseTab]);
|
}, [handleNewQuery, handleCloseTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="tusk-noise flex h-screen flex-col">
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup orientation="horizontal">
|
<ResizablePanelGroup orientation="horizontal">
|
||||||
|
|||||||
@@ -52,21 +52,21 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b bg-muted/50 px-2 py-1">
|
<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 text-purple-500" />
|
<Sparkles className="h-3.5 w-3.5 shrink-0 tusk-ai-icon" />
|
||||||
<Input
|
<Input
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Describe the query you want..."
|
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
|
autoFocus
|
||||||
disabled={generateMutation.isPending}
|
disabled={generateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
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}
|
onClick={handleGenerate}
|
||||||
disabled={generateMutation.isPending || !prompt.trim()}
|
disabled={generateMutation.isPending || !prompt.trim()}
|
||||||
>
|
>
|
||||||
@@ -78,23 +78,23 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
|
|||||||
</Button>
|
</Button>
|
||||||
{prompt.trim() && (
|
{prompt.trim() && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => setPrompt("")}
|
onClick={() => setPrompt("")}
|
||||||
title="Clear prompt"
|
title="Clear prompt"
|
||||||
disabled={generateMutation.isPending}
|
disabled={generateMutation.isPending}
|
||||||
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Eraser className="h-3 w-3" />
|
<Eraser className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<AiSettingsPopover />
|
<AiSettingsPopover />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close AI bar"
|
title="Close AI bar"
|
||||||
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
82
src/components/ai/AiSettingsFields.tsx
Normal file
82
src/components/ai/AiSettingsFields.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,10 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
|
||||||
import {
|
import { Settings } from "lucide-react";
|
||||||
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 { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { AiSettingsFields } from "./AiSettingsFields";
|
||||||
|
|
||||||
export function AiSettingsPopover() {
|
export function AiSettingsPopover() {
|
||||||
const { data: settings } = useAiSettings();
|
const { data: settings } = useAiSettings();
|
||||||
@@ -27,16 +20,9 @@ export function AiSettingsPopover() {
|
|||||||
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
||||||
const currentModel = model ?? settings?.model ?? "";
|
const currentModel = model ?? settings?.model ?? "";
|
||||||
|
|
||||||
const {
|
|
||||||
data: models,
|
|
||||||
isLoading: modelsLoading,
|
|
||||||
isError: modelsError,
|
|
||||||
refetch: refetchModels,
|
|
||||||
} = useOllamaModels(currentUrl);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
saveMutation.mutate(
|
saveMutation.mutate(
|
||||||
{ ollama_url: currentUrl, model: currentModel },
|
{ provider: "ollama", ollama_url: currentUrl, model: currentModel },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success("AI settings saved"),
|
onSuccess: () => toast.success("AI settings saved"),
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
@@ -63,53 +49,12 @@ export function AiSettingsPopover() {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h4 className="text-sm font-medium">Ollama Settings</h4>
|
<h4 className="text-sm font-medium">Ollama Settings</h4>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<AiSettingsFields
|
||||||
<label className="text-xs text-muted-foreground">Ollama URL</label>
|
ollamaUrl={currentUrl}
|
||||||
<Input
|
onOllamaUrlChange={setUrl}
|
||||||
value={currentUrl}
|
model={currentModel}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onModelChange={setModel}
|
||||||
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={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}>
|
<Button size="sm" className="h-7 text-xs" onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -99,7 +99,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|||||||
const saveMutation = useSaveConnection();
|
const saveMutation = useSaveConnection();
|
||||||
const testMutation = useTestConnection();
|
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) {
|
if (open) {
|
||||||
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
|
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
|
||||||
setForm(config);
|
setForm(config);
|
||||||
@@ -107,7 +109,7 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|||||||
setDsn(connection ? buildDsn(config) : "");
|
setDsn(connection ? buildDsn(config) : "");
|
||||||
setDsnError(null);
|
setDsnError(null);
|
||||||
}
|
}
|
||||||
}, [open, connection]);
|
}
|
||||||
|
|
||||||
const update = (field: keyof ConnectionConfig, value: string | number) => {
|
const update = (field: keyof ConnectionConfig, value: string | number) => {
|
||||||
setForm((f) => ({ ...f, [field]: value }));
|
setForm((f) => ({ ...f, [field]: value }));
|
||||||
|
|||||||
297
src/components/data-generator/GenerateDataDialog.tsx
Normal file
297
src/components/data-generator/GenerateDataDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
498
src/components/docker/CloneDatabaseDialog.tsx
Normal file
498
src/components/docker/CloneDatabaseDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/components/docker/DockerContainersList.tsx
Normal file
179
src/components/docker/DockerContainersList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/components/erd/ErdDiagram.tsx
Normal file
186
src/components/erd/ErdDiagram.tsx
Normal 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 "{schema}".
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
|
minZoom={0.05}
|
||||||
|
maxZoom={3}
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
panOnScroll
|
||||||
|
panOnScrollMode={PanOnScrollMode.Free}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background gap={16} size={1} />
|
||||||
|
<Controls className="!bg-card !border !shadow-sm [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground" />
|
||||||
|
<MiniMap
|
||||||
|
className="!bg-card !border"
|
||||||
|
nodeColor="var(--muted)"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.7)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/erd/ErdTableNode.tsx
Normal file
54
src/components/erd/ErdTableNode.tsx
Normal 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);
|
||||||
232
src/components/index-advisor/IndexAdvisorPanel.tsx
Normal file
232
src/components/index-advisor/IndexAdvisorPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/index-advisor/RecommendationCard.tsx
Normal file
86
src/components/index-advisor/RecommendationCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,24 +36,24 @@ export function ReadOnlyToggle() {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className={`h-7 gap-1.5 text-xs font-medium ${
|
className={`gap-1.5 font-medium ${
|
||||||
isReadOnly
|
isReadOnly
|
||||||
? "text-yellow-600 dark:text-yellow-500"
|
? "text-amber-500 hover:bg-amber-500/10 hover:text-amber-500"
|
||||||
: "text-green-600 dark:text-green-500"
|
: "text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
>
|
>
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="h-3.5 w-3.5" />
|
<Lock className="h-3 w-3" />
|
||||||
Read-Only
|
<span className="text-[11px] tracking-wide">Read-Only</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LockOpen className="h-3.5 w-3.5" />
|
<LockOpen className="h-3 w-3" />
|
||||||
Read-Write
|
<span className="text-[11px] tracking-wide">Read-Write</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SchemaTree } from "@/components/schema/SchemaTree";
|
|||||||
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
||||||
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
||||||
import { AdminPanel } from "@/components/management/AdminPanel";
|
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";
|
type SidebarView = "schema" | "history" | "saved" | "admin";
|
||||||
|
|
||||||
@@ -20,6 +20,13 @@ const SCHEMA_QUERY_KEYS = [
|
|||||||
"functions", "sequences", "completionSchema", "column-details",
|
"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() {
|
export function Sidebar() {
|
||||||
const [view, setView] = useState<SidebarView>("schema");
|
const [view, setView] = useState<SidebarView>("schema");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -32,58 +39,33 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-card">
|
<div className="flex h-full flex-col" style={{ background: "var(--sidebar)" }}>
|
||||||
<div className="flex border-b text-xs">
|
{/* Sidebar navigation tabs */}
|
||||||
<button
|
<div className="flex border-b border-border/50">
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
{SIDEBAR_TABS.map((tab) => (
|
||||||
view === "schema"
|
<button
|
||||||
? "bg-background text-foreground"
|
key={tab.id}
|
||||||
: "text-muted-foreground hover:text-foreground"
|
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
|
||||||
onClick={() => setView("schema")}
|
? "text-foreground tusk-sidebar-tab-active"
|
||||||
>
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
Schema
|
}`}
|
||||||
</button>
|
onClick={() => setView(tab.id)}
|
||||||
<button
|
>
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
{tab.icon}
|
||||||
view === "history"
|
<span className="hidden min-[220px]:inline">{tab.label}</span>
|
||||||
? "bg-background text-foreground"
|
</button>
|
||||||
: "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
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "schema" ? (
|
{view === "schema" ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1 p-2">
|
<div className="flex items-center gap-1 p-2">
|
||||||
<div className="relative flex-1">
|
<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
|
<Input
|
||||||
placeholder="Search objects..."
|
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}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -94,6 +76,7 @@ export function Sidebar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
onClick={handleRefreshSchema}
|
onClick={handleRefreshSchema}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { Circle } from "lucide-react";
|
import { useMcpStatus } from "@/hooks/use-settings";
|
||||||
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
function formatDbVersion(version: string): string {
|
function formatDbVersion(version: string): string {
|
||||||
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
|
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
|
||||||
@@ -21,6 +22,7 @@ interface Props {
|
|||||||
export function StatusBar({ rowCount, executionTime }: Props) {
|
export function StatusBar({ rowCount, executionTime }: Props) {
|
||||||
const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore();
|
const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore();
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
|
const { data: mcpStatus } = useMcpStatus();
|
||||||
|
|
||||||
const activeConn = connections?.find((c) => c.id === activeConnectionId);
|
const activeConn = connections?.find((c) => c.id === activeConnectionId);
|
||||||
const isConnected = activeConnectionId
|
const isConnected = activeConnectionId
|
||||||
@@ -28,40 +30,75 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
{activeConn?.color ? (
|
{activeConn?.color ? (
|
||||||
<span
|
<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 }}
|
style={{ backgroundColor: activeConn.color }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Circle
|
<span
|
||||||
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
|
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"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeConn ? activeConn.name : "No connection"}
|
<span className="font-medium">
|
||||||
|
{activeConn ? activeConn.name : "No connection"}
|
||||||
|
</span>
|
||||||
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
||||||
</span>
|
</span>
|
||||||
{isConnected && activeConnectionId && (
|
{isConnected && activeConnectionId && (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`rounded px-1 py-px text-[10px] font-semibold tracking-wide ${
|
||||||
(readOnlyMap[activeConnectionId] ?? true)
|
(readOnlyMap[activeConnectionId] ?? true)
|
||||||
? "text-yellow-600 dark:text-yellow-500"
|
? "bg-amber-500/10 text-amber-500"
|
||||||
: "text-green-600 dark:text-green-500"
|
: "bg-emerald-500/10 text-emerald-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"}
|
{(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pgVersion && (
|
{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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{rowCount != null && <span>{rowCount.toLocaleString()} rows</span>}
|
{rowCount != null && (
|
||||||
{executionTime != null && <span>{executionTime} ms</span>}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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() {
|
export function TabBar() {
|
||||||
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
||||||
@@ -16,51 +16,62 @@ export function TabBar() {
|
|||||||
roles: <Users className="h-3 w-3" />,
|
roles: <Users className="h-3 w-3" />,
|
||||||
sessions: <Activity className="h-3 w-3" />,
|
sessions: <Activity className="h-3 w-3" />,
|
||||||
lookup: <Search 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 (
|
return (
|
||||||
<div className="border-b bg-card">
|
<div className="border-b border-border/40" style={{ background: "var(--card)" }}>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => {
|
||||||
<div
|
const isActive = activeTabId === tab.id;
|
||||||
key={tab.id}
|
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
|
||||||
className={`group flex h-8 cursor-pointer items-center gap-1.5 border-r px-3 text-xs ${
|
|
||||||
activeTabId === tab.id
|
return (
|
||||||
? "bg-background text-foreground"
|
<div
|
||||||
: "text-muted-foreground hover:text-foreground"
|
key={tab.id}
|
||||||
}`}
|
className={`group relative flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs transition-colors ${
|
||||||
onClick={() => setActiveTabId(tab.id)}
|
isActive
|
||||||
>
|
? "bg-background text-foreground tusk-tab-active"
|
||||||
{(() => {
|
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground/80"
|
||||||
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
|
}`}
|
||||||
return tabColor ? (
|
onClick={() => setActiveTabId(tab.id)}
|
||||||
|
>
|
||||||
|
{tabColor && (
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 shrink-0 rounded-full"
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: tabColor }}
|
style={{ backgroundColor: tabColor }}
|
||||||
/>
|
/>
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{iconMap[tab.type]}
|
|
||||||
<span className="max-w-[150px] truncate">
|
|
||||||
{tab.title}
|
|
||||||
{tab.database && (
|
|
||||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
|
||||||
{tab.database}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
<span className="opacity-60">{iconMap[tab.type]}</span>
|
||||||
<button
|
<span className="max-w-[150px] truncate font-medium">
|
||||||
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
|
{tab.title}
|
||||||
onClick={(e) => {
|
{tab.database && (
|
||||||
e.stopPropagation();
|
<span className="ml-1 text-[10px] font-normal text-muted-foreground/60">
|
||||||
closeTab(tab.id);
|
{tab.database}
|
||||||
}}
|
</span>
|
||||||
>
|
)}
|
||||||
<X className="h-3 w-3" />
|
</span>
|
||||||
</button>
|
<button
|
||||||
</div>
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
|
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
|
||||||
import { ConnectionList } from "@/components/connections/ConnectionList";
|
import { ConnectionList } from "@/components/connections/ConnectionList";
|
||||||
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
|
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
|
||||||
@@ -8,13 +7,15 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections, useReconnect } from "@/hooks/use-connections";
|
import { useConnections, useReconnect } from "@/hooks/use-connections";
|
||||||
import { toast } from "sonner";
|
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 type { ConnectionConfig, Tab } from "@/types";
|
||||||
import { getEnvironment } from "@/lib/environment";
|
import { getEnvironment } from "@/lib/environment";
|
||||||
|
import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet";
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
||||||
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
@@ -59,67 +60,76 @@ export function Toolbar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="flex h-10 items-center gap-2 border-b px-3 bg-card"
|
className="tusk-toolbar tusk-conn-strip flex h-10 items-center gap-1.5 px-3"
|
||||||
style={{ borderLeftWidth: activeColor ? 3 : 0, borderLeftColor: activeColor }}
|
style={{
|
||||||
|
"--strip-width": activeColor ? "3px" : "0px",
|
||||||
|
"--strip-color": activeColor ?? "transparent",
|
||||||
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setListOpen(true)}
|
onClick={() => setListOpen(true)}
|
||||||
>
|
>
|
||||||
<Database className="h-3.5 w-3.5" />
|
<Database className="h-3.5 w-3.5" />
|
||||||
Connections
|
<span className="text-xs font-medium">Connections</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<ConnectionSelector />
|
<ConnectionSelector />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
onClick={handleReconnect}
|
onClick={handleReconnect}
|
||||||
disabled={!activeConnectionId || reconnectMutation.isPending}
|
disabled={!activeConnectionId || reconnectMutation.isPending}
|
||||||
title="Reconnect"
|
title="Reconnect"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<ReadOnlyToggle />
|
<ReadOnlyToggle />
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleNewQuery}
|
onClick={handleNewQuery}
|
||||||
disabled={!activeConnectionId}
|
disabled={!activeConnectionId}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
New Query
|
<span className="text-xs font-medium">New Query</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleNewLookup}
|
onClick={handleNewLookup}
|
||||||
disabled={!activeConnectionId}
|
disabled={!activeConnectionId}
|
||||||
>
|
>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
Entity Lookup
|
<span className="text-xs font-medium">Lookup</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<span className="text-xs font-semibold text-muted-foreground tracking-wide">
|
<Button
|
||||||
TUSK
|
variant="ghost"
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<ConnectionList
|
<ConnectionList
|
||||||
@@ -140,6 +150,11 @@ export function Toolbar() {
|
|||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
connection={editingConn}
|
connection={editingConn}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppSettingsSheet
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { DockerContainersList } from "@/components/docker/DockerContainersList";
|
||||||
import type { Tab, RoleInfo } from "@/types";
|
import type { Tab, RoleInfo } from "@/types";
|
||||||
|
|
||||||
export function AdminPanel() {
|
export function AdminPanel() {
|
||||||
@@ -72,6 +73,7 @@ export function AdminPanel() {
|
|||||||
addTab(tab);
|
addTab(tab);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DockerContainersList />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +34,9 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
|
|||||||
|
|
||||||
const alterMutation = useAlterRole();
|
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) {
|
if (open && role) {
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setLogin(role.can_login);
|
setLogin(role.can_login);
|
||||||
@@ -47,7 +49,7 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
|
|||||||
setValidUntil(role.valid_until ?? "");
|
setValidUntil(role.valid_until ?? "");
|
||||||
setRenameTo("");
|
setRenameTo("");
|
||||||
}
|
}
|
||||||
}, [open, role]);
|
}
|
||||||
|
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -35,7 +35,9 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
const { data: roles } = useRoles(open ? connectionId : null);
|
const { data: roles } = useRoles(open ? connectionId : null);
|
||||||
const createMutation = useCreateDatabase();
|
const createMutation = useCreateDatabase();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
setName("");
|
||||||
setOwner("__default__");
|
setOwner("__default__");
|
||||||
@@ -43,7 +45,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
setEncoding("UTF8");
|
setEncoding("UTF8");
|
||||||
setConnectionLimit(-1);
|
setConnectionLimit(-1);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +34,9 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
|
|||||||
const { data: roles } = useRoles(open ? connectionId : null);
|
const { data: roles } = useRoles(open ? connectionId : null);
|
||||||
const createMutation = useCreateRole();
|
const createMutation = useCreateRole();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
setName("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -48,7 +50,7 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
|
|||||||
setValidUntil("");
|
setValidUntil("");
|
||||||
setInRoles([]);
|
setInRoles([]);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -61,14 +61,16 @@ export function GrantRevokeDialog({
|
|||||||
);
|
);
|
||||||
const grantRevokeMutation = useGrantRevoke();
|
const grantRevokeMutation = useGrantRevoke();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setAction("GRANT");
|
setAction("GRANT");
|
||||||
setRoleName("");
|
setRoleName("");
|
||||||
setPrivileges([]);
|
setPrivileges([]);
|
||||||
setWithGrantOption(false);
|
setWithGrantOption(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
|
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ export function ResultsTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`truncate px-2 py-1 ${
|
className={`truncate px-2 py-1 font-mono text-[12px] ${
|
||||||
value === null
|
value === null
|
||||||
? "italic text-muted-foreground"
|
? "tusk-grid-cell-null"
|
||||||
: isHighlighted
|
: isHighlighted
|
||||||
? "bg-yellow-900/30"
|
? "tusk-grid-cell-highlight"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={() =>
|
onDoubleClick={() =>
|
||||||
@@ -77,6 +77,7 @@ export function ResultsTable({
|
|||||||
[colNames, onCellDoubleClick, highlightedCells]
|
[colNames, onCellDoubleClick, highlightedCells]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
@@ -108,7 +109,6 @@ export function ResultsTable({
|
|||||||
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
|
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
if (externalSort) {
|
if (externalSort) {
|
||||||
// Cycle: none → ASC → DESC → none
|
|
||||||
if (externalSort.column !== colName) {
|
if (externalSort.column !== colName) {
|
||||||
externalSort.onSort(colName, "ASC");
|
externalSort.onSort(colName, "ASC");
|
||||||
} else if (externalSort.direction === "ASC") {
|
} else if (externalSort.direction === "ASC") {
|
||||||
@@ -141,16 +141,16 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="h-full select-text overflow-auto">
|
<div ref={parentRef} className="h-full select-text overflow-auto">
|
||||||
{/* Header */}
|
{/* 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) =>
|
{table.getHeaderGroups().map((headerGroup) =>
|
||||||
headerGroup.headers.map((header) => (
|
headerGroup.headers.map((header) => (
|
||||||
<div
|
<div
|
||||||
key={header.id}
|
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() }}
|
style={{ width: header.getSize(), minWidth: header.getSize() }}
|
||||||
>
|
>
|
||||||
<div
|
<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())}
|
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -158,10 +158,10 @@ export function ResultsTable({
|
|||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
|
{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" && (
|
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
|
||||||
<ArrowDown className="h-3 w-3" />
|
<ArrowDown className="h-3 w-3 text-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Resize handle */}
|
{/* Resize handle */}
|
||||||
@@ -169,7 +169,7 @@ export function ResultsTable({
|
|||||||
onMouseDown={header.getResizeHandler()}
|
onMouseDown={header.getResizeHandler()}
|
||||||
onTouchStart={header.getResizeHandler()}
|
onTouchStart={header.getResizeHandler()}
|
||||||
onDoubleClick={() => header.column.resetSize()}
|
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" : ""
|
header.column.getIsResizing() ? "bg-primary" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +190,7 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="absolute left-0 flex hover:bg-accent/50"
|
className="tusk-grid-row absolute left-0 flex transition-colors"
|
||||||
style={{
|
style={{
|
||||||
top: `${virtualRow.start}px`,
|
top: `${virtualRow.start}px`,
|
||||||
height: `${virtualRow.size}px`,
|
height: `${virtualRow.size}px`,
|
||||||
@@ -201,7 +201,7 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cell.id}
|
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 }}
|
style={{ width: w, minWidth: w }}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,9 +23,11 @@ export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const saveMutation = useSaveQuery();
|
const saveMutation = useSaveQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) setName("");
|
if (open) setName("");
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
|
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";
|
import type { Tab, SchemaObject } from "@/types";
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
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.row_count != null) parts.push(formatCount(item.row_count));
|
||||||
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
|
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
|
||||||
return (
|
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(", ")}
|
{parts.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -65,18 +67,22 @@ export function SchemaTree() {
|
|||||||
const { data: databases } = useDatabases(activeConnectionId);
|
const { data: databases } = useDatabases(activeConnectionId);
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
const switchDbMutation = useSwitchDatabase();
|
const switchDbMutation = useSwitchDatabase();
|
||||||
|
const [cloneTarget, setCloneTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!activeConnectionId) {
|
if (!activeConnectionId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 p-6 text-center">
|
||||||
Connect to a database to browse schema.
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!databases || databases.length === 0) {
|
if (!databases || databases.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground/60">
|
||||||
No databases found.
|
No databases found.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,6 +118,7 @@ export function SchemaTree() {
|
|||||||
connectionId={activeConnectionId}
|
connectionId={activeConnectionId}
|
||||||
onSwitch={() => handleSwitchDb(db)}
|
onSwitch={() => handleSwitchDb(db)}
|
||||||
isSwitching={switchDbMutation.isPending}
|
isSwitching={switchDbMutation.isPending}
|
||||||
|
onCloneToDocker={(dbName) => setCloneTarget(dbName)}
|
||||||
onOpenTable={(schema, table) => {
|
onOpenTable={(schema, table) => {
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -136,8 +143,25 @@ export function SchemaTree() {
|
|||||||
};
|
};
|
||||||
addTab(tab);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,16 +172,20 @@ function DatabaseNode({
|
|||||||
connectionId,
|
connectionId,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
|
onCloneToDocker,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
|
onCloneToDocker: (dbName: string) => void;
|
||||||
onOpenTable: (schema: string, table: string) => void;
|
onOpenTable: (schema: string, table: string) => void;
|
||||||
onViewStructure: (schema: string, table: string) => void;
|
onViewStructure: (schema: string, table: string) => void;
|
||||||
|
onViewErd: (schema: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
|
||||||
@@ -193,22 +221,26 @@ function DatabaseNode({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div
|
<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 ${
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
<span className="text-muted-foreground/50">
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{expanded ? (
|
||||||
) : (
|
<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
|
<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>
|
<span className="truncate">{name}</span>
|
||||||
{isActive && (
|
{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>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -218,6 +250,58 @@ function DatabaseNode({
|
|||||||
>
|
>
|
||||||
Properties
|
Properties
|
||||||
</ContextMenuItem>
|
</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 />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
disabled={isActive || isReadOnly}
|
disabled={isActive || isReadOnly}
|
||||||
@@ -234,11 +318,12 @@ function DatabaseNode({
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
onOpenTable={onOpenTable}
|
onOpenTable={onOpenTable}
|
||||||
onViewStructure={onViewStructure}
|
onViewStructure={onViewStructure}
|
||||||
|
onViewErd={onViewErd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{expanded && !isActive && (
|
{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"}
|
{isSwitching ? "Switching..." : "Click to switch to this database"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -250,16 +335,18 @@ function SchemasForCurrentDb({
|
|||||||
connectionId,
|
connectionId,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
onOpenTable: (schema: string, table: string) => void;
|
onOpenTable: (schema: string, table: string) => void;
|
||||||
onViewStructure: (schema: string, table: string) => void;
|
onViewStructure: (schema: string, table: string) => void;
|
||||||
|
onViewErd: (schema: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: schemas } = useSchemas(connectionId);
|
const { data: schemas } = useSchemas(connectionId);
|
||||||
|
|
||||||
if (!schemas || schemas.length === 0) {
|
if (!schemas || schemas.length === 0) {
|
||||||
return (
|
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}
|
connectionId={connectionId}
|
||||||
onOpenTable={(table) => onOpenTable(schema, table)}
|
onOpenTable={(table) => onOpenTable(schema, table)}
|
||||||
onViewStructure={(table) => onViewStructure(schema, table)}
|
onViewStructure={(table) => onViewStructure(schema, table)}
|
||||||
|
onViewErd={() => onViewErd(schema)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -283,32 +371,45 @@ function SchemaNode({
|
|||||||
connectionId,
|
connectionId,
|
||||||
onOpenTable,
|
onOpenTable,
|
||||||
onViewStructure,
|
onViewStructure,
|
||||||
|
onViewErd,
|
||||||
}: {
|
}: {
|
||||||
schema: string;
|
schema: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
onOpenTable: (table: string) => void;
|
onOpenTable: (table: string) => void;
|
||||||
onViewStructure: (table: string) => void;
|
onViewStructure: (table: string) => void;
|
||||||
|
onViewErd: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<ContextMenu>
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
|
<ContextMenuTrigger>
|
||||||
onClick={() => setExpanded(!expanded)}
|
<div
|
||||||
>
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium"
|
||||||
{expanded ? (
|
onClick={() => setExpanded(!expanded)}
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
>
|
||||||
) : (
|
<span className="text-muted-foreground/50">
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
{expanded ? (
|
||||||
)}
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
{expanded ? (
|
) : (
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
) : (
|
)}
|
||||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
</span>
|
||||||
)}
|
{expanded ? (
|
||||||
<span>{schema}</span>
|
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
|
||||||
</div>
|
) : (
|
||||||
|
<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 && (
|
{expanded && (
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<CategoryNode
|
<CategoryNode
|
||||||
@@ -350,10 +451,10 @@ function SchemaNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryIcons = {
|
const categoryIcons = {
|
||||||
tables: <Table2 className="h-3.5 w-3.5 text-blue-400" />,
|
tables: <Table2 className="h-3.5 w-3.5 text-sky-400/80" />,
|
||||||
views: <Eye className="h-3.5 w-3.5 text-green-400" />,
|
views: <Eye className="h-3.5 w-3.5 text-emerald-400/80" />,
|
||||||
functions: <FunctionSquare className="h-3.5 w-3.5 text-purple-400" />,
|
functions: <FunctionSquare className="h-3.5 w-3.5 text-violet-400/80" />,
|
||||||
sequences: <Hash className="h-3.5 w-3.5 text-orange-400" />,
|
sequences: <Hash className="h-3.5 w-3.5 text-amber-400/80" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CategoryNode({
|
function CategoryNode({
|
||||||
@@ -373,6 +474,7 @@ function CategoryNode({
|
|||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [privilegesTarget, setPrivilegesTarget] = useState<string | null>(null);
|
const [privilegesTarget, setPrivilegesTarget] = useState<string | null>(null);
|
||||||
|
const [dataGenTarget, setDataGenTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
const tablesQuery = useTables(
|
const tablesQuery = useTables(
|
||||||
expanded && category === "tables" ? connectionId : null,
|
expanded && category === "tables" ? connectionId : null,
|
||||||
@@ -405,18 +507,24 @@ function CategoryNode({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<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)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
<span className="text-muted-foreground/50">
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{expanded ? (
|
||||||
) : (
|
<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}
|
{icon}
|
||||||
<span className="truncate">
|
<span className="truncate font-medium">
|
||||||
{label}
|
{label}
|
||||||
{items ? ` (${items.length})` : ""}
|
{items && (
|
||||||
|
<span className="ml-1 font-normal text-muted-foreground/40">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -429,12 +537,12 @@ function CategoryNode({
|
|||||||
<ContextMenu key={item.name}>
|
<ContextMenu key={item.name}>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<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-0.5 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
|
||||||
onDoubleClick={() => onOpenTable(item.name)}
|
onDoubleClick={() => onOpenTable(item.name)}
|
||||||
>
|
>
|
||||||
<span className="w-3.5 shrink-0" />
|
<span className="w-3.5 shrink-0" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate text-foreground/80">{item.name}</span>
|
||||||
{category === "tables" && <TableSizeInfo item={item} />}
|
{category === "tables" && <TableSizeInfo item={item} />}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -447,6 +555,13 @@ function CategoryNode({
|
|||||||
>
|
>
|
||||||
View Structure
|
View Structure
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
{category === "tables" && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => setDataGenTarget(item.name)}
|
||||||
|
>
|
||||||
|
Generate Test Data
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => setPrivilegesTarget(item.name)}
|
onClick={() => setPrivilegesTarget(item.name)}
|
||||||
@@ -461,11 +576,11 @@ function CategoryNode({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
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" />
|
<span className="w-3.5 shrink-0" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate text-foreground/80">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -482,6 +597,15 @@ function CategoryNode({
|
|||||||
table={privilegesTarget}
|
table={privilegesTarget}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{dataGenTarget && (
|
||||||
|
<GenerateDataDialog
|
||||||
|
open={!!dataGenTarget}
|
||||||
|
onOpenChange={(open) => !open && setDataGenTarget(null)}
|
||||||
|
connectionId={connectionId}
|
||||||
|
schema={schema}
|
||||||
|
table={dataGenTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
258
src/components/settings/AppSettingsSheet.tsx
Normal file
258
src/components/settings/AppSettingsSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/components/snapshots/CreateSnapshotDialog.tsx
Normal file
276
src/components/snapshots/CreateSnapshotDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/components/snapshots/RestoreSnapshotDialog.tsx
Normal file
248
src/components/snapshots/RestoreSnapshotDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/snapshots/SnapshotPanel.tsx
Normal file
122
src/components/snapshots/SnapshotPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,30 +94,46 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
[pendingChanges, isReadOnly]
|
[pendingChanges, isReadOnly]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const usesCtid = pkColumns.length === 0;
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
if (!data || pkColumns.length === 0) {
|
if (!data) return;
|
||||||
toast.error("Cannot save: no primary key detected");
|
if (pkColumns.length === 0 && (!data.ctids || data.ctids.length === 0)) {
|
||||||
|
toast.error("Cannot save: no primary key and no ctid available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
for (const [_key, change] of pendingChanges) {
|
for (const [, change] of pendingChanges) {
|
||||||
const row = data.rows[change.rowIndex];
|
const row = data.rows[change.rowIndex];
|
||||||
const pkValues = pkColumns.map((pkCol) => {
|
|
||||||
const idx = data.columns.indexOf(pkCol);
|
|
||||||
return row[idx];
|
|
||||||
});
|
|
||||||
const colName = data.columns[change.colIndex];
|
const colName = data.columns[change.colIndex];
|
||||||
|
|
||||||
await updateRowApi({
|
if (usesCtid) {
|
||||||
connectionId,
|
await updateRowApi({
|
||||||
schema,
|
connectionId,
|
||||||
table,
|
schema,
|
||||||
pkColumns,
|
table,
|
||||||
pkValues: pkValues as unknown[],
|
pkColumns: [],
|
||||||
column: colName,
|
pkValues: [],
|
||||||
value: change.value,
|
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];
|
||||||
|
});
|
||||||
|
await updateRowApi({
|
||||||
|
connectionId,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
pkColumns,
|
||||||
|
pkValues: pkValues as unknown[],
|
||||||
|
column: colName,
|
||||||
|
value: change.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPendingChanges(new Map());
|
setPendingChanges(new Map());
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -182,6 +198,14 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
Read-Only
|
Read-Only
|
||||||
</span>
|
</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" />
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="WHERE clause (e.g. id > 10)"
|
placeholder="WHERE clause (e.g. id > 10)"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getTableColumns,
|
getTableColumns,
|
||||||
getTableConstraints,
|
getTableConstraints,
|
||||||
getTableIndexes,
|
getTableIndexes,
|
||||||
|
getTableTriggers,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,11 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
queryFn: () => getTableIndexes(connectionId, schema, table),
|
queryFn: () => getTableIndexes(connectionId, schema, table),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: triggers } = useQuery({
|
||||||
|
queryKey: ["table-triggers", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableTriggers(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="columns" className="flex h-full flex-col">
|
<Tabs defaultValue="columns" className="flex h-full flex-col">
|
||||||
<TabsList className="mx-2 mt-2 w-fit">
|
<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">
|
<TabsTrigger value="indexes" className="text-xs">
|
||||||
Indexes{indexes ? ` (${indexes.length})` : ""}
|
Indexes{indexes ? ` (${indexes.length})` : ""}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="triggers" className="text-xs">
|
||||||
|
Triggers{triggers ? ` (${triggers.length})` : ""}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="columns" className="flex-1 overflow-hidden mt-0">
|
<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">Nullable</TableHead>
|
||||||
<TableHead className="text-xs">Default</TableHead>
|
<TableHead className="text-xs">Default</TableHead>
|
||||||
<TableHead className="text-xs">Key</TableHead>
|
<TableHead className="text-xs">Key</TableHead>
|
||||||
|
<TableHead className="text-xs">Comment</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -84,7 +94,7 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
{col.is_nullable ? "YES" : "NO"}
|
{col.is_nullable ? "YES" : "NO"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
{col.column_default ?? "—"}
|
{col.column_default ?? "\u2014"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{col.is_primary_key && (
|
{col.is_primary_key && (
|
||||||
@@ -93,6 +103,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
|
{col.comment ?? "\u2014"}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -108,6 +121,9 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TableHead className="text-xs">Name</TableHead>
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
<TableHead className="text-xs">Type</TableHead>
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
<TableHead className="text-xs">Columns</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -124,6 +140,17 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{c.columns.join(", ")}
|
{c.columns.join(", ")}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -163,6 +190,49 @@ export function TableStructure({ connectionId, schema, table }: Props) {
|
|||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
@@ -5,19 +6,19 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
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",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|||||||
216
src/components/validation/ValidationPanel.tsx
Normal file
216
src/components/validation/ValidationPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/validation/ValidationRuleCard.tsx
Normal file
138
src/components/validation/ValidationRuleCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { TableStructure } from "@/components/table-viewer/TableStructure";
|
|||||||
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
||||||
import { SessionsView } from "@/components/management/SessionsView";
|
import { SessionsView } from "@/components/management/SessionsView";
|
||||||
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
|
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() {
|
export function TabContent() {
|
||||||
const { tabs, activeTabId, updateTab } = useAppStore();
|
const { tabs, activeTabId, updateTab } = useAppStore();
|
||||||
@@ -72,6 +76,35 @@ export function TabContent() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
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:
|
default:
|
||||||
content = null;
|
content = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,11 +255,12 @@ export function WorkspacePanel({
|
|||||||
<ResizablePanelGroup orientation="vertical">
|
<ResizablePanelGroup orientation="vertical">
|
||||||
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
||||||
<div className="flex h-full flex-col">
|
<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
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
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}
|
onClick={handleExecute}
|
||||||
disabled={queryMutation.isPending || !sqlValue.trim()}
|
disabled={queryMutation.isPending || !sqlValue.trim()}
|
||||||
>
|
>
|
||||||
@@ -271,9 +272,9 @@ export function WorkspacePanel({
|
|||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleExplain}
|
onClick={handleExplain}
|
||||||
disabled={queryMutation.isPending || !sqlValue.trim()}
|
disabled={queryMutation.isPending || !sqlValue.trim()}
|
||||||
>
|
>
|
||||||
@@ -285,9 +286,9 @@ export function WorkspacePanel({
|
|||||||
Explain
|
Explain
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleFormat}
|
onClick={handleFormat}
|
||||||
disabled={!sqlValue.trim()}
|
disabled={!sqlValue.trim()}
|
||||||
title="Format SQL (Shift+Alt+F)"
|
title="Format SQL (Shift+Alt+F)"
|
||||||
@@ -296,9 +297,9 @@ export function WorkspacePanel({
|
|||||||
Format
|
Format
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={() => setSaveDialogOpen(true)}
|
onClick={() => setSaveDialogOpen(true)}
|
||||||
disabled={!sqlValue.trim()}
|
disabled={!sqlValue.trim()}
|
||||||
title="Save query"
|
title="Save query"
|
||||||
@@ -306,20 +307,24 @@ export function WorkspacePanel({
|
|||||||
<Bookmark className="h-3 w-3" />
|
<Bookmark className="h-3 w-3" />
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* AI actions group — purple-branded */}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant={aiBarOpen ? "secondary" : "ghost"}
|
variant={aiBarOpen ? "secondary" : "ghost"}
|
||||||
className="h-6 gap-1 text-xs"
|
className={`gap-1 text-[11px] ${aiBarOpen ? "text-tusk-purple" : ""}`}
|
||||||
onClick={() => setAiBarOpen(!aiBarOpen)}
|
onClick={() => setAiBarOpen(!aiBarOpen)}
|
||||||
title="AI SQL Generator"
|
title="AI SQL Generator"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className={`h-3 w-3 ${aiBarOpen ? "tusk-ai-icon" : ""}`} />
|
||||||
AI
|
AI
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleAiExplain}
|
onClick={handleAiExplain}
|
||||||
disabled={isAiLoading || !sqlValue.trim()}
|
disabled={isAiLoading || !sqlValue.trim()}
|
||||||
title="Explain query with AI"
|
title="Explain query with AI"
|
||||||
@@ -331,35 +336,42 @@ export function WorkspacePanel({
|
|||||||
)}
|
)}
|
||||||
AI Explain
|
AI Explain
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{result && result.columns.length > 0 && (
|
{result && result.columns.length > 0 && (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
||||||
<Button
|
<DropdownMenu>
|
||||||
size="sm"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
className="h-6 gap-1 text-xs"
|
size="xs"
|
||||||
>
|
variant="ghost"
|
||||||
<Download className="h-3 w-3" />
|
className="gap-1 text-[11px]"
|
||||||
Export
|
>
|
||||||
</Button>
|
<Download className="h-3 w-3" />
|
||||||
</DropdownMenuTrigger>
|
Export
|
||||||
<DropdownMenuContent align="start">
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
</DropdownMenuTrigger>
|
||||||
Export CSV
|
<DropdownMenuContent align="start">
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||||
<DropdownMenuItem onClick={() => handleExport("json")}>
|
Export CSV
|
||||||
Export JSON
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleExport("json")}>
|
||||||
</DropdownMenuContent>
|
Export JSON
|
||||||
</DropdownMenu>
|
</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>
|
</span>
|
||||||
{isReadOnly && (
|
{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">
|
<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-3 w-3" />
|
<Lock className="h-2.5 w-2.5" />
|
||||||
Read-Only
|
READ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -389,35 +401,41 @@ export function WorkspacePanel({
|
|||||||
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{(explainData || result || error || aiExplanation) && (
|
{(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
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
||||||
resultView === "results"
|
resultView === "results"
|
||||||
? "bg-background text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultView("results")}
|
onClick={() => setResultView("results")}
|
||||||
>
|
>
|
||||||
Results
|
Results
|
||||||
|
{resultView === "results" && (
|
||||||
|
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{explainData && (
|
{explainData && (
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
||||||
resultView === "explain"
|
resultView === "explain"
|
||||||
? "bg-background text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultView("explain")}
|
onClick={() => setResultView("explain")}
|
||||||
>
|
>
|
||||||
Explain
|
Explain
|
||||||
|
{resultView === "explain" && (
|
||||||
|
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{resultView === "results" && result && result.columns.length > 0 && (
|
{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
|
<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"
|
resultViewMode === "table"
|
||||||
? "bg-muted text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultViewMode("table")}
|
onClick={() => setResultViewMode("table")}
|
||||||
@@ -427,9 +445,9 @@ export function WorkspacePanel({
|
|||||||
Table
|
Table
|
||||||
</button>
|
</button>
|
||||||
<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"
|
resultViewMode === "json"
|
||||||
? "bg-muted text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultViewMode("json")}
|
onClick={() => setResultViewMode("json")}
|
||||||
|
|||||||
84
src/hooks/use-data-generator.ts
Normal file
84
src/hooks/use-data-generator.ts
Normal 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
122
src/hooks/use-docker.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/hooks/use-index-advisor.ts
Normal file
20
src/hooks/use-index-advisor.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
listSequences,
|
listSequences,
|
||||||
switchDatabase,
|
switchDatabase,
|
||||||
getColumnDetails,
|
getColumnDetails,
|
||||||
|
getSchemaErd,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
|
||||||
@@ -88,3 +89,12 @@ export function useColumnDetails(connectionId: string | null, schema: string | n
|
|||||||
staleTime: 5 * 60 * 1000,
|
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
30
src/hooks/use-settings.ts
Normal 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
153
src/hooks/use-snapshots.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/hooks/use-validation.ts
Normal file
38
src/hooks/use-validation.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
110
src/lib/tauri.ts
110
src/lib/tauri.ts
@@ -11,6 +11,8 @@ import type {
|
|||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
ConstraintInfo,
|
ConstraintInfo,
|
||||||
IndexInfo,
|
IndexInfo,
|
||||||
|
TriggerInfo,
|
||||||
|
ErdData,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
SavedQuery,
|
SavedQuery,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
@@ -26,6 +28,22 @@ import type {
|
|||||||
OllamaModel,
|
OllamaModel,
|
||||||
EntityLookupResult,
|
EntityLookupResult,
|
||||||
LookupProgress,
|
LookupProgress,
|
||||||
|
DockerStatus,
|
||||||
|
CloneToDockerParams,
|
||||||
|
CloneProgress,
|
||||||
|
CloneResult,
|
||||||
|
TuskContainer,
|
||||||
|
AppSettings,
|
||||||
|
McpStatus,
|
||||||
|
ValidationRule,
|
||||||
|
GenerateDataParams,
|
||||||
|
GeneratedDataPreview,
|
||||||
|
DataGenProgress,
|
||||||
|
IndexAdvisorReport,
|
||||||
|
SnapshotMetadata,
|
||||||
|
CreateSnapshotParams,
|
||||||
|
RestoreSnapshotParams,
|
||||||
|
SnapshotProgress,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// Connections
|
// Connections
|
||||||
@@ -115,6 +133,15 @@ export const getTableIndexes = (
|
|||||||
table: string
|
table: string
|
||||||
) => invoke<IndexInfo[]>("get_table_indexes", { connectionId, schema, table });
|
) => 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
|
// Data
|
||||||
export const getTableData = (params: {
|
export const getTableData = (params: {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -135,6 +162,7 @@ export const updateRow = (params: {
|
|||||||
pkValues: unknown[];
|
pkValues: unknown[];
|
||||||
column: string;
|
column: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
ctid?: string;
|
||||||
}) => invoke<void>("update_row", params);
|
}) => invoke<void>("update_row", params);
|
||||||
|
|
||||||
export const insertRow = (params: {
|
export const insertRow = (params: {
|
||||||
@@ -151,6 +179,7 @@ export const deleteRows = (params: {
|
|||||||
table: string;
|
table: string;
|
||||||
pkColumns: string[];
|
pkColumns: string[];
|
||||||
pkValuesList: unknown[][];
|
pkValuesList: unknown[][];
|
||||||
|
ctids?: string[];
|
||||||
}) => invoke<number>("delete_rows", params);
|
}) => invoke<number>("delete_rows", params);
|
||||||
|
|
||||||
// History
|
// History
|
||||||
@@ -280,3 +309,84 @@ export const onLookupProgress = (
|
|||||||
callback: (p: LookupProgress) => void
|
callback: (p: LookupProgress) => void
|
||||||
): Promise<UnlistenFn> =>
|
): Promise<UnlistenFn> =>
|
||||||
listen<LookupProgress>("lookup-progress", (e) => callback(e.payload));
|
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));
|
||||||
|
|||||||
217
src/stores/__tests__/app-store.test.ts
Normal file
217
src/stores/__tests__/app-store.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
TUSK — "Twilight" Design System
|
||||||
|
Soft dark with blue undertones and teal accents
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
@@ -43,50 +48,399 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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 {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
/* Soft twilight palette — comfortable, not eye-straining */
|
||||||
--card: oklch(0.205 0 0);
|
--background: oklch(0.2 0.012 250);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.9 0.005 250);
|
||||||
--popover: oklch(0.205 0 0);
|
--card: oklch(0.23 0.012 250);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.9 0.005 250);
|
||||||
--primary: oklch(0.922 0 0);
|
--popover: oklch(0.25 0.014 250);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--popover-foreground: oklch(0.9 0.005 250);
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
/* Teal primary — slightly softer for the lighter background */
|
||||||
--muted: oklch(0.269 0 0);
|
--primary: oklch(0.72 0.14 170);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--primary-foreground: oklch(0.18 0.015 250);
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
/* Surfaces — gentle stepping */
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--secondary: oklch(0.27 0.012 250);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--secondary-foreground: oklch(0.85 0.008 250);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--muted: oklch(0.27 0.012 250);
|
||||||
--ring: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.62 0.015 250);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--accent: oklch(0.28 0.014 250);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--accent-foreground: oklch(0.9 0.005 250);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
/* Status */
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--destructive: oklch(0.65 0.2 15);
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
/* Borders & inputs — more visible, less transparent */
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--border: oklch(0.34 0.015 250 / 70%);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--input: oklch(0.36 0.015 250 / 60%);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--ring: oklch(0.72 0.14 170 / 40%);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
/* Chart palette */
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
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
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -30,6 +30,7 @@ export interface PaginatedQueryResult extends QueryResult {
|
|||||||
total_rows: number;
|
total_rows: number;
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
|
ctids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemaObject {
|
export interface SchemaObject {
|
||||||
@@ -56,12 +57,18 @@ export interface ColumnInfo {
|
|||||||
ordinal_position: number;
|
ordinal_position: number;
|
||||||
character_maximum_length: number | null;
|
character_maximum_length: number | null;
|
||||||
is_primary_key: boolean;
|
is_primary_key: boolean;
|
||||||
|
comment: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConstraintInfo {
|
export interface ConstraintInfo {
|
||||||
name: string;
|
name: string;
|
||||||
constraint_type: string;
|
constraint_type: string;
|
||||||
columns: 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 {
|
export interface IndexInfo {
|
||||||
@@ -223,8 +230,13 @@ export interface SavedQuery {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiProvider = "ollama" | "openai" | "anthropic";
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
|
provider: AiProvider;
|
||||||
ollama_url: string;
|
ollama_url: string;
|
||||||
|
openai_api_key?: string;
|
||||||
|
anthropic_api_key?: string;
|
||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +284,117 @@ export interface LookupProgress {
|
|||||||
total: number;
|
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 {
|
export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -287,3 +409,159 @@ export interface Tab {
|
|||||||
lookupColumn?: string;
|
lookupColumn?: string;
|
||||||
lookupValue?: 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
15
vitest.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user