From 29c00c0cdfa82b5edf5c3b854f5d296d29e19ca8 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Tue, 3 Mar 2026 20:11:49 -0800 Subject: [PATCH] Extract shared frontend design system into turnstone/shared_static/ (#17) * Extract shared frontend design system into turnstone/shared_static/ The server UI and console UI had ~60% CSS overlap and significant JS duplication. Extract shared assets into a new turnstone/shared_static/ package mounted at /shared/ in both servers: - base.css: design tokens, reset, typography, login/toast/kb overlays, dashboard table, state dots, health bar, scrollbar, reduced motion - auth.js: authFetch, login overlay with focus trap, logout (hooks for page-specific post-login/logout callbacks) - theme.js: dark/light toggle with system preference detection - toast.js: notification queue with configurable timeout - utils.js: escapeHtml, formatTokens, ctxClass, formatUptime, formatCount - kb.js: keyboard shortcuts overlay with configurable content, focus management, and focus restore on dismiss Console proxy updated: JS shim injection moved from proxy_static (app.js prepend) to proxy_index (inline \n' + '' + ) + prefix = "/node/test-node" + rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/') + rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/') + rewritten = rewritten.replace('href="/shared/', f'href="{prefix}/shared/') + rewritten = rewritten.replace('src="/shared/', f'src="{prefix}/shared/') + assert "/node/test-node/shared/base.css" in rewritten + assert "/node/test-node/shared/utils.js" in rewritten + assert "/node/test-node/static/style.css" in rewritten + assert "/node/test-node/static/app.js" in rewritten + assert 'href="/shared/' not in rewritten + assert 'src="/shared/' not in rewritten + + def test_proxy_shim_injected_in_html(self): + """Verify shim is injected as inline script in proxied HTML.""" + import json + + from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM + + sample_html = "
content
" + prefix = "/node/test-node" + banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node") + shim = ( + "" + ) + result = sample_html.replace("", "" + banner + shim, 1) + assert "" + ) + page = page.replace("", "" + banner + shim, 1) return HTMLResponse(page) except httpx.HTTPError as exc: log.debug("Proxy index error for %s: %s", node_id, exc) @@ -438,23 +446,39 @@ async def proxy_static(request: Request) -> Response: return JSONResponse({"error": "Node not found"}, status_code=404) client: httpx.AsyncClient = request.app.state.proxy_client - safe_node = urllib.parse.quote(node_id, safe="") - prefix = f"/node/{safe_node}" try: resp = await client.get(f"{server_url}/static/{path}") - content_type = resp.headers.get("content-type", "application/octet-stream") - body = resp.content - # Inject proxy shim into app.js - if path == "app.js": - shim = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)) - body = shim.encode("utf-8") + body - content_type = "application/javascript; charset=utf-8" - return Response(content=body, status_code=resp.status_code, media_type=content_type) + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type", "application/octet-stream"), + ) except httpx.HTTPError as exc: log.debug("Proxy static error for %s/%s: %s", node_id, path, exc) return JSONResponse({"error": "Node unreachable"}, status_code=502) +async def proxy_shared_static(request: Request) -> Response: + """GET /node/{node_id}/shared/{path} — proxy shared static files.""" + node_id = request.path_params["node_id"] + path = request.path_params["path"] + server_url = _get_server_url(request, node_id) + if not server_url: + return JSONResponse({"error": "Node not found"}, status_code=404) + + client: httpx.AsyncClient = request.app.state.proxy_client + try: + resp = await client.get(f"{server_url}/shared/{path}") + return Response( + content=resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type", "application/octet-stream"), + ) + except httpx.HTTPError as exc: + log.debug("Proxy shared static error for %s/%s: %s", node_id, path, exc) + return JSONResponse({"error": "Node unreachable"}, status_code=502) + + async def proxy_api(request: Request) -> Response: """Proxy API requests to target node. Detects SSE vs regular.""" node_id = request.path_params["node_id"] @@ -620,9 +644,11 @@ def create_app( Route("/api/auth/login", auth_login, methods=["POST"]), Route("/api/auth/logout", auth_logout, methods=["POST"]), Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), + Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"), # Proxy routes — serve server UI through console port Route("/node/{node_id}/", proxy_index), Route("/node/{node_id}/static/{path:path}", proxy_static), + Route("/node/{node_id}/shared/{path:path}", proxy_shared_static), Route("/node/{node_id}/api/{path:path}", proxy_api, methods=["GET", "POST"]), Route("/node/{node_id}/{path:path}", proxy_non_api), ], diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index ec566888..3f5a03a4 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1,49 +1,28 @@ -// --- Theme --- -function toggleTheme() { - var next = document.documentElement.dataset.theme === "light" ? "" : "light"; - document.documentElement.dataset.theme = next; - localStorage.setItem("turnstone-theme", next || "dark"); +// --- Shared hooks --- +window.onLoginSuccess = function () { + connectSSE(); + if (currentView === "overview") loadOverview(); + else if (currentView === "node") drillDownToNode(currentNodeId); + else if (currentView === "filtered") loadFilteredWorkstreams(); +}; +window.onLogout = function () { + if (evtSource) { + evtSource.close(); + evtSource = null; + } +}; +window.onThemeChange = function (next) { var btn = document.getElementById("theme-toggle"); if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E"; -} -(function initTheme() { - var stored = localStorage.getItem("turnstone-theme"); - if (stored === "light") { - document.documentElement.dataset.theme = "light"; - } else if ( - !stored && - window.matchMedia && - window.matchMedia("(prefers-color-scheme: light)").matches - ) { - document.documentElement.dataset.theme = "light"; - } +}; +// Set initial theme button text +(function () { var btn = document.getElementById("theme-toggle"); if (btn) btn.textContent = document.documentElement.dataset.theme === "light" ? "\u2600" : "\u263E"; })(); -/* Auth-aware fetch — shows login overlay on 401, retries on 429 */ -async function authFetch(url, opts) { - var maxRetries = 2; - for (var attempt = 0; attempt <= maxRetries; attempt++) { - var r = await fetch(url, opts); - if (r.status === 401) { - showLogin(); - throw new Error("auth"); - } - if (r.status === 429 && attempt < maxRetries) { - var retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10); - showToast("Rate limited \u2014 retrying in " + retryAfter + "s"); - await new Promise(function (resolve) { - setTimeout(resolve, retryAfter * 1000); - }); - continue; - } - return r; - } -} - // --- State --- var currentView = "overview"; // "overview" | "node" | "filtered" var currentNodeId = null; @@ -65,68 +44,6 @@ var STATE_DISPLAY = { }; var STATE_ORDER = ["running", "thinking", "attention", "error", "idle"]; -// --- Helpers --- -function escapeHtml(s) { - var el = document.createElement("span"); - el.textContent = s; - return el.innerHTML; -} -function formatTokens(n) { - if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; - if (n >= 1000) return (n / 1000).toFixed(1) + "k"; - return String(n || 0); -} -function ctxClass(ratio) { - if (ratio <= 0) return "ctx-idle"; - var pct = ratio * 100; - if (pct < 30) return "ctx-low"; - if (pct < 50) return "ctx-mid"; - if (pct < 80) return "ctx-high"; - return "ctx-danger"; -} -function formatUptime(seconds) { - if (!seconds) return ""; - if (seconds < 60) return seconds + "s"; - var min = Math.floor(seconds / 60); - if (min < 60) return min + "m"; - var hr = Math.floor(min / 60); - return hr + "h " + (min % 60) + "m"; -} -function formatCount(n) { - if (n >= 1000) return (n / 1000).toFixed(1) + "k"; - return String(n); -} - -// --- Toast --- -var _toastQueue = []; -var _toastTimer = null; -var _toastShowing = false; -function showToast(message) { - var el = document.getElementById("toast"); - if (!el) return; - if (_toastShowing) { - _toastQueue.push(message); - return; - } - _displayToast(el, message); -} -function _displayToast(el, message) { - el.textContent = message; - el.classList.add("visible"); - _toastShowing = true; - if (_toastTimer) clearTimeout(_toastTimer); - _toastTimer = setTimeout(function () { - el.classList.remove("visible"); - _toastShowing = false; - _toastTimer = null; - if (_toastQueue.length) { - setTimeout(function () { - _displayToast(el, _toastQueue.shift()); - }, 300); - } - }, 4000); -} - // --- SSE Connection --- function connectSSE() { if (evtSource) { @@ -990,163 +907,6 @@ function renderWsTable(container, wsList) { }); } -// --- Login Overlay --- -var _loginTrapHandler = null; -var _loginBusy = false; - -function initLogin() { - var overlay = document.createElement("div"); - overlay.id = "login-overlay"; - overlay.style.display = "none"; - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-labelledby", "login-title"); - overlay.innerHTML = - '
' + - '

