From 5a4d0e1c9178d094d753a3a1add75e3a27433db8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:37:20 +0200 Subject: [PATCH] Show a run while it is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record used to arrive only when a run was over, so "how far is it" could only be answered by looking at files on the machine doing the work -- which the owner could not see at all, and which is a poor way for me to watch it either. The runner opens the record before the first case and updates it after each one, and the Testy tab draws a bar with the case in flight underneath. Publishing can never fail a run: if the server is unreachable the record is not opened and every later update is a no-op. The page follows a live run every five seconds instead of waiting out the ordinary minute, and goes back to sleep when nothing is moving. A run that dies leaves its record saying "running" for ever, so the card reads how long ago it last moved rather than trusting the flag: five minutes without progress reads as "běh se zastavil", which is generous on purpose -- a source picker legitimately waits 150 seconds on one case. Screenshots still go at the end. They are the slow part and nothing on the dashboard needs them while the bar is still moving. Co-Authored-By: Claude Opus 5 --- pb_migrations/1796500000_e2e_runs_progress.js | 34 +++++++++++ pb_public/status.html | 60 +++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 pb_migrations/1796500000_e2e_runs_progress.js diff --git a/pb_migrations/1796500000_e2e_runs_progress.js b/pb_migrations/1796500000_e2e_runs_progress.js new file mode 100644 index 0000000..1bae874 --- /dev/null +++ b/pb_migrations/1796500000_e2e_runs_progress.js @@ -0,0 +1,34 @@ +/// + +// Live progress for a run in flight. +// +// The record used to arrive only when a run finished, so the answer to "how far +// is it" was to poll files on the machine doing the work -- which the owner +// could not see at all. The runner now creates the record before the first case +// and updates it after each one, so the dashboard can draw a bar. +// +// `status` is the only field that needs explaining: a run that dies leaves its +// record saying `running` forever, so the dashboard reads `updated` as well and +// calls a run stalled when nothing has moved for a few minutes. Better than a +// heartbeat nobody sends when the process is killed. + +migrate( + (app) => { + const runs = app.findCollectionByNameOrId("e2e_runs"); + runs.fields.add( + new TextField({ name: "status", max: 12 }), // running | done + new TextField({ name: "current", max: 120 }), // the case in flight + new NumberField({ name: "total", onlyInt: true }), // cases planned + new NumberField({ name: "done", onlyInt: true }), // cases finished + ); + app.save(runs); + }, + (app) => { + const runs = app.findCollectionByNameOrId("e2e_runs"); + for (const name of ["status", "current", "total", "done"]) { + const f = runs.fields.getByName(name); + if (f) runs.fields.removeById(f.id); + } + app.save(runs); + }, +); diff --git a/pb_public/status.html b/pb_public/status.html index 78b5238..e37e951 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -47,6 +47,16 @@ .big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums} /* A run is a card with its own verdict line, and its cases are a list under a
— a run has ninety of them and nobody wants ninety rows open. */ + /* A run in flight: the bar is the whole point, so it sits directly under the + heading rather than among the counts. */ + .bar{height:8px;border-radius:999px;background:var(--panel2);overflow:hidden; + margin:10px 0 6px;border:1px solid var(--line)} + .bar i{display:block;height:100%;background:var(--accent);transition:width .4s} + .bar.stalled i{background:var(--warn)} + .runningnow{color:var(--muted);font-size:12.5px;display:flex;gap:10px; + align-items:center;flex-wrap:wrap} + .runningnow code{color:var(--text)} + .live{background:rgba(240,166,60,.16);color:var(--warn)} .run{border:1px solid var(--line);border-radius:10px;padding:14px 16px; margin-bottom:10px;background:var(--panel2)} .run h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px; @@ -698,7 +708,24 @@ function outcomePill(o){ return 'nenapsáno'; } -function runVerdict(c){ +// A run that dies leaves its record saying "running" for ever, so "is it alive" +// is answered by when it last moved rather than by the flag alone. Two minutes: +// a case can legitimately take a while (a source picker waits 150s), so this is +// deliberately generous and only ever says "stalled", never "failed". +var STALL_MS = 300000; + +function isLive(r){ + return r.status === "running" && + (Date.now() - Date.parse(String(r.updated).replace(" ", "T") + "Z")) < STALL_MS; +} + +function isStalled(r){ + return r.status === "running" && !isLive(r); +} + +function runVerdict(c, r){ + if (r && isLive(r)) return 'běží'; + if (r && isStalled(r)) return 'běh se zastavil'; // A build ships when nothing that blocks a release has failed. Cases nobody // has written yet are not a verdict, they are a gap, and the card says both. if ((c.fail || 0) > 0) return 'nevydávat'; @@ -706,6 +733,19 @@ function runVerdict(c){ return 'nic nebrání vydání'; } +function progressBar(r){ + if (r.status !== "running") return ""; + var total = r.total || 0, done = r.done || 0; + var pct = total ? Math.round((done / total) * 100) : 0; + return '
' + + '
' + + '
' + done + " z " + total + "" + + (r.current ? "" + esc(r.current) + "" : "") + + (isStalled(r) ? "naposledy se pohnul " + + esc(String(r.updated).substring(11, 16)) + "" : "") + + "
"; +} + // PocketBase rewrites an uploaded filename: it flattens everything that is not // a letter or a digit to "_", collapses runs of them, and appends a random // token. So `signin.happy__home.png` arrives as `signin_happy_home_czik8h4h7b`. @@ -722,6 +762,9 @@ function normName(s){ // browser sends no Authorization header with an image request. Without this // every screenshot on the page is a broken icon. var fileTok = ""; +// Set while a run is in flight, so the page follows it instead of waiting out +// the ordinary minute. Nothing else on this dashboard changes that fast. +var liveRun = false; function shotUrl(r, caseId, shotName){ var want = normName(caseId + "_" + shotName); @@ -768,11 +811,12 @@ function runCard(r){ var quiet = cases.filter(function(x){ return x.outcome === "unsupported" || x.outcome === "unimplemented"; }); - return '

' + runVerdict(c) + + return '

' + runVerdict(c, r) + '' + esc(r.target) + "" + "" + esc(r.appVersion) + "" + esc(r.commit) + "" + '' + esc(String(r.created).substring(0, 16)) + "

" + + progressBar(r) + '
' + '
prošlo
' + (c.pass || 0) + "
" + '
spadlo
' + (c.fail || 0) + "
" + @@ -1368,6 +1412,7 @@ async function load(){ if (prefs.view === "e2e") { var rs = await records("e2e_runs", { sort: "-created", perPage: 40 }) .catch(function(){ return { items: [] }; }); + liveRun = (rs.items || []).some(isLive); fileTok = ""; try { var ft = await api("/api/files/token", { method: "POST" }); @@ -1406,9 +1451,16 @@ function start(){ syncTabs(); load(); if (timer) clearInterval(timer); - // A minute is plenty; this is not a metrics system. The admin view is excluded + // A minute is plenty for everything here except a test run in flight, which + // moves every few seconds and is worth watching. The admin view is excluded // because a refresh under a half-typed form would wipe it. - timer = setInterval(function(){ if (prefs.view !== "admin") load(); }, 60000); + var ticks = 0; + timer = setInterval(function(){ + if (prefs.view === "admin") return; + ticks++; + var following = liveRun && prefs.view === "e2e"; + if (following || ticks >= 12) { ticks = 0; load(); } + }, 5000); } if (tok) start(); else el("login").hidden = false;