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.
Files changed (25) hide show
  1. brainpalace_dashboard-26.6.26/PKG-INFO +21 -0
  2. brainpalace_dashboard-26.6.26/README.md +3 -0
  3. brainpalace_dashboard-26.6.26/brainpalace_dashboard/__init__.py +3 -0
  4. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/__init__.py +0 -0
  5. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_config.py +65 -0
  6. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_data.py +167 -0
  7. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_events.py +52 -0
  8. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_instances.py +70 -0
  9. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_queries.py +79 -0
  10. brainpalace_dashboard-26.6.26/brainpalace_dashboard/api/routes_settings.py +80 -0
  11. brainpalace_dashboard-26.6.26/brainpalace_dashboard/app.py +144 -0
  12. brainpalace_dashboard-26.6.26/brainpalace_dashboard/config.py +125 -0
  13. brainpalace_dashboard-26.6.26/brainpalace_dashboard/coverage_maps.py +111 -0
  14. brainpalace_dashboard-26.6.26/brainpalace_dashboard/server.py +299 -0
  15. brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/__init__.py +0 -0
  16. brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/capabilities.py +22 -0
  17. brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/config_svc.py +158 -0
  18. brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/instances.py +249 -0
  19. brainpalace_dashboard-26.6.26/brainpalace_dashboard/services/proxy.py +76 -0
  20. brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/assets/index--evOdt1x.css +1 -0
  21. brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/assets/index-BG8aGfSn.js +361 -0
  22. brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/favicon.svg +6 -0
  23. brainpalace_dashboard-26.6.26/brainpalace_dashboard/static/index.html +15 -0
  24. brainpalace_dashboard-26.6.26/brainpalace_dashboard/ui_schema.py +262 -0
  25. 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
+
@@ -0,0 +1,3 @@
1
+ # brainpalace-dashboard
2
+
3
+ Control-plane web dashboard for managing all BrainPalace project servers. See docs/DASHBOARD.md.
@@ -0,0 +1,3 @@
1
+ """BrainPalace control-plane dashboard."""
2
+
3
+ __version__ = "26.6.26"
@@ -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