turnstone console

' + - '' + - '' + - '' + - '' + - "
"; - document.body.appendChild(overlay); - document.getElementById("login-submit").onclick = submitLogin; - document - .getElementById("login-token") - .addEventListener("keydown", function (e) { - if (e.key === "Enter") submitLogin(); - if (e.key === "Escape") { - var errEl = document.getElementById("login-error"); - if (errEl && errEl.style.display !== "none") { - errEl.style.display = "none"; - errEl.textContent = ""; - } - } - }); -} - -function showLogin() { - var overlay = document.getElementById("login-overlay"); - if (!overlay) return; - overlay.style.display = "flex"; - document.body.style.overflow = "hidden"; - var logoutBtn = document.getElementById("logout-btn"); - if (logoutBtn) logoutBtn.style.display = "none"; - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.style.display = "none"; - errEl.textContent = ""; - } - setTimeout(function () { - var inp = document.getElementById("login-token"); - if (inp) { - inp.value = ""; - inp.focus(); - } - }, 50); - // Focus trap - if (_loginTrapHandler) - document.removeEventListener("keydown", _loginTrapHandler); - _loginTrapHandler = function (e) { - if (e.key === "Tab") { - var box = document.getElementById("login-box"); - var focusable = box.querySelectorAll("input, button"); - var first = focusable[0]; - var last = focusable[focusable.length - 1]; - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last.focus(); - } - } else { - if (document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - } - }; - document.addEventListener("keydown", _loginTrapHandler); -} - -function hideLogin() { - var overlay = document.getElementById("login-overlay"); - if (overlay) overlay.style.display = "none"; - document.body.style.overflow = ""; - if (_loginTrapHandler) { - document.removeEventListener("keydown", _loginTrapHandler); - _loginTrapHandler = null; - } -} - -function submitLogin() { - if (_loginBusy) return; - var token = (document.getElementById("login-token").value || "").trim(); - if (!token) { - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.textContent = "Token is required"; - errEl.style.display = "block"; - } - document.getElementById("login-token").focus(); - return; - } - - _loginBusy = true; - var btn = document.getElementById("login-submit"); - var inp = document.getElementById("login-token"); - btn.disabled = true; - btn.textContent = "Signing in\u2026"; - inp.disabled = true; - - fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: token }), - }) - .then(function (r) { - if (r.status === 401 || r.status === 403) throw new Error("invalid"); - if (!r.ok) throw new Error("server"); - return r.json(); - }) - .then(function () { - _loginBusy = false; - btn.disabled = false; - btn.textContent = "Sign in"; - inp.disabled = false; - hideLogin(); - document.getElementById("logout-btn").style.display = ""; - connectSSE(); - if (currentView === "overview") loadOverview(); - else if (currentView === "node") drillDownToNode(currentNodeId); - else if (currentView === "filtered") loadFilteredWorkstreams(); - }) - .catch(function (err) { - _loginBusy = false; - btn.disabled = false; - btn.textContent = "Sign in"; - inp.disabled = false; - var errEl = document.getElementById("login-error"); - if (errEl) { - errEl.textContent = - err.message === "invalid" - ? "Invalid token" - : "Connection failed \u2014 try again"; - errEl.style.display = "block"; - } - }); -} - -function logout() { - fetch("/api/auth/logout", { method: "POST" }).then(function () { - if (evtSource) { - evtSource.close(); - evtSource = null; - } - showLogin(); - }); -} - // --- Navigation --- window.addEventListener("popstate", function (e) { var overlay = document.getElementById("login-overlay"); @@ -1165,54 +925,6 @@ window.addEventListener("popstate", function (e) { } }); -// --- Keyboard shortcuts help --- -function showKbHelp() { - var existing = document.getElementById("kb-overlay"); - if (existing) { - existing.remove(); - } - var overlay = document.createElement("div"); - overlay.id = "kb-overlay"; - overlay.innerHTML = - '"; - overlay.onclick = function (e) { - if (e.target === overlay) hideKbHelp(); - }; - document.body.appendChild(overlay); - document.getElementById("kb-box").focus(); -} -function hideKbHelp() { - var el = document.getElementById("kb-overlay"); - if (el) el.remove(); -} -document.addEventListener("keydown", function (e) { - // Don't trigger when typing in inputs or when login overlay is open - if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; - var login = document.getElementById("login-overlay"); - if (login && login.style.display !== "none") return; - if (e.key === "?" && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - showKbHelp(); - } - if (e.key === "Escape") { - var kb = document.getElementById("kb-overlay"); - if (kb) { - e.preventDefault(); - hideKbHelp(); - } - } -}); - // --- New Workstream Modal --- var _newWsTrapHandler = null; diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 1c5e5c38..85559fca 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -7,6 +7,7 @@ + @@ -80,6 +81,26 @@
+ + + + + +