From a8ef8ee5529a8f152a795ce6db1abe063858e809 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 27 May 2026 22:01:06 +0200 Subject: [PATCH] Split monitor UI into separate HTML and JS assets --- src/server.py | 6 +- src/ui.py | 511 +--------------------------------- src/ui_assets/monitoring.html | 82 ++++++ src/ui_assets/monitoring.js | 418 +++++++++++++++++++++++++++ tests/test_server_api.py | 3 + 5 files changed, 519 insertions(+), 501 deletions(-) create mode 100644 src/ui_assets/monitoring.html create mode 100644 src/ui_assets/monitoring.js diff --git a/src/server.py b/src/server.py index f26a8c7..1e7cde0 100644 --- a/src/server.py +++ b/src/server.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, Field from .config import AppConfig, load_app_config from .storage import HistoryDB from .task_manager import JobManager -from .ui import monitoring_page_html +from .ui import monitoring_js_path, monitoring_page_html class CreateJobRequest(BaseModel): @@ -387,6 +387,10 @@ def create_app(config: AppConfig | None = None) -> FastAPI: def ui_root() -> str: return monitoring_page_html(device_hostname=device_hostname) + @app.get("/ui/monitoring.js") + def ui_monitoring_js() -> FileResponse: + return FileResponse(str(monitoring_js_path()), media_type="application/javascript") + @app.websocket("/ws") async def ws_endpoint(websocket: WebSocket, token: str = Query(default="")) -> None: if not token or not secrets.compare_digest(token, app_config.screenjob_token): diff --git a/src/ui.py b/src/ui.py index f4c9939..60cff46 100644 --- a/src/ui.py +++ b/src/ui.py @@ -1,508 +1,19 @@ from __future__ import annotations from html import escape +from pathlib import Path + + +_UI_DIR = Path(__file__).resolve().parent / "ui_assets" +_HTML_TEMPLATE_PATH = _UI_DIR / "monitoring.html" +_JS_PATH = _UI_DIR / "monitoring.js" + def monitoring_page_html(device_hostname: str = "") -> str: host_suffix = f" ({escape(device_hostname)})" if device_hostname else "" - return """ - - - - - ScreenJob Monitor - - - -
-
-
-

ScreenJob Monitor__MONITOR_HOST__

-

Read-only monitoring for active and historical tasks.

-
-
- - -
-
+ html = _HTML_TEMPLATE_PATH.read_text(encoding="utf-8") + return html.replace("__MONITOR_HOST__", host_suffix) -
-
-
-
-

Jobs

- -
-
-
- -
-

Job Detail

-

-        

Latest Visual

-
- Latest visual update -
-
-

Replay

-
No replay loaded.
-
-
- - - - -
- -
-
- Replay frame - -
-
-
-
-
-

Live Events

- -
-
-
-
-
- - - - -""".replace("__MONITOR_HOST__", host_suffix) +def monitoring_js_path() -> Path: + return _JS_PATH diff --git a/src/ui_assets/monitoring.html b/src/ui_assets/monitoring.html new file mode 100644 index 0000000..ae0dec4 --- /dev/null +++ b/src/ui_assets/monitoring.html @@ -0,0 +1,82 @@ + + + + + + ScreenJob Monitor + + + +
+
+
+

ScreenJob Monitor__MONITOR_HOST__

+

Read-only monitoring for active and historical tasks.

+
+
+ + +
+
+ +
+ +
+
+
+

Jobs

+ +
+
+
+ +
+

Job Detail

+

+        

Latest Visual

+
+ Latest visual update +
+
+

Replay

+
No replay loaded.
+
+
+ + + + +
+ +
+
+ Replay frame + +
+
+
+
+
+

Live Events

+ +
+
+
+
+
+ + + + diff --git a/src/ui_assets/monitoring.js b/src/ui_assets/monitoring.js new file mode 100644 index 0000000..1df1af3 --- /dev/null +++ b/src/ui_assets/monitoring.js @@ -0,0 +1,418 @@ +const tokenInput = document.getElementById("tokenInput"); +const saveTokenBtn = document.getElementById("saveTokenBtn"); +const refreshBtn = document.getElementById("refreshBtn"); +const jobListEl = document.getElementById("jobList"); +const jobDetailEl = document.getElementById("jobDetail"); +const eventsEl = document.getElementById("events"); +const statsEl = document.getElementById("stats"); +const latestVisualEl = document.getElementById("latestVisual"); +const eventsViewToggle = document.getElementById("eventsViewToggle"); +const replayVisualEl = document.getElementById("replayVisual"); +const replayOverlayEl = document.getElementById("replayOverlay"); +const replayFrameMetaEl = document.getElementById("replayFrameMeta"); +const replayFrameEventsEl = document.getElementById("replayFrameEvents"); +const replayStatusEl = document.getElementById("replayStatus"); +const replayPlayBtn = document.getElementById("replayPlayBtn"); +const replayPrevBtn = document.getElementById("replayPrevBtn"); +const replayNextBtn = document.getElementById("replayNextBtn"); +const replaySpeedEl = document.getElementById("replaySpeed"); +const replaySeekEl = document.getElementById("replaySeek"); + +const state = { + token: localStorage.getItem("screenjob_token") || "", + jobs: [], + selectedJobId: null, + ws: null, + wsReconnectTimer: null, + eventsViewMode: localStorage.getItem("screenjob_events_view_mode") === "beautiful" ? "beautiful" : "raw", + replay: { + frames: [], + trailingEvents: [], + frameIndex: 0, + isPlaying: false, + speed: 1, + timer: null + } +}; +const manuallyClosedSockets = new WeakSet(); +tokenInput.value = state.token; + +function authHeaders() { + return { "Authorization": "Bearer " + state.token }; +} + +async function api(path, opts = {}) { + if (!state.token) throw new Error("Token required"); + const headers = Object.assign({}, authHeaders(), opts.headers || {}); + const response = await fetch(path, Object.assign({}, opts, { headers })); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +function renderStats(stats) { + const cards = [ + ["Total Jobs", stats.total_jobs || 0], + ["Running", stats.running_jobs || 0], + ["Completed", stats.completed_jobs || 0], + ["Failed", stats.failed_jobs || 0], + ["Cancelled", stats.cancelled_jobs || 0], + ["Total Cost (USD)", Number(stats.total_estimated_cost || 0).toFixed(4)] + ]; + statsEl.innerHTML = cards.map(([name, val]) => ` +
+
${name}
+
${val}
+
+ `).join(""); +} + +function renderJobs() { + jobListEl.innerHTML = state.jobs.map((job) => { + const active = job.job_id === state.selectedJobId; + return ` + + `; + }).join(""); + for (const btn of jobListEl.querySelectorAll("button[data-job-id]")) { + btn.addEventListener("click", () => { + state.selectedJobId = btn.getAttribute("data-job-id"); + renderJobs(); + refreshJobDetail(); + }); + } +} + +function pushEventLine(obj) { + if (!obj || !obj.job_id || !obj.event_type) return; + const line = document.createElement("div"); + const ts = obj.ts || "-"; + const step = (obj.step ?? "-"); + if (state.eventsViewMode === "raw") { + line.className = "border-b border-slate-800 pb-1"; + line.textContent = `[${ts}] ${obj.job_id} step=${step} ${obj.event_type} ${JSON.stringify(obj.payload || {})}`; + } else { + const typeColors = { + info: "bg-sky-900/50 text-sky-200 border border-sky-800", + warning: "bg-amber-900/40 text-amber-200 border border-amber-800", + error: "bg-rose-900/40 text-rose-200 border border-rose-800", + visual_update: "bg-emerald-900/40 text-emerald-200 border border-emerald-800", + tool_call: "bg-violet-900/40 text-violet-200 border border-violet-800", + tool_result: "bg-indigo-900/40 text-indigo-200 border border-indigo-800" + }; + const dt = new Date(ts); + const tsText = Number.isNaN(dt.getTime()) ? ts : dt.toLocaleString(); + const payload = obj.payload || {}; + + line.className = "rounded-lg border border-slate-800 bg-slate-900/80 p-2 space-y-2"; + const header = document.createElement("div"); + header.className = "flex flex-wrap items-center gap-2"; + + const typePill = document.createElement("span"); + typePill.className = `px-2 py-0.5 rounded text-[10px] font-semibold ${typeColors[obj.event_type] || "bg-slate-800 text-slate-200 border border-slate-700"}`; + typePill.textContent = obj.event_type; + + const stepPill = document.createElement("span"); + stepPill.className = "px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700"; + stepPill.textContent = `step ${step}`; + + const tsSpan = document.createElement("span"); + tsSpan.className = "text-[10px] text-slate-400"; + tsSpan.textContent = tsText; + + header.appendChild(typePill); + header.appendChild(stepPill); + header.appendChild(tsSpan); + + const jobLine = document.createElement("div"); + jobLine.className = "text-[11px] text-slate-300 font-medium"; + jobLine.textContent = obj.job_id; + + const body = document.createElement("pre"); + body.className = "bg-slate-950 border border-slate-800 rounded p-2 text-[11px] text-slate-200 overflow-auto"; + body.textContent = JSON.stringify(payload, null, 2); + + line.appendChild(header); + line.appendChild(jobLine); + line.appendChild(body); + } + eventsEl.prepend(line); + while (eventsEl.childNodes.length > 400) { + eventsEl.removeChild(eventsEl.lastChild); + } +} + +function clearReplayTimer() { + if (state.replay.timer) { + clearTimeout(state.replay.timer); + state.replay.timer = null; + } +} + +function stopReplay() { + state.replay.isPlaying = false; + clearReplayTimer(); + replayPlayBtn.textContent = "Play"; +} + +function replayImageSrc(path) { + const q = encodeURIComponent(path || ""); + return `/api/jobs/${state.selectedJobId}/artifact?path=${q}&token=${encodeURIComponent(state.token)}`; +} + +function renderReplayOverlay(frame) { + replayOverlayEl.innerHTML = ""; + const size = frame && frame.screen_size; + if (!frame || !frame.is_fullscreen || !size || !size.width || !size.height) { + replayOverlayEl.removeAttribute("viewBox"); + return; + } + + replayOverlayEl.setAttribute("viewBox", `0 0 ${size.width} ${size.height}`); + const overlayEvents = Array.isArray(frame.overlays) ? frame.overlays : []; + const points = overlayEvents.filter((ev) => ev && ev.kind === "tool_result" && ev.tool === "click" && ev.click); + for (const ev of points) { + const x = Number(ev.click.x); + const y = Number(ev.click.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + + const halo = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + halo.setAttribute("cx", String(x)); + halo.setAttribute("cy", String(y)); + halo.setAttribute("r", "14"); + halo.setAttribute("fill", "rgba(14, 165, 233, 0.22)"); + halo.setAttribute("stroke", "#38bdf8"); + halo.setAttribute("stroke-width", "2"); + + const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + dot.setAttribute("cx", String(x)); + dot.setAttribute("cy", String(y)); + dot.setAttribute("r", "4"); + dot.setAttribute("fill", "#38bdf8"); + + replayOverlayEl.appendChild(halo); + replayOverlayEl.appendChild(dot); + } +} + +function renderReplayFrameEvents(frame) { + replayFrameEventsEl.innerHTML = ""; + if (!frame) return; + const events = Array.isArray(frame.overlays) ? frame.overlays : []; + const shown = events.slice(-8); + for (const ev of shown) { + const row = document.createElement("div"); + row.className = "text-[11px] rounded border border-slate-800 bg-slate-900/80 px-2 py-1"; + row.textContent = ev.label || `${ev.kind || "event"} ${ev.tool || ""}`.trim(); + replayFrameEventsEl.appendChild(row); + } + if (!shown.length) { + const empty = document.createElement("div"); + empty.className = "text-[11px] text-slate-500"; + empty.textContent = "No overlay events for this frame."; + replayFrameEventsEl.appendChild(empty); + } +} + +function setReplayFrame(index) { + const frames = state.replay.frames; + if (!frames.length) { + replayVisualEl.removeAttribute("src"); + replayOverlayEl.innerHTML = ""; + replayFrameMetaEl.textContent = "No replay frames."; + replaySeekEl.value = "0"; + replaySeekEl.max = "0"; + replayStatusEl.textContent = "No replay loaded."; + return; + } + const bounded = Math.max(0, Math.min(index, frames.length - 1)); + state.replay.frameIndex = bounded; + const frame = frames[bounded]; + replayVisualEl.src = replayImageSrc(frame.image_path); + replayFrameMetaEl.textContent = `Frame ${bounded + 1}/${frames.length} | step ${frame.step} | ${frame.kind} | ${frame.ts}`; + replaySeekEl.max = String(Math.max(0, frames.length - 1)); + replaySeekEl.value = String(bounded); + replayStatusEl.textContent = state.replay.isPlaying ? "Playing replay." : "Replay ready."; + renderReplayOverlay(frame); + renderReplayFrameEvents(frame); +} + +function advanceReplay() { + const frames = state.replay.frames; + if (!state.replay.isPlaying || !frames.length) return; + if (state.replay.frameIndex >= frames.length - 1) { + stopReplay(); + setReplayFrame(frames.length - 1); + replayStatusEl.textContent = "Replay finished."; + return; + } + setReplayFrame(state.replay.frameIndex + 1); + clearReplayTimer(); + const delayMs = Math.max(120, Math.round(700 / (state.replay.speed || 1))); + state.replay.timer = setTimeout(advanceReplay, delayMs); +} + +function toggleReplayPlay() { + if (!state.replay.frames.length) return; + if (state.replay.isPlaying) { + stopReplay(); + setReplayFrame(state.replay.frameIndex); + return; + } + state.replay.isPlaying = true; + replayPlayBtn.textContent = "Pause"; + replayStatusEl.textContent = "Playing replay."; + advanceReplay(); +} + +function resetReplay(payload) { + stopReplay(); + const replayPayload = payload || {}; + state.replay.frames = Array.isArray(replayPayload.frames) ? replayPayload.frames : []; + state.replay.trailingEvents = Array.isArray(replayPayload.trailing_events) ? replayPayload.trailing_events : []; + state.replay.frameIndex = 0; + setReplayFrame(0); +} + +function scheduleWsReconnect() { + if (state.wsReconnectTimer || !state.token) return; + state.wsReconnectTimer = setTimeout(() => { + state.wsReconnectTimer = null; + connectWs(); + }, 1200); +} + +function updateLatestVisualFromEvent(ev) { + if (!ev || ev.event_type !== "visual_update") return; + if (!state.selectedJobId || ev.job_id !== state.selectedJobId) return; + const imagePath = ev.payload && ev.payload.image_meta && ev.payload.image_meta.path; + if (!imagePath) return; + const q = encodeURIComponent(imagePath); + latestVisualEl.src = `/api/jobs/${state.selectedJobId}/artifact?path=${q}&token=${encodeURIComponent(state.token)}`; +} + +async function refreshJobs() { + const payload = await api("/api/jobs?limit=100"); + state.jobs = payload.jobs || []; + if (!state.selectedJobId && state.jobs.length > 0) state.selectedJobId = state.jobs[0].job_id; + renderJobs(); +} + +async function refreshStats() { + const payload = await api("/api/stats"); + renderStats(payload); +} + +async function refreshJobDetail() { + if (!state.selectedJobId) return; + const [job, events, replay] = await Promise.all([ + api(`/api/jobs/${state.selectedJobId}`), + api(`/api/jobs/${state.selectedJobId}/events?limit=120`), + api(`/api/jobs/${state.selectedJobId}/replay?limit=5000`) + ]); + jobDetailEl.textContent = JSON.stringify(job, null, 2); + eventsEl.innerHTML = ""; + const list = (events.events || []).slice().reverse(); + for (const ev of list) pushEventLine(ev); + const visual = list.find((ev) => ev.event_type === "visual_update"); + if (visual) updateLatestVisualFromEvent(visual); + resetReplay(replay); +} + +function connectWs() { + if (!state.token) return; + if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) { + return; + } + const scheme = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${scheme}://${location.host}/ws?token=${encodeURIComponent(state.token)}`); + state.ws = ws; + ws.onmessage = async (event) => { + try { + const payload = JSON.parse(event.data); + if (!payload || payload.event_type === "connected") return; + pushEventLine(payload); + updateLatestVisualFromEvent(payload); + if (!state.selectedJobId || payload.job_id === state.selectedJobId) { + await refreshJobDetail(); + } + await refreshJobs(); + await refreshStats(); + } catch (err) { + console.error(err); + } + }; + ws.onclose = () => { + if (state.ws === ws) state.ws = null; + if (manuallyClosedSockets.has(ws)) { + manuallyClosedSockets.delete(ws); + return; + } + scheduleWsReconnect(); + }; +} + +async function fullRefresh() { + await refreshJobs(); + await refreshStats(); + await refreshJobDetail(); +} + +async function connect() { + state.token = tokenInput.value.trim(); + localStorage.setItem("screenjob_token", state.token); + if (state.ws) { + manuallyClosedSockets.add(state.ws); + try { state.ws.close(); } catch (_) {} + state.ws = null; + } + if (state.wsReconnectTimer) { + clearTimeout(state.wsReconnectTimer); + state.wsReconnectTimer = null; + } + await fullRefresh(); + connectWs(); +} + +function syncEventsViewToggle() { + eventsViewToggle.checked = state.eventsViewMode === "beautiful"; +} + +saveTokenBtn.addEventListener("click", () => connect().catch((err) => alert(err.message))); +refreshBtn.addEventListener("click", () => fullRefresh().catch((err) => alert(err.message))); +eventsViewToggle.addEventListener("change", () => { + state.eventsViewMode = eventsViewToggle.checked ? "beautiful" : "raw"; + localStorage.setItem("screenjob_events_view_mode", state.eventsViewMode); + refreshJobDetail().catch((err) => alert(err.message)); +}); +replayPlayBtn.addEventListener("click", () => toggleReplayPlay()); +replayPrevBtn.addEventListener("click", () => { + stopReplay(); + setReplayFrame(state.replay.frameIndex - 1); +}); +replayNextBtn.addEventListener("click", () => { + stopReplay(); + setReplayFrame(state.replay.frameIndex + 1); +}); +replaySpeedEl.addEventListener("change", () => { + const speed = Number(replaySpeedEl.value); + state.replay.speed = Number.isFinite(speed) && speed > 0 ? speed : 1; + if (state.replay.isPlaying) { + clearReplayTimer(); + advanceReplay(); + } +}); +replaySeekEl.addEventListener("input", () => { + stopReplay(); + setReplayFrame(Number(replaySeekEl.value || 0)); +}); +syncEventsViewToggle(); +resetReplay(null); +if (state.token) connect().catch(() => {}); diff --git a/tests/test_server_api.py b/tests/test_server_api.py index 1c8b035..49efdf7 100644 --- a/tests/test_server_api.py +++ b/tests/test_server_api.py @@ -276,6 +276,9 @@ def test_ui_toggle(tmp_path: Path, monkeypatch: Any) -> None: root_enabled = client_enabled.get("/") assert root_enabled.status_code == 200 assert "ScreenJob Monitor" in root_enabled.text + js_enabled = client_enabled.get("/ui/monitoring.js") + assert js_enabled.status_code == 200 + assert "const tokenInput" in js_enabled.text app_disabled, _ = _build_app(tmp_path / "disabled", monkeypatch, disable_ui=True) client_disabled = TestClient(app_disabled)