Show a run while it is running
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 <noreply@anthropic.com>
This commit is contained in:
parent
679ab38424
commit
5a4d0e1c91
2 changed files with 90 additions and 4 deletions
34
pb_migrations/1796500000_e2e_runs_progress.js
Normal file
34
pb_migrations/1796500000_e2e_runs_progress.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -47,6 +47,16 @@
|
||||||
.big{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums}
|
.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 is a card with its own verdict line, and its cases are a list under a
|
||||||
<details> — a run has ninety of them and nobody wants ninety rows open. */
|
<details> — 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;
|
.run{border:1px solid var(--line);border-radius:10px;padding:14px 16px;
|
||||||
margin-bottom:10px;background:var(--panel2)}
|
margin-bottom:10px;background:var(--panel2)}
|
||||||
.run h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px;
|
.run h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px;
|
||||||
|
|
@ -698,7 +708,24 @@ function outcomePill(o){
|
||||||
return '<span class="pill un">nenapsáno</span>';
|
return '<span class="pill un">nenapsáno</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
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 '<span class="pill live">běží</span>';
|
||||||
|
if (r && isStalled(r)) return '<span class="pill wn">běh se zastavil</span>';
|
||||||
// A build ships when nothing that blocks a release has failed. Cases nobody
|
// 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.
|
// has written yet are not a verdict, they are a gap, and the card says both.
|
||||||
if ((c.fail || 0) > 0) return '<span class="pill no">nevydávat</span>';
|
if ((c.fail || 0) > 0) return '<span class="pill no">nevydávat</span>';
|
||||||
|
|
@ -706,6 +733,19 @@ function runVerdict(c){
|
||||||
return '<span class="pill ok">nic nebrání vydání</span>';
|
return '<span class="pill ok">nic nebrání vydání</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 '<div class="bar' + (isStalled(r) ? " stalled" : "") + '">' +
|
||||||
|
'<i style="width:' + pct + '%"></i></div>' +
|
||||||
|
'<div class="runningnow"><span>' + done + " z " + total + "</span>" +
|
||||||
|
(r.current ? "<code>" + esc(r.current) + "</code>" : "") +
|
||||||
|
(isStalled(r) ? "<span>naposledy se pohnul " +
|
||||||
|
esc(String(r.updated).substring(11, 16)) + "</span>" : "") +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
// PocketBase rewrites an uploaded filename: it flattens everything that is not
|
// 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
|
// 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`.
|
// 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
|
// browser sends no Authorization header with an image request. Without this
|
||||||
// every screenshot on the page is a broken icon.
|
// every screenshot on the page is a broken icon.
|
||||||
var fileTok = "";
|
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){
|
function shotUrl(r, caseId, shotName){
|
||||||
var want = normName(caseId + "_" + shotName);
|
var want = normName(caseId + "_" + shotName);
|
||||||
|
|
@ -768,11 +811,12 @@ function runCard(r){
|
||||||
var quiet = cases.filter(function(x){
|
var quiet = cases.filter(function(x){
|
||||||
return x.outcome === "unsupported" || x.outcome === "unimplemented";
|
return x.outcome === "unsupported" || x.outcome === "unimplemented";
|
||||||
});
|
});
|
||||||
return '<div class="run"><h3>' + runVerdict(c) +
|
return '<div class="run"><h3>' + runVerdict(c, r) +
|
||||||
'<span class="pill un">' + esc(r.target) + "</span>" +
|
'<span class="pill un">' + esc(r.target) + "</span>" +
|
||||||
"<code>" + esc(r.appVersion) + "</code><code>" + esc(r.commit) + "</code>" +
|
"<code>" + esc(r.appVersion) + "</code><code>" + esc(r.commit) + "</code>" +
|
||||||
'<span class="grow"></span><span class="when">' +
|
'<span class="grow"></span><span class="when">' +
|
||||||
esc(String(r.created).substring(0, 16)) + "</span></h3>" +
|
esc(String(r.created).substring(0, 16)) + "</span></h3>" +
|
||||||
|
progressBar(r) +
|
||||||
'<div class="kv">' +
|
'<div class="kv">' +
|
||||||
'<div><div class="lbl">prošlo</div><div class="big">' + (c.pass || 0) + "</div></div>" +
|
'<div><div class="lbl">prošlo</div><div class="big">' + (c.pass || 0) + "</div></div>" +
|
||||||
'<div><div class="lbl">spadlo</div><div class="big">' + (c.fail || 0) + "</div></div>" +
|
'<div><div class="lbl">spadlo</div><div class="big">' + (c.fail || 0) + "</div></div>" +
|
||||||
|
|
@ -1368,6 +1412,7 @@ async function load(){
|
||||||
if (prefs.view === "e2e") {
|
if (prefs.view === "e2e") {
|
||||||
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
|
var rs = await records("e2e_runs", { sort: "-created", perPage: 40 })
|
||||||
.catch(function(){ return { items: [] }; });
|
.catch(function(){ return { items: [] }; });
|
||||||
|
liveRun = (rs.items || []).some(isLive);
|
||||||
fileTok = "";
|
fileTok = "";
|
||||||
try {
|
try {
|
||||||
var ft = await api("/api/files/token", { method: "POST" });
|
var ft = await api("/api/files/token", { method: "POST" });
|
||||||
|
|
@ -1406,9 +1451,16 @@ function start(){
|
||||||
syncTabs();
|
syncTabs();
|
||||||
load();
|
load();
|
||||||
if (timer) clearInterval(timer);
|
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.
|
// 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;
|
if (tok) start(); else el("login").hidden = false;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue