queryview 0.0.7__tar.gz → 0.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queryview-0.0.7 → queryview-0.0.8}/PKG-INFO +20 -6
- {queryview-0.0.7 → queryview-0.0.8}/README.md +19 -5
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/connect.py +65 -34
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/dashboard_queries.py +2 -2
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/main.py +124 -35
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/mcp_server.py +51 -12
- queryview-0.0.8/backend/queryview/migrations/versions/d8e9f0a1b2c3_sessions.py +50 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/remote.py +8 -48
- queryview-0.0.8/backend/queryview/sessions.py +388 -0
- queryview-0.0.8/backend/queryview/static/assets/index-CgV5cxdD.css +2 -0
- queryview-0.0.8/backend/queryview/static/assets/index-DdgDsY5d.js +46 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/static/index.html +2 -2
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_connect_flow.py +44 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_dashboards.py +16 -8
- queryview-0.0.8/backend/queryview/test_mcp_connections.py +129 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_mcp_gitsync.py +5 -2
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_remote.py +45 -59
- queryview-0.0.8/backend/queryview/test_session_api.py +65 -0
- queryview-0.0.8/backend/queryview/test_sessions.py +246 -0
- {queryview-0.0.7 → queryview-0.0.8}/docs/api.md +17 -9
- {queryview-0.0.7 → queryview-0.0.8}/docs/connect.md +36 -27
- {queryview-0.0.7 → queryview-0.0.8}/docs/dashboard.md +3 -3
- {queryview-0.0.7 → queryview-0.0.8}/docs/explorer.md +6 -4
- {queryview-0.0.7 → queryview-0.0.8}/docs/future.md +2 -1
- {queryview-0.0.7 → queryview-0.0.8}/docs/query.md +7 -5
- {queryview-0.0.7 → queryview-0.0.8}/docs/queryview.md +11 -16
- {queryview-0.0.7 → queryview-0.0.8}/docs/remote.md +27 -14
- queryview-0.0.8/docs/session.md +132 -0
- {queryview-0.0.7 → queryview-0.0.8}/docs/workspace.md +3 -2
- {queryview-0.0.7 → queryview-0.0.8}/e2e/conftest.py +26 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_drivers.py +2 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_explorer.py +6 -6
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_query.py +5 -1
- queryview-0.0.8/e2e/test_sessions.py +128 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_workspaces.py +5 -2
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/App.tsx +153 -154
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/DashboardView.tsx +6 -5
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/ExplorerView.tsx +19 -18
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/QueryView.tsx +69 -38
- queryview-0.0.8/frontend/src/app/api.ts +14 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/ExportImportControls.tsx +1 -1
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/GitSyncControls.tsx +0 -0
- queryview-0.0.8/frontend/src/app/controls/SessionSwitcher.tsx +181 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/WorkspaceSwitcher.tsx +4 -4
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/explorerSettings.test.ts +20 -13
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/explorerSettings.ts +5 -4
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/gitsync.ts +6 -4
- queryview-0.0.8/frontend/src/app/session.test.ts +221 -0
- queryview-0.0.8/frontend/src/app/session.ts +195 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/sessionLock.ts +3 -1
- queryview-0.0.8/frontend/src/app/tabStorage.ts +31 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/workspace.ts +9 -26
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/yamlio.ts +3 -1
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/index.ts +3 -0
- queryview-0.0.8/frontend/src/core/nameFilter.test.ts +33 -0
- queryview-0.0.8/frontend/src/core/nameFilter.ts +11 -0
- queryview-0.0.8/frontend/src/core/presentation/FieldPickers.tsx +212 -0
- queryview-0.0.8/frontend/src/core/presentation/SearchPanel.tsx +91 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/ResultsTable.tsx +8 -2
- {queryview-0.0.7/frontend/src/app/controls → queryview-0.0.8/frontend/src/core}/useDismiss.ts +9 -3
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/index.css +9 -0
- queryview-0.0.7/backend/queryview/static/assets/index-Cysz_FnM.js +0 -46
- queryview-0.0.7/backend/queryview/static/assets/index-h_3ZdSNd.css +0 -2
- queryview-0.0.7/frontend/src/app/databaseFilter.test.ts +0 -28
- queryview-0.0.7/frontend/src/app/databaseFilter.ts +0 -7
- queryview-0.0.7/frontend/src/app/testStorage.ts +0 -25
- queryview-0.0.7/frontend/src/app/viewSettings.test.ts +0 -65
- queryview-0.0.7/frontend/src/app/viewSettings.ts +0 -31
- queryview-0.0.7/frontend/src/app/workspace.test.ts +0 -23
- queryview-0.0.7/frontend/src/core/presentation/FieldPickers.tsx +0 -153
- {queryview-0.0.7 → queryview-0.0.8}/.claude/settings.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.github/actions/start-git-daemon/action.yaml +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.github/workflows/ci.yml +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.github/workflows/publish.yaml +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.gitignore +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.mcp.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/.pre-commit-config.yaml +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/CLAUDE.md +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/CONTRIBUTING.md +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/LICENSE +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/alembic.ini +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/__init__.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/conftest.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/dashboards.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/__init__.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/base.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/clickhouse.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/duckdb.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/postgres.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_base.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_clickhouse.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_contract.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_duckdb.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_postgres.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/gitsync.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/env.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/script.py.mako +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/queries.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/static/favicon.svg +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_db.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_export_import.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_gitsync.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_workspaces.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_connect_store.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_dashboard_queries.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_data_dir.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_gitsync.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_main.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_mcp_mount.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_migrations.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_queries.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_validation.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_workspaces.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_yamlio.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/validation.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/workspaces.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/yamlio.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/docker/Dockerfile +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/docs/export-import.md +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/docs/gitsync.md +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_app.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_dashboard.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_export_import.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_gitsync.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_popovers.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/e2e/test_remote.py +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/.gitignore +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/README.md +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/eslint.config.js +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/index.html +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/package.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/public/favicon.svg +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/compactNumber.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/compactNumber.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/connection.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/Spinner.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/Toast.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/drivers.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/gitsync.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/promptSuggestions.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/promptSuggestions.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/sessionLock.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/yamlio.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/CellDataModal.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/CellViewModal.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/ComplexCell.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellView.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellView.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellViewYaml.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellViewYaml.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/complexCells.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/complexCells.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structured.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structured.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structuredTree.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structuredTree.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/DashboardFrame.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/srcDoc.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/srcDoc.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/params/queryParams.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/params/queryParams.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/presentation/presentation.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/presentation/presentation.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/cellWidth.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/cellWidth.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/rows.test.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/rows.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/src/main.tsx +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.app.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.node.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/frontend/vite.config.ts +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/package-lock.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/package.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/pyproject.toml +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/pyrightconfig.json +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/scripts/dev.sh +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/scripts/setup.sh +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_browser.sh +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_clickhouse.sh +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_postgres.sh +0 -0
- {queryview-0.0.7 → queryview-0.0.8}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queryview
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: Local SQL workbench for ClickHouse, Postgres and DuckDB, with a built-in MCP server for AI agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/kolodkin/queryview
|
|
6
6
|
Project-URL: Repository, https://github.com/kolodkin/queryview
|
|
@@ -127,6 +127,17 @@ To let Docker own the location instead, swap the path for a named volume,
|
|
|
127
127
|
ownership is already right and there is nothing to create up front, but the
|
|
128
128
|
state no longer lines up with a local run.
|
|
129
129
|
|
|
130
|
+
### Connecting to databases on your machine
|
|
131
|
+
|
|
132
|
+
Inside the container, `localhost` is the container itself. To reach a database:
|
|
133
|
+
|
|
134
|
+
- **On the host:** use `host.docker.internal` (on Linux, add
|
|
135
|
+
`--add-host=host.docker.internal:host-gateway`).
|
|
136
|
+
- **In another container:** `docker network connect <net> queryview`, then use
|
|
137
|
+
its container name as the host.
|
|
138
|
+
- **Host networking:** `--network host` makes `localhost` your machine (Linux,
|
|
139
|
+
or Docker Desktop 4.34+ with host networking enabled); `-p` is then ignored.
|
|
140
|
+
|
|
130
141
|
### Git sync
|
|
131
142
|
|
|
132
143
|
The image ships `git`, so workspace git sync works in the container, and its
|
|
@@ -179,10 +190,11 @@ connection and rewrite workspace git state, so don't publish the port. Bind the
|
|
|
179
190
|
container to loopback — `docker run -p 127.0.0.1:8000:8000 ...` — since a plain
|
|
180
191
|
`-p 8000:8000` listens on all interfaces.
|
|
181
192
|
|
|
182
|
-
Tools: `
|
|
183
|
-
|
|
184
|
-
`
|
|
185
|
-
|
|
193
|
+
Tools: `list_connections` (saved connection names; call it before `run_query`
|
|
194
|
+
when unsure), `run_query` (read-only SQL, rows returned to the agent),
|
|
195
|
+
`push_query` and `push_dashboard` (fill a live browser session),
|
|
196
|
+
`list_queries` / `list_dashboards`, and `git_store` / `git_history` /
|
|
197
|
+
`git_restore` (workspace git backups). The push tools target an **armed** browser session: enable
|
|
186
198
|
"Allow remote control" from the agent icon next to the connection pill and use
|
|
187
199
|
the session id it shows. See [docs/remote.md](docs/remote.md) for the full
|
|
188
200
|
protocol.
|
|
@@ -198,7 +210,9 @@ and let [git sync](docs/gitsync.md) do it.
|
|
|
198
210
|
## Documentation
|
|
199
211
|
|
|
200
212
|
- [queryview.md](docs/queryview.md) — the single-prompt page.
|
|
201
|
-
- [connect.md](docs/connect.md) — connections, drivers,
|
|
213
|
+
- [connect.md](docs/connect.md) — connections, drivers, storage.
|
|
214
|
+
- [session.md](docs/session.md) — sessions: what a tab remembers, refresh and
|
|
215
|
+
new-tab behaviour, the session switcher.
|
|
202
216
|
- [query.md](docs/query.md) — the query panel: pagination, predefined queries, CSV.
|
|
203
217
|
- [explorer.md](docs/explorer.md) — the table navigator.
|
|
204
218
|
- [dashboard.md](docs/dashboard.md) — dashboards and how agents author them.
|
|
@@ -103,6 +103,17 @@ To let Docker own the location instead, swap the path for a named volume,
|
|
|
103
103
|
ownership is already right and there is nothing to create up front, but the
|
|
104
104
|
state no longer lines up with a local run.
|
|
105
105
|
|
|
106
|
+
### Connecting to databases on your machine
|
|
107
|
+
|
|
108
|
+
Inside the container, `localhost` is the container itself. To reach a database:
|
|
109
|
+
|
|
110
|
+
- **On the host:** use `host.docker.internal` (on Linux, add
|
|
111
|
+
`--add-host=host.docker.internal:host-gateway`).
|
|
112
|
+
- **In another container:** `docker network connect <net> queryview`, then use
|
|
113
|
+
its container name as the host.
|
|
114
|
+
- **Host networking:** `--network host` makes `localhost` your machine (Linux,
|
|
115
|
+
or Docker Desktop 4.34+ with host networking enabled); `-p` is then ignored.
|
|
116
|
+
|
|
106
117
|
### Git sync
|
|
107
118
|
|
|
108
119
|
The image ships `git`, so workspace git sync works in the container, and its
|
|
@@ -155,10 +166,11 @@ connection and rewrite workspace git state, so don't publish the port. Bind the
|
|
|
155
166
|
container to loopback — `docker run -p 127.0.0.1:8000:8000 ...` — since a plain
|
|
156
167
|
`-p 8000:8000` listens on all interfaces.
|
|
157
168
|
|
|
158
|
-
Tools: `
|
|
159
|
-
|
|
160
|
-
`
|
|
161
|
-
|
|
169
|
+
Tools: `list_connections` (saved connection names; call it before `run_query`
|
|
170
|
+
when unsure), `run_query` (read-only SQL, rows returned to the agent),
|
|
171
|
+
`push_query` and `push_dashboard` (fill a live browser session),
|
|
172
|
+
`list_queries` / `list_dashboards`, and `git_store` / `git_history` /
|
|
173
|
+
`git_restore` (workspace git backups). The push tools target an **armed** browser session: enable
|
|
162
174
|
"Allow remote control" from the agent icon next to the connection pill and use
|
|
163
175
|
the session id it shows. See [docs/remote.md](docs/remote.md) for the full
|
|
164
176
|
protocol.
|
|
@@ -174,7 +186,9 @@ and let [git sync](docs/gitsync.md) do it.
|
|
|
174
186
|
## Documentation
|
|
175
187
|
|
|
176
188
|
- [queryview.md](docs/queryview.md) — the single-prompt page.
|
|
177
|
-
- [connect.md](docs/connect.md) — connections, drivers,
|
|
189
|
+
- [connect.md](docs/connect.md) — connections, drivers, storage.
|
|
190
|
+
- [session.md](docs/session.md) — sessions: what a tab remembers, refresh and
|
|
191
|
+
new-tab behaviour, the session switcher.
|
|
178
192
|
- [query.md](docs/query.md) — the query panel: pagination, predefined queries, CSV.
|
|
179
193
|
- [explorer.md](docs/explorer.md) — the table navigator.
|
|
180
194
|
- [dashboard.md](docs/dashboard.md) — dashboards and how agents author them.
|
|
@@ -192,13 +192,6 @@ def _row_to_stored(row: Connection | None) -> StoredConnection | None:
|
|
|
192
192
|
)
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
async def _latest_active_connection() -> StoredConnection | None:
|
|
196
|
-
await _ensure_schema()
|
|
197
|
-
async with AsyncSession(_engine_for_db()) as s:
|
|
198
|
-
row = (await s.exec(select(Connection).order_by(col(Connection.last_active_at).desc()).limit(1))).first()
|
|
199
|
-
return _row_to_stored(row)
|
|
200
|
-
|
|
201
|
-
|
|
202
195
|
async def list_connection_names() -> list[str]:
|
|
203
196
|
"""All saved connection names, most-recently-active first (for `connect`
|
|
204
197
|
autocomplete)."""
|
|
@@ -208,6 +201,27 @@ async def list_connection_names() -> list[str]:
|
|
|
208
201
|
return list(rows.all())
|
|
209
202
|
|
|
210
203
|
|
|
204
|
+
async def list_connections() -> list[dict[str, Any]]:
|
|
205
|
+
"""Every saved connection as {name, type, database}, most-recently-active
|
|
206
|
+
first. Reads only those columns: the encrypted config (host, credentials)
|
|
207
|
+
never leaves this module."""
|
|
208
|
+
await _ensure_schema()
|
|
209
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
210
|
+
rows = await s.exec(
|
|
211
|
+
select(Connection.name, Connection.type, Connection.database).order_by(
|
|
212
|
+
col(Connection.last_active_at).desc()
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return [{"name": n, "type": t, "database": d} for n, t, d in rows.all()]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def unknown_connection_message(name: str) -> str:
|
|
219
|
+
"""The error for an unresolvable connection name, listing the valid ones so
|
|
220
|
+
a caller (typically an agent) can retry without guessing."""
|
|
221
|
+
names = await list_connection_names()
|
|
222
|
+
return f'no connection named "{name}"; available: {", ".join(names) if names else "(none)"}'
|
|
223
|
+
|
|
224
|
+
|
|
211
225
|
async def _connection_by_name(name: str) -> StoredConnection | None:
|
|
212
226
|
await _ensure_schema()
|
|
213
227
|
async with AsyncSession(_engine_for_db()) as s:
|
|
@@ -248,18 +262,6 @@ _sessions: OrderedDict[str, _SessionState] = OrderedDict()
|
|
|
248
262
|
MAX_SESSIONS = int(os.environ.get("MAX_SESSIONS", "1000"))
|
|
249
263
|
|
|
250
264
|
|
|
251
|
-
# Cookies that explicitly disconnected. Kept distinct from "never seen" so
|
|
252
|
-
# _ensure_session can suppress auto-reconnect for them; bounded like _sessions.
|
|
253
|
-
_disconnected: OrderedDict[str, None] = OrderedDict()
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def _mark_disconnected(sid: str) -> None:
|
|
257
|
-
_disconnected[sid] = None
|
|
258
|
-
_disconnected.move_to_end(sid)
|
|
259
|
-
while len(_disconnected) > MAX_SESSIONS:
|
|
260
|
-
_disconnected.popitem(last=False)
|
|
261
|
-
|
|
262
|
-
|
|
263
265
|
def _get_session_entry(sid: str) -> _SessionState | None:
|
|
264
266
|
s = _sessions.get(sid)
|
|
265
267
|
if s is not None:
|
|
@@ -274,13 +276,32 @@ def _set_session_entry(sid: str, state: _SessionState) -> None:
|
|
|
274
276
|
_sessions.popitem(last=False)
|
|
275
277
|
|
|
276
278
|
|
|
279
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _in_container() -> bool:
|
|
283
|
+
return Path("/.dockerenv").exists()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _with_localhost_hint(message: str, config: DriverConfig) -> str:
|
|
287
|
+
"""Inside a container `localhost` is the container itself, so a failed
|
|
288
|
+
connect to it most likely meant the host machine's database."""
|
|
289
|
+
host = getattr(config, "host", None)
|
|
290
|
+
if host in _LOCAL_HOSTS and _in_container():
|
|
291
|
+
return (
|
|
292
|
+
f"{message} — QueryView runs in a container, where {host} is the container "
|
|
293
|
+
"itself; for a database on your machine use host.docker.internal"
|
|
294
|
+
)
|
|
295
|
+
return message
|
|
296
|
+
|
|
297
|
+
|
|
277
298
|
async def _build_session(
|
|
278
299
|
name: str, config: DriverConfig, database: str | None, conn_type: str = "clickhouse"
|
|
279
300
|
) -> tuple[_SessionState | None, str | None]:
|
|
280
301
|
"""List a connection's databases and build a session object."""
|
|
281
302
|
ok, result = await DRIVERS[conn_type].list_databases(config)
|
|
282
303
|
if not ok:
|
|
283
|
-
return None, result # type: ignore[
|
|
304
|
+
return None, _with_localhost_hint(result, config) # type: ignore[arg-type]
|
|
284
305
|
databases: list[str] = result # type: ignore[assignment]
|
|
285
306
|
return (
|
|
286
307
|
_SessionState(
|
|
@@ -295,22 +316,26 @@ async def _build_session(
|
|
|
295
316
|
|
|
296
317
|
|
|
297
318
|
async def _ensure_session(sid: str) -> None:
|
|
298
|
-
"""
|
|
299
|
-
|
|
300
|
-
|
|
319
|
+
"""Rebuild this session's live driver state from its row. The row is the
|
|
320
|
+
durable truth; `_sessions` is only a cache of the decrypted config and the
|
|
321
|
+
database list, so a backend restart costs one reconnect, not the session."""
|
|
322
|
+
if not sid or _get_session_entry(sid):
|
|
301
323
|
return
|
|
302
|
-
|
|
324
|
+
rec = await sessions.get_session_rec(sid)
|
|
325
|
+
if rec is None or rec.connection_name is None:
|
|
303
326
|
return
|
|
304
|
-
stored = await
|
|
327
|
+
stored = await _connection_by_name(rec.connection_name)
|
|
305
328
|
if stored is None:
|
|
329
|
+
# The connection was deleted, or its config no longer decrypts. The
|
|
330
|
+
# session stays as it is and reads as disconnected; the user reconnects.
|
|
306
331
|
return
|
|
307
|
-
state, _ = await _build_session(stored.name, stored.config,
|
|
332
|
+
state, _ = await _build_session(stored.name, stored.config, rec.database, stored.type)
|
|
308
333
|
if state is not None:
|
|
309
334
|
_set_session_entry(sid, state)
|
|
310
335
|
|
|
311
336
|
|
|
312
337
|
async def get_session(sid: str) -> dict[str, Any]:
|
|
313
|
-
"""This session's state
|
|
338
|
+
"""This session's connection state, rebuilt from its row if not cached."""
|
|
314
339
|
await _ensure_session(sid)
|
|
315
340
|
s = _get_session_entry(sid)
|
|
316
341
|
if s is None:
|
|
@@ -329,9 +354,9 @@ async def connect_new(sid: str, name: str, config: DriverConfig, conn_type: str)
|
|
|
329
354
|
state, message = await _build_session(name, config, None, conn_type)
|
|
330
355
|
if state is None:
|
|
331
356
|
return {"ok": False, "message": message}
|
|
332
|
-
_disconnected.pop(sid, None)
|
|
333
357
|
_set_session_entry(sid, state)
|
|
334
358
|
await _save_active_connection(name, config, conn_type)
|
|
359
|
+
await sessions.set_connection(sid, name)
|
|
335
360
|
return {"ok": True, "name": name, "type": state.type, "databases": state.databases}
|
|
336
361
|
|
|
337
362
|
|
|
@@ -341,25 +366,24 @@ async def open_saved(sid: str, name: str) -> dict[str, Any]:
|
|
|
341
366
|
if stored is None:
|
|
342
367
|
return {
|
|
343
368
|
"ok": False,
|
|
344
|
-
"message":
|
|
369
|
+
"message": await unknown_connection_message(name),
|
|
345
370
|
"not_found": True,
|
|
346
371
|
}
|
|
347
372
|
# Reset the database so `connect <name>` always lands on the picker.
|
|
348
373
|
state, message = await _build_session(stored.name, stored.config, None, stored.type)
|
|
349
374
|
if state is None:
|
|
350
375
|
return {"ok": False, "message": message}
|
|
351
|
-
_disconnected.pop(sid, None)
|
|
352
376
|
_set_session_entry(sid, state)
|
|
353
377
|
await _touch_connection(name)
|
|
378
|
+
await sessions.set_connection(sid, name)
|
|
354
379
|
return {"ok": True, "name": name, "type": state.type, "databases": state.databases}
|
|
355
380
|
|
|
356
381
|
|
|
357
382
|
async def disconnect(sid: str) -> dict[str, Any]:
|
|
358
|
-
"""Drop this session's active connection
|
|
359
|
-
|
|
360
|
-
still reopens them."""
|
|
383
|
+
"""Drop this session's active connection. Saved connections are left intact
|
|
384
|
+
— `connect <name>` still reopens them."""
|
|
361
385
|
_sessions.pop(sid, None)
|
|
362
|
-
|
|
386
|
+
await sessions.set_connection(sid, None)
|
|
363
387
|
return {"ok": True}
|
|
364
388
|
|
|
365
389
|
|
|
@@ -372,6 +396,7 @@ async def select_database(sid: str, database: str) -> dict[str, Any]:
|
|
|
372
396
|
return {"ok": False, "message": "unknown database", "reason": "unknown"}
|
|
373
397
|
s.database = database
|
|
374
398
|
await _save_selected_database(s.name, database)
|
|
399
|
+
await sessions.set_database(sid, database)
|
|
375
400
|
return {"ok": True}
|
|
376
401
|
|
|
377
402
|
|
|
@@ -452,3 +477,9 @@ async def export_csv(
|
|
|
452
477
|
if not r.ok:
|
|
453
478
|
return {"ok": False, "message": r.value}
|
|
454
479
|
return {"ok": True, "output": r.value}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# Imported last, not at the top: sessions.py builds on this module's engine and
|
|
483
|
+
# schema helpers, so a top-level import here would be a cycle. connect.py calls
|
|
484
|
+
# into it only to write a session's connection through.
|
|
485
|
+
from . import sessions # noqa: E402
|
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from .connect import _connection_by_name
|
|
10
|
+
from .connect import _connection_by_name, unknown_connection_message
|
|
11
11
|
from .drivers import DRIVERS
|
|
12
12
|
from .drivers.base import rows_to_columns
|
|
13
13
|
|
|
@@ -33,7 +33,7 @@ async def run_queries_for_connection(
|
|
|
33
33
|
return {
|
|
34
34
|
"ok": False,
|
|
35
35
|
"reason": "no-connection",
|
|
36
|
-
"message":
|
|
36
|
+
"message": await unknown_connection_message(name),
|
|
37
37
|
}
|
|
38
38
|
driver = DRIVERS[stored.type]
|
|
39
39
|
if driver.requires_database and not stored.database:
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""FastAPI app: the JSON API under /api/*, the
|
|
2
|
-
SERVE_STATIC=1) serving the built SPA with an
|
|
1
|
+
"""FastAPI app: the JSON API under /api/*, the X-QV-Session header that names
|
|
2
|
+
the caller's session, and (when SERVE_STATIC=1) serving the built SPA with an
|
|
3
|
+
index.html fallback."""
|
|
3
4
|
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
6
7
|
import os
|
|
7
|
-
import uuid
|
|
8
8
|
from contextlib import asynccontextmanager
|
|
9
9
|
from functools import lru_cache
|
|
10
10
|
from pathlib import Path
|
|
@@ -19,7 +19,7 @@ from fastapi.responses import (
|
|
|
19
19
|
StreamingResponse,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
from . import gitsync, remote, workspaces, yamlio
|
|
22
|
+
from . import gitsync, remote, sessions, workspaces, yamlio
|
|
23
23
|
from .connect import (
|
|
24
24
|
_ensure_schema,
|
|
25
25
|
connect_new,
|
|
@@ -116,16 +116,11 @@ async def mcp_slash_redirect() -> RedirectResponse:
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
@app.middleware("http")
|
|
119
|
-
async def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
request.state.sid = sid
|
|
125
|
-
response = await call_next(request)
|
|
126
|
-
if new_session:
|
|
127
|
-
response.set_cookie("qv_session", sid, path="/", httponly=True, samesite="lax")
|
|
128
|
-
return response
|
|
119
|
+
async def session_header(request: Request, call_next):
|
|
120
|
+
"""Identify the session from the header the SPA sends on every /api call.
|
|
121
|
+
There is no cookie: a session is named by its id, not by the browser."""
|
|
122
|
+
request.state.sid = request.headers.get("X-QV-Session") or ""
|
|
123
|
+
return await call_next(request)
|
|
129
124
|
|
|
130
125
|
|
|
131
126
|
@app.get("/api/health")
|
|
@@ -138,6 +133,118 @@ async def session(request: Request) -> dict[str, Any]:
|
|
|
138
133
|
return await get_session(request.state.sid)
|
|
139
134
|
|
|
140
135
|
|
|
136
|
+
_SESSION_ERRORS = {
|
|
137
|
+
"unknown": (404, "unknown session"),
|
|
138
|
+
"held": (409, "session is open in another tab"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _session_error(reason: str) -> JSONResponse:
|
|
143
|
+
"""A session store reason code as a response. The store returns codes, not
|
|
144
|
+
prose, so rewording a message cannot silently change an HTTP status."""
|
|
145
|
+
status, message = _SESSION_ERRORS.get(reason, (409, reason or "cannot do that"))
|
|
146
|
+
return JSONResponse({"ok": False, "message": message}, status_code=status)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _str_or_none(body: dict[str, Any], key: str) -> str | None:
|
|
150
|
+
"""A string field of a patch body, or None when absent. Unlike `_clean_str`
|
|
151
|
+
an empty string is kept: that is how a label unpins."""
|
|
152
|
+
value = body.get(key)
|
|
153
|
+
return value if isinstance(value, str) else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _session_payload(rec: sessions.SessionRec) -> dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"id": rec.id,
|
|
159
|
+
"label": sessions.display_label(rec),
|
|
160
|
+
"pinned": rec.label is not None,
|
|
161
|
+
"connection": rec.connection_name,
|
|
162
|
+
"database": rec.database,
|
|
163
|
+
"workspace": rec.workspace,
|
|
164
|
+
"url": rec.url,
|
|
165
|
+
"ui": rec.ui,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Resolve and claim this tab's session (see sessions.attach); doubles as the
|
|
170
|
+
# claim heartbeat.
|
|
171
|
+
@app.post("/api/sessions/attach")
|
|
172
|
+
async def sessions_attach(request: Request):
|
|
173
|
+
b = await _read_json(request) or {}
|
|
174
|
+
tab = _clean_str(b.get("tab"))
|
|
175
|
+
if not tab:
|
|
176
|
+
return JSONResponse({"ok": False, "message": "tab is required"}, status_code=400)
|
|
177
|
+
raw_id = _clean_str(b.get("session_id"))
|
|
178
|
+
rec, created = await sessions.attach(tab, raw_id or None)
|
|
179
|
+
return {"ok": True, "created": created, "session": _session_payload(rec)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Switch this tab to a specific session, or (id omitted/null) to a new one.
|
|
183
|
+
@app.post("/api/sessions/select")
|
|
184
|
+
async def sessions_select(request: Request):
|
|
185
|
+
b = await _read_json(request) or {}
|
|
186
|
+
tab = _clean_str(b.get("tab"))
|
|
187
|
+
if not tab:
|
|
188
|
+
return JSONResponse({"ok": False, "message": "tab is required"}, status_code=400)
|
|
189
|
+
raw_id = _clean_str(b.get("id"))
|
|
190
|
+
rec, reason = await sessions.select_session(tab, raw_id or None)
|
|
191
|
+
if rec is None:
|
|
192
|
+
return _session_error(reason)
|
|
193
|
+
return {"ok": True, "session": _session_payload(rec)}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/api/sessions")
|
|
197
|
+
async def sessions_list() -> dict[str, Any]:
|
|
198
|
+
return {"sessions": await sessions.list_sessions()}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.patch("/api/sessions/{sid}")
|
|
202
|
+
async def sessions_patch(sid: str, request: Request):
|
|
203
|
+
b = await _read_json(request) or {}
|
|
204
|
+
ui = b.get("ui")
|
|
205
|
+
rec = await sessions.patch_session(
|
|
206
|
+
sid,
|
|
207
|
+
url=_str_or_none(b, "url"),
|
|
208
|
+
ui=ui if isinstance(ui, dict) else None,
|
|
209
|
+
label=_str_or_none(b, "label"),
|
|
210
|
+
workspace=_str_or_none(b, "workspace"),
|
|
211
|
+
)
|
|
212
|
+
if rec is None:
|
|
213
|
+
return JSONResponse({"ok": False, "message": "unknown session"}, status_code=404)
|
|
214
|
+
return {"ok": True, "session": _session_payload(rec)}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.delete("/api/sessions/{sid}")
|
|
218
|
+
async def sessions_delete(sid: str):
|
|
219
|
+
ok, reason = await sessions.delete_session(sid)
|
|
220
|
+
if not ok:
|
|
221
|
+
return _session_error(reason)
|
|
222
|
+
return {"ok": True}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# The pagehide beacon: apply whatever the tab had not flushed yet, then free its
|
|
226
|
+
# claim rather than waiting out the TTL. A reload fires pagehide, so this is what
|
|
227
|
+
# keeps a change made inside the client's debounce window.
|
|
228
|
+
@app.post("/api/sessions/release")
|
|
229
|
+
async def sessions_release(request: Request):
|
|
230
|
+
b = await _read_json(request) or {}
|
|
231
|
+
tab = _clean_str(b.get("tab"))
|
|
232
|
+
if not tab:
|
|
233
|
+
return {"ok": True}
|
|
234
|
+
sid = _clean_str(b.get("session_id"))
|
|
235
|
+
if sid:
|
|
236
|
+
ui = b.get("ui")
|
|
237
|
+
await sessions.patch_session(
|
|
238
|
+
sid,
|
|
239
|
+
url=_str_or_none(b, "url"),
|
|
240
|
+
ui=ui if isinstance(ui, dict) else None,
|
|
241
|
+
label=_str_or_none(b, "label"),
|
|
242
|
+
workspace=_str_or_none(b, "workspace"),
|
|
243
|
+
)
|
|
244
|
+
await sessions.release(tab)
|
|
245
|
+
return {"ok": True}
|
|
246
|
+
|
|
247
|
+
|
|
141
248
|
# Drop this session's active connection (disconnect command).
|
|
142
249
|
@app.post("/api/db/disconnect")
|
|
143
250
|
async def db_disconnect(request: Request) -> dict[str, Any]:
|
|
@@ -375,7 +482,9 @@ async def _event_stream(remote_id: str, request: Request):
|
|
|
375
482
|
# arms "remote control". Closing the EventSource unregisters the channel.
|
|
376
483
|
@app.get("/api/remote/events")
|
|
377
484
|
async def remote_events(request: Request):
|
|
378
|
-
|
|
485
|
+
# EventSource cannot send headers, so the browser passes its session id in
|
|
486
|
+
# the query string; other callers still identify by header.
|
|
487
|
+
remote_id = remote.register(request.query_params.get("session") or request.state.sid)
|
|
379
488
|
return StreamingResponse(
|
|
380
489
|
_event_stream(remote_id, request),
|
|
381
490
|
media_type="text/event-stream",
|
|
@@ -419,26 +528,6 @@ async def remote_push(request: Request):
|
|
|
419
528
|
return {"ok": ok, "message": message}
|
|
420
529
|
|
|
421
530
|
|
|
422
|
-
@app.post("/api/remote/db")
|
|
423
|
-
async def remote_db(request: Request):
|
|
424
|
-
"""Browser reports the database its live session targets, so the agent's
|
|
425
|
-
push_query/push_dashboard responses can echo it. Called on arm and whenever
|
|
426
|
-
the active database changes."""
|
|
427
|
-
body = await _read_json(request)
|
|
428
|
-
b = body if isinstance(body, dict) else {}
|
|
429
|
-
raw_sid = b.get("session_id")
|
|
430
|
-
session_id = raw_sid.strip() if isinstance(raw_sid, str) else ""
|
|
431
|
-
raw_db = b.get("database")
|
|
432
|
-
database = raw_db if isinstance(raw_db, str) and raw_db else None
|
|
433
|
-
if not session_id:
|
|
434
|
-
return JSONResponse({"ok": False, "message": "session_id required"}, status_code=400)
|
|
435
|
-
ok = remote.set_session_database(session_id, database)
|
|
436
|
-
if "workspace" in b:
|
|
437
|
-
raw_ws = b.get("workspace")
|
|
438
|
-
remote.set_session_workspace(session_id, raw_ws if isinstance(raw_ws, str) and raw_ws else None)
|
|
439
|
-
return {"ok": ok}
|
|
440
|
-
|
|
441
|
-
|
|
442
531
|
@app.post("/api/remote/lock")
|
|
443
532
|
async def remote_lock(request: Request):
|
|
444
533
|
"""Browser-only edit-lock control for a live session. action=acquire is sent
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from mcp.server.fastmcp import FastMCP
|
|
10
10
|
|
|
11
|
-
from . import gitsync, remote
|
|
11
|
+
from . import gitsync, remote, sessions
|
|
12
12
|
from .dashboards import _push_dashboard
|
|
13
13
|
from .queries import list_predefined_queries_view
|
|
14
14
|
|
|
@@ -29,9 +29,11 @@ async def push_query(
|
|
|
29
29
|
) -> dict[str, Any]:
|
|
30
30
|
"""Push a SQL query to a live QueryView browser session.
|
|
31
31
|
|
|
32
|
-
The targeted browser fills its query panel and auto-runs the query
|
|
33
|
-
`session_id` from the QueryView UI:
|
|
34
|
-
status pill, after enabling "Allow
|
|
32
|
+
The targeted browser fills its query panel and auto-runs the query against
|
|
33
|
+
the session's own connection. Get the `session_id` from the QueryView UI:
|
|
34
|
+
the agent icon next to the connection status pill, after enabling "Allow
|
|
35
|
+
remote control". To explore data first with run_query, call
|
|
36
|
+
list_connections for the saved connection names rather than guessing one.
|
|
35
37
|
|
|
36
38
|
Args:
|
|
37
39
|
session_id: The session id shown in the QueryView popover.
|
|
@@ -61,7 +63,7 @@ async def push_query(
|
|
|
61
63
|
"name": name,
|
|
62
64
|
}
|
|
63
65
|
ok, message = remote.push(session_id, payload)
|
|
64
|
-
return {"ok": ok, "message": message, "database":
|
|
66
|
+
return {"ok": ok, "message": message, "database": await sessions.database_of(session_id)}
|
|
65
67
|
|
|
66
68
|
|
|
67
69
|
def _columns_to_rows(cols: dict[str, list]) -> dict[str, Any]:
|
|
@@ -92,6 +94,10 @@ async def run_query(
|
|
|
92
94
|
`database` is the connection's currently-selected database (the user can
|
|
93
95
|
change it from the connection pill), so check it before deciding whether to
|
|
94
96
|
fully-qualify tables as db.table.
|
|
97
|
+
|
|
98
|
+
`connection` is a saved connection name. The default "clickhouse" is only a
|
|
99
|
+
guess: if you don't know the name, call list_connections first. An unknown
|
|
100
|
+
name fails with a message listing the available ones.
|
|
95
101
|
"""
|
|
96
102
|
from .connect import _connection_by_name
|
|
97
103
|
from .dashboard_queries import run_queries_for_connection
|
|
@@ -113,15 +119,41 @@ async def run_query(
|
|
|
113
119
|
|
|
114
120
|
|
|
115
121
|
async def _session_workspace_rec(session_id: str | None):
|
|
116
|
-
"""The workspace the given
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
"""The workspace the given session is on, read from its row, else the
|
|
123
|
+
default workspace. MCP is deliberately workspace-unaware: the human picks
|
|
124
|
+
the workspace in the UI; the agent works in session scope."""
|
|
119
125
|
from . import workspaces
|
|
120
126
|
|
|
121
|
-
name =
|
|
127
|
+
name = await sessions.workspace_of(session_id) if session_id else None
|
|
122
128
|
return await workspaces.resolve(name or workspaces.DEFAULT_WORKSPACE)
|
|
123
129
|
|
|
124
130
|
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
async def list_connections(session_id: str | None = None) -> dict[str, Any]:
|
|
133
|
+
"""List the saved connections: the valid `connection` names for run_query
|
|
134
|
+
and push_dashboard. Call this before run_query / push_query whenever the
|
|
135
|
+
connection name is unknown — don't guess.
|
|
136
|
+
|
|
137
|
+
Returns {"connections": [{name, type, database}]}, most recently used
|
|
138
|
+
first; `database` is the connection's currently-selected database (null if
|
|
139
|
+
none). Hosts and credentials are never returned. With a `session_id`, also
|
|
140
|
+
returns "session_connection": the connection that session is on (null if
|
|
141
|
+
disconnected) — the natural choice for run_query.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
session_id: Optional armed-session id. Connections are shared across
|
|
145
|
+
workspaces, so the list is the same for every session; the id only
|
|
146
|
+
adds "session_connection".
|
|
147
|
+
"""
|
|
148
|
+
from .connect import list_connections as _list_connections
|
|
149
|
+
|
|
150
|
+
out: dict[str, Any] = {"connections": await _list_connections()}
|
|
151
|
+
if session_id:
|
|
152
|
+
rec = await sessions.get_session_rec(session_id)
|
|
153
|
+
out["session_connection"] = rec.connection_name if rec else None
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
125
157
|
@mcp.tool()
|
|
126
158
|
async def list_queries(conn_type: str = "clickhouse", session_id: str | None = None) -> dict[str, Any]:
|
|
127
159
|
"""List the saved (predefined) queries for a connection type.
|
|
@@ -187,18 +219,25 @@ async def push_dashboard(
|
|
|
187
219
|
Args:
|
|
188
220
|
session_id: The session id shown in the QueryView popover.
|
|
189
221
|
name: Dashboard name (the name the user's Save will persist under).
|
|
190
|
-
connection: Saved connection name the queries run against
|
|
222
|
+
connection: Saved connection name the queries run against (see
|
|
223
|
+
list_connections).
|
|
191
224
|
html: The dashboard HTML document (renders in a sandboxed iframe).
|
|
192
225
|
queries: Map of query name to SQL.
|
|
193
226
|
|
|
194
|
-
Returns {ok, pushed, message}.
|
|
227
|
+
Returns {ok, pushed, message}. An unknown `connection` fails without
|
|
228
|
+
pushing, with a message listing the available names (see list_connections).
|
|
195
229
|
"""
|
|
230
|
+
from .connect import _connection_by_name, unknown_connection_message
|
|
231
|
+
|
|
232
|
+
if await _connection_by_name(connection) is None:
|
|
233
|
+
message = await unknown_connection_message(connection)
|
|
234
|
+
return {"ok": False, "pushed": False, "message": message, "database": None}
|
|
196
235
|
pushed, message = await _push_dashboard(name, connection, html, queries, session_id or None)
|
|
197
236
|
return {
|
|
198
237
|
"ok": pushed,
|
|
199
238
|
"pushed": pushed,
|
|
200
239
|
"message": message,
|
|
201
|
-
"database":
|
|
240
|
+
"database": await sessions.database_of(session_id),
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
|