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
89 lines
3.6 KiB
Python
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)
|