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 analyticsMetaEl = document.getElementById("analyticsMeta"); const analyticsSummaryEl = document.getElementById("analyticsSummary"); const analyticsCategorySummaryEl = document.getElementById("analyticsCategorySummary"); const analyticsCategoriesEl = document.getElementById("analyticsCategories"); const analyticsTrendSummaryEl = document.getElementById("analyticsTrendSummary"); const analyticsTrendsEl = document.getElementById("analyticsTrends"); 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(); const analyticsRefreshEvents = new Set(["job_finished", "job_failed", "job_rejected"]); 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 escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]); } function formatNumber(value, digits = 2) { const num = Number(value); return Number.isFinite(num) ? num.toFixed(digits) : "—"; } function formatCurrency(value, digits = 6) { const num = Number(value); return Number.isFinite(num) ? `$${num.toFixed(digits)}` : "—"; } function formatPercent(value) { const num = Number(value); return Number.isFinite(num) ? `${num.toFixed(1)}%` : "—"; } function formatDateLabel(value) { const dt = new Date(value); if (Number.isNaN(dt.getTime())) return String(value || "—"); return dt.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function renderMetricCard(label, value) { return `
${escapeHtml(label)}
${escapeHtml(value)}
`; } function renderLineChart(title, points, options = {}) { const color = options.color || "#22d3ee"; const valueLabel = options.valueLabel || ""; const sourcePoints = Array.isArray(points) ? points.filter((point) => Number.isFinite(Number(point.value))) : []; if (!sourcePoints.length) { return `
${escapeHtml(title)}
No data yet
`; } const width = 640; const height = 220; const margin = { top: 20, right: 18, bottom: 34, left: 44 }; const values = sourcePoints.map((point) => Number(point.value)); const minValue = Math.min(...values); const maxValue = Math.max(...values); const span = maxValue - minValue || 1; const chartWidth = width - margin.left - margin.right; const chartHeight = height - margin.top - margin.bottom; const xStep = sourcePoints.length > 1 ? chartWidth / (sourcePoints.length - 1) : 0; const coords = sourcePoints.map((point, index) => ({ x: margin.left + (index * xStep), y: margin.top + ((maxValue - Number(point.value)) / span) * chartHeight, })); const linePath = coords.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" "); const baseline = height - margin.bottom; const midIndex = Math.floor(sourcePoints.length / 2); const xLabels = [ { index: 0, label: sourcePoints[0].label }, { index: midIndex, label: sourcePoints[midIndex].label }, { index: sourcePoints.length - 1, label: sourcePoints[sourcePoints.length - 1].label }, ].filter((item, index, array) => item.label && array.findIndex((candidate) => candidate.index === item.index) === index); const minLabel = options.formatValue ? options.formatValue(minValue) : formatNumber(minValue, 2); const maxLabel = options.formatValue ? options.formatValue(maxValue) : formatNumber(maxValue, 2); const latest = sourcePoints[sourcePoints.length - 1]; const latestValue = options.formatValue ? options.formatValue(latest.value) : formatNumber(latest.value, 2); return `
${escapeHtml(title)}
${escapeHtml(latestValue)}${valueLabel ? ` ${escapeHtml(valueLabel)}` : ""}
${escapeHtml(sourcePoints.length)} points
${escapeHtml(minLabel)} - ${escapeHtml(maxLabel)}
${Array.from({ length: 4 }, (_, idx) => { const y = margin.top + (chartHeight / 3) * idx; return ``; }).join("")} ${coords.map((point) => ` `).join("")} ${escapeHtml(maxLabel)} ${escapeHtml(minLabel)} ${xLabels.map((item) => ` ${escapeHtml(formatDateLabel(item.label))} `).join("")}
`; } function renderAnalytics(payload) { const analytics = payload || {}; const categories = Array.isArray(analytics.by_category) ? analytics.by_category : []; const timeline = Array.isArray(analytics.timeline) ? analytics.timeline : []; const finishedCategories = categories.filter((row) => Number(row.finished_jobs || 0) > 0); if (analyticsMetaEl) { analyticsMetaEl.textContent = analytics.generated_at ? `Updated ${new Date(analytics.generated_at).toLocaleString()}` : "Historical snapshot"; } analyticsSummaryEl.innerHTML = [ renderMetricCard("Finished Jobs", analytics.finished_jobs || 0), renderMetricCard("Success Rate", formatPercent(analytics.success_rate)), renderMetricCard("Avg Steps", formatNumber(analytics.avg_steps, 1)), renderMetricCard("Avg Cost", formatCurrency(analytics.avg_cost_usd)), ].join(""); analyticsCategorySummaryEl.textContent = finishedCategories.length ? `${finishedCategories.length} categories` : "No finished jobs yet"; if (finishedCategories.length) { analyticsCategoriesEl.innerHTML = finishedCategories.map((row) => { const successRate = Number(row.success_rate || 0); const completed = Number(row.completed_jobs || 0); const finished = Number(row.finished_jobs || 0); const total = Number(row.total_jobs || 0); const avgSteps = row.avg_steps == null ? "—" : formatNumber(row.avg_steps, 1); const avgCost = row.avg_cost_usd == null ? "—" : formatCurrency(row.avg_cost_usd); return `
${escapeHtml(row.label || "Other")}
${finished} finished · ${completed} completed · ${total} total
${formatPercent(successRate)}
success rate
Avg steps: ${escapeHtml(avgSteps)}
Avg cost: ${escapeHtml(avgCost)}
`; }).join(""); } else { analyticsCategoriesEl.innerHTML = `
No finished jobs yet.
`; } analyticsTrendSummaryEl.textContent = timeline.length ? `${timeline.length} days` : "No daily data yet"; analyticsTrendsEl.innerHTML = [ renderLineChart("Average steps per day", timeline.map((row) => ({ label: row.label, value: row.avg_steps })), { color: "#38bdf8" }), renderLineChart("Average cost per day", timeline.map((row) => ({ label: row.label, value: row.avg_cost_usd })), { color: "#34d399", valueLabel: "USD", formatValue: (value) => formatCurrency(value), }), ].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 refreshAnalytics() { const payload = await api("/api/analytics"); renderAnalytics(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(); if (analyticsRefreshEvents.has(payload.event_type)) { await refreshAnalytics(); } } 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 refreshAnalytics(); 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(() => {});