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.
Files changed (54) hide show
  1. queryview/__init__.py +0 -0
  2. queryview/conftest.py +86 -0
  3. queryview/connect.py +428 -0
  4. queryview/dashboard_queries.py +75 -0
  5. queryview/dashboards.py +156 -0
  6. queryview/drivers/__init__.py +10 -0
  7. queryview/drivers/base.py +165 -0
  8. queryview/drivers/clickhouse.py +138 -0
  9. queryview/drivers/duckdb.py +153 -0
  10. queryview/drivers/postgres.py +166 -0
  11. queryview/drivers/test_base.py +49 -0
  12. queryview/drivers/test_clickhouse.py +53 -0
  13. queryview/drivers/test_contract.py +77 -0
  14. queryview/drivers/test_duckdb.py +80 -0
  15. queryview/drivers/test_postgres.py +80 -0
  16. queryview/gitsync.py +374 -0
  17. queryview/main.py +740 -0
  18. queryview/mcp_server.py +294 -0
  19. queryview/migrations/env.py +39 -0
  20. queryview/migrations/script.py.mako +29 -0
  21. queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
  22. queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
  23. queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
  24. queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
  25. queryview/queries.py +159 -0
  26. queryview/remote.py +141 -0
  27. queryview/static/assets/index-CvnC_D68.js +47 -0
  28. queryview/static/assets/index-Qe7bhycG.css +2 -0
  29. queryview/static/favicon.svg +1 -0
  30. queryview/static/index.html +14 -0
  31. queryview/test_api_db.py +51 -0
  32. queryview/test_api_export_import.py +85 -0
  33. queryview/test_api_gitsync.py +83 -0
  34. queryview/test_api_workspaces.py +44 -0
  35. queryview/test_connect_flow.py +123 -0
  36. queryview/test_connect_store.py +34 -0
  37. queryview/test_dashboards.py +216 -0
  38. queryview/test_gitsync.py +346 -0
  39. queryview/test_main.py +18 -0
  40. queryview/test_mcp_gitsync.py +72 -0
  41. queryview/test_migrations.py +99 -0
  42. queryview/test_queries.py +170 -0
  43. queryview/test_remote.py +260 -0
  44. queryview/test_validation.py +87 -0
  45. queryview/test_workspaces.py +109 -0
  46. queryview/test_yamlio.py +198 -0
  47. queryview/validation.py +111 -0
  48. queryview/workspaces.py +167 -0
  49. queryview/yamlio.py +245 -0
  50. queryview-0.0.2.dist-info/METADATA +183 -0
  51. queryview-0.0.2.dist-info/RECORD +54 -0
  52. queryview-0.0.2.dist-info/WHEEL +4 -0
  53. queryview-0.0.2.dist-info/entry_points.txt +3 -0
  54. 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())