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;