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 """
-
-
-
+ `;
+ }).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)