queryview 0.0.2__py3-none-any.whl
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/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/main.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
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."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
|
15
|
+
|
|
16
|
+
from . import gitsync, remote, workspaces, yamlio
|
|
17
|
+
from .connect import (
|
|
18
|
+
_ensure_schema,
|
|
19
|
+
connect_new,
|
|
20
|
+
describe_query,
|
|
21
|
+
disconnect,
|
|
22
|
+
get_session,
|
|
23
|
+
list_connection_names,
|
|
24
|
+
list_tables,
|
|
25
|
+
open_saved,
|
|
26
|
+
run_query,
|
|
27
|
+
select_database,
|
|
28
|
+
)
|
|
29
|
+
from .dashboard_queries import run_queries_for_connection
|
|
30
|
+
from .dashboards import _upsert_and_push, get_dashboard, list_dashboards
|
|
31
|
+
from .drivers import DRIVERS
|
|
32
|
+
from .mcp_server import mcp
|
|
33
|
+
from .queries import list_predefined_queries_view, save_predefined_query
|
|
34
|
+
from .validation import cell_view_error, presentation_error
|
|
35
|
+
|
|
36
|
+
# SPA bundle shipped inside the wheel (release CI copies frontend/dist here);
|
|
37
|
+
# absent in a source checkout, where the repo's frontend/dist is used instead.
|
|
38
|
+
_PACKAGED_STATIC = Path(__file__).resolve().parent / "static"
|
|
39
|
+
|
|
40
|
+
# Opt-in in a source checkout; defaults on for an installed wheel (which ships
|
|
41
|
+
# its SPA bundle) so `pip install queryview` serves the UI out of the box.
|
|
42
|
+
SERVE_STATIC = os.environ.get("SERVE_STATIC", "1" if _PACKAGED_STATIC.is_dir() else "") == "1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@lru_cache(maxsize=1)
|
|
46
|
+
def _static_root() -> Path:
|
|
47
|
+
env = os.environ.get("STATIC_ROOT")
|
|
48
|
+
if env:
|
|
49
|
+
return Path(env).resolve()
|
|
50
|
+
repo_dist = (Path(__file__).resolve().parent.parent.parent / "frontend" / "dist").resolve()
|
|
51
|
+
if repo_dist.is_dir():
|
|
52
|
+
return repo_dist
|
|
53
|
+
return _PACKAGED_STATIC
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _read_json(request: Request) -> Any:
|
|
57
|
+
try:
|
|
58
|
+
return await request.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _clean_str(value: Any) -> str:
|
|
64
|
+
return value.strip() if isinstance(value, str) else ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clean_queries(raw: Any) -> dict[str, str]:
|
|
68
|
+
"""Keep only string→string entries with a non-empty name and SQL; ignore
|
|
69
|
+
anything else so a malformed `queries` map can't reach the runner."""
|
|
70
|
+
if not isinstance(raw, dict):
|
|
71
|
+
return {}
|
|
72
|
+
return {k: v for k, v in raw.items() if isinstance(k, str) and k and isinstance(v, str) and v.strip()}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_int(value: Any, default: int) -> int:
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
return default
|
|
78
|
+
if isinstance(value, int):
|
|
79
|
+
return value
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
try:
|
|
82
|
+
return int(value)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return default
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def lifespan(app: FastAPI):
|
|
90
|
+
# Schema to head before serving any request (single-process, no lock needed).
|
|
91
|
+
await _ensure_schema()
|
|
92
|
+
# A mounted sub-app's lifespan isn't run by the parent, so run the MCP session
|
|
93
|
+
# manager here. streamable_http_app() (at mount, below) initializes
|
|
94
|
+
# mcp.session_manager before this runs.
|
|
95
|
+
async with mcp.session_manager.run():
|
|
96
|
+
yield
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
app = FastAPI(title="queryview-backend", lifespan=lifespan)
|
|
100
|
+
app.mount("/mcp", mcp.streamable_http_app())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.middleware("http")
|
|
104
|
+
async def session_cookie(request: Request, call_next):
|
|
105
|
+
sid = request.cookies.get("qv_session")
|
|
106
|
+
new_session = sid is None
|
|
107
|
+
if not sid:
|
|
108
|
+
sid = str(uuid.uuid4())
|
|
109
|
+
request.state.sid = sid
|
|
110
|
+
response = await call_next(request)
|
|
111
|
+
if new_session:
|
|
112
|
+
response.set_cookie("qv_session", sid, path="/", httponly=True, samesite="lax")
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.get("/api/health")
|
|
117
|
+
async def health() -> dict[str, str]:
|
|
118
|
+
return {"status": "ok", "service": "queryview-backend"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.get("/api/session")
|
|
122
|
+
async def session(request: Request) -> dict[str, Any]:
|
|
123
|
+
return await get_session(request.state.sid)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Drop this session's active connection (disconnect command).
|
|
127
|
+
@app.post("/api/db/disconnect")
|
|
128
|
+
async def db_disconnect(request: Request) -> dict[str, Any]:
|
|
129
|
+
return await disconnect(request.state.sid)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Saved connection names, for the `connect <name>` autocomplete.
|
|
133
|
+
@app.get("/api/db/connections")
|
|
134
|
+
async def db_connections() -> dict[str, Any]:
|
|
135
|
+
return {"names": await list_connection_names()}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _driver_and_config(body: Any):
|
|
139
|
+
"""Resolve (driver, config) from a request body's `type`, or the 400
|
|
140
|
+
response to return for an unknown type / invalid config."""
|
|
141
|
+
b = body if isinstance(body, dict) else {}
|
|
142
|
+
raw_type = b.get("type")
|
|
143
|
+
conn_type = raw_type if isinstance(raw_type, str) else ""
|
|
144
|
+
driver = DRIVERS.get(conn_type)
|
|
145
|
+
if driver is None:
|
|
146
|
+
message = f"unknown connection type: {conn_type or '(none)'}"
|
|
147
|
+
return JSONResponse({"ok": False, "message": message}, status_code=400)
|
|
148
|
+
config, error = driver.parse_config(b)
|
|
149
|
+
if error or config is None:
|
|
150
|
+
return JSONResponse({"ok": False, "message": error}, status_code=400)
|
|
151
|
+
return driver, config
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Test only: a throwaway connectivity check, no save, no activation.
|
|
155
|
+
@app.post("/api/db/test")
|
|
156
|
+
async def db_test(request: Request):
|
|
157
|
+
resolved = _driver_and_config(await _read_json(request))
|
|
158
|
+
if isinstance(resolved, JSONResponse):
|
|
159
|
+
return resolved
|
|
160
|
+
driver, config = resolved
|
|
161
|
+
return await driver.test(config)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Create + open a connection for this session.
|
|
165
|
+
@app.post("/api/db/connect")
|
|
166
|
+
async def db_connect(request: Request):
|
|
167
|
+
body = await _read_json(request)
|
|
168
|
+
resolved = _driver_and_config(body)
|
|
169
|
+
if isinstance(resolved, JSONResponse):
|
|
170
|
+
return resolved
|
|
171
|
+
driver, config = resolved
|
|
172
|
+
b = body if isinstance(body, dict) else {}
|
|
173
|
+
raw_name = b.get("name")
|
|
174
|
+
name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else driver.type
|
|
175
|
+
return await connect_new(request.state.sid, name, config, driver.type)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Open a saved connection by name for this session (connect <name>).
|
|
179
|
+
@app.post("/api/db/open")
|
|
180
|
+
async def db_open(request: Request):
|
|
181
|
+
b = await _read_json(request) or {}
|
|
182
|
+
raw_name = b.get("name") if isinstance(b, dict) else None
|
|
183
|
+
name = raw_name.strip() if isinstance(raw_name, str) else ""
|
|
184
|
+
if not name:
|
|
185
|
+
return JSONResponse({"ok": False, "message": "name required"}, status_code=400)
|
|
186
|
+
r = await open_saved(request.state.sid, name)
|
|
187
|
+
if not r["ok"]:
|
|
188
|
+
return JSONResponse(
|
|
189
|
+
{"ok": False, "message": r["message"]},
|
|
190
|
+
status_code=404 if r.get("not_found") else 200,
|
|
191
|
+
)
|
|
192
|
+
return {"ok": True, "name": r["name"], "type": r["type"], "databases": r["databases"]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Select this session's active connection's database.
|
|
196
|
+
@app.post("/api/db/database")
|
|
197
|
+
async def db_database(request: Request):
|
|
198
|
+
b = await _read_json(request) or {}
|
|
199
|
+
raw_db = b.get("database") if isinstance(b, dict) else None
|
|
200
|
+
database = raw_db if isinstance(raw_db, str) else ""
|
|
201
|
+
r = await select_database(request.state.sid, database)
|
|
202
|
+
if not r["ok"]:
|
|
203
|
+
return JSONResponse(
|
|
204
|
+
{"ok": False, "message": r["message"]},
|
|
205
|
+
status_code=409 if r["reason"] == "no-session" else 400,
|
|
206
|
+
)
|
|
207
|
+
return {"ok": True}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _gate_error(r: dict[str, Any], reasons_409: tuple[str, ...]) -> JSONResponse:
|
|
211
|
+
"""Failed query/describe/tables result → response. Session-gate reasons in
|
|
212
|
+
`reasons_409` map to 409; anything else (a driver error) stays 200."""
|
|
213
|
+
status = 409 if r.get("reason") in reasons_409 else 200
|
|
214
|
+
return JSONResponse({"ok": False, "message": r["message"]}, status_code=status)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Run a SQL query (paginated) against this session's selected database.
|
|
218
|
+
@app.post("/api/db/query")
|
|
219
|
+
async def db_query(request: Request):
|
|
220
|
+
body = await _read_json(request)
|
|
221
|
+
b = body if isinstance(body, dict) else {}
|
|
222
|
+
raw_sql = b.get("query")
|
|
223
|
+
sql = raw_sql.strip() if isinstance(raw_sql, str) else ""
|
|
224
|
+
if not sql:
|
|
225
|
+
return JSONResponse({"ok": False, "message": "query required"}, status_code=400)
|
|
226
|
+
limit = _parse_int(b.get("limit"), 100)
|
|
227
|
+
limit = 100 if limit < 1 else min(limit, 1000)
|
|
228
|
+
offset = _parse_int(b.get("offset"), 0)
|
|
229
|
+
offset = 0 if offset < 0 else offset
|
|
230
|
+
fmt = "csv" if b.get("format") == "csv" else "tsv"
|
|
231
|
+
raw_order = b.get("order_by")
|
|
232
|
+
order_by = raw_order if isinstance(raw_order, list) else None
|
|
233
|
+
r = await run_query(request.state.sid, sql, limit, offset, fmt, order_by)
|
|
234
|
+
if not r["ok"]:
|
|
235
|
+
return _gate_error(r, ("no-session",))
|
|
236
|
+
return {"ok": True, "output": r["output"]}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Tables of this session's selected database (the Explorer page's sidebar).
|
|
240
|
+
@app.get("/api/db/tables")
|
|
241
|
+
async def db_tables(request: Request):
|
|
242
|
+
r = await list_tables(request.state.sid)
|
|
243
|
+
if not r["ok"]:
|
|
244
|
+
return _gate_error(r, ("no-session", "no-database"))
|
|
245
|
+
return {"ok": True, "tables": r["tables"]}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Describe a query's output columns (name + type) without scanning data.
|
|
249
|
+
@app.post("/api/db/describe")
|
|
250
|
+
async def db_describe(request: Request):
|
|
251
|
+
body = await _read_json(request)
|
|
252
|
+
b = body if isinstance(body, dict) else {}
|
|
253
|
+
raw_sql = b.get("query")
|
|
254
|
+
sql = raw_sql.strip() if isinstance(raw_sql, str) else ""
|
|
255
|
+
if not sql:
|
|
256
|
+
return JSONResponse({"ok": False, "message": "query required"}, status_code=400)
|
|
257
|
+
r = await describe_query(request.state.sid, sql)
|
|
258
|
+
if not r["ok"]:
|
|
259
|
+
return _gate_error(r, ("no-session", "no-database"))
|
|
260
|
+
return {"ok": True, "fields": r["fields"]}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def _resolve_workspace(raw: Any) -> workspaces.WorkspaceRec | JSONResponse:
|
|
264
|
+
"""The workspace for a request's optional `workspace` field (empty/missing
|
|
265
|
+
means the default workspace), or the error response to return."""
|
|
266
|
+
name = _clean_str(raw) or workspaces.DEFAULT_WORKSPACE
|
|
267
|
+
try:
|
|
268
|
+
return await workspaces.resolve(name)
|
|
269
|
+
except workspaces.WorkspaceError as e:
|
|
270
|
+
return JSONResponse({"ok": False, "message": str(e)}, status_code=e.status)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Predefined queries: keyed by connection type within a workspace.
|
|
274
|
+
@app.get("/api/predefined-queries")
|
|
275
|
+
async def predefined_queries_list(request: Request):
|
|
276
|
+
conn_type = request.query_params.get("type") or "clickhouse"
|
|
277
|
+
ws = await _resolve_workspace(request.query_params.get("workspace"))
|
|
278
|
+
if isinstance(ws, JSONResponse):
|
|
279
|
+
return ws
|
|
280
|
+
return {"queries": await list_predefined_queries_view(conn_type, ws.id)}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.post("/api/predefined-queries")
|
|
284
|
+
async def predefined_queries_save(request: Request):
|
|
285
|
+
body = await _read_json(request)
|
|
286
|
+
b = body if isinstance(body, dict) else {}
|
|
287
|
+
name = b.get("query_name")
|
|
288
|
+
conn_type = b.get("type")
|
|
289
|
+
query = b.get("query")
|
|
290
|
+
name = name.strip() if isinstance(name, str) else ""
|
|
291
|
+
conn_type = conn_type.strip() if isinstance(conn_type, str) else ""
|
|
292
|
+
query = query.strip() if isinstance(query, str) else ""
|
|
293
|
+
if not name or not conn_type or not query:
|
|
294
|
+
return JSONResponse(
|
|
295
|
+
{"ok": False, "message": "query_name, type and query are required"},
|
|
296
|
+
status_code=400,
|
|
297
|
+
)
|
|
298
|
+
raw_cv = b.get("cell_view")
|
|
299
|
+
cverr = cell_view_error(raw_cv)
|
|
300
|
+
if cverr is not None:
|
|
301
|
+
return JSONResponse({"ok": False, "message": cverr}, status_code=400)
|
|
302
|
+
# Store the raw YAML text verbatim; empty string => NULL (no custom views).
|
|
303
|
+
cell_view = raw_cv if isinstance(raw_cv, str) and raw_cv.strip() else None
|
|
304
|
+
order_by_arr = b.get("order_by")
|
|
305
|
+
fields_arr = b.get("fields")
|
|
306
|
+
perr = presentation_error(order_by_arr, fields_arr)
|
|
307
|
+
if perr is not None:
|
|
308
|
+
return JSONResponse({"ok": False, "message": perr}, status_code=400)
|
|
309
|
+
import json
|
|
310
|
+
|
|
311
|
+
order_by = json.dumps(order_by_arr) if isinstance(order_by_arr, list) and order_by_arr else None
|
|
312
|
+
fields = json.dumps(fields_arr) if isinstance(fields_arr, list) and fields_arr else None
|
|
313
|
+
ws = await _resolve_workspace(b.get("workspace"))
|
|
314
|
+
if isinstance(ws, JSONResponse):
|
|
315
|
+
return ws
|
|
316
|
+
await save_predefined_query(name, conn_type, query, cell_view, order_by, fields, workspace_id=ws.id)
|
|
317
|
+
return {"ok": True}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --- Remote control (MCP push -> live browser session) --------------------
|
|
321
|
+
|
|
322
|
+
_SSE_POLL_SECONDS = 1.0
|
|
323
|
+
_SSE_HEARTBEAT_SECONDS = 15.0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _sse(event: str, data: dict[str, Any]) -> bytes:
|
|
327
|
+
import json
|
|
328
|
+
|
|
329
|
+
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _event_stream(remote_id: str, request: Request):
|
|
333
|
+
"""Yield SSE: a `ready` event with the id, then pushed payloads (each under the
|
|
334
|
+
SSE event named by its `type` field) plus a heartbeat. Polls disconnect every
|
|
335
|
+
second so disarming (the browser closing the EventSource) unregisters the
|
|
336
|
+
channel promptly."""
|
|
337
|
+
try:
|
|
338
|
+
yield _sse("ready", {"id": remote_id})
|
|
339
|
+
elapsed = 0.0
|
|
340
|
+
while True:
|
|
341
|
+
if await request.is_disconnected():
|
|
342
|
+
break
|
|
343
|
+
msg = await remote.next_message(remote_id, _SSE_POLL_SECONDS)
|
|
344
|
+
if msg is None:
|
|
345
|
+
elapsed += _SSE_POLL_SECONDS
|
|
346
|
+
if elapsed >= _SSE_HEARTBEAT_SECONDS:
|
|
347
|
+
elapsed = 0.0
|
|
348
|
+
yield b": ping\n\n"
|
|
349
|
+
continue
|
|
350
|
+
yield _sse(msg.get("type", "query"), msg)
|
|
351
|
+
finally:
|
|
352
|
+
remote.unregister(remote_id)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Open an SSE channel for this browser; the browser does this when the user
|
|
356
|
+
# arms "remote control". Closing the EventSource unregisters the channel.
|
|
357
|
+
@app.get("/api/remote/events")
|
|
358
|
+
async def remote_events(request: Request):
|
|
359
|
+
remote_id = remote.register()
|
|
360
|
+
return StreamingResponse(
|
|
361
|
+
_event_stream(remote_id, request),
|
|
362
|
+
media_type="text/event-stream",
|
|
363
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# Push a query to a live session (used by the MCP tool and, in tests, directly).
|
|
368
|
+
@app.post("/api/remote/push")
|
|
369
|
+
async def remote_push(request: Request):
|
|
370
|
+
body = await _read_json(request)
|
|
371
|
+
b = body if isinstance(body, dict) else {}
|
|
372
|
+
raw_sid = b.get("session_id")
|
|
373
|
+
session_id = raw_sid.strip() if isinstance(raw_sid, str) else ""
|
|
374
|
+
raw_sql = b.get("query")
|
|
375
|
+
query = raw_sql.strip() if isinstance(raw_sql, str) else ""
|
|
376
|
+
if not session_id or not query:
|
|
377
|
+
return JSONResponse(
|
|
378
|
+
{"ok": False, "message": "session_id and query are required"},
|
|
379
|
+
status_code=400,
|
|
380
|
+
)
|
|
381
|
+
limit = _parse_int(b.get("limit"), 100)
|
|
382
|
+
offset = _parse_int(b.get("offset"), 0)
|
|
383
|
+
raw_cv = b.get("cell_view")
|
|
384
|
+
cell_view = raw_cv if isinstance(raw_cv, str) and raw_cv.strip() else None
|
|
385
|
+
raw_name = b.get("name")
|
|
386
|
+
name = raw_name if isinstance(raw_name, str) and raw_name.strip() else None
|
|
387
|
+
# Pass order_by/fields raw so remote.push's validator can reject malformed
|
|
388
|
+
# input (fail-fast) rather than silently coercing it away.
|
|
389
|
+
payload = {
|
|
390
|
+
"type": "query",
|
|
391
|
+
"query": query,
|
|
392
|
+
"limit": limit,
|
|
393
|
+
"offset": offset,
|
|
394
|
+
"order_by": b.get("order_by"),
|
|
395
|
+
"fields": b.get("fields"),
|
|
396
|
+
"cell_view": cell_view,
|
|
397
|
+
"name": name,
|
|
398
|
+
}
|
|
399
|
+
ok, message = remote.push(session_id, payload)
|
|
400
|
+
return {"ok": ok, "message": message}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.post("/api/remote/db")
|
|
404
|
+
async def remote_db(request: Request):
|
|
405
|
+
"""Browser reports the database its live session targets, so the agent's
|
|
406
|
+
push_query/push_dashboard responses can echo it. Called on arm and whenever
|
|
407
|
+
the active database changes."""
|
|
408
|
+
body = await _read_json(request)
|
|
409
|
+
b = body if isinstance(body, dict) else {}
|
|
410
|
+
raw_sid = b.get("session_id")
|
|
411
|
+
session_id = raw_sid.strip() if isinstance(raw_sid, str) else ""
|
|
412
|
+
raw_db = b.get("database")
|
|
413
|
+
database = raw_db if isinstance(raw_db, str) and raw_db else None
|
|
414
|
+
if not session_id:
|
|
415
|
+
return JSONResponse({"ok": False, "message": "session_id required"}, status_code=400)
|
|
416
|
+
ok = remote.set_session_database(session_id, database)
|
|
417
|
+
if "workspace" in b:
|
|
418
|
+
raw_ws = b.get("workspace")
|
|
419
|
+
remote.set_session_workspace(session_id, raw_ws if isinstance(raw_ws, str) and raw_ws else None)
|
|
420
|
+
return {"ok": ok}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.post("/api/remote/lock")
|
|
424
|
+
async def remote_lock(request: Request):
|
|
425
|
+
"""Browser-only edit-lock control for a live session. action=acquire is sent
|
|
426
|
+
on panel focus (and as a ~10s heartbeat); action=release on blur. Owner is
|
|
427
|
+
always 'human' — the agent never calls this."""
|
|
428
|
+
body = await _read_json(request)
|
|
429
|
+
b = body if isinstance(body, dict) else {}
|
|
430
|
+
raw_sid = b.get("session_id")
|
|
431
|
+
session_id = raw_sid.strip() if isinstance(raw_sid, str) else ""
|
|
432
|
+
action = b.get("action")
|
|
433
|
+
if not session_id or action not in ("acquire", "release"):
|
|
434
|
+
return JSONResponse(
|
|
435
|
+
{"ok": False, "message": "session_id and action (acquire|release) required"},
|
|
436
|
+
status_code=400,
|
|
437
|
+
)
|
|
438
|
+
if action == "acquire":
|
|
439
|
+
ok, message = remote.acquire(session_id, "human")
|
|
440
|
+
else:
|
|
441
|
+
ok, message = remote.release(session_id, "human")
|
|
442
|
+
return {"ok": ok, "message": message}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# --- Dashboards (persist + reopen + run-against-a-named-connection) --------
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Run a dashboard's named queries against a named connection. Fail-fast: any
|
|
449
|
+
# failure returns an HTTP error and no partial results.
|
|
450
|
+
@app.post("/api/runqueries")
|
|
451
|
+
async def run_queries(request: Request):
|
|
452
|
+
body = await _read_json(request)
|
|
453
|
+
b = body if isinstance(body, dict) else {}
|
|
454
|
+
connection = _clean_str(b.get("connection"))
|
|
455
|
+
queries = _clean_queries(b.get("queries"))
|
|
456
|
+
if not connection or not queries:
|
|
457
|
+
return JSONResponse(
|
|
458
|
+
{"ok": False, "message": "connection and queries are required"},
|
|
459
|
+
status_code=400,
|
|
460
|
+
)
|
|
461
|
+
r = await run_queries_for_connection(connection, queries)
|
|
462
|
+
if not r["ok"]:
|
|
463
|
+
status = 404 if r.get("reason") == "no-connection" else 400
|
|
464
|
+
return JSONResponse({"ok": False, "message": r["message"]}, status_code=status)
|
|
465
|
+
return {"ok": True, "results": r["results"]}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# Upsert a dashboard and (with a session_id) push it to a live browser session.
|
|
469
|
+
# REST mirror of the upsert_dashboard MCP tool.
|
|
470
|
+
@app.post("/api/dashboards")
|
|
471
|
+
async def dashboards_upsert(request: Request):
|
|
472
|
+
body = await _read_json(request)
|
|
473
|
+
b = body if isinstance(body, dict) else {}
|
|
474
|
+
name = _clean_str(b.get("name"))
|
|
475
|
+
connection = _clean_str(b.get("connection"))
|
|
476
|
+
raw_html = b.get("html")
|
|
477
|
+
html = raw_html if isinstance(raw_html, str) else ""
|
|
478
|
+
queries = _clean_queries(b.get("queries"))
|
|
479
|
+
if not name or not connection or not html.strip():
|
|
480
|
+
return JSONResponse(
|
|
481
|
+
{"ok": False, "message": "name, connection and html are required"},
|
|
482
|
+
status_code=400,
|
|
483
|
+
)
|
|
484
|
+
ws = await _resolve_workspace(b.get("workspace"))
|
|
485
|
+
if isinstance(ws, JSONResponse):
|
|
486
|
+
return ws
|
|
487
|
+
session_id = _clean_str(b.get("session_id"))
|
|
488
|
+
persisted, pushed, message = await _upsert_and_push(
|
|
489
|
+
name, connection, html, queries, session_id or None, workspace_id=ws.id
|
|
490
|
+
)
|
|
491
|
+
return {"ok": persisted, "persisted": persisted, "pushed": pushed, "message": message}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@app.get("/api/dashboards")
|
|
495
|
+
async def dashboards_list(request: Request):
|
|
496
|
+
ws = await _resolve_workspace(request.query_params.get("workspace"))
|
|
497
|
+
if isinstance(ws, JSONResponse):
|
|
498
|
+
return ws
|
|
499
|
+
return {"dashboards": await list_dashboards(ws.id)}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@app.get("/api/dashboards/{name}")
|
|
503
|
+
async def dashboards_get(name: str, request: Request):
|
|
504
|
+
ws = await _resolve_workspace(request.query_params.get("workspace"))
|
|
505
|
+
if isinstance(ws, JSONResponse):
|
|
506
|
+
return ws
|
|
507
|
+
d = await get_dashboard(name, ws.id)
|
|
508
|
+
if d is None:
|
|
509
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
510
|
+
return d
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# --- Git sync: per-entity backup/restore (see docs/gitsync.md) -------------
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _gitsync_args(kind: Any, name: Any, conn_type: Any) -> tuple[str, str, str | None] | JSONResponse:
|
|
517
|
+
"""Validated (kind, name, conn_type), or the 400 response to return,
|
|
518
|
+
mirroring _driver_and_config's convention."""
|
|
519
|
+
kind = _clean_str(kind)
|
|
520
|
+
name = _clean_str(name)
|
|
521
|
+
conn_type = _clean_str(conn_type)
|
|
522
|
+
if kind not in ("query", "dashboard") or not name or (kind == "query" and not conn_type):
|
|
523
|
+
return JSONResponse(
|
|
524
|
+
{
|
|
525
|
+
"ok": False,
|
|
526
|
+
"message": "kind ('query'|'dashboard'), name and (for queries) conn_type are required",
|
|
527
|
+
},
|
|
528
|
+
status_code=400,
|
|
529
|
+
)
|
|
530
|
+
return kind, name, conn_type or None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
async def _gitsync_json(coro):
|
|
534
|
+
"""Await a gitsync operation, mapping its result (or GitSyncError) to the
|
|
535
|
+
endpoint response — the one place the /api/git error contract lives."""
|
|
536
|
+
try:
|
|
537
|
+
r = await coro
|
|
538
|
+
except gitsync.GitSyncError as e:
|
|
539
|
+
return JSONResponse({"ok": False, "message": str(e)}, status_code=e.status)
|
|
540
|
+
return {"ok": True, **r}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@app.get("/api/git/status")
|
|
544
|
+
async def git_status(request: Request):
|
|
545
|
+
ws = await _resolve_workspace(request.query_params.get("workspace"))
|
|
546
|
+
if isinstance(ws, JSONResponse):
|
|
547
|
+
return ws
|
|
548
|
+
return {"configured": gitsync.configured(ws)}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.post("/api/git/store")
|
|
552
|
+
async def git_store(request: Request):
|
|
553
|
+
body = await _read_json(request)
|
|
554
|
+
b = body if isinstance(body, dict) else {}
|
|
555
|
+
args = _gitsync_args(b.get("kind"), b.get("name"), b.get("conn_type"))
|
|
556
|
+
if isinstance(args, JSONResponse):
|
|
557
|
+
return args
|
|
558
|
+
kind, name, conn_type = args
|
|
559
|
+
ws = await _resolve_workspace(b.get("workspace"))
|
|
560
|
+
if isinstance(ws, JSONResponse):
|
|
561
|
+
return ws
|
|
562
|
+
message = _clean_str(b.get("message")) or None
|
|
563
|
+
return await _gitsync_json(gitsync.store(ws, kind, name, conn_type, message))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@app.get("/api/git/history")
|
|
567
|
+
async def git_history(request: Request):
|
|
568
|
+
q = request.query_params
|
|
569
|
+
args = _gitsync_args(q.get("kind"), q.get("name"), q.get("conn_type"))
|
|
570
|
+
if isinstance(args, JSONResponse):
|
|
571
|
+
return args
|
|
572
|
+
kind, name, conn_type = args
|
|
573
|
+
try:
|
|
574
|
+
limit = max(1, min(int(q.get("limit") or 10), 100))
|
|
575
|
+
except ValueError:
|
|
576
|
+
limit = 10
|
|
577
|
+
ws = await _resolve_workspace(q.get("workspace"))
|
|
578
|
+
if isinstance(ws, JSONResponse):
|
|
579
|
+
return ws
|
|
580
|
+
return await _gitsync_json(gitsync.history(ws, kind, name, conn_type, q.get("before") or None, limit))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.post("/api/git/restore")
|
|
584
|
+
async def git_restore(request: Request):
|
|
585
|
+
body = await _read_json(request)
|
|
586
|
+
b = body if isinstance(body, dict) else {}
|
|
587
|
+
args = _gitsync_args(b.get("kind"), b.get("name"), b.get("conn_type"))
|
|
588
|
+
if isinstance(args, JSONResponse):
|
|
589
|
+
return args
|
|
590
|
+
kind, name, conn_type = args
|
|
591
|
+
ws = await _resolve_workspace(b.get("workspace"))
|
|
592
|
+
if isinstance(ws, JSONResponse):
|
|
593
|
+
return ws
|
|
594
|
+
return await _gitsync_json(gitsync.restore(ws, kind, name, conn_type, _clean_str(b.get("ref")) or None))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# --- YAML export / import (see docs/export-import.md) ----------------------
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# Download one entity — or the whole workspace — as a self-describing YAML
|
|
601
|
+
# document (its `kind` field drives import, never the filename). Validation,
|
|
602
|
+
# dispatch and the download filename live in yamlio.export.
|
|
603
|
+
@app.get("/api/export")
|
|
604
|
+
async def export_yaml(request: Request):
|
|
605
|
+
q = request.query_params
|
|
606
|
+
ws = await _resolve_workspace(q.get("workspace"))
|
|
607
|
+
if isinstance(ws, JSONResponse):
|
|
608
|
+
return ws
|
|
609
|
+
try:
|
|
610
|
+
filename, text = await yamlio.export(
|
|
611
|
+
_clean_str(q.get("kind")), _clean_str(q.get("name")), _clean_str(q.get("conn_type")), ws
|
|
612
|
+
)
|
|
613
|
+
except yamlio.YamlIOError as e:
|
|
614
|
+
return JSONResponse({"ok": False, "message": str(e)}, status_code=e.status)
|
|
615
|
+
return PlainTextResponse(
|
|
616
|
+
text,
|
|
617
|
+
media_type="application/x-yaml",
|
|
618
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# Import a YAML document (raw request body) into a workspace, upserting by
|
|
623
|
+
# name. The document's `kind` decides what gets written.
|
|
624
|
+
@app.post("/api/import")
|
|
625
|
+
async def import_yaml(request: Request):
|
|
626
|
+
ws = await _resolve_workspace(request.query_params.get("workspace"))
|
|
627
|
+
if isinstance(ws, JSONResponse):
|
|
628
|
+
return ws
|
|
629
|
+
text = (await request.body()).decode("utf-8", "replace")
|
|
630
|
+
if not text.strip():
|
|
631
|
+
return JSONResponse({"ok": False, "message": "YAML body required"}, status_code=400)
|
|
632
|
+
try:
|
|
633
|
+
r = await yamlio.import_text(text, ws.id)
|
|
634
|
+
except yamlio.YamlIOError as e:
|
|
635
|
+
return JSONResponse({"ok": False, "message": str(e)}, status_code=e.status)
|
|
636
|
+
return {"ok": True, **r}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# --- Workspaces (see docs/workspace.md) ------------------------------------
|
|
640
|
+
# Admin configuration with secrets (the remote URL may embed a token): exposed
|
|
641
|
+
# over REST/UI only, deliberately not over MCP.
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _workspace_error(e: workspaces.WorkspaceError) -> JSONResponse:
|
|
645
|
+
return JSONResponse({"ok": False, "message": str(e)}, status_code=e.status)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@app.get("/api/workspaces")
|
|
649
|
+
async def workspaces_list():
|
|
650
|
+
return {"workspaces": await workspaces.list_workspaces()}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.post("/api/workspaces")
|
|
654
|
+
async def workspaces_create(request: Request):
|
|
655
|
+
b = await _read_json(request)
|
|
656
|
+
b = b if isinstance(b, dict) else {}
|
|
657
|
+
name = _clean_str(b.get("name"))
|
|
658
|
+
if not name:
|
|
659
|
+
return JSONResponse({"ok": False, "message": "name is required"}, status_code=400)
|
|
660
|
+
remote_url = _clean_str(b.get("remote")) or None
|
|
661
|
+
branch = _clean_str(b.get("branch")) or "main"
|
|
662
|
+
try:
|
|
663
|
+
await workspaces.create_workspace(name, remote_url, branch)
|
|
664
|
+
except workspaces.WorkspaceError as e:
|
|
665
|
+
return _workspace_error(e)
|
|
666
|
+
return {"ok": True}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@app.patch("/api/workspaces/{name}")
|
|
670
|
+
async def workspaces_update(name: str, request: Request):
|
|
671
|
+
b = await _read_json(request)
|
|
672
|
+
b = b if isinstance(b, dict) else {}
|
|
673
|
+
kwargs: dict[str, Any] = {}
|
|
674
|
+
if "name" in b:
|
|
675
|
+
kwargs["new_name"] = _clean_str(b.get("name")) or None
|
|
676
|
+
if "remote" in b: # present-but-null clears; absent leaves as-is
|
|
677
|
+
kwargs["remote"] = _clean_str(b.get("remote")) or None
|
|
678
|
+
if "branch" in b:
|
|
679
|
+
kwargs["branch"] = _clean_str(b.get("branch")) or None
|
|
680
|
+
try:
|
|
681
|
+
await workspaces.update_workspace(name, **kwargs)
|
|
682
|
+
except workspaces.WorkspaceError as e:
|
|
683
|
+
return _workspace_error(e)
|
|
684
|
+
return {"ok": True}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@app.delete("/api/workspaces/{name}")
|
|
688
|
+
async def workspaces_delete(name: str):
|
|
689
|
+
try:
|
|
690
|
+
await workspaces.delete_workspace(name)
|
|
691
|
+
except workspaces.WorkspaceError as e:
|
|
692
|
+
return _workspace_error(e)
|
|
693
|
+
return {"ok": True}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@app.api_route("/api/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
697
|
+
async def api_not_found(rest: str):
|
|
698
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
if SERVE_STATIC:
|
|
702
|
+
|
|
703
|
+
@app.get("/{full_path:path}")
|
|
704
|
+
async def spa(full_path: str):
|
|
705
|
+
root = _static_root()
|
|
706
|
+
candidate = (root / full_path).resolve()
|
|
707
|
+
try:
|
|
708
|
+
candidate.relative_to(root)
|
|
709
|
+
except ValueError:
|
|
710
|
+
candidate = None
|
|
711
|
+
if candidate is not None and candidate.is_file():
|
|
712
|
+
return FileResponse(candidate)
|
|
713
|
+
# SPA fallback: serve index.html (200) for any unknown path so
|
|
714
|
+
# client-side routing works.
|
|
715
|
+
return FileResponse(root / "index.html")
|
|
716
|
+
|
|
717
|
+
else:
|
|
718
|
+
|
|
719
|
+
@app.get("/{full_path:path}")
|
|
720
|
+
async def not_found(full_path: str):
|
|
721
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _resolve_port(argv: list[str] | None = None) -> int:
|
|
725
|
+
"""Listen port: --port beats the PORT env var, which beats 8000."""
|
|
726
|
+
import argparse
|
|
727
|
+
|
|
728
|
+
parser = argparse.ArgumentParser(prog="queryview")
|
|
729
|
+
parser.add_argument("--port", type=int, help="listen port (default: $PORT or 8000)")
|
|
730
|
+
args = parser.parse_args(argv)
|
|
731
|
+
if args.port is not None:
|
|
732
|
+
return args.port
|
|
733
|
+
return int(os.environ.get("PORT", "8000"))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def run() -> None:
|
|
737
|
+
"""Console-script entry point: launch uvicorn on the resolved port."""
|
|
738
|
+
import uvicorn
|
|
739
|
+
|
|
740
|
+
uvicorn.run(app, host="0.0.0.0", port=_resolve_port())
|