brainpalace-dashboard 26.6.26__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.
- brainpalace_dashboard-26.6.26/PKG-INFO +21 -0
- brainpalace_dashboard-26.6.26/README.md +3 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/__init__.py +3 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/__init__.py +0 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_config.py +65 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_data.py +167 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_events.py +52 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_instances.py +70 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_queries.py +79 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_settings.py +80 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/app.py +144 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/config.py +125 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/coverage_maps.py +111 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/server.py +299 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/__init__.py +0 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/capabilities.py +22 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/config_svc.py +158 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/instances.py +249 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/proxy.py +76 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/assets/index--evOdt1x.css +1 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/assets/index-BG8aGfSn.js +361 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/favicon.svg +6 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/index.html +15 -0
- brainpalace_dashboard-26.6.26/brainpalace_dashboard/ui_schema.py +262 -0
- brainpalace_dashboard-26.6.26/pyproject.toml +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brainpalace-dashboard
|
|
3
|
+
Version: 26.6.26
|
|
4
|
+
Summary: BrainPalace control-plane web dashboard
|
|
5
|
+
Author: BrainPalace
|
|
6
|
+
Requires-Python: >=3.12,<4.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: brainpalace-cli (==26.6.26)
|
|
12
|
+
Requires-Dist: fastapi (>=0.115,<0.116)
|
|
13
|
+
Requires-Dist: httpx (>=0.28.0,<0.29.0)
|
|
14
|
+
Requires-Dist: sse-starlette (>=2.1,<3.0)
|
|
15
|
+
Requires-Dist: uvicorn (>=0.32.0,<0.33.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# brainpalace-dashboard
|
|
19
|
+
|
|
20
|
+
Control-plane web dashboard for managing all BrainPalace project servers. See docs/DASHBOARD.md.
|
|
21
|
+
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Config schema + per-instance config GET/PATCH (batched, all-or-nothing)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from brainpalace_dashboard.services.config_svc import ConfigService, ConfigWriteError
|
|
13
|
+
from brainpalace_dashboard.services.instances import (
|
|
14
|
+
InstanceNotFound,
|
|
15
|
+
InstanceService,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/dashboard/api", tags=["config"])
|
|
19
|
+
config_service = ConfigService()
|
|
20
|
+
instance_service = InstanceService()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _state_dir_for(id_: str) -> Path:
|
|
24
|
+
entry = instance_service._resolve(id_) # raises InstanceNotFound
|
|
25
|
+
root = Path(entry["project_root"])
|
|
26
|
+
return Path(entry["state_dir"]) if entry.get("state_dir") else root / ".brainpalace"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigPatch(BaseModel):
|
|
30
|
+
values: dict[str, Any]
|
|
31
|
+
restart: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/schema")
|
|
35
|
+
def get_schema() -> dict[str, Any]:
|
|
36
|
+
return config_service.schema()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/instances/{id_}/config")
|
|
40
|
+
def get_config(id_: str) -> dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
return config_service.read(_state_dir_for(id_))
|
|
43
|
+
except InstanceNotFound:
|
|
44
|
+
raise HTTPException(status_code=404, detail="instance not found") from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.patch("/instances/{id_}/config")
|
|
48
|
+
def patch_config(id_: str, body: ConfigPatch) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
state_dir = _state_dir_for(id_)
|
|
51
|
+
except InstanceNotFound:
|
|
52
|
+
raise HTTPException(status_code=404, detail="instance not found") from None
|
|
53
|
+
try:
|
|
54
|
+
config_service.write(state_dir, body.values)
|
|
55
|
+
except ConfigWriteError as e:
|
|
56
|
+
# Body is exactly {"errors": [...]} so the client reads .errors directly.
|
|
57
|
+
return JSONResponse(status_code=422, content={"errors": e.errors})
|
|
58
|
+
restarted = False
|
|
59
|
+
if body.restart:
|
|
60
|
+
try:
|
|
61
|
+
instance_service.restart(id_)
|
|
62
|
+
restarted = True
|
|
63
|
+
except Exception as e: # surface but don't lose the saved config
|
|
64
|
+
return {"ok": True, "restarted": False, "restart_error": str(e)}
|
|
65
|
+
return {"ok": True, "restarted": restarted}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Per-instance data reads + action proxies.
|
|
2
|
+
|
|
3
|
+
Each route is a thin wrapper that forwards to the live project server via
|
|
4
|
+
``ProxyService`` and normalizes any upstream/transport failure to
|
|
5
|
+
``{error, detail, upstream_status}`` (never a blank 500). Upstream paths are the
|
|
6
|
+
*live* server routes confirmed against ``/openapi.json`` — note that cache,
|
|
7
|
+
folders and jobs live under the ``/index/`` prefix on the server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Body
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from brainpalace_dashboard.services.capabilities import parse_openapi
|
|
18
|
+
from brainpalace_dashboard.services.proxy import ProxyService, UpstreamError
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/dashboard/api/instances/{id_}", tags=["data"])
|
|
21
|
+
proxy = ProxyService()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _call(
|
|
25
|
+
id_: str,
|
|
26
|
+
method: str,
|
|
27
|
+
path: str,
|
|
28
|
+
json: Any | None = None,
|
|
29
|
+
params: dict[str, Any] | None = None,
|
|
30
|
+
) -> Any:
|
|
31
|
+
try:
|
|
32
|
+
return await proxy.request(id_, method, path, json=json, params=params)
|
|
33
|
+
except UpstreamError as e:
|
|
34
|
+
return JSONResponse(
|
|
35
|
+
status_code=e.upstream_status,
|
|
36
|
+
content={
|
|
37
|
+
"error": "upstream",
|
|
38
|
+
"detail": e.detail,
|
|
39
|
+
"upstream_status": e.upstream_status,
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- reads ----
|
|
45
|
+
@router.get("/health")
|
|
46
|
+
async def health(id_: str) -> Any:
|
|
47
|
+
"""Liveness + server version/mode (the project server's ``GET /health/``)."""
|
|
48
|
+
return await _call(id_, "GET", "/health/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/status")
|
|
52
|
+
async def status(id_: str) -> Any:
|
|
53
|
+
return await _call(id_, "GET", "/health/status")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/providers")
|
|
57
|
+
async def providers(id_: str) -> Any:
|
|
58
|
+
return await _call(id_, "GET", "/health/providers")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/postgres")
|
|
62
|
+
async def postgres(id_: str) -> Any:
|
|
63
|
+
return await _call(id_, "GET", "/health/postgres")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/folders")
|
|
67
|
+
async def folders(id_: str) -> Any:
|
|
68
|
+
return await _call(id_, "GET", "/index/folders/")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/jobs")
|
|
72
|
+
async def jobs(id_: str) -> Any:
|
|
73
|
+
return await _call(id_, "GET", "/index/jobs/")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/jobs/{job_id}")
|
|
77
|
+
async def job(id_: str, job_id: str) -> Any:
|
|
78
|
+
return await _call(id_, "GET", f"/index/jobs/{job_id}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get("/cache")
|
|
82
|
+
async def cache(id_: str) -> Any:
|
|
83
|
+
return await _call(id_, "GET", "/index/cache/")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/graph")
|
|
87
|
+
async def graph(id_: str) -> Any:
|
|
88
|
+
# graph stats live inside /health/status; expose a focused view client-side.
|
|
89
|
+
return await _call(id_, "GET", "/health/status")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/memories")
|
|
93
|
+
async def memories(id_: str) -> Any:
|
|
94
|
+
return await _call(id_, "GET", "/memories/")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/runtime")
|
|
98
|
+
async def runtime(id_: str) -> Any:
|
|
99
|
+
return await _call(id_, "GET", "/runtime/")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/logs")
|
|
103
|
+
async def logs(id_: str, lines: int = 200, level: str | None = None) -> Any:
|
|
104
|
+
"""Tail the server log file (proxy onto the server's ``/health/logs``)."""
|
|
105
|
+
params: dict[str, Any] = {"lines": lines}
|
|
106
|
+
if level:
|
|
107
|
+
params["level"] = level
|
|
108
|
+
return await _call(id_, "GET", "/health/logs", params=params)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/capabilities")
|
|
112
|
+
async def capabilities(id_: str) -> Any:
|
|
113
|
+
doc = await _call(id_, "GET", "/openapi.json")
|
|
114
|
+
if isinstance(doc, JSONResponse):
|
|
115
|
+
return doc
|
|
116
|
+
return parse_openapi(doc)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- actions ----
|
|
120
|
+
@router.post("/index")
|
|
121
|
+
async def add_folder(id_: str, body: Annotated[dict[str, Any], Body(...)]) -> Any:
|
|
122
|
+
return await _call(id_, "POST", "/index/", json=body)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.delete("/folders")
|
|
126
|
+
async def remove_folder(id_: str, body: Annotated[dict[str, Any], Body(...)]) -> Any:
|
|
127
|
+
return await _call(id_, "DELETE", "/index/folders/", json=body)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.delete("/index")
|
|
131
|
+
async def reset_index(id_: str) -> Any:
|
|
132
|
+
return await _call(id_, "DELETE", "/index/")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.delete("/cache")
|
|
136
|
+
async def clear_cache(id_: str) -> Any:
|
|
137
|
+
return await _call(id_, "DELETE", "/index/cache/")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.delete("/jobs/{job_id}")
|
|
141
|
+
async def cancel_job(id_: str, job_id: str) -> Any:
|
|
142
|
+
return await _call(id_, "DELETE", f"/index/jobs/{job_id}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post("/git/reindex")
|
|
146
|
+
async def git_reindex(id_: str) -> Any:
|
|
147
|
+
return await _call(id_, "POST", "/git/reindex")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.post("/sessions/reindex")
|
|
151
|
+
async def sessions_reindex(id_: str) -> Any:
|
|
152
|
+
return await _call(id_, "POST", "/sessions/reindex")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.post("/memories/{memory_id}/obsolete")
|
|
156
|
+
async def memory_obsolete(id_: str, memory_id: str) -> Any:
|
|
157
|
+
return await _call(id_, "POST", f"/memories/{memory_id}/obsolete")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.delete("/memories/{memory_id}")
|
|
161
|
+
async def memory_delete(id_: str, memory_id: str) -> Any:
|
|
162
|
+
return await _call(id_, "DELETE", f"/memories/{memory_id}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.post("/memories/rebuild")
|
|
166
|
+
async def memory_rebuild(id_: str) -> Any:
|
|
167
|
+
return await _call(id_, "POST", "/memories/rebuild")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Server-Sent Events stream for live dashboard updates.
|
|
2
|
+
|
|
3
|
+
A single ``GET /dashboard/api/events`` stream emits an ``instances`` event
|
|
4
|
+
(the current fleet list) every ``poll_s`` seconds. The SPA subscribes once via
|
|
5
|
+
``EventSource`` and feeds each payload into its TanStack Query cache, instead of
|
|
6
|
+
every tab polling ``/instances`` independently.
|
|
7
|
+
|
|
8
|
+
``max_ticks`` bounds the number of emissions so tests can consume a finite
|
|
9
|
+
stream; in production it defaults to ``None`` (stream until the client
|
|
10
|
+
disconnects).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, Query
|
|
21
|
+
from sse_starlette.sse import EventSourceResponse
|
|
22
|
+
|
|
23
|
+
from brainpalace_dashboard.services.instances import InstanceService
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/dashboard/api", tags=["events"])
|
|
26
|
+
service = InstanceService()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _instance_events(
|
|
30
|
+
poll_s: float, max_ticks: int | None
|
|
31
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
32
|
+
"""Yield one ``instances`` SSE event per tick until ``max_ticks`` (if set)."""
|
|
33
|
+
ticks = 0
|
|
34
|
+
while max_ticks is None or ticks < max_ticks:
|
|
35
|
+
try:
|
|
36
|
+
data = service.list()
|
|
37
|
+
except Exception: # never let a transient listing error kill the stream
|
|
38
|
+
data = []
|
|
39
|
+
yield {"event": "instances", "data": json.dumps(data)}
|
|
40
|
+
ticks += 1
|
|
41
|
+
if max_ticks is not None and ticks >= max_ticks:
|
|
42
|
+
break
|
|
43
|
+
await asyncio.sleep(poll_s)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/events")
|
|
47
|
+
async def events(
|
|
48
|
+
poll_s: float = Query(5.0, ge=0.0),
|
|
49
|
+
max_ticks: int | None = Query(None, ge=1),
|
|
50
|
+
) -> EventSourceResponse:
|
|
51
|
+
"""Live SSE stream of the fleet ``instances`` list."""
|
|
52
|
+
return EventSourceResponse(_instance_events(poll_s, max_ticks))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Instance lifecycle endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from brainpalace_dashboard.services.instances import InstanceNotFound, InstanceService
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/dashboard/api/instances", tags=["instances"])
|
|
13
|
+
service = InstanceService()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegisterBody(BaseModel):
|
|
17
|
+
path: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("")
|
|
21
|
+
def list_instances() -> list[dict[str, Any]]:
|
|
22
|
+
return service.list()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("/register")
|
|
26
|
+
def register_instance(body: RegisterBody) -> dict[str, Any]:
|
|
27
|
+
"""Add an existing project dir to the dashboard list."""
|
|
28
|
+
return service.register(body.path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/{id_}")
|
|
32
|
+
def get_instance(id_: str) -> dict[str, Any]:
|
|
33
|
+
for row in service.list():
|
|
34
|
+
if row["id"] == id_:
|
|
35
|
+
return row
|
|
36
|
+
raise HTTPException(status_code=404, detail="instance not found")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post("/{id_}/start")
|
|
40
|
+
def start_instance(id_: str) -> dict[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
return service.start(id_)
|
|
43
|
+
except InstanceNotFound as exc:
|
|
44
|
+
raise HTTPException(status_code=404, detail="instance not found") from exc
|
|
45
|
+
except RuntimeError as exc:
|
|
46
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post("/{id_}/stop")
|
|
50
|
+
def stop_instance(id_: str, force: bool = Query(False)) -> dict[str, Any]:
|
|
51
|
+
try:
|
|
52
|
+
return service.stop(id_, force=force)
|
|
53
|
+
except InstanceNotFound as exc:
|
|
54
|
+
raise HTTPException(status_code=404, detail="instance not found") from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/{id_}/restart")
|
|
58
|
+
def restart_instance(id_: str) -> dict[str, Any]:
|
|
59
|
+
try:
|
|
60
|
+
return service.restart(id_)
|
|
61
|
+
except InstanceNotFound as exc:
|
|
62
|
+
raise HTTPException(status_code=404, detail="instance not found") from exc
|
|
63
|
+
except RuntimeError as exc:
|
|
64
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.delete("/{id_}")
|
|
68
|
+
def forget_instance(id_: str) -> dict[str, Any]:
|
|
69
|
+
"""Remove a project from the dashboard list. Does not delete anything on disk."""
|
|
70
|
+
return service.forget(id_)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Control-plane Queries routes — history list/detail + live replay.
|
|
2
|
+
|
|
3
|
+
Thin proxies onto the project server's ``/query/history[...]`` reads and a
|
|
4
|
+
``/query/`` replay. Every upstream/transport failure is normalized to
|
|
5
|
+
``{error, detail, upstream_status}`` (never a blank 500), matching the pattern
|
|
6
|
+
in ``routes_data.py``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Body
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
|
|
16
|
+
from brainpalace_dashboard.services.proxy import ProxyService, UpstreamError
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/dashboard/api/instances/{id_}/queries", tags=["queries"])
|
|
19
|
+
proxy = ProxyService()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _call(
|
|
23
|
+
id_: str,
|
|
24
|
+
method: str,
|
|
25
|
+
path: str,
|
|
26
|
+
json: Any | None = None,
|
|
27
|
+
params: dict[str, Any] | None = None,
|
|
28
|
+
) -> Any:
|
|
29
|
+
try:
|
|
30
|
+
return await proxy.request(id_, method, path, json=json, params=params)
|
|
31
|
+
except UpstreamError as e:
|
|
32
|
+
return JSONResponse(
|
|
33
|
+
status_code=e.upstream_status,
|
|
34
|
+
content={
|
|
35
|
+
"error": "upstream",
|
|
36
|
+
"detail": e.detail,
|
|
37
|
+
"upstream_status": e.upstream_status,
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("")
|
|
43
|
+
async def history(
|
|
44
|
+
id_: str,
|
|
45
|
+
since: float | None = None,
|
|
46
|
+
mode: str | None = None,
|
|
47
|
+
contains: str | None = None,
|
|
48
|
+
limit: int = 100,
|
|
49
|
+
offset: int = 0,
|
|
50
|
+
) -> Any:
|
|
51
|
+
params = {
|
|
52
|
+
k: v
|
|
53
|
+
for k, v in {
|
|
54
|
+
"since": since,
|
|
55
|
+
"mode": mode,
|
|
56
|
+
"contains": contains,
|
|
57
|
+
"limit": limit,
|
|
58
|
+
"offset": offset,
|
|
59
|
+
}.items()
|
|
60
|
+
if v is not None
|
|
61
|
+
}
|
|
62
|
+
return await _call(id_, "GET", "/query/history", params=params)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get("/{qid}")
|
|
66
|
+
async def detail(id_: str, qid: str) -> Any:
|
|
67
|
+
return await _call(id_, "GET", f"/query/history/{qid}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/replay")
|
|
71
|
+
async def replay(id_: str, body: Annotated[dict[str, Any], Body(...)]) -> Any:
|
|
72
|
+
payload: dict[str, Any] = {
|
|
73
|
+
"query": body["query"],
|
|
74
|
+
"mode": body.get("mode", "hybrid"),
|
|
75
|
+
"top_k": body.get("top_k", 5),
|
|
76
|
+
}
|
|
77
|
+
if "alpha" in body:
|
|
78
|
+
payload["alpha"] = body["alpha"]
|
|
79
|
+
return await _call(id_, "POST", "/query/", json=payload)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Control-plane (dashboard "server") settings — distinct from per-instance config.
|
|
2
|
+
|
|
3
|
+
The dashboard's own settings (`dashboard:` block in the XDG config.yaml:
|
|
4
|
+
host/port/poll_s/token) are fleet-wide and govern the control-plane process
|
|
5
|
+
itself, not any single project server. The per-instance Config tab edits each
|
|
6
|
+
project's `config.yaml`; this surface edits the dashboard.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from brainpalace_dashboard import __version__
|
|
18
|
+
from brainpalace_dashboard.config import (
|
|
19
|
+
TOKEN_MASK,
|
|
20
|
+
DashboardConfigError,
|
|
21
|
+
load_dashboard_config,
|
|
22
|
+
save_dashboard_config,
|
|
23
|
+
)
|
|
24
|
+
from brainpalace_dashboard.server import read_dashboard_runtime
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/dashboard/api/settings", tags=["settings"])
|
|
27
|
+
|
|
28
|
+
# Fields that only take effect when the dashboard process is restarted.
|
|
29
|
+
RESTART_FIELDS = {"host", "port", "token"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("")
|
|
33
|
+
def get_settings() -> dict[str, Any]:
|
|
34
|
+
cfg = load_dashboard_config()
|
|
35
|
+
runtime = read_dashboard_runtime() or {}
|
|
36
|
+
return {
|
|
37
|
+
"host": cfg.host,
|
|
38
|
+
"port": cfg.port,
|
|
39
|
+
"poll_s": cfg.poll_s,
|
|
40
|
+
# Never expose the real token; the SPA only learns whether one is set and
|
|
41
|
+
# echoes back the mask to keep it unchanged.
|
|
42
|
+
"token_set": cfg.token is not None,
|
|
43
|
+
"token": TOKEN_MASK if cfg.token is not None else "",
|
|
44
|
+
"version": __version__,
|
|
45
|
+
"runtime": {
|
|
46
|
+
"running": bool(runtime.get("pid")),
|
|
47
|
+
"port": runtime.get("port"),
|
|
48
|
+
"base_url": runtime.get("base_url"),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SettingsPatch(BaseModel):
|
|
54
|
+
host: str | None = None
|
|
55
|
+
port: int | None = None
|
|
56
|
+
poll_s: int | None = None
|
|
57
|
+
token: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.patch("", response_model=None)
|
|
61
|
+
def patch_settings(body: SettingsPatch) -> dict[str, Any] | JSONResponse:
|
|
62
|
+
current = load_dashboard_config()
|
|
63
|
+
values = body.model_dump(exclude_unset=True)
|
|
64
|
+
|
|
65
|
+
# Which submitted fields actually change a restart-sensitive setting?
|
|
66
|
+
restart_required: list[str] = []
|
|
67
|
+
if "host" in values and values["host"] != current.host:
|
|
68
|
+
restart_required.append("host")
|
|
69
|
+
if "port" in values and values["port"] != current.port:
|
|
70
|
+
restart_required.append("port")
|
|
71
|
+
if "token" in values and values["token"] != TOKEN_MASK:
|
|
72
|
+
new = values["token"] or None
|
|
73
|
+
if new != current.token:
|
|
74
|
+
restart_required.append("token")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
save_dashboard_config(values)
|
|
78
|
+
except DashboardConfigError as e:
|
|
79
|
+
return JSONResponse(status_code=422, content={"errors": e.errors})
|
|
80
|
+
return {"ok": True, "restart_required": restart_required}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""FastAPI application factory for the control-plane dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, Request
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
15
|
+
from starlette.responses import Response
|
|
16
|
+
|
|
17
|
+
from brainpalace_dashboard import __version__
|
|
18
|
+
from brainpalace_dashboard.api import (
|
|
19
|
+
routes_config,
|
|
20
|
+
routes_data,
|
|
21
|
+
routes_events,
|
|
22
|
+
routes_instances,
|
|
23
|
+
routes_queries,
|
|
24
|
+
routes_settings,
|
|
25
|
+
)
|
|
26
|
+
from brainpalace_dashboard.config import load_dashboard_config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _static_dir() -> Path:
|
|
30
|
+
"""Directory holding the built SPA (``index.html`` + ``assets/``).
|
|
31
|
+
|
|
32
|
+
Overridable via ``BRAINPALACE_DASHBOARD_STATIC`` (used by tests and by
|
|
33
|
+
deployments that ship the build separately). Defaults to the package-local
|
|
34
|
+
``static/`` directory populated by ``npm run build``.
|
|
35
|
+
"""
|
|
36
|
+
override = os.environ.get("BRAINPALACE_DASHBOARD_STATIC")
|
|
37
|
+
if override:
|
|
38
|
+
return Path(override)
|
|
39
|
+
return Path(__file__).parent / "static"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mount_spa(app: FastAPI) -> None:
|
|
43
|
+
"""Serve the built SPA under ``/dashboard/`` with client-side-routing
|
|
44
|
+
fallback.
|
|
45
|
+
|
|
46
|
+
Registered AFTER the API routers so that the ``/dashboard/{path:path}``
|
|
47
|
+
catch-all never shadows ``/dashboard/api/...`` endpoints. If no build is
|
|
48
|
+
present (e.g. a source checkout without ``npm run build``) this is a no-op,
|
|
49
|
+
leaving the API fully functional.
|
|
50
|
+
"""
|
|
51
|
+
static = _static_dir()
|
|
52
|
+
if not (static / "index.html").exists():
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
assets = static / "assets"
|
|
56
|
+
if assets.is_dir():
|
|
57
|
+
app.mount(
|
|
58
|
+
"/dashboard/assets",
|
|
59
|
+
StaticFiles(directory=assets),
|
|
60
|
+
name="dashboard-assets",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@app.get("/dashboard/{path:path}", include_in_schema=False)
|
|
64
|
+
def spa(path: str) -> FileResponse:
|
|
65
|
+
candidate = (static / path).resolve()
|
|
66
|
+
# Guard against path traversal escaping the static root.
|
|
67
|
+
if path and static.resolve() in candidate.parents and candidate.is_file():
|
|
68
|
+
return FileResponse(candidate)
|
|
69
|
+
return FileResponse(static / "index.html")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: API paths exempt from the bearer-token guard even when a token is set.
|
|
73
|
+
_AUTH_EXEMPT_PATHS = frozenset({"/dashboard/api/health"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_token() -> str | None:
|
|
77
|
+
"""Resolve the configured dashboard token.
|
|
78
|
+
|
|
79
|
+
Precedence: the ``BRAINPALACE_DASHBOARD_TOKEN`` env var, then the
|
|
80
|
+
``dashboard.token`` config value. Returns ``None`` when no token is set
|
|
81
|
+
(meaning the dashboard is unguarded — the default for localhost use).
|
|
82
|
+
"""
|
|
83
|
+
env_token = os.environ.get("BRAINPALACE_DASHBOARD_TOKEN")
|
|
84
|
+
if env_token:
|
|
85
|
+
return env_token
|
|
86
|
+
return load_dashboard_config().token
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BearerTokenMiddleware(BaseHTTPMiddleware):
|
|
90
|
+
"""Guard ``/dashboard/api/**`` with a static bearer token when configured.
|
|
91
|
+
|
|
92
|
+
Only API paths are guarded; static/SPA routes and ``/dashboard/api/health``
|
|
93
|
+
are always open. When no token is configured the middleware is a pass-through
|
|
94
|
+
(enabling the guard is intended for shared machines, not localhost).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, app: Any, token: str) -> None:
|
|
98
|
+
super().__init__(app)
|
|
99
|
+
self._token = token
|
|
100
|
+
|
|
101
|
+
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
102
|
+
path = request.url.path
|
|
103
|
+
if path.startswith("/dashboard/api/") and path not in _AUTH_EXEMPT_PATHS:
|
|
104
|
+
header = request.headers.get("Authorization", "")
|
|
105
|
+
expected = f"Bearer {self._token}"
|
|
106
|
+
if header != expected:
|
|
107
|
+
return JSONResponse(
|
|
108
|
+
{"detail": "Unauthorized"},
|
|
109
|
+
status_code=401,
|
|
110
|
+
)
|
|
111
|
+
response: Response = await call_next(request)
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@asynccontextmanager
|
|
116
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
117
|
+
yield
|
|
118
|
+
# Close the shared proxy httpx clients on shutdown.
|
|
119
|
+
await routes_data.proxy.aclose()
|
|
120
|
+
await routes_queries.proxy.aclose()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_app() -> FastAPI:
|
|
124
|
+
app = FastAPI(title="BrainPalace Dashboard", version=__version__, lifespan=lifespan)
|
|
125
|
+
|
|
126
|
+
token = _resolve_token()
|
|
127
|
+
if token:
|
|
128
|
+
app.add_middleware(BearerTokenMiddleware, token=token)
|
|
129
|
+
|
|
130
|
+
@app.get("/dashboard/api/health")
|
|
131
|
+
def health() -> dict[str, str]:
|
|
132
|
+
return {"status": "ok", "version": __version__}
|
|
133
|
+
|
|
134
|
+
app.include_router(routes_instances.router)
|
|
135
|
+
app.include_router(routes_config.router)
|
|
136
|
+
app.include_router(routes_data.router)
|
|
137
|
+
app.include_router(routes_queries.router)
|
|
138
|
+
app.include_router(routes_events.router)
|
|
139
|
+
app.include_router(routes_settings.router)
|
|
140
|
+
|
|
141
|
+
# SPA catch-all is registered last so API routers win.
|
|
142
|
+
_mount_spa(app)
|
|
143
|
+
|
|
144
|
+
return app
|