feat: add shared runtime with FastAPI job server and safety pipeline
This commit is contained in:
193
src/ui.py
Normal file
193
src/ui.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def monitoring_page_html() -> str:
|
||||
return """<!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</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">Live Events</h3>
|
||||
<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 state = {
|
||||
token: localStorage.getItem("screenjob_token") || "",
|
||||
jobs: [],
|
||||
selectedJobId: null,
|
||||
ws: null
|
||||
};
|
||||
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) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "border-b border-slate-800 pb-1";
|
||||
line.textContent = `[${obj.ts}] ${obj.job_id} step=${obj.step} ${obj.event_type} ${JSON.stringify(obj.payload || {})}`;
|
||||
eventsEl.prepend(line);
|
||||
while (eventsEl.childNodes.length > 400) {
|
||||
eventsEl.removeChild(eventsEl.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
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] = await Promise.all([
|
||||
api(`/api/jobs/${state.selectedJobId}`),
|
||||
api(`/api/jobs/${state.selectedJobId}/events?limit=120`)
|
||||
]);
|
||||
jobDetailEl.textContent = JSON.stringify(job, null, 2);
|
||||
eventsEl.innerHTML = "";
|
||||
for (const ev of (events.events || []).slice().reverse()) pushEventLine(ev);
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
if (state.ws) {
|
||||
try { state.ws.close(); } catch (_) {}
|
||||
}
|
||||
if (!state.token) 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);
|
||||
pushEventLine(payload);
|
||||
if (!state.selectedJobId || payload.job_id === state.selectedJobId) {
|
||||
await refreshJobDetail();
|
||||
}
|
||||
await refreshJobs();
|
||||
await refreshStats();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWs, 1200);
|
||||
}
|
||||
|
||||
async function fullRefresh() {
|
||||
await refreshJobs();
|
||||
await refreshStats();
|
||||
await refreshJobDetail();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
state.token = tokenInput.value.trim();
|
||||
localStorage.setItem("screenjob_token", state.token);
|
||||
await fullRefresh();
|
||||
connectWs();
|
||||
}
|
||||
|
||||
saveTokenBtn.addEventListener("click", () => connect().catch((err) => alert(err.message)));
|
||||
refreshBtn.addEventListener("click", () => fullRefresh().catch((err) => alert(err.message)));
|
||||
if (state.token) connect().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user