Files
app/backend/fluksio/spa.py
T
stroblme b8a15e4133 Code editor: CodeMirror 6 replaces Monaco
Node source (Python) and the JSON dialog share one CodeMirror editor with
find and replace, autocomplete, folding, indent guides, comment toggle and a
JSON lint, themed from the CSS tokens so light and dark follow the .dark class
without a second palette. Programmatic value changes no longer fire onChange,
blur ignores focus moving into the find bar, readOnly keeps selection and copy.

The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco,
which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on
slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in
1.5 s instead of 6.7 s. Typing without a key delay keeps every character.

monaco-editor, @monaco-editor/react and the dompurify override are removed.
Specs: typed code reaches the node source, a JSON setting saves only text that
parses. Docs name Tab and Ctrl-M.

Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
2026-10-28 17:32:49 +01:00

89 lines
3.6 KiB
Python

"""The dashboard, served by the API on its own origin.
One process and one port: the built SPA comes off disk, and every path the API
does not own answers with ``index.html``, so a client-side route survives a
reload. Same origin also means the dashboard calls the API as a path, needs no
CORS allowance and no second hostname, which is what lets the whole instance
sit behind a single published port.
"""
from __future__ import annotations
import base64
import hashlib
import mimetypes
import re
from pathlib import Path
from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import FileResponse
from starlette.types import ASGIApp, Receive, Scope, Send
#: What the API answers itself. Everything else is the dashboard's.
API_PREFIXES = ("/api/", "/hooks/", "/mcp", "/.well-known/", "/docs", "/redoc")
# Older mime tables predate the manifest, and it would go out as a download.
mimetypes.add_type("application/manifest+json", ".webmanifest")
def content_security_policy(index_html: str) -> str:
"""The policy for the dashboard, admitting its inline scripts by hash.
The hash is taken from the file being served, so editing the theme script
in ``index.html`` cannot leave a stale one behind.
style-src 'unsafe-inline': CodeMirror, Radix's scroll lock and the toasts insert
<style> elements at runtime, which no hash can name. img/frame-src take any
http(s): a camera stream or website widget points wherever its dashboard
says.
"""
hashes = "".join(
f" 'sha256-{base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()}'"
for script in re.findall(r"<script>([\s\S]*?)</script>", index_html)
)
return (
f"default-src 'self'; script-src 'self'{hashes}; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; "
"media-src 'self' blob:; font-src 'self'; connect-src 'self'; "
"worker-src 'self'; frame-src http: https:; frame-ancestors 'self'; "
"object-src 'none'; base-uri 'self'; form-action 'self'"
)
class Dashboard:
"""Serve the built SPA for every GET the API does not own."""
def __init__(self, app: ASGIApp, dist: Path) -> None:
self.app = app
self.dist = dist.resolve()
self.index = self.dist / "index.html"
self.headers = {
"Content-Security-Policy": content_security_policy(self.index.read_text()),
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
path: str = scope.get("path", "")
if (
scope["type"] != "http"
or scope["method"] not in ("GET", "HEAD")
or path.startswith(API_PREFIXES)
):
await self.app(scope, receive, send)
return
file = (self.dist / path.lstrip("/")).resolve()
if file.is_file() and file.is_relative_to(self.dist) and file != self.index:
headers = dict(self.headers)
# Vite puts a content hash in every asset's name, so one never
# changes under its URL.
if path.startswith("/assets/"):
headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
# The one file that must not be cached: it names the current
# assets, so a stale copy pins the browser to the previous build.
file = self.index
headers = {**self.headers, "Cache-Control": "no-cache"}
response = FileResponse(file, headers=headers)
await GZipMiddleware(response, minimum_size=1024)(scope, receive, send)