Split monitor UI into separate HTML and JS assets
All checks were successful
CI / test (push) Successful in 7s
All checks were successful
CI / test (push) Successful in 7s
This commit is contained in:
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
|
|||||||
from .config import AppConfig, load_app_config
|
from .config import AppConfig, load_app_config
|
||||||
from .storage import HistoryDB
|
from .storage import HistoryDB
|
||||||
from .task_manager import JobManager
|
from .task_manager import JobManager
|
||||||
from .ui import monitoring_page_html
|
from .ui import monitoring_js_path, monitoring_page_html
|
||||||
|
|
||||||
|
|
||||||
class CreateJobRequest(BaseModel):
|
class CreateJobRequest(BaseModel):
|
||||||
@@ -387,6 +387,10 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
|
|||||||
def ui_root() -> str:
|
def ui_root() -> str:
|
||||||
return monitoring_page_html(device_hostname=device_hostname)
|
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")
|
@app.websocket("/ws")
|
||||||
async def ws_endpoint(websocket: WebSocket, token: str = Query(default="")) -> None:
|
async def ws_endpoint(websocket: WebSocket, token: str = Query(default="")) -> None:
|
||||||
if not token or not secrets.compare_digest(token, app_config.screenjob_token):
|
if not token or not secrets.compare_digest(token, app_config.screenjob_token):
|
||||||
|
|||||||
511
src/ui.py
511
src/ui.py
@@ -1,508 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from html import escape
|
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:
|
def monitoring_page_html(device_hostname: str = "") -> str:
|
||||||
host_suffix = f" ({escape(device_hostname)})" if device_hostname else ""
|
host_suffix = f" ({escape(device_hostname)})" if device_hostname else ""
|
||||||
return """<!doctype html>
|
html = _HTML_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||||
<html lang="en">
|
return html.replace("__MONITOR_HOST__", host_suffix)
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>ScreenJob Monitor</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
|
||||||
<div class="max-w-7xl mx-auto p-4 md:p-8 space-y-6">
|
|
||||||
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold tracking-tight">ScreenJob Monitor<span class="text-slate-400 text-base md:text-lg font-medium">__MONITOR_HOST__</span></h1>
|
|
||||||
<p class="text-slate-400 text-sm">Read-only monitoring for active and historical tasks.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row gap-2 md:items-center">
|
|
||||||
<input id="tokenInput" type="password" placeholder="SCREENJOB_TOKEN" class="bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm w-72" />
|
|
||||||
<button id="saveTokenBtn" class="bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-semibold px-4 py-2 rounded">Connect</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="grid grid-cols-2 md:grid-cols-6 gap-3" id="stats"></section>
|
|
||||||
|
|
||||||
<section class="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
def monitoring_js_path() -> Path:
|
||||||
<div class="lg:col-span-2 bg-slate-900/70 border border-slate-800 rounded-xl p-4">
|
return _JS_PATH
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h2 class="font-semibold">Jobs</h2>
|
|
||||||
<button id="refreshBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div id="jobList" class="space-y-2 max-h-[62vh] overflow-auto"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-3 bg-slate-900/70 border border-slate-800 rounded-xl p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold">Job Detail</h2>
|
|
||||||
<pre id="jobDetail" class="bg-slate-950 border border-slate-800 rounded p-3 text-xs overflow-auto max-h-[24vh]"></pre>
|
|
||||||
<h3 class="font-semibold text-sm">Latest Visual</h3>
|
|
||||||
<div class="bg-slate-950 border border-slate-800 rounded p-2">
|
|
||||||
<img id="latestVisual" alt="Latest visual update" class="max-h-[24vh] w-full object-contain rounded" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="font-semibold text-sm">Replay</h3>
|
|
||||||
<div id="replayStatus" class="text-[11px] text-slate-400">No replay loaded.</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<button id="replayPlayBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Play</button>
|
|
||||||
<button id="replayPrevBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Prev</button>
|
|
||||||
<button id="replayNextBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Next</button>
|
|
||||||
<label class="text-xs text-slate-300 flex items-center gap-1">
|
|
||||||
Speed
|
|
||||||
<select id="replaySpeed" class="bg-slate-900 border border-slate-700 rounded px-1 py-0.5">
|
|
||||||
<option value="0.5">0.5x</option>
|
|
||||||
<option value="1" selected>1.0x</option>
|
|
||||||
<option value="1.5">1.5x</option>
|
|
||||||
<option value="2">2.0x</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<input id="replaySeek" type="range" min="0" max="0" value="0" class="w-full accent-cyan-400" />
|
|
||||||
<div class="bg-slate-950 border border-slate-800 rounded p-2">
|
|
||||||
<div class="relative w-full min-h-[180px] bg-black/40 rounded">
|
|
||||||
<img id="replayVisual" alt="Replay frame" class="max-h-[30vh] w-full object-contain rounded" />
|
|
||||||
<svg id="replayOverlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="xMidYMid meet"></svg>
|
|
||||||
</div>
|
|
||||||
<div id="replayFrameMeta" class="text-[11px] text-slate-400 mt-2"></div>
|
|
||||||
<div id="replayFrameEvents" class="mt-2 space-y-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="font-semibold text-sm">Live Events</h3>
|
|
||||||
<label for="eventsViewToggle" class="flex items-center gap-2 text-xs text-slate-300 cursor-pointer select-none">
|
|
||||||
<span>Raw</span>
|
|
||||||
<input id="eventsViewToggle" type="checkbox" class="accent-cyan-400 h-4 w-4" />
|
|
||||||
<span>Beautiful</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="events" class="bg-slate-950 border border-slate-800 rounded p-3 text-xs overflow-auto max-h-[36vh] space-y-1"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
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]) => `
|
|
||||||
<div class="bg-slate-900/70 border border-slate-800 rounded-xl p-3">
|
|
||||||
<div class="text-slate-400 text-xs">${name}</div>
|
|
||||||
<div class="text-lg font-semibold">${val}</div>
|
|
||||||
</div>
|
|
||||||
`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderJobs() {
|
|
||||||
jobListEl.innerHTML = state.jobs.map((job) => {
|
|
||||||
const active = job.job_id === state.selectedJobId;
|
|
||||||
return `
|
|
||||||
<button data-job-id="${job.job_id}" class="w-full text-left p-3 rounded border ${active ? "border-cyan-400 bg-slate-800" : "border-slate-800 bg-slate-950"} hover:bg-slate-800">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium">${job.job_id}</span>
|
|
||||||
<span class="text-xs px-2 py-0.5 rounded bg-slate-700">${job.status}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-400 mt-1">${job.model}</div>
|
|
||||||
<div class="text-xs text-slate-300 mt-1 line-clamp-2">${job.objective}</div>
|
|
||||||
<div class="text-xs text-slate-500 mt-1">$${Number((job.usage && job.usage.estimated_cost_usd) || 0).toFixed(6)}</div>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}).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(() => {});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".replace("__MONITOR_HOST__", host_suffix)
|
|
||||||
|
|||||||
82
src/ui_assets/monitoring.html
Normal file
82
src/ui_assets/monitoring.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>ScreenJob Monitor</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||||
|
<div class="max-w-7xl mx-auto p-4 md:p-8 space-y-6">
|
||||||
|
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold tracking-tight">ScreenJob Monitor<span class="text-slate-400 text-base md:text-lg font-medium">__MONITOR_HOST__</span></h1>
|
||||||
|
<p class="text-slate-400 text-sm">Read-only monitoring for active and historical tasks.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row gap-2 md:items-center">
|
||||||
|
<input id="tokenInput" type="password" placeholder="SCREENJOB_TOKEN" class="bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm w-72" />
|
||||||
|
<button id="saveTokenBtn" class="bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-semibold px-4 py-2 rounded">Connect</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-2 md:grid-cols-6 gap-3" id="stats"></section>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||||
|
<div class="lg:col-span-2 bg-slate-900/70 border border-slate-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="font-semibold">Jobs</h2>
|
||||||
|
<button id="refreshBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="jobList" class="space-y-2 max-h-[62vh] overflow-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-3 bg-slate-900/70 border border-slate-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="font-semibold">Job Detail</h2>
|
||||||
|
<pre id="jobDetail" class="bg-slate-950 border border-slate-800 rounded p-3 text-xs overflow-auto max-h-[24vh]"></pre>
|
||||||
|
<h3 class="font-semibold text-sm">Latest Visual</h3>
|
||||||
|
<div class="bg-slate-950 border border-slate-800 rounded p-2">
|
||||||
|
<img id="latestVisual" alt="Latest visual update" class="max-h-[24vh] w-full object-contain rounded" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-sm">Replay</h3>
|
||||||
|
<div id="replayStatus" class="text-[11px] text-slate-400">No replay loaded.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button id="replayPlayBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Play</button>
|
||||||
|
<button id="replayPrevBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Prev</button>
|
||||||
|
<button id="replayNextBtn" class="text-xs bg-slate-800 px-2 py-1 rounded">Next</button>
|
||||||
|
<label class="text-xs text-slate-300 flex items-center gap-1">
|
||||||
|
Speed
|
||||||
|
<select id="replaySpeed" class="bg-slate-900 border border-slate-700 rounded px-1 py-0.5">
|
||||||
|
<option value="0.5">0.5x</option>
|
||||||
|
<option value="1" selected>1.0x</option>
|
||||||
|
<option value="1.5">1.5x</option>
|
||||||
|
<option value="2">2.0x</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input id="replaySeek" type="range" min="0" max="0" value="0" class="w-full accent-cyan-400" />
|
||||||
|
<div class="bg-slate-950 border border-slate-800 rounded p-2">
|
||||||
|
<div class="relative w-full min-h-[180px] bg-black/40 rounded">
|
||||||
|
<img id="replayVisual" alt="Replay frame" class="max-h-[30vh] w-full object-contain rounded" />
|
||||||
|
<svg id="replayOverlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
|
</div>
|
||||||
|
<div id="replayFrameMeta" class="text-[11px] text-slate-400 mt-2"></div>
|
||||||
|
<div id="replayFrameEvents" class="mt-2 space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-sm">Live Events</h3>
|
||||||
|
<label for="eventsViewToggle" class="flex items-center gap-2 text-xs text-slate-300 cursor-pointer select-none">
|
||||||
|
<span>Raw</span>
|
||||||
|
<input id="eventsViewToggle" type="checkbox" class="accent-cyan-400 h-4 w-4" />
|
||||||
|
<span>Beautiful</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="events" class="bg-slate-950 border border-slate-800 rounded p-3 text-xs overflow-auto max-h-[36vh] space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/ui/monitoring.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
418
src/ui_assets/monitoring.js
Normal file
418
src/ui_assets/monitoring.js
Normal file
@@ -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]) => `
|
||||||
|
<div class="bg-slate-900/70 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div class="text-slate-400 text-xs">${name}</div>
|
||||||
|
<div class="text-lg font-semibold">${val}</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJobs() {
|
||||||
|
jobListEl.innerHTML = state.jobs.map((job) => {
|
||||||
|
const active = job.job_id === state.selectedJobId;
|
||||||
|
return `
|
||||||
|
<button data-job-id="${job.job_id}" class="w-full text-left p-3 rounded border ${active ? "border-cyan-400 bg-slate-800" : "border-slate-800 bg-slate-950"} hover:bg-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">${job.job_id}</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-slate-700">${job.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">${job.model}</div>
|
||||||
|
<div class="text-xs text-slate-300 mt-1 line-clamp-2">${job.objective}</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">$${Number((job.usage && job.usage.estimated_cost_usd) || 0).toFixed(6)}</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).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(() => {});
|
||||||
@@ -276,6 +276,9 @@ def test_ui_toggle(tmp_path: Path, monkeypatch: Any) -> None:
|
|||||||
root_enabled = client_enabled.get("/")
|
root_enabled = client_enabled.get("/")
|
||||||
assert root_enabled.status_code == 200
|
assert root_enabled.status_code == 200
|
||||||
assert "ScreenJob Monitor" in root_enabled.text
|
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)
|
app_disabled, _ = _build_app(tmp_path / "disabled", monkeypatch, disable_ui=True)
|
||||||
client_disabled = TestClient(app_disabled)
|
client_disabled = TestClient(app_disabled)
|
||||||
|
|||||||
Reference in New Issue
Block a user