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.
Files changed (186) hide show
  1. {queryview-0.0.7 → queryview-0.0.8}/PKG-INFO +20 -6
  2. {queryview-0.0.7 → queryview-0.0.8}/README.md +19 -5
  3. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/connect.py +65 -34
  4. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/dashboard_queries.py +2 -2
  5. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/main.py +124 -35
  6. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/mcp_server.py +51 -12
  7. queryview-0.0.8/backend/queryview/migrations/versions/d8e9f0a1b2c3_sessions.py +50 -0
  8. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/remote.py +8 -48
  9. queryview-0.0.8/backend/queryview/sessions.py +388 -0
  10. queryview-0.0.8/backend/queryview/static/assets/index-CgV5cxdD.css +2 -0
  11. queryview-0.0.8/backend/queryview/static/assets/index-DdgDsY5d.js +46 -0
  12. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/static/index.html +2 -2
  13. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_connect_flow.py +44 -0
  14. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_dashboards.py +16 -8
  15. queryview-0.0.8/backend/queryview/test_mcp_connections.py +129 -0
  16. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_mcp_gitsync.py +5 -2
  17. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_remote.py +45 -59
  18. queryview-0.0.8/backend/queryview/test_session_api.py +65 -0
  19. queryview-0.0.8/backend/queryview/test_sessions.py +246 -0
  20. {queryview-0.0.7 → queryview-0.0.8}/docs/api.md +17 -9
  21. {queryview-0.0.7 → queryview-0.0.8}/docs/connect.md +36 -27
  22. {queryview-0.0.7 → queryview-0.0.8}/docs/dashboard.md +3 -3
  23. {queryview-0.0.7 → queryview-0.0.8}/docs/explorer.md +6 -4
  24. {queryview-0.0.7 → queryview-0.0.8}/docs/future.md +2 -1
  25. {queryview-0.0.7 → queryview-0.0.8}/docs/query.md +7 -5
  26. {queryview-0.0.7 → queryview-0.0.8}/docs/queryview.md +11 -16
  27. {queryview-0.0.7 → queryview-0.0.8}/docs/remote.md +27 -14
  28. queryview-0.0.8/docs/session.md +132 -0
  29. {queryview-0.0.7 → queryview-0.0.8}/docs/workspace.md +3 -2
  30. {queryview-0.0.7 → queryview-0.0.8}/e2e/conftest.py +26 -0
  31. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_drivers.py +2 -0
  32. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_explorer.py +6 -6
  33. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_query.py +5 -1
  34. queryview-0.0.8/e2e/test_sessions.py +128 -0
  35. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_workspaces.py +5 -2
  36. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/App.tsx +153 -154
  37. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/DashboardView.tsx +6 -5
  38. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/ExplorerView.tsx +19 -18
  39. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/QueryView.tsx +69 -38
  40. queryview-0.0.8/frontend/src/app/api.ts +14 -0
  41. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/ExportImportControls.tsx +1 -1
  42. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/GitSyncControls.tsx +0 -0
  43. queryview-0.0.8/frontend/src/app/controls/SessionSwitcher.tsx +181 -0
  44. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/WorkspaceSwitcher.tsx +4 -4
  45. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/explorerSettings.test.ts +20 -13
  46. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/explorerSettings.ts +5 -4
  47. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/gitsync.ts +6 -4
  48. queryview-0.0.8/frontend/src/app/session.test.ts +221 -0
  49. queryview-0.0.8/frontend/src/app/session.ts +195 -0
  50. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/sessionLock.ts +3 -1
  51. queryview-0.0.8/frontend/src/app/tabStorage.ts +31 -0
  52. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/workspace.ts +9 -26
  53. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/yamlio.ts +3 -1
  54. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/index.ts +3 -0
  55. queryview-0.0.8/frontend/src/core/nameFilter.test.ts +33 -0
  56. queryview-0.0.8/frontend/src/core/nameFilter.ts +11 -0
  57. queryview-0.0.8/frontend/src/core/presentation/FieldPickers.tsx +212 -0
  58. queryview-0.0.8/frontend/src/core/presentation/SearchPanel.tsx +91 -0
  59. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/ResultsTable.tsx +8 -2
  60. {queryview-0.0.7/frontend/src/app/controls → queryview-0.0.8/frontend/src/core}/useDismiss.ts +9 -3
  61. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/index.css +9 -0
  62. queryview-0.0.7/backend/queryview/static/assets/index-Cysz_FnM.js +0 -46
  63. queryview-0.0.7/backend/queryview/static/assets/index-h_3ZdSNd.css +0 -2
  64. queryview-0.0.7/frontend/src/app/databaseFilter.test.ts +0 -28
  65. queryview-0.0.7/frontend/src/app/databaseFilter.ts +0 -7
  66. queryview-0.0.7/frontend/src/app/testStorage.ts +0 -25
  67. queryview-0.0.7/frontend/src/app/viewSettings.test.ts +0 -65
  68. queryview-0.0.7/frontend/src/app/viewSettings.ts +0 -31
  69. queryview-0.0.7/frontend/src/app/workspace.test.ts +0 -23
  70. queryview-0.0.7/frontend/src/core/presentation/FieldPickers.tsx +0 -153
  71. {queryview-0.0.7 → queryview-0.0.8}/.claude/settings.json +0 -0
  72. {queryview-0.0.7 → queryview-0.0.8}/.github/actions/start-git-daemon/action.yaml +0 -0
  73. {queryview-0.0.7 → queryview-0.0.8}/.github/workflows/ci.yml +0 -0
  74. {queryview-0.0.7 → queryview-0.0.8}/.github/workflows/publish.yaml +0 -0
  75. {queryview-0.0.7 → queryview-0.0.8}/.gitignore +0 -0
  76. {queryview-0.0.7 → queryview-0.0.8}/.mcp.json +0 -0
  77. {queryview-0.0.7 → queryview-0.0.8}/.pre-commit-config.yaml +0 -0
  78. {queryview-0.0.7 → queryview-0.0.8}/CLAUDE.md +0 -0
  79. {queryview-0.0.7 → queryview-0.0.8}/CONTRIBUTING.md +0 -0
  80. {queryview-0.0.7 → queryview-0.0.8}/LICENSE +0 -0
  81. {queryview-0.0.7 → queryview-0.0.8}/backend/alembic.ini +0 -0
  82. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/__init__.py +0 -0
  83. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/conftest.py +0 -0
  84. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/dashboards.py +0 -0
  85. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/__init__.py +0 -0
  86. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/base.py +0 -0
  87. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/clickhouse.py +0 -0
  88. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/duckdb.py +0 -0
  89. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/postgres.py +0 -0
  90. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_base.py +0 -0
  91. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_clickhouse.py +0 -0
  92. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_contract.py +0 -0
  93. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_duckdb.py +0 -0
  94. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/drivers/test_postgres.py +0 -0
  95. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/gitsync.py +0 -0
  96. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/env.py +0 -0
  97. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/script.py.mako +0 -0
  98. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
  99. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
  100. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
  101. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
  102. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/queries.py +0 -0
  103. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/static/favicon.svg +0 -0
  104. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_db.py +0 -0
  105. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_export_import.py +0 -0
  106. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_gitsync.py +0 -0
  107. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_api_workspaces.py +0 -0
  108. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_connect_store.py +0 -0
  109. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_dashboard_queries.py +0 -0
  110. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_data_dir.py +0 -0
  111. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_gitsync.py +0 -0
  112. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_main.py +0 -0
  113. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_mcp_mount.py +0 -0
  114. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_migrations.py +0 -0
  115. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_queries.py +0 -0
  116. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_validation.py +0 -0
  117. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_workspaces.py +0 -0
  118. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/test_yamlio.py +0 -0
  119. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/validation.py +0 -0
  120. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/workspaces.py +0 -0
  121. {queryview-0.0.7 → queryview-0.0.8}/backend/queryview/yamlio.py +0 -0
  122. {queryview-0.0.7 → queryview-0.0.8}/docker/Dockerfile +0 -0
  123. {queryview-0.0.7 → queryview-0.0.8}/docs/export-import.md +0 -0
  124. {queryview-0.0.7 → queryview-0.0.8}/docs/gitsync.md +0 -0
  125. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_app.py +0 -0
  126. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_dashboard.py +0 -0
  127. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_export_import.py +0 -0
  128. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_gitsync.py +0 -0
  129. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_popovers.py +0 -0
  130. {queryview-0.0.7 → queryview-0.0.8}/e2e/test_remote.py +0 -0
  131. {queryview-0.0.7 → queryview-0.0.8}/frontend/.gitignore +0 -0
  132. {queryview-0.0.7 → queryview-0.0.8}/frontend/README.md +0 -0
  133. {queryview-0.0.7 → queryview-0.0.8}/frontend/eslint.config.js +0 -0
  134. {queryview-0.0.7 → queryview-0.0.8}/frontend/index.html +0 -0
  135. {queryview-0.0.7 → queryview-0.0.8}/frontend/package.json +0 -0
  136. {queryview-0.0.7 → queryview-0.0.8}/frontend/public/favicon.svg +0 -0
  137. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/compactNumber.test.ts +0 -0
  138. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/compactNumber.ts +0 -0
  139. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/connection.ts +0 -0
  140. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/Spinner.tsx +0 -0
  141. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/controls/Toast.tsx +0 -0
  142. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/drivers.ts +0 -0
  143. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/gitsync.test.ts +0 -0
  144. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/promptSuggestions.test.ts +0 -0
  145. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/promptSuggestions.ts +0 -0
  146. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/sessionLock.test.ts +0 -0
  147. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/app/yamlio.test.ts +0 -0
  148. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/CellDataModal.tsx +0 -0
  149. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/CellViewModal.tsx +0 -0
  150. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/ComplexCell.tsx +0 -0
  151. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellView.test.ts +0 -0
  152. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellView.ts +0 -0
  153. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellViewYaml.test.ts +0 -0
  154. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/cellViewYaml.tsx +0 -0
  155. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/complexCells.test.ts +0 -0
  156. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/complexCells.ts +0 -0
  157. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structured.test.ts +0 -0
  158. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structured.ts +0 -0
  159. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structuredTree.test.ts +0 -0
  160. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/cells/structuredTree.ts +0 -0
  161. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/DashboardFrame.tsx +0 -0
  162. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/srcDoc.test.ts +0 -0
  163. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/dashboard/srcDoc.ts +0 -0
  164. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/params/queryParams.test.ts +0 -0
  165. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/params/queryParams.ts +0 -0
  166. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/presentation/presentation.test.ts +0 -0
  167. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/presentation/presentation.ts +0 -0
  168. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/cellWidth.test.ts +0 -0
  169. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/cellWidth.ts +0 -0
  170. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/rows.test.ts +0 -0
  171. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/core/results/rows.ts +0 -0
  172. {queryview-0.0.7 → queryview-0.0.8}/frontend/src/main.tsx +0 -0
  173. {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.app.json +0 -0
  174. {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.json +0 -0
  175. {queryview-0.0.7 → queryview-0.0.8}/frontend/tsconfig.node.json +0 -0
  176. {queryview-0.0.7 → queryview-0.0.8}/frontend/vite.config.ts +0 -0
  177. {queryview-0.0.7 → queryview-0.0.8}/package-lock.json +0 -0
  178. {queryview-0.0.7 → queryview-0.0.8}/package.json +0 -0
  179. {queryview-0.0.7 → queryview-0.0.8}/pyproject.toml +0 -0
  180. {queryview-0.0.7 → queryview-0.0.8}/pyrightconfig.json +0 -0
  181. {queryview-0.0.7 → queryview-0.0.8}/scripts/dev.sh +0 -0
  182. {queryview-0.0.7 → queryview-0.0.8}/scripts/setup.sh +0 -0
  183. {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_browser.sh +0 -0
  184. {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_clickhouse.sh +0 -0
  185. {queryview-0.0.7 → queryview-0.0.8}/scripts/setup_postgres.sh +0 -0
  186. {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.7
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: `run_query` (read-only SQL, rows returned to the agent), `push_query`
183
- and `push_dashboard` (fill a live browser session), `list_queries` /
184
- `list_dashboards`, and `git_store` / `git_history` / `git_restore` (workspace
185
- git backups). The push tools target an **armed** browser session: enable
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, sessions, storage.
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: `run_query` (read-only SQL, rows returned to the agent), `push_query`
159
- and `push_dashboard` (fill a live browser session), `list_queries` /
160
- `list_dashboards`, and `git_store` / `git_history` / `git_restore` (workspace
161
- git backups). The push tools target an **armed** browser session: enable
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, sessions, storage.
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[return-value]
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
- """At session start (a cookie we haven't seen), reconnect the latest active
299
- connection so a fresh session resumes where the last one left off."""
300
- if _get_session_entry(sid):
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
- if sid in _disconnected:
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 _latest_active_connection()
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, stored.database, stored.type)
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; auto-connects the latest active for an unseen cookie."""
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": f'no connection named "{name}"',
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 and suppress auto-reconnect until
359
- it connects again. Saved connections are left intact — `connect <name>`
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
- _mark_disconnected(sid)
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": f'no connection named "{name}"',
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 per-session cookie, and (when
2
- SERVE_STATIC=1) serving the built SPA with an index.html fallback."""
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 session_cookie(request: Request, call_next):
120
- sid = request.cookies.get("qv_session")
121
- new_session = sid is None
122
- if not sid:
123
- sid = str(uuid.uuid4())
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
- remote_id = remote.register()
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. Get the
33
- `session_id` from the QueryView UI: the agent icon next to the connection
34
- status pill, after enabling "Allow remote control".
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": remote.session_database(session_id)}
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 armed session is on (reported by the browser),
117
- else the default workspace. MCP is deliberately workspace-unaware: the
118
- human picks the workspace in the UI; the agent works in session scope."""
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 = remote.session_workspace(session_id) if session_id else None
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": remote.session_database(session_id),
240
+ "database": await sessions.database_of(session_id),
202
241
  }
203
242
 
204
243