From 05fcb7f2f2fbcc2e703095d4a65042eb6836b5f5 Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Mon, 28 Sep 2026 16:28:36 -0400 Subject: [PATCH] feat: POST /log from the page + load state.json on page open - Browser can record entries (e.g. meter readings) into session.md/jsonl; broadcast to other open clients. Log feed renders as text, not HTML. - Page loads state.json on open instead of only localStorage, so a new device (or one that missed the WS push) shows current content. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/workbench/scaffold.html | 22 +++++++++++++++------- src/workbench/server.py | 12 ++++++++++++ tests/test_server.py | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/workbench/scaffold.html b/src/workbench/scaffold.html index 189795b..16b5bbb 100644 --- a/src/workbench/scaffold.html +++ b/src/workbench/scaffold.html @@ -114,18 +114,26 @@ function handleLog(entry) { const div = document.createElement('div'); div.className = 'log-entry'; const time = new Date().toLocaleTimeString(); - div.innerHTML = `${time}${entry}`; + const t = document.createElement('span'); + t.className = 'log-time'; + t.textContent = time; + div.append(t, entry); // text, not HTML — browsers can POST entries now const feed = document.getElementById('log-entries'); feed.appendChild(div); feed.scrollTop = feed.scrollHeight; } -try { - const saved = JSON.parse(localStorage.getItem('workbench-state')); - if (saved) handleState(saved); -} catch(e) {} - -connect(); +// state.json on disk is the source of truth; localStorage only covers the server being unreachable +fetch('state.json', {cache: 'no-store'}) + .then(r => r.json()) + .then(s => { if (Object.keys(s).length) handleState(s); else throw 0; }) + .catch(() => { + try { + const saved = JSON.parse(localStorage.getItem('workbench-state')); + if (saved) handleState(saved); + } catch(e) {} + }) + .finally(connect); diff --git a/src/workbench/server.py b/src/workbench/server.py index 9f33cf2..3d64549 100644 --- a/src/workbench/server.py +++ b/src/workbench/server.py @@ -109,7 +109,19 @@ class WorkbenchServer: return web.Response(status=404, text="Not found") return web.FileResponse(file_path) + async def log_handler(request): + # lets the page record readings, so they outlive the browser + body = await request.json() + entry = str(body.get("entry", "")).strip() + if not entry: + return web.json_response({"error": "entry required"}, status=400) + data = body.get("data") if isinstance(body.get("data"), dict) else None + append_log(request.app["project_name"], entry, data=data, workbench_dir=self.workbench_dir) + await self._broadcast_ws(request.app["project_name"], {"type": "log", "entry": entry}) + return web.json_response({"ok": True}) + app.router.add_get("/ws", ws_handler) + app.router.add_post("/log", log_handler) app.router.add_get("/{path:.*}", static_handler) app.router.add_get("/", static_handler) diff --git a/tests/test_server.py b/tests/test_server.py index caab25f..07920d8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -124,3 +124,19 @@ async def test_static_blocks_path_traversal(server, tmp_workbench): async with session.get(base + "/") as r: assert r.status == 200 await server.workbench_stop("p") + + +@pytest.mark.asyncio +async def test_browser_post_log_persists_and_broadcasts(server, tmp_workbench): + from workbench.project import read_log + session, base = await _live(server) + async with session: + ws = await session.ws_connect(base + "/ws") + async with session.post(base + "/log", json={"entry": "R303 3.31M", "data": {"part": "R303"}}) as r: + assert r.status == 200 + msg = await asyncio.wait_for(ws.receive_json(), 3) + assert msg == {"type": "log", "entry": "R303 3.31M"} + await ws.close() + entries = read_log("p", workbench_dir=tmp_workbench) + assert entries[-1]["entry"] == "R303 3.31M" and entries[-1]["part"] == "R303" + await server.workbench_stop("p")