mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
forgetest's page moves onto Bootstrap with forgectrl's theme; forgectrl pinned at the panel overhaul
The acceptance page is assembled by page.py from forgetest/forgetest/ui/ (index.html, page.css, help.js, app.js) plus theme.css and the vendored Bootstrap files, which are byte for byte the ones forgectrl's panel carries, so the two pages look like one product and share the light and dark themes (same localStorage key). A plain file is read in a checkout; on the dev image the recipe installs ui/ gzipped and page.py reads the .gz sibling, inflating once at first request: the rootfs is raw ext4, so bytes in the package are bytes on the image. The explanatory prose (campaign rules, the queues, the campaign actions, the prerequisites switch, the bench intro) is a "?" popover with a link into the documentation site; operator steps, prompts, notices and the live-laser acknowledgment stay in the page, and confirmLive() stays a blocking dialog. The page's own rules hold: rows, prompt buttons and tool entries are built once and updated in place, and the popovers sit on static markup only, so no rebuild orphans one. On a phone the Run pane goes to the top for the duration of a run. scripts/check-ui-vendor.py compares the shared files against forgectrl at its pinned revision (or a local checkout with --forgectrl); it runs in forgetest-ci.yml, so the copies cannot drift. Tests: test_page.py (the gzipped install assembles to the same bytes as a checkout, one self-contained response, the token placeholder once, a missing marker refused); test_server asserts the served page's invariants; test_responsiveness keeps its rules with needles pointed at the new files, its ASCII rule applied to our own sources (Bootstrap's CSS carries an em dash of its own), and its self-contained rule testing asset tags rather than the presence of https:// (the documentation links are meant to be there). forgectrl.panel-serves gains two needles for the panel's theme attribute and save bar. Proof: the unit suite, and the page in Chrome against a fake catalog (both themes, popovers, the bench tab, a full operator run with its prompt, abort). forgectrl pinned at 9d1f6f2 (the panel on Bootstrap, one save bar, help popovers, themes, the gzipped page); PV unchanged. The pin moves only forgectrl's fingerprint. The forgetest changes are the harness's own and have no catalog consequence.
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
# suites replayed on the machine's own log lines, and the check that
|
||||
# every log phrase the cloud suite greps for is one the pinned cloud
|
||||
# app can log (it reads the app sources from the manifest cache)
|
||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||
# carries are byte-identical to forgectrl's at its pinned revision
|
||||
# - coverage lint: every source path of every component pinned by the
|
||||
# recipes must be selected by some catalog test's coverage globs (the
|
||||
# tree manifest is generated from the pins with git - no Yocto build);
|
||||
@@ -54,6 +56,10 @@ jobs:
|
||||
working-directory: forgefirm/forgetest
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
- name: Shared UI files match forgectrl at its pinned revision
|
||||
working-directory: forgefirm
|
||||
run: python scripts/check-ui-vendor.py
|
||||
|
||||
- name: Coverage lint (enforced)
|
||||
working-directory: forgefirm/forgetest
|
||||
run: python -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+2
-1
@@ -433,7 +433,8 @@ recorded in `/data/forgetest/bench.jsonl` and never enter a campaign.
|
||||
artifact.py export + gate verification
|
||||
runner.py one run at a time, prompts, abort, takeover, queues
|
||||
baseline.py the fresh-boot idle state around every run
|
||||
server.py / page.py HTTP API + the page (forgectrl's access rules)
|
||||
server.py / page.py / ui/ HTTP API + the page (forgectrl's access rules;
|
||||
Bootstrap and the OpenGlow theme shared with the panel)
|
||||
bench.py / coverage.py bench registry + subprocess runner; the lint
|
||||
suite/ the catalog, one module per subsystem
|
||||
forgetest/tests/ host unit tests (python3 -m unittest discover -s tests)
|
||||
|
||||
+56
-481
@@ -1,486 +1,61 @@
|
||||
"""The single page: acceptance tab + bench tab. Self-contained (inline
|
||||
CSS/JS, ES5, no external assets); the visual identity follows the
|
||||
forgectrl control panel. State comes from GET /state, polled every 2 s
|
||||
when the machine is idle and every second during a run; the catalog and
|
||||
the bench listing are fetched once per tab and refreshed after a run
|
||||
finishes.
|
||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||
one self-contained response (no external assets). Bootstrap and the
|
||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||
bytes forgectrl's panel carries, so the two pages look like one product
|
||||
and follow the same light and dark themes (scripts/check-ui-vendor.py
|
||||
holds them identical).
|
||||
|
||||
Rows, prompt buttons and tool entries are built once and thereafter only
|
||||
updated in place. A poll that rebuilt them would swallow the click it
|
||||
landed on: the button that took the mousedown would be gone before the
|
||||
mouseup, so no click event would ever be raised. Every action also greys
|
||||
its control out on the press and pulls the next poll forward, so the page
|
||||
answers the operator rather than the timer."""
|
||||
ui/ holds index.html, theme.css, page.css, help.js, app.js and vendor/.
|
||||
A plain file is read when it is there (a checkout); otherwise its .gz
|
||||
sibling is, which is what the recipe installs on the dev image: the
|
||||
rootfs is raw ext4, so bytes in the package are bytes on the image, and
|
||||
the page is inflated once at first request. The token placeholder is
|
||||
spliced in per render."""
|
||||
import gzip
|
||||
import os
|
||||
|
||||
_HTML = r"""<!DOCTYPE html><html><head><meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
<title>ForgeFIRM acceptance</title>
|
||||
<style>
|
||||
:root{--navy:#2b2b5e;--red:#e8262a;--blue:#0088cc;--bg:#f0f1f4;--card:#fff;--line:#dde0e6;
|
||||
--txt:#222;--dim:#767a82;--ok:#3d854d;--warn:#c7760a;--inh:#5b6ab0}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px}
|
||||
header{background:var(--navy);color:#fff;display:flex;align-items:center;padding:10px 16px;gap:14px}
|
||||
header .app{font-size:19px;font-weight:600;letter-spacing:.3px}
|
||||
header .sub{color:rgba(255,255,255,.65);font-size:13px}
|
||||
header .ver{margin-left:auto;color:rgba(255,255,255,.7);font-size:13px;font-family:ui-monospace,Consolas,monospace}
|
||||
nav{background:var(--card);border-bottom:1px solid var(--line);display:flex;padding:0 8px}
|
||||
nav a{padding:10px 14px;color:var(--dim);text-decoration:none;border-bottom:2px solid transparent;cursor:pointer}
|
||||
nav a.on{color:var(--navy);font-weight:600;border-color:var(--red)}
|
||||
main{display:flex;gap:14px;padding:14px;align-items:flex-start}
|
||||
#left{flex:1 1 640px;min-width:0}
|
||||
#right{flex:0 0 420px;position:sticky;top:10px}
|
||||
@media(max-width:1000px){main{flex-direction:column}#right{position:static;flex:1 1 auto;width:100%}}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:12px}
|
||||
.card h2{font-size:12.5px;margin:0 0 10px;color:var(--navy);text-transform:uppercase;letter-spacing:.5px}
|
||||
.banner{display:flex;flex-wrap:wrap;gap:18px;align-items:center}
|
||||
.auth{font-size:20px;font-weight:700;padding:6px 14px;border-radius:6px;color:#fff;background:var(--red)}
|
||||
.auth.yes{background:var(--ok)}
|
||||
.kv{color:var(--dim);font-size:12.5px;line-height:1.7}
|
||||
.kv b{color:var(--txt);font-weight:600}
|
||||
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{font-size:11.5px;color:var(--dim);text-align:left;font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
|
||||
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
/* One grid for every subsystem. The groups are separate tables, so
|
||||
left to themselves each sizes its own columns from its own content
|
||||
and no two line up. Fixed widths put them all on the same rails. */
|
||||
#groups .grp{overflow-x:auto}
|
||||
#groups table{table-layout:fixed;min-width:700px}
|
||||
#groups col.k{width:132px}
|
||||
#groups col.s{width:250px}
|
||||
#groups col.l{width:210px}
|
||||
#groups col.a{width:96px}
|
||||
/* Why a test stands where it does reads as one block under Status. It
|
||||
used to sit under the Start button, where a requires list of three
|
||||
test ids wrapped into a ragged stack and dragged that group's columns
|
||||
out of line with every other group's. */
|
||||
#groups .req{overflow-wrap:break-word}
|
||||
/* The details sit in a full-width row of their own: inside the Test
|
||||
cell they would have to be read through a column, and opening one
|
||||
would push that group's grid out of step with the rest. */
|
||||
#groups td{border-bottom:0}
|
||||
#groups tr.detrow td{padding:0 8px;border-bottom:1px solid var(--line)}
|
||||
#groups tr.detrow:last-child td{border-bottom:0}
|
||||
#groups .details{margin:2px 0 8px}
|
||||
#groups .details .dsc{margin-bottom:6px}
|
||||
.tid{color:var(--dim);font-size:11.5px;font-family:ui-monospace,Consolas,monospace}
|
||||
.badge{display:inline-block;font-size:10.5px;padding:2px 6px;border-radius:9px;margin-right:4px;background:#e8e9ec;color:#444;font-weight:600;letter-spacing:.2px;text-transform:uppercase}
|
||||
.badge.live{background:#fbe1e1;color:#a11}
|
||||
.badge.operator{background:#fdf3e3;color:#8a5200}
|
||||
.badge.takeover{background:#e6e8f5;color:#33407a}
|
||||
.badge.mode{background:#e3f1e8;color:#1f5e3a}
|
||||
.badge.core{background:var(--navy);color:#fff}
|
||||
.st{font-weight:600}
|
||||
.st.pass{color:var(--ok)}.st.inherited{color:var(--inh)}.st.fail,.st.error{color:var(--red)}
|
||||
.st.stale,.st.aborted{color:var(--warn)}.st.none{color:var(--dim)}.st.running{color:var(--blue)}
|
||||
.req{font-size:11.5px;color:var(--warn)}
|
||||
button{background:#fff;color:var(--txt);border:1px solid #c9cdd4;border-radius:4px;padding:5px 11px;font-size:13px;cursor:pointer}
|
||||
button:hover{border-color:var(--navy)}
|
||||
button.pri{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600}
|
||||
button.pri:hover{background:#0077b3}
|
||||
button.danger{background:var(--red);border-color:var(--red);color:#fff}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
input[type=text],input[type=number],select{background:#fff;border:1px solid #c9cdd4;border-radius:4px;padding:5px 7px;font-size:13px}
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.hint{color:var(--dim);font-size:12.5px;line-height:1.55;margin:8px 0 0}
|
||||
pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:10px;border-radius:4px;height:380px;overflow:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all}
|
||||
#prompt{background:#fdf3e3;border:1px solid #eccb90;border-radius:6px;padding:10px 12px;margin:8px 0}
|
||||
#notice{background:#e8f1fb;border:1px solid #9dbde6;border-radius:6px;padding:10px 12px;margin:8px 0;font-weight:600}
|
||||
#steps{background:#f7f8fa;border-radius:6px;padding:8px 12px;margin:8px 0;font-size:12.5px;line-height:1.5}
|
||||
#steps .sh{font-weight:600;margin-bottom:4px}
|
||||
#steps .sub{color:var(--dim);margin:6px 0 2px}
|
||||
#steps ol{margin:2px 0 4px 20px}
|
||||
#steps .auto{color:var(--dim);font-style:italic}
|
||||
.tsel{cursor:pointer}.tsel:hover{text-decoration:underline}
|
||||
#prompt .q{font-weight:600;margin-bottom:8px}
|
||||
.details{display:none;background:#f7f8fa;padding:8px 10px;border-radius:4px;font-size:12.5px;line-height:1.55;margin-top:6px}
|
||||
.details.on{display:block}
|
||||
.msg{color:var(--blue);font-size:13px;margin:6px 0}
|
||||
.err{color:var(--red);font-size:13px;margin:6px 0}
|
||||
.note{background:#fdf3e3;border:1px solid #eccb90;border-radius:6px;padding:8px 12px;margin:8px 0;font-size:13px}
|
||||
.ack{display:block;margin:8px 0;font-size:12.5px}
|
||||
.switch{display:flex;align-items:center;gap:8px;margin:10px 0 0;font-size:13px}
|
||||
.switch input{width:16px;height:16px;margin:0}
|
||||
.switch .on{color:var(--warn);font-weight:600}
|
||||
.req.over{color:var(--dim)}
|
||||
.grp{margin-top:6px}
|
||||
.queue{background:#f7f8fa;margin:10px 0 0;padding:10px 12px}
|
||||
.queue h2{margin-bottom:8px}
|
||||
.queue .hint{margin-top:8px}
|
||||
.qline{font-size:12.5px;color:var(--dim);line-height:1.7;margin-top:8px}
|
||||
.qline b{color:var(--txt)}
|
||||
.qbar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:#e2e4e9;margin:6px 0}
|
||||
.qbar i{display:block}
|
||||
.qbar i.ok{background:var(--ok)}.qbar i.bad{background:var(--red)}
|
||||
.qbar i.skip{background:var(--warn)}.qbar i.now{background:var(--blue)}
|
||||
.badge.queued{background:#dde7f5;color:#24507f}
|
||||
.tool .argrow{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0}
|
||||
.tool .argrow label{font-size:12px;color:var(--dim)}
|
||||
</style></head><body>
|
||||
<header><span class='app'>ForgeFIRM acceptance</span><span class='sub' id='hdrsub'></span><span class='ver' id='hdrver'></span></header>
|
||||
<nav><a id='tab-acceptance' class='on' onclick='showTab("acceptance")'>Release acceptance</a><a id='tab-bench' onclick='showTab("bench")'>Bench diagnostics</a></nav>
|
||||
<main>
|
||||
<div id='left'>
|
||||
<div id='pane-acceptance'>
|
||||
<div class='card'><h2>Campaign</h2>
|
||||
<div class='banner'><div class='auth' id='auth'>?</div>
|
||||
<div class='kv' id='banner'></div></div>
|
||||
<div id='invnote'></div>
|
||||
<div class='card queue'><h2>Run what is left</h2>
|
||||
<div class='actions'>
|
||||
<button class='pri' id='q-unattended' onclick='startBatch("unattended")'>Unattended</button>
|
||||
<button class='pri' id='q-attended' onclick='startBatch("attended")'>Operator and live</button>
|
||||
<button class='danger' id='q-stop' onclick='stopBatch()' disabled>Stop the queue</button>
|
||||
</div>
|
||||
<div id='qstate'></div><div id='qmsg'></div><div id='fixture' class='hint'></div>
|
||||
<p class='hint'>Each queue takes every test of its kind that the campaign does not
|
||||
already count as satisfied, runs them one at a time in prerequisite order, and stops
|
||||
on the first result that is not a PASS. The unattended queue needs nobody in the room;
|
||||
with the bench fixture up, the operator tests it can perform by itself run there too.
|
||||
The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is
|
||||
still waiting and lets the run in progress finish; Abort ends that one too.</p>
|
||||
</div>
|
||||
<div class='actions' style='margin-top:10px'>
|
||||
<button class='pri' id='exportbtn' onclick='doExport()'>Export release artifact</button>
|
||||
<a id='dljson' href='/export/acceptance.json' style='display:none'><button>acceptance.json</button></a>
|
||||
<a id='dlmd' href='/export/acceptance.md' style='display:none'><button>acceptance.md</button></a>
|
||||
<a href='/log'><button>Raw log</button></a>
|
||||
<a href='/journal'><button>Runner journal</button></a>
|
||||
<button onclick='toggleInv()'>Invalidate all…</button>
|
||||
<button onclick='doReset()'>Reset campaign</button>
|
||||
</div>
|
||||
<div id='invform' style='display:none;margin-top:8px'>
|
||||
<input type='text' id='invreason' size='60' placeholder='reason (required): what changed on the bench'>
|
||||
<button class='danger' onclick='doInvalidate()'>Invalidate all results</button>
|
||||
</div>
|
||||
<div id='actmsg'></div>
|
||||
<label class='switch'><input type='checkbox' id='ignreq' onchange='setIgnoreReq(this.checked)'>
|
||||
<span>Ignore prerequisites - Start any test on its own, whatever its <i>requires</i> list says</span>
|
||||
<span id='ignreqon'></span></label>
|
||||
<p class='hint'>A release is authorized when a campaign is open on this image and every catalog test is satisfied - by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign. Invalidate-all forces a full campaign; give the reason. A test's <i>requires</i> list only orders the runs: with the switch above on, any test starts alone and its record notes which prerequisites were unmet - the release still needs every test satisfied.</p>
|
||||
</div>
|
||||
<div id='groups'></div>
|
||||
</div>
|
||||
<div id='pane-bench' style='display:none'>
|
||||
<div class='card'><h2>Bench diagnostics</h2>
|
||||
<p class='hint'>The bench tools (scripts/bench), run on the board with the output below. Runs here never enter a campaign. Tools not yet ported are listed for completeness; live tools need the operator acknowledgment; takeover tools stop forgectrl for the duration.</p>
|
||||
<div id='benchmsg'></div>
|
||||
</div>
|
||||
<div id='tools'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id='right'>
|
||||
<div class='card'><h2 id='runtitle'>Run</h2>
|
||||
<div id='runhead' class='kv'>idle</div>
|
||||
<div id='steps' style='display:none'></div>
|
||||
<div id='notice' style='display:none'></div>
|
||||
<div id='prompt' style='display:none'><div class='q' id='promptq'></div><div class='actions' id='promptb'></div></div>
|
||||
<pre id='log'></pre>
|
||||
<div class='actions'><button class='danger' id='abortbtn' onclick='doAbort()' disabled>Abort</button><span class='hint' id='runfoot'></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
var TOKEN='__TOKEN__';
|
||||
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
|
||||
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
|
||||
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
|
||||
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
|
||||
var PENDING_MS=6000;
|
||||
/* A poll that never answers must not wedge the loop: it times out
|
||||
and the next one is scheduled as usual. Actions carry no timeout,
|
||||
because a POST the server did act on must not read as a failure. */
|
||||
var POLL_TIMEOUT_MS=20000;
|
||||
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
|
||||
function $(id){return document.getElementById(id)}
|
||||
function nowMs(){return (new Date()).getTime()}
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
/* Touch the DOM only where the value really changed. Replacing a node
|
||||
under the operator's finger loses the click: the button that took the
|
||||
mousedown is gone before the mouseup, so no click event is ever
|
||||
raised, and the press does nothing. Nothing on a poll may rebuild a
|
||||
control that is only sitting there unchanged. */
|
||||
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
|
||||
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
|
||||
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
|
||||
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
|
||||
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
|
||||
if(timeoutMs)x.timeout=timeoutMs;
|
||||
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
|
||||
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
|
||||
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
|
||||
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
|
||||
x.send(body===undefined||body===null?null:JSON.stringify(body))}
|
||||
/* The state poll. It carries the last ETag, so an unchanged state costs
|
||||
a 304 and no re-render; a run gets a faster tick, and every action
|
||||
pulls the next poll forward instead of waiting out the interval. */
|
||||
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
|
||||
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
|
||||
function kick(){schedule(120)}
|
||||
function poll(){if(polling){schedule(150);return}polling=true;
|
||||
/* While an action is still in flight the state is asked for in full:
|
||||
a 304 would skip the render that releases the greyed-out buttons. */
|
||||
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
|
||||
api('GET','/state',null,function(s,d,x){polling=false;
|
||||
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
|
||||
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
|
||||
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
|
||||
function showTab(t){tab=t;$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
|
||||
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
|
||||
if(t==='bench'&&!bench)loadBench()}
|
||||
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
|
||||
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
|
||||
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
|
||||
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
|
||||
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
|
||||
if(state&&catalog)renderGroups()}
|
||||
/* A start already sent but not yet seen in the state counts as busy, so
|
||||
the buttons grey out on the click rather than on the next poll. The
|
||||
window is capped in case the answer never arrives. A queue holds the
|
||||
machine between its tests as well as during them. */
|
||||
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
|
||||
function isBusy(){if(state&&(state.running||batchActive()))return true;
|
||||
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
|
||||
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
|
||||
function render(){if(!state||!catalog)return;
|
||||
if(state.running||batchActive()){pending=0;pendingId=null}
|
||||
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
|
||||
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
|
||||
var c=state.campaign,cn=state.counts||{};var b='';
|
||||
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
|
||||
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
|
||||
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> · catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
|
||||
if(state.log_corrupt)b+='<br><span style="color:var(--red)">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
|
||||
setHtml($('banner'),b);
|
||||
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
|
||||
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
|
||||
/* The two queues: what each would run now, and how the running one is
|
||||
getting on. Built from the state, so a reload picks the queue back up
|
||||
exactly where it is - the queue lives in the runner, not in this tab. */
|
||||
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
|
||||
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
|
||||
if(!f){setHtml(e,'');return}
|
||||
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
|
||||
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
|
||||
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
|
||||
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
|
||||
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
|
||||
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
|
||||
renderFixture();
|
||||
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
|
||||
var ids=av[p[0]]||[];
|
||||
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
|
||||
setDis(e,busy||!ids.length);
|
||||
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
|
||||
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
|
||||
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
|
||||
if(!b){setHtml($('qstate'),'');return}
|
||||
var total=b.order.length||1,ok=0,bad=0;
|
||||
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
|
||||
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
|
||||
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
|
||||
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
|
||||
esc(fmtTs(b.ts))+" · ";
|
||||
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
|
||||
(b.stopping?'stopping':'starting'));
|
||||
h+=" · <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
|
||||
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
|
||||
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
|
||||
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
|
||||
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
|
||||
setHtml($('qstate'),h+"</div>")}
|
||||
/* The rows are built once for a given catalog and then only updated in
|
||||
place: a poll never rewrites the table, so a Start button survives the
|
||||
press that is landing on it. */
|
||||
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
|
||||
var sig=catalogHash+'|'+ids.join(',');
|
||||
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
|
||||
updateGroups()}
|
||||
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
|
||||
function buildGroups(){var groups={},order=[];
|
||||
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
|
||||
var h='';
|
||||
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);var badges='';
|
||||
if(t.always)badges+="<span class='badge core'>core</span>";badges+="<span class='badge "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
|
||||
if(t.hardware==='takeover')badges+="<span class='badge takeover'>takeover</span>";
|
||||
if(t.mode)badges+="<span class='badge mode'>"+esc(t.mode)+"</span>";
|
||||
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
|
||||
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
|
||||
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
|
||||
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
|
||||
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
|
||||
"<br><span id='detdyn-"+d+"'></span></div>";
|
||||
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
|
||||
"</td><td>"+badges+"</td>"+
|
||||
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
|
||||
"<td id='last-"+d+"'></td>"+
|
||||
"<td><button class='pri' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
|
||||
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
|
||||
h+="</table></div>"});
|
||||
$('groups').innerHTML=h;rowEls={};
|
||||
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
|
||||
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
|
||||
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
|
||||
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
|
||||
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
|
||||
var st;
|
||||
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting…</span>"}
|
||||
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
|
||||
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
|
||||
if(queued[t.id])st+="<br><span class='badge queued'>queued</span>";
|
||||
setHtml(e.st,st);
|
||||
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
|
||||
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
|
||||
setHtml(e.last,last);
|
||||
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
|
||||
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
|
||||
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
|
||||
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
|
||||
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> · est. "+esc(t.est_min)+" min"+
|
||||
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
|
||||
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
|
||||
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
|
||||
if(key!==lastRunKey)abortSent=false;
|
||||
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
|
||||
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
|
||||
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
|
||||
var hd=esc(r.id)+' · started '+esc(fmtTs(r.started))+' · '+r.elapsed_s+' s';
|
||||
if(r.finished){hd+=' · <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
|
||||
else if(r.aborting){hd+=' · <b class="st stale">aborting</b>'}
|
||||
setHtml($('runhead'),hd);
|
||||
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
|
||||
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
|
||||
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
|
||||
lastRunKey=key;
|
||||
var nt=(r.notice&&!r.finished)?r.notice.text:'';
|
||||
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
|
||||
/* The prompt buttons are rebuilt only when the prompt itself changes -
|
||||
they are the ones the operator stares at before pressing. */
|
||||
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
|
||||
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
|
||||
$('promptq').textContent=r.prompt.question;var pb='';
|
||||
r.prompt.options.forEach(function(o,i){pb+="<button class='pri' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
|
||||
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
|
||||
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
|
||||
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
|
||||
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
|
||||
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
|
||||
/* What the operator will be asked to do, shown before it is asked: the
|
||||
running test's steps for the whole run, the queue's attended tests
|
||||
before the first one starts, or the test the operator clicked on. The
|
||||
prompts and notices that follow are these steps, taken in turn. */
|
||||
var selected=null;
|
||||
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
|
||||
function stepsOf(t,head){var h='';
|
||||
var acts=(t.actions||[]);
|
||||
if(head)h+="<div class='sub'>"+head+"</div>";
|
||||
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
|
||||
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
|
||||
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
|
||||
return h}
|
||||
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
|
||||
if(r&&r.kind==='test'){var t=findTest(r.id);
|
||||
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
|
||||
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
|
||||
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
|
||||
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
|
||||
else if(selected&&!r){var t=findTest(selected);
|
||||
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
|
||||
setHtml(e,h);e.style.display=h?'':'none'}
|
||||
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
|
||||
if(ignoreReq)body.ignore_requires=true;
|
||||
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
|
||||
setMsg('actmsg','starting '+id+'...');
|
||||
api('POST','/start',body,function(s,d){
|
||||
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
|
||||
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
function confirmLive(live){return window.confirm(
|
||||
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
|
||||
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
|
||||
(live?'Start the queue?':'Start the test?'))}
|
||||
/* A queue takes the machine for a long stretch, so both what it will run
|
||||
and the acknowledgment it needs are put in front of the operator once,
|
||||
before anything starts. */
|
||||
function startBatch(group){if(isBusy())return;
|
||||
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
|
||||
if(!ids.length)return;
|
||||
var body={group:group};if(ignoreReq)body.ignore_requires=true;
|
||||
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
|
||||
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
|
||||
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
|
||||
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
|
||||
setMsg('qmsg','starting the '+group+' queue...');
|
||||
api('POST','/batch',body,function(s,d){
|
||||
if(s!==200){pending=0;updateGroups();renderQueue()}
|
||||
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
|
||||
setMsg('qmsg','stopping the queue...');
|
||||
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
|
||||
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
|
||||
promptBusy(true);setMsg('actmsg','answer sent: '+v);
|
||||
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
|
||||
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
|
||||
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
|
||||
setMsg('actmsg','aborting...');
|
||||
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
|
||||
api('POST','/export',{},function(s,d){setDis(b,false);
|
||||
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
|
||||
else setMsg('actmsg',d.message||d.error,true)})}
|
||||
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
|
||||
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
|
||||
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
|
||||
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
|
||||
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
/* Same rule as the acceptance rows: the tool list is built once and then
|
||||
only updated, so a run starting never rewrites the arg fields the
|
||||
operator has typed into or the button being pressed. */
|
||||
function renderBench(){if(!bench)return;var ids=[];
|
||||
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
|
||||
var sig=ids.join(',');
|
||||
if(sig!==benchKey){benchKey=sig;buildBench()}
|
||||
updateBench()}
|
||||
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
|
||||
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
|
||||
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
|
||||
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);
|
||||
h+="<div class='tool' style='border-bottom:1px solid var(--line);padding:8px 0'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='badge'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='badge'>unported</span>")+"</div>";
|
||||
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
|
||||
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
|
||||
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
|
||||
else{h+="<label>"+esc(a.name)+" <input type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"' style='width:90px'></label>"}});h+="</div>"}
|
||||
h+="<div class='actions'><button class='pri' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
|
||||
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
|
||||
h+="</div>"});
|
||||
$('tools').innerHTML=h;benchEls={};
|
||||
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
|
||||
function updateBench(){if(!benchEls)return;var busy=isBusy();
|
||||
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
|
||||
var can=t.ported&&t.installed&&!busy;
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
|
||||
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
|
||||
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
|
||||
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
|
||||
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=null;updateBench();updateGroups();
|
||||
setMsg('benchmsg','starting '+id+'...');
|
||||
api('POST','/bench/start',body,function(s,d){
|
||||
if(s!==200){pending=0;updateBench();updateGroups()}
|
||||
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
|
||||
setIgnoreReq(ignoreReq);
|
||||
poll();
|
||||
</script></body></html>
|
||||
"""
|
||||
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui")
|
||||
# The files index.html links, in load order: one marker tag per file.
|
||||
CSS_FILES = ("vendor/bootstrap.min.css", "theme.css", "page.css")
|
||||
JS_FILES = ("vendor/bootstrap.bundle.min.js", "help.js", "app.js")
|
||||
TOKEN_MARK = "__TOKEN__"
|
||||
|
||||
|
||||
def read_ui(name, ui_dir=UI_DIR):
|
||||
"""A ui/ file as text: the plain file, or its gzipped install."""
|
||||
path = os.path.join(ui_dir, *name.split("/"))
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read().decode("utf-8")
|
||||
except FileNotFoundError:
|
||||
with gzip.open(path + ".gz", "rb") as f:
|
||||
return f.read().decode("utf-8")
|
||||
|
||||
|
||||
def assemble(ui_dir=UI_DIR):
|
||||
"""index.html with every linked stylesheet and script inlined in place
|
||||
of its tag. A missing marker is a broken page, not a silent one."""
|
||||
html = read_ui("index.html", ui_dir)
|
||||
for name in CSS_FILES:
|
||||
tag = '<link rel="stylesheet" href="%s" />' % name
|
||||
if tag not in html:
|
||||
raise ValueError("index.html lacks the marker %s" % tag)
|
||||
html = html.replace(tag, "<style>\n" + read_ui(name, ui_dir) + "</style>", 1)
|
||||
for name in JS_FILES:
|
||||
tag = '<script src="%s"></script>' % name
|
||||
if tag not in html:
|
||||
raise ValueError("index.html lacks the marker %s" % tag)
|
||||
html = html.replace(tag, "<script>\n" + read_ui(name, ui_dir) + "</script>", 1)
|
||||
if html.count(TOKEN_MARK) != 1:
|
||||
raise ValueError("the page must carry %s exactly once" % TOKEN_MARK)
|
||||
return html
|
||||
|
||||
|
||||
_html = None
|
||||
|
||||
|
||||
def render(token):
|
||||
return _HTML.replace("__TOKEN__", token)
|
||||
global _html
|
||||
if _html is None:
|
||||
_html = assemble()
|
||||
return _html.replace(TOKEN_MARK, token)
|
||||
|
||||
@@ -227,6 +227,11 @@ def panel_serves(ctx):
|
||||
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
|
||||
ctx.check("<link " not in text and "<script src=" not in text,
|
||||
"the panel references an external asset (the build did not bundle src/ui/)")
|
||||
# The daemon stores the page gzipped and inflates it once at first
|
||||
# request; what it serves is the plain page with the theme attribute
|
||||
# the head script sets and the one save bar every settings tab shares.
|
||||
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
|
||||
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
|
||||
|
||||
s = fc.status()
|
||||
for key in ("state", "switches", "coolant", "fans"):
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* app.js - forgetest: the acceptance page
|
||||
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Acceptance tab + bench tab. State comes from GET /state, polled every
|
||||
* 2 s when the machine is idle and every second during a run; the catalog
|
||||
* and the bench listing are fetched once per tab and refreshed after a
|
||||
* run finishes.
|
||||
*
|
||||
* Rows, prompt buttons and tool entries are built once and thereafter
|
||||
* only updated in place. A poll that rebuilt them would swallow the click
|
||||
* it landed on: the button that took the mousedown would be gone before
|
||||
* the mouseup, so no click event would ever be raised. Every action also
|
||||
* greys its control out on the press and pulls the next poll forward, so
|
||||
* the page answers the operator rather than the timer. The help popovers
|
||||
* (help.js) sit on static elements only, so no rebuild ever orphans one.
|
||||
*
|
||||
* TOKEN is the bearer token page.py splices in when it serves the page.
|
||||
*/
|
||||
var TOKEN='__TOKEN__';
|
||||
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
|
||||
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
|
||||
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
|
||||
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
|
||||
var PENDING_MS=6000;
|
||||
/* A poll that never answers must not wedge the loop: it times out
|
||||
and the next one is scheduled as usual. Actions carry no timeout,
|
||||
because a POST the server did act on must not read as a failure. */
|
||||
var POLL_TIMEOUT_MS=20000;
|
||||
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
|
||||
function $(id){return document.getElementById(id)}
|
||||
function nowMs(){return (new Date()).getTime()}
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
/* Touch the DOM only where the value really changed. Replacing a node
|
||||
under the operator's finger loses the click: the button that took the
|
||||
mousedown is gone before the mouseup, so no click event is ever
|
||||
raised, and the press does nothing. Nothing on a poll may rebuild a
|
||||
control that is only sitting there unchanged. */
|
||||
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
|
||||
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
|
||||
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
|
||||
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
|
||||
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
|
||||
if(timeoutMs)x.timeout=timeoutMs;
|
||||
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
|
||||
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
|
||||
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
|
||||
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
|
||||
x.send(body===undefined||body===null?null:JSON.stringify(body))}
|
||||
/* The state poll. It carries the last ETag, so an unchanged state costs
|
||||
a 304 and no re-render; a run gets a faster tick, and every action
|
||||
pulls the next poll forward instead of waiting out the interval. */
|
||||
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
|
||||
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
|
||||
function kick(){schedule(120)}
|
||||
function poll(){if(polling){schedule(150);return}polling=true;
|
||||
/* While an action is still in flight the state is asked for in full:
|
||||
a 304 would skip the render that releases the greyed-out buttons. */
|
||||
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
|
||||
api('GET','/state',null,function(s,d,x){polling=false;
|
||||
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
|
||||
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
|
||||
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
|
||||
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
|
||||
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
|
||||
if(t==='bench'&&!bench)loadBench()}
|
||||
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
|
||||
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
|
||||
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
|
||||
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
|
||||
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
|
||||
if(state&&catalog)renderGroups()}
|
||||
/* A start already sent but not yet seen in the state counts as busy, so
|
||||
the buttons grey out on the click rather than on the next poll. The
|
||||
window is capped in case the answer never arrives. A queue holds the
|
||||
machine between its tests as well as during them. */
|
||||
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
|
||||
function isBusy(){if(state&&(state.running||batchActive()))return true;
|
||||
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
|
||||
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
|
||||
function render(){if(!state||!catalog)return;
|
||||
if(state.running||batchActive()){pending=0;pendingId=null}
|
||||
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
|
||||
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
|
||||
var c=state.campaign,cn=state.counts||{};var b='';
|
||||
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
|
||||
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
|
||||
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> · catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
|
||||
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
|
||||
setHtml($('banner'),b);
|
||||
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
|
||||
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
|
||||
/* The two queues: what each would run now, and how the running one is
|
||||
getting on. Built from the state, so a reload picks the queue back up
|
||||
exactly where it is - the queue lives in the runner, not in this tab. */
|
||||
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
|
||||
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
|
||||
if(!f){setHtml(e,'');return}
|
||||
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
|
||||
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
|
||||
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
|
||||
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
|
||||
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
|
||||
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
|
||||
renderFixture();
|
||||
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
|
||||
var ids=av[p[0]]||[];
|
||||
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
|
||||
setDis(e,busy||!ids.length);
|
||||
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
|
||||
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
|
||||
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
|
||||
if(!b){setHtml($('qstate'),'');return}
|
||||
var total=b.order.length||1,ok=0,bad=0;
|
||||
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
|
||||
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
|
||||
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
|
||||
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
|
||||
esc(fmtTs(b.ts))+" · ";
|
||||
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
|
||||
(b.stopping?'stopping':'starting'));
|
||||
h+=" · <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
|
||||
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
|
||||
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
|
||||
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
|
||||
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
|
||||
setHtml($('qstate'),h+"</div>")}
|
||||
/* The rows are built once for a given catalog and then only updated in
|
||||
place: a poll never rewrites the table, so a Start button survives the
|
||||
press that is landing on it. */
|
||||
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
|
||||
var sig=catalogHash+'|'+ids.join(',');
|
||||
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
|
||||
updateGroups()}
|
||||
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
|
||||
function buildGroups(){var groups={},order=[];
|
||||
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
|
||||
var h='';
|
||||
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
|
||||
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
|
||||
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
|
||||
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
|
||||
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
|
||||
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
|
||||
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
|
||||
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
|
||||
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
|
||||
"<br><span id='detdyn-"+d+"'></span></div>";
|
||||
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
|
||||
"</td><td>"+tags+"</td>"+
|
||||
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
|
||||
"<td id='last-"+d+"'></td>"+
|
||||
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
|
||||
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
|
||||
h+="</table></div>"});
|
||||
$('groups').innerHTML=h;rowEls={};
|
||||
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
|
||||
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
|
||||
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
|
||||
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
|
||||
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
|
||||
var st;
|
||||
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting…</span>"}
|
||||
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
|
||||
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
|
||||
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
|
||||
setHtml(e.st,st);
|
||||
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
|
||||
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
|
||||
setHtml(e.last,last);
|
||||
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
|
||||
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
|
||||
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
|
||||
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
|
||||
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> · est. "+esc(t.est_min)+" min"+
|
||||
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
|
||||
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
|
||||
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
|
||||
if(key!==lastRunKey)abortSent=false;
|
||||
document.querySelector('main').classList.toggle('running',!!state.running);
|
||||
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
|
||||
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
|
||||
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
|
||||
var hd=esc(r.id)+' · started '+esc(fmtTs(r.started))+' · '+r.elapsed_s+' s';
|
||||
if(r.finished){hd+=' · <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
|
||||
else if(r.aborting){hd+=' · <b class="st stale">aborting</b>'}
|
||||
setHtml($('runhead'),hd);
|
||||
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
|
||||
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
|
||||
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
|
||||
lastRunKey=key;
|
||||
var nt=(r.notice&&!r.finished)?r.notice.text:'';
|
||||
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
|
||||
/* The prompt buttons are rebuilt only when the prompt itself changes -
|
||||
they are the ones the operator stares at before pressing. */
|
||||
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
|
||||
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
|
||||
$('promptq').textContent=r.prompt.question;var pb='';
|
||||
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
|
||||
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
|
||||
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
|
||||
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
|
||||
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
|
||||
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
|
||||
/* What the operator will be asked to do, shown before it is asked: the
|
||||
running test's steps for the whole run, the queue's attended tests
|
||||
before the first one starts, or the test the operator clicked on. The
|
||||
prompts and notices that follow are these steps, taken in turn. */
|
||||
var selected=null;
|
||||
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
|
||||
function stepsOf(t,head){var h='';
|
||||
var acts=(t.actions||[]);
|
||||
if(head)h+="<div class='sub'>"+head+"</div>";
|
||||
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
|
||||
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
|
||||
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
|
||||
return h}
|
||||
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
|
||||
if(r&&r.kind==='test'){var t=findTest(r.id);
|
||||
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
|
||||
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
|
||||
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
|
||||
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
|
||||
else if(selected&&!r){var t=findTest(selected);
|
||||
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
|
||||
setHtml(e,h);e.style.display=h?'':'none'}
|
||||
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
|
||||
if(ignoreReq)body.ignore_requires=true;
|
||||
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
|
||||
setMsg('actmsg','starting '+id+'...');
|
||||
api('POST','/start',body,function(s,d){
|
||||
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
|
||||
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
|
||||
is the one thing on this page that must not be dismissed by a stray
|
||||
click or a poll. */
|
||||
function confirmLive(live){return window.confirm(
|
||||
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
|
||||
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
|
||||
(live?'Start the queue?':'Start the test?'))}
|
||||
/* A queue takes the machine for a long stretch, so both what it will run
|
||||
and the acknowledgment it needs are put in front of the operator once,
|
||||
before anything starts. */
|
||||
function startBatch(group){if(isBusy())return;
|
||||
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
|
||||
if(!ids.length)return;
|
||||
var body={group:group};if(ignoreReq)body.ignore_requires=true;
|
||||
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
|
||||
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
|
||||
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
|
||||
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
|
||||
setMsg('qmsg','starting the '+group+' queue...');
|
||||
api('POST','/batch',body,function(s,d){
|
||||
if(s!==200){pending=0;updateGroups();renderQueue()}
|
||||
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
|
||||
setMsg('qmsg','stopping the queue...');
|
||||
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
|
||||
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
|
||||
promptBusy(true);setMsg('actmsg','answer sent: '+v);
|
||||
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
|
||||
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
|
||||
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
|
||||
setMsg('actmsg','aborting...');
|
||||
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
|
||||
api('POST','/export',{},function(s,d){setDis(b,false);
|
||||
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
|
||||
else setMsg('actmsg',d.message||d.error,true)})}
|
||||
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
|
||||
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
|
||||
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
|
||||
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
|
||||
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
/* Same rule as the acceptance rows: the tool list is built once and then
|
||||
only updated, so a run starting never rewrites the arg fields the
|
||||
operator has typed into or the button being pressed. */
|
||||
function renderBench(){if(!bench)return;var ids=[];
|
||||
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
|
||||
var sig=ids.join(',');
|
||||
if(sig!==benchKey){benchKey=sig;buildBench()}
|
||||
updateBench()}
|
||||
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
|
||||
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
|
||||
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
|
||||
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);
|
||||
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
|
||||
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
|
||||
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
|
||||
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
|
||||
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
|
||||
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
|
||||
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
|
||||
h+="</div>"});
|
||||
$('tools').innerHTML=h;benchEls={};
|
||||
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
|
||||
function updateBench(){if(!benchEls)return;var busy=isBusy();
|
||||
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
|
||||
var can=t.ported&&t.installed&&!busy;
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
|
||||
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
|
||||
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
|
||||
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
|
||||
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=null;updateBench();updateGroups();
|
||||
setMsg('benchmsg','starting '+id+'...');
|
||||
api('POST','/bench/start',body,function(s,d){
|
||||
if(s!==200){pending=0;updateBench();updateGroups()}
|
||||
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
|
||||
|
||||
/* ---- Theme ----------------------------------------------------------
|
||||
Light, dark, or auto (the system preference); the choice lives in
|
||||
localStorage under the key forgectrl's panel uses, so the two pages
|
||||
agree. The head script in index.html applies it before first paint. */
|
||||
var THEMES=['auto','light','dark'];
|
||||
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
|
||||
function applyTheme(){var c=themeChoice(),t=c;
|
||||
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
||||
document.documentElement.setAttribute('data-bs-theme',t);
|
||||
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
|
||||
$('themebtn').title='Theme: '+c+' (click to change)'}
|
||||
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
|
||||
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
|
||||
applyTheme()}
|
||||
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
|
||||
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
|
||||
|
||||
initHelp();
|
||||
setIgnoreReq(ignoreReq);
|
||||
poll();
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* help.js - forgetest: the page's help text
|
||||
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Every explanation the page offers lives here, keyed by the data-help
|
||||
* attribute of the "?" button that opens it (index.html): a title, its
|
||||
* paragraphs, and the page of the documentation site it deep-links to
|
||||
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
|
||||
* click anywhere else, Escape, or a tab change closes the open one. The
|
||||
* buttons sit on static elements only: the rows, prompts and tool entries
|
||||
* are rebuilt by app.js, and a popover on one of those would be orphaned.
|
||||
* What the operator must read mid-run (the steps, prompts, notices and
|
||||
* the live-laser acknowledgment) stays in the page and never moves here.
|
||||
*/
|
||||
var DOC_BASE = 'https://docs.openglow.org/forgefirm/';
|
||||
var HELP = {
|
||||
campaign: {
|
||||
t: 'Campaign',
|
||||
d: 'acceptance/campaign',
|
||||
p: [
|
||||
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
|
||||
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
|
||||
]
|
||||
},
|
||||
queues: {
|
||||
t: 'Run what is left',
|
||||
d: 'acceptance/queues',
|
||||
p: [
|
||||
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
|
||||
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
|
||||
]
|
||||
},
|
||||
actions: {
|
||||
t: 'Campaign actions',
|
||||
d: 'acceptance/actions',
|
||||
p: [
|
||||
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
|
||||
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
|
||||
]
|
||||
},
|
||||
ignore: {
|
||||
t: 'Ignore prerequisites',
|
||||
d: 'acceptance/prerequisites',
|
||||
p: [
|
||||
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
|
||||
]
|
||||
},
|
||||
bench: {
|
||||
t: 'Bench diagnostics',
|
||||
d: 'bench/tools',
|
||||
p: [
|
||||
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
|
||||
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var helpOpen = null;
|
||||
function helpContent(h) {
|
||||
var d = document.createElement('div'),
|
||||
i,
|
||||
p,
|
||||
a;
|
||||
for (i = 0; i < h.p.length; i++) {
|
||||
p = document.createElement('p');
|
||||
p.textContent = h.p[i];
|
||||
d.appendChild(p);
|
||||
}
|
||||
a = document.createElement('a');
|
||||
a.className = 'doclink';
|
||||
a.href = DOC_BASE + h.d;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Documentation \u2192';
|
||||
d.appendChild(a);
|
||||
return d;
|
||||
}
|
||||
function closeHelp() {
|
||||
if (helpOpen) {
|
||||
helpOpen.hide();
|
||||
helpOpen = null;
|
||||
}
|
||||
}
|
||||
function initHelp() {
|
||||
var els = document.querySelectorAll('[data-help]'),
|
||||
i;
|
||||
for (i = 0; i < els.length; i++) {
|
||||
(function (el) {
|
||||
var h = HELP[el.getAttribute('data-help')];
|
||||
if (!h) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var pop = new bootstrap.Popover(el, {
|
||||
trigger: 'manual',
|
||||
html: true,
|
||||
placement: 'auto',
|
||||
title: h.t,
|
||||
content: helpContent(h),
|
||||
container: 'body'
|
||||
});
|
||||
el.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (helpOpen === pop) {
|
||||
closeHelp();
|
||||
return;
|
||||
}
|
||||
closeHelp();
|
||||
pop.show();
|
||||
helpOpen = pop;
|
||||
});
|
||||
el.addEventListener('hidden.bs.popover', function () {
|
||||
if (helpOpen === pop) helpOpen = null;
|
||||
});
|
||||
})(els[i]);
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var tip = document.querySelector('.popover.show');
|
||||
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeHelp();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<!--
|
||||
index.html - forgetest: the acceptance page
|
||||
Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
Served at "/" as one self-contained page: page.py inlines every
|
||||
stylesheet and script linked below, in order, in place of its
|
||||
link/script tag. No external assets. Bootstrap (vendor/) is the UI
|
||||
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
|
||||
for byte, in a light and a dark variant selected by the data-bs-theme
|
||||
attribute the head script sets before first paint; page.css is this
|
||||
page's own layout. Design notes are at the top of app.js.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>ForgeFIRM acceptance</title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = 'light';
|
||||
try {
|
||||
var s = localStorage.getItem('ff_theme');
|
||||
if (s === 'light' || s === 'dark') t = s;
|
||||
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
t = 'dark';
|
||||
} catch (e) {}
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="theme.css" />
|
||||
<link rel="stylesheet" href="page.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="hrow">
|
||||
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
|
||||
><span class="ver" id="hdrver"></span
|
||||
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<div class="nrow">
|
||||
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
|
||||
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="left">
|
||||
<div id="pane-acceptance">
|
||||
<div class="card">
|
||||
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
|
||||
<div class="banner">
|
||||
<div class="auth" id="auth">?</div>
|
||||
<div class="info" id="banner"></div>
|
||||
</div>
|
||||
<div id="invnote"></div>
|
||||
<div class="card queue">
|
||||
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
|
||||
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
|
||||
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
|
||||
</div>
|
||||
<div id="qstate"></div>
|
||||
<div id="qmsg"></div>
|
||||
<div id="fixture" class="hint"></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 10px">
|
||||
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all…</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
|
||||
<button class="hlp" data-help="actions" type="button">?</button>
|
||||
</div>
|
||||
<div id="invform" style="display: none">
|
||||
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
|
||||
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
|
||||
</div>
|
||||
<div id="actmsg"></div>
|
||||
<div class="switch">
|
||||
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
|
||||
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
|
||||
<span id="ignreqon"></span>
|
||||
<button class="hlp" data-help="ignore" type="button">?</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="groups"></div>
|
||||
</div>
|
||||
<div id="pane-bench" style="display: none">
|
||||
<div class="card">
|
||||
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
|
||||
<p class="hint">
|
||||
The bench tools (scripts/bench), run on the board with their output in the Run
|
||||
pane. Runs here never enter a campaign; live tools need the operator
|
||||
acknowledgment, and takeover tools stop forgectrl for the duration.
|
||||
</p>
|
||||
<div id="benchmsg"></div>
|
||||
</div>
|
||||
<div id="tools"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<div class="card">
|
||||
<h2 id="runtitle">Run</h2>
|
||||
<div id="runhead" class="info">idle</div>
|
||||
<div id="steps" style="display: none"></div>
|
||||
<div id="notice" style="display: none"></div>
|
||||
<div id="prompt" style="display: none">
|
||||
<div class="q" id="promptq"></div>
|
||||
<div class="actions" id="promptb"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
|
||||
><span class="hint" id="runfoot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* page.css - forgetest: the acceptance page's own styles
|
||||
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Everything specific to this page, on the tokens theme.css defines
|
||||
* (theme.css is the same file forgectrl's panel uses, so the two look like
|
||||
* one product and follow the same light and dark themes). This page is a
|
||||
* wide two-pane layout, so the 900 px column theme.css gives the panel is
|
||||
* lifted here.
|
||||
*/
|
||||
.hrow,
|
||||
.nrow,
|
||||
main {
|
||||
max-width: none;
|
||||
}
|
||||
.hrow .appname {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.hrow .sub {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.hrow .ver {
|
||||
margin-left: auto;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12.5px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
nav a {
|
||||
cursor: pointer;
|
||||
}
|
||||
main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#left {
|
||||
flex: 1 1 640px;
|
||||
min-width: 0;
|
||||
}
|
||||
#right {
|
||||
flex: 0 0 420px;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
#right {
|
||||
position: static;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
/* A run in progress is what the operator is watching: on a phone the
|
||||
run pane goes to the top for its duration. */
|
||||
main.running #right {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.auth {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: var(--og-red);
|
||||
}
|
||||
.auth.yes {
|
||||
background: var(--og-ok);
|
||||
}
|
||||
.info {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.info b {
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mono {
|
||||
font-size: 12px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
font-size: 11.5px;
|
||||
color: var(--og-dim);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
vertical-align: top;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* One grid for every subsystem. The groups are separate tables, so
|
||||
left to themselves each sizes its own columns from its own content
|
||||
and no two line up. Fixed widths put them all on the same rails. */
|
||||
#groups .grp {
|
||||
overflow-x: auto;
|
||||
}
|
||||
#groups table {
|
||||
table-layout: fixed;
|
||||
min-width: 700px;
|
||||
}
|
||||
#groups col.k {
|
||||
width: 132px;
|
||||
}
|
||||
#groups col.s {
|
||||
width: 250px;
|
||||
}
|
||||
#groups col.l {
|
||||
width: 210px;
|
||||
}
|
||||
#groups col.a {
|
||||
width: 96px;
|
||||
}
|
||||
/* Why a test stands where it does reads as one block under Status; a
|
||||
requires list of three test ids would otherwise wrap into a ragged
|
||||
stack under the Start button and drag the columns out of line. */
|
||||
#groups .req {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
/* The details sit in a full-width row of their own: inside the Test
|
||||
cell they would have to be read through a column, and opening one
|
||||
would push that group's grid out of step with the rest. */
|
||||
#groups td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
#groups tr.detrow td {
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
#groups tr.detrow:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
#groups .details {
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
#groups .details .dsc {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tid {
|
||||
color: var(--og-dim);
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 10.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 9px;
|
||||
margin-right: 4px;
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.live {
|
||||
background: var(--og-bad-bg);
|
||||
color: var(--og-red);
|
||||
}
|
||||
.tag.operator {
|
||||
background: var(--og-note-bg);
|
||||
color: var(--og-note-txt);
|
||||
}
|
||||
.tag.takeover {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-title);
|
||||
}
|
||||
.tag.mode {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.tag.core {
|
||||
background: var(--og-navy);
|
||||
color: #fff;
|
||||
}
|
||||
.tag.queued {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-action);
|
||||
}
|
||||
.st {
|
||||
font-weight: 600;
|
||||
}
|
||||
.st.pass {
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.st.inherited {
|
||||
color: var(--og-title);
|
||||
}
|
||||
.st.fail,
|
||||
.st.error {
|
||||
color: var(--og-red);
|
||||
}
|
||||
.st.stale,
|
||||
.st.aborted {
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.st.none {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.st.running {
|
||||
color: var(--og-action);
|
||||
}
|
||||
.req {
|
||||
font-size: 11.5px;
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.req.over {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.actions .hint {
|
||||
margin: 0;
|
||||
}
|
||||
pre#log {
|
||||
background: var(--og-term-bg);
|
||||
color: var(--og-term-txt);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
height: 380px;
|
||||
overflow: auto;
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
#prompt {
|
||||
background: var(--og-note-bg);
|
||||
border: 1px solid var(--og-note-line);
|
||||
color: var(--og-note-txt);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
#prompt .q {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#notice {
|
||||
background: var(--og-line-soft);
|
||||
border: 1px solid var(--og-action);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
#steps {
|
||||
background: var(--og-line-soft);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#steps .sh {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#steps .sub {
|
||||
color: var(--og-dim);
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
#steps ol {
|
||||
margin: 2px 0 4px 20px;
|
||||
}
|
||||
#steps .auto {
|
||||
color: var(--og-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.tsel {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tsel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.details {
|
||||
display: none;
|
||||
background: var(--og-line-soft);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.details.on {
|
||||
display: block;
|
||||
}
|
||||
.msg {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.err {
|
||||
color: var(--og-red);
|
||||
font-size: 12.5px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.switch input {
|
||||
margin: 0;
|
||||
}
|
||||
.switch .on {
|
||||
color: var(--og-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.grp {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.queue {
|
||||
background: var(--og-line-soft);
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.queue h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.qline {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-dim);
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.qline b {
|
||||
color: var(--og-txt);
|
||||
}
|
||||
.qbar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--og-line);
|
||||
margin: 6px 0;
|
||||
}
|
||||
.qbar i {
|
||||
display: block;
|
||||
}
|
||||
.qbar i.ok {
|
||||
background: var(--og-ok);
|
||||
}
|
||||
.qbar i.bad {
|
||||
background: var(--og-red);
|
||||
}
|
||||
.qbar i.skip {
|
||||
background: var(--og-warn);
|
||||
}
|
||||
.qbar i.now {
|
||||
background: var(--og-action);
|
||||
}
|
||||
.tool {
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.tool:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tool .argrow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0;
|
||||
align-items: center;
|
||||
}
|
||||
.tool .argrow label {
|
||||
font-size: 12px;
|
||||
color: var(--og-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.tool .argrow input,
|
||||
.tool .argrow select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.tool .argrow input {
|
||||
width: 90px;
|
||||
}
|
||||
#invform {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#invform input {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
/*
|
||||
* theme.css - forgectrl: the OpenGlow theme on top of Bootstrap
|
||||
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Every color the panel uses is a token here, defined once for the light
|
||||
* theme and once more for the dark one; Bootstrap's own component
|
||||
* variables are pointed at the tokens, so the stock components follow the
|
||||
* theme without a Sass build. The visual identity is OpenGlow's
|
||||
* (community.openglow.org): navy header with the starburst wordmark, blue
|
||||
* actions, laser red reserved for the brand accent and fault states. The
|
||||
* header keeps its navy in both themes; the log viewers stay dark in both
|
||||
* because they are terminals.
|
||||
*
|
||||
* Density: the panel is a machine control surface read at a glance, so
|
||||
* everything runs a size tighter than Bootstrap's defaults (13.5 px body,
|
||||
* small controls, compact cards).
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme='light'] {
|
||||
--og-navy: #2b2b5e;
|
||||
--og-red: #e8262a;
|
||||
--og-action: #0088cc;
|
||||
--og-action-hover: #0077b3;
|
||||
--og-ok: #3d854d;
|
||||
--og-warn: #c7760a;
|
||||
--og-dim: #767a82;
|
||||
--og-bg: #f0f1f4;
|
||||
--og-card: #ffffff;
|
||||
--og-line: #dde0e6;
|
||||
--og-line-soft: #eceef1;
|
||||
--og-txt: #222222;
|
||||
--og-field-bg: #ffffff;
|
||||
--og-field-line: #c9cdd4;
|
||||
--og-placeholder: #a6aab2;
|
||||
--og-note-bg: #fdf3e3;
|
||||
--og-note-line: #eccb90;
|
||||
--og-note-txt: #7a5a12;
|
||||
--og-warn-bg: #fff6e6;
|
||||
--og-warn-txt: #7a4a06;
|
||||
--og-bad-bg: #fdeeee;
|
||||
--og-bad-txt: #7a1f1f;
|
||||
--og-safety-bg: #fdeaea;
|
||||
--og-safety-txt: #8a1518;
|
||||
--og-cam-bg: #e8e9ec;
|
||||
--og-term-bg: #23252b;
|
||||
--og-term-txt: #cfd3da;
|
||||
--og-title: var(--og-navy);
|
||||
--og-nav-on: var(--og-navy);
|
||||
}
|
||||
[data-bs-theme='dark'] {
|
||||
--og-navy: #2b2b5e;
|
||||
--og-red: #f05a5e;
|
||||
--og-action: #2ea3e0;
|
||||
--og-action-hover: #46b3ec;
|
||||
--og-ok: #5fb374;
|
||||
--og-warn: #e09a3a;
|
||||
--og-dim: #9296a2;
|
||||
--og-bg: #15161c;
|
||||
--og-card: #1e2028;
|
||||
--og-line: #32353f;
|
||||
--og-line-soft: #2a2d36;
|
||||
--og-txt: #d9dce3;
|
||||
--og-field-bg: #14151b;
|
||||
--og-field-line: #3d404b;
|
||||
--og-placeholder: #6b6f7a;
|
||||
--og-note-bg: #2e2717;
|
||||
--og-note-line: #6a5323;
|
||||
--og-note-txt: #e3c584;
|
||||
--og-warn-bg: #2f2415;
|
||||
--og-warn-txt: #eab86a;
|
||||
--og-bad-bg: #331c1e;
|
||||
--og-bad-txt: #f0a3a6;
|
||||
--og-safety-bg: #361a1c;
|
||||
--og-safety-txt: #f3a7aa;
|
||||
--og-cam-bg: #2a2d36;
|
||||
--og-term-bg: #0f1014;
|
||||
--og-term-txt: #cfd3da;
|
||||
--og-title: #b9bcd8;
|
||||
--og-nav-on: #e6e8f5;
|
||||
}
|
||||
|
||||
/* ---- Bootstrap variables pointed at the tokens ---------------------- */
|
||||
:root,
|
||||
[data-bs-theme='light'],
|
||||
[data-bs-theme='dark'] {
|
||||
--bs-body-bg: var(--og-bg);
|
||||
--bs-body-color: var(--og-txt);
|
||||
--bs-body-font-size: 13.5px;
|
||||
--bs-body-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--bs-border-color: var(--og-line);
|
||||
--bs-secondary-color: var(--og-dim);
|
||||
--bs-link-color: var(--og-action);
|
||||
--bs-link-hover-color: var(--og-action-hover);
|
||||
--bs-link-color-rgb: 0, 136, 204;
|
||||
--bs-link-hover-color-rgb: 0, 119, 179;
|
||||
--bs-primary: var(--og-action);
|
||||
--bs-danger: var(--og-red);
|
||||
--bs-success: var(--og-ok);
|
||||
--bs-warning: var(--og-warn);
|
||||
--bs-font-monospace: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.btn-primary {
|
||||
--bs-btn-bg: var(--og-action);
|
||||
--bs-btn-border-color: var(--og-action);
|
||||
--bs-btn-hover-bg: var(--og-action-hover);
|
||||
--bs-btn-hover-border-color: var(--og-action-hover);
|
||||
--bs-btn-active-bg: var(--og-action-hover);
|
||||
--bs-btn-active-border-color: var(--og-action-hover);
|
||||
--bs-btn-disabled-bg: var(--og-action);
|
||||
--bs-btn-disabled-border-color: var(--og-action);
|
||||
--bs-btn-font-weight: 600;
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: var(--og-txt);
|
||||
--bs-btn-border-color: var(--og-field-line);
|
||||
--bs-btn-bg: var(--og-field-bg);
|
||||
--bs-btn-hover-color: var(--og-txt);
|
||||
--bs-btn-hover-bg: var(--og-field-bg);
|
||||
--bs-btn-hover-border-color: var(--og-nav-on);
|
||||
--bs-btn-active-color: var(--og-txt);
|
||||
--bs-btn-active-bg: var(--og-line-soft);
|
||||
--bs-btn-active-border-color: var(--og-nav-on);
|
||||
--bs-btn-disabled-color: var(--og-txt);
|
||||
--bs-btn-disabled-bg: var(--og-field-bg);
|
||||
--bs-btn-disabled-border-color: var(--og-field-line);
|
||||
}
|
||||
.btn-danger {
|
||||
--bs-btn-bg: var(--og-red);
|
||||
--bs-btn-border-color: var(--og-red);
|
||||
--bs-btn-font-weight: 600;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.form-control,
|
||||
.form-select {
|
||||
--bs-body-bg: var(--og-field-bg);
|
||||
--bs-border-color: var(--og-field-line);
|
||||
color: var(--og-txt);
|
||||
}
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--og-action);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
.form-control::placeholder {
|
||||
color: var(--og-placeholder);
|
||||
}
|
||||
.form-control.dirty,
|
||||
.form-select.dirty {
|
||||
border-color: var(--og-warn);
|
||||
}
|
||||
.card {
|
||||
--bs-card-bg: var(--og-card);
|
||||
--bs-card-border-color: var(--og-line);
|
||||
--bs-card-border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.popover {
|
||||
--bs-popover-max-width: 400px;
|
||||
--bs-popover-font-size: 12.5px;
|
||||
--bs-popover-bg: var(--og-card);
|
||||
--bs-popover-border-color: var(--og-line);
|
||||
--bs-popover-body-color: var(--og-txt);
|
||||
--bs-popover-header-bg: var(--og-line-soft);
|
||||
--bs-popover-header-color: var(--og-title);
|
||||
--bs-popover-header-font-size: 12px;
|
||||
}
|
||||
.popover-header {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.popover-body p {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.popover-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.popover-body .doclink {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal {
|
||||
--bs-modal-bg: var(--og-card);
|
||||
--bs-modal-border-color: var(--og-line);
|
||||
}
|
||||
.toast {
|
||||
--bs-toast-bg: var(--og-card);
|
||||
--bs-toast-border-color: var(--og-line);
|
||||
--bs-toast-color: var(--og-txt);
|
||||
--bs-toast-font-size: 13px;
|
||||
}
|
||||
.toast.t-ok {
|
||||
border-left: 4px solid var(--og-ok);
|
||||
}
|
||||
.toast.t-bad {
|
||||
border-left: 4px solid var(--og-red);
|
||||
}
|
||||
|
||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
body.has-savebar {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
header {
|
||||
background: var(--og-navy);
|
||||
}
|
||||
.hrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.appname {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding-left: 14px;
|
||||
}
|
||||
#host {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 12.5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
#themebtn {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 5px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#themebtn:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
nav {
|
||||
background: var(--og-card);
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
.nrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
nav a {
|
||||
padding: 9px 13px;
|
||||
color: var(--og-dim);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
nav a.on {
|
||||
color: var(--og-nav-on);
|
||||
font-weight: 600;
|
||||
border-color: var(--og-red);
|
||||
}
|
||||
nav a:hover {
|
||||
color: var(--og-nav-on);
|
||||
}
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 20px 12px;
|
||||
}
|
||||
section {
|
||||
display: none;
|
||||
}
|
||||
section.on {
|
||||
display: block;
|
||||
}
|
||||
footer {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 20px 28px;
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---- Cards and settings rows --------------------------------------- */
|
||||
.card h2 {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--og-title);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 11.5px;
|
||||
margin: 12px 0 4px;
|
||||
color: var(--og-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card h3:first-of-type {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 24px;
|
||||
}
|
||||
.frow {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 6px 10px;
|
||||
align-items: center;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.frow label {
|
||||
color: var(--og-txt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.frow .u {
|
||||
color: var(--og-dim);
|
||||
font-size: 11.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.hint {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.btns {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.msg {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-action);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.brk {
|
||||
word-break: break-all;
|
||||
}
|
||||
/* The help button: a small ring that opens the field's popover. */
|
||||
.hlp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--og-dim);
|
||||
background: transparent;
|
||||
color: var(--og-dim);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.hlp:hover,
|
||||
.hlp[aria-describedby] {
|
||||
color: var(--og-action);
|
||||
border-color: var(--og-action);
|
||||
}
|
||||
|
||||
/* ---- Status widgets ------------------------------------------------ */
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 2px;
|
||||
border-bottom: 1px solid var(--og-line-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.kv > span:first-child {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.kv .btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.b-ok {
|
||||
color: var(--og-ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-run {
|
||||
color: var(--og-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-bad {
|
||||
color: var(--og-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-dim {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.b-warn {
|
||||
color: var(--og-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bigpass {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.bigfail {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--og-red);
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.segbtn {
|
||||
padding: 7px 20px;
|
||||
border-radius: 6px;
|
||||
background: var(--og-field-bg);
|
||||
color: var(--og-txt);
|
||||
border: 1px solid var(--og-field-line);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segbtn:hover {
|
||||
border-color: var(--og-nav-on);
|
||||
}
|
||||
.segbtn.segon {
|
||||
background: var(--og-navy);
|
||||
border-color: var(--og-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.segbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#cam,
|
||||
#camvid {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--og-line);
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background: var(--og-cam-bg);
|
||||
min-height: 60px;
|
||||
}
|
||||
.camwrap {
|
||||
text-align: center;
|
||||
}
|
||||
.camctl {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.on {
|
||||
background: var(--og-navy);
|
||||
border-color: var(--og-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Banners ------------------------------------------------------- */
|
||||
.banner {
|
||||
background: var(--og-warn-bg);
|
||||
border: 1px solid var(--og-warn);
|
||||
color: var(--og-warn-txt);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.banner a {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.banner-bad {
|
||||
border-color: var(--og-red);
|
||||
background: var(--og-bad-bg);
|
||||
color: var(--og-bad-txt);
|
||||
}
|
||||
.safety {
|
||||
background: var(--og-safety-bg);
|
||||
border: 1px solid var(--og-red);
|
||||
color: var(--og-safety-txt);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#locknote,
|
||||
.note {
|
||||
display: none;
|
||||
background: var(--og-note-bg);
|
||||
border: 1px solid var(--og-note-line);
|
||||
color: var(--og-note-txt);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.note {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gnotes {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 4px 0 6px;
|
||||
}
|
||||
.gnotes .gn-warn {
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.gnotes .gn-off {
|
||||
color: var(--og-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Terminals: the diagnostic log and the log viewer -------------- */
|
||||
#diaglog,
|
||||
#logview {
|
||||
background: var(--og-term-bg);
|
||||
color: var(--og-term-txt);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#diaglog {
|
||||
line-height: 1.6;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
display: none;
|
||||
}
|
||||
#logview {
|
||||
line-height: 1.5;
|
||||
height: 380px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.logtbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.logtbl th {
|
||||
text-align: left;
|
||||
color: var(--og-dim);
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
.logtbl td {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--og-line-soft);
|
||||
}
|
||||
.logtbl select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ---- The fuse identity dialog -------------------------------------- */
|
||||
#fusewrap {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 22, 30, 0.55);
|
||||
z-index: 1050;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#fusewrap.on {
|
||||
display: flex;
|
||||
}
|
||||
.fusebox {
|
||||
background: var(--og-card);
|
||||
border: 1px solid var(--og-line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
max-width: 560px;
|
||||
width: 92%;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.fusebox h2 {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--og-title);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
|
||||
/* ---- The save bar -------------------------------------------------- */
|
||||
#savebar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1040;
|
||||
background: var(--og-card);
|
||||
border-top: 1px solid var(--og-line);
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
#savebar.on {
|
||||
display: block;
|
||||
}
|
||||
.sbrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 9px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#sb-count {
|
||||
font-weight: 600;
|
||||
color: var(--og-warn);
|
||||
}
|
||||
#sb-msg {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-red);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.frow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hrow,
|
||||
.nrow,
|
||||
main,
|
||||
.sbrow {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2025 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
"""The page assembly: the ui/ files inlined in order, the gzipped install
|
||||
(what the dev image carries) assembling to the same bytes as a checkout,
|
||||
the token placeholder carried exactly once, and a missing marker refused
|
||||
rather than served."""
|
||||
import gzip
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import page
|
||||
|
||||
|
||||
def gzipped_copy(src_dir):
|
||||
"""A copy of src_dir with every file gzipped in place, the way the
|
||||
recipe installs ui/."""
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
|
||||
for root, _, files in os.walk(src_dir):
|
||||
rel = os.path.relpath(root, src_dir)
|
||||
dst_dir = os.path.normpath(os.path.join(tmp, rel))
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
for f in files:
|
||||
with open(os.path.join(root, f), "rb") as i, \
|
||||
gzip.open(os.path.join(dst_dir, f + ".gz"), "wb") as o:
|
||||
o.write(i.read())
|
||||
return tmp
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
def test_checkout_and_gzipped_install_agree(self):
|
||||
plain = page.assemble()
|
||||
tmp = gzipped_copy(page.UI_DIR)
|
||||
try:
|
||||
self.assertFalse(os.path.exists(os.path.join(tmp, "index.html")))
|
||||
self.assertEqual(page.assemble(tmp), plain)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_page_is_self_contained(self):
|
||||
html = page.assemble()
|
||||
self.assertNotIn("<link ", html)
|
||||
self.assertNotIn("<script src=", html)
|
||||
self.assertEqual(html.count(page.TOKEN_MARK), 1)
|
||||
self.assertIn("data-bs-theme", html)
|
||||
for name in page.CSS_FILES + page.JS_FILES:
|
||||
self.assertNotIn('href="%s"' % name, html)
|
||||
self.assertNotIn('src="%s"' % name, html)
|
||||
|
||||
def test_render_substitutes_the_token(self):
|
||||
out = page.render("deadbeef0123")
|
||||
self.assertEqual(out.count("deadbeef0123"), 1)
|
||||
self.assertNotIn(page.TOKEN_MARK, out)
|
||||
|
||||
def test_missing_marker_is_refused(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-ui-")
|
||||
try:
|
||||
with open(os.path.join(tmp, "index.html"), "w", encoding="utf-8") as f:
|
||||
f.write("<html><head></head><body>no markers</body></html>")
|
||||
with self.assertRaises(ValueError):
|
||||
page.assemble(tmp)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -188,16 +188,20 @@ class PageTests(unittest.TestCase):
|
||||
# the queue controls are static markup: a poll relabels them and
|
||||
# flips disabled, it never replaces the node
|
||||
for bid in ("q-unattended", "q-attended", "q-stop"):
|
||||
self.assertIn("id='%s'" % bid, html)
|
||||
self.assertIn('id="%s"' % bid, html)
|
||||
self.assertNotIn("id='" + bid + "-", html)
|
||||
self.assertNotIn('id="' + bid + "-", html)
|
||||
self.assertIn("function renderQueue()", html)
|
||||
# the help popovers sit on static markup only: a rebuilt row or
|
||||
# tool entry would orphan one
|
||||
self.assertNotIn("data-help", html[html.index("function buildGroups("):])
|
||||
|
||||
def test_every_group_is_built_on_the_same_grid(self):
|
||||
"""The subsystems are separate tables. Left to size themselves
|
||||
from their own content no two line up, which is what made the
|
||||
page look busy, so they share one colgroup and a fixed layout."""
|
||||
html = page.render("0" * 32)
|
||||
self.assertIn("#groups table{table-layout:fixed", html)
|
||||
self.assertRegex(html, r"#groups table\s*\{\s*table-layout:\s*fixed")
|
||||
# one colgroup definition, emitted into every group's table
|
||||
self.assertEqual(html.count('var COLS="<colgroup>'), 1)
|
||||
self.assertEqual(html.count('<table>"+COLS+"'), 1,
|
||||
@@ -210,7 +214,7 @@ class PageTests(unittest.TestCase):
|
||||
"a column per header, the action column included")
|
||||
# every fixed column carries a width, or the grid is only a wish
|
||||
for cls in re.findall(r"<col class='(\w)'", colgroup):
|
||||
self.assertRegex(html, r"#groups col\.%s\{width:\d+px\}" % cls)
|
||||
self.assertRegex(html, r"#groups col\.%s\s*\{\s*width:\s*\d+px" % cls)
|
||||
|
||||
def test_details_and_the_requires_note_stay_out_of_the_columns(self):
|
||||
"""Both are long enough to stretch a cell. In a column they would
|
||||
@@ -223,16 +227,24 @@ class PageTests(unittest.TestCase):
|
||||
# message; the action cell carries the button and nothing else
|
||||
self.assertIn("<td><div id='st-\"+d+\"'></div><div id='unmet-\"+d+"
|
||||
"\"'></div><div id='note-\"+d+\"'></div></td>", html)
|
||||
self.assertIn("<td><button class='pri' id='btn-\"+d+\"' "
|
||||
self.assertIn("<td><button class='btn btn-sm btn-primary' id='btn-\"+d+\"' "
|
||||
"onclick='startTest(\\\"\"+d+\"\\\")'>Start</button></td>", html)
|
||||
|
||||
def test_page_is_self_contained_ascii(self):
|
||||
html = page.render("0" * 32)
|
||||
self.assertNotIn("__TOKEN__", html)
|
||||
html.encode("ascii") # no stray typography in an embedded page
|
||||
# our own sources stay ASCII (no stray typography in an embedded
|
||||
# page); the vendored Bootstrap carries its own glyphs
|
||||
for name in ("index.html",) + page.CSS_FILES + page.JS_FILES:
|
||||
if name.startswith("vendor/"):
|
||||
continue
|
||||
page.read_ui(name).encode("ascii")
|
||||
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
|
||||
self.assertEqual(stray, [], "control characters in the page source")
|
||||
for remote in ("http://", "https://", "//cdn"):
|
||||
# nothing fetched from anywhere: the documentation links open a
|
||||
# site, they are not assets
|
||||
for remote in ("<link ", "<script src=", "@import", "url(http", "url(//",
|
||||
'src="http', "src='http", "//cdn"):
|
||||
self.assertNotIn(remote, html)
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +152,13 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/", token=False)
|
||||
self.assertEqual(st, 200)
|
||||
self.assertIn(self.token.encode(), d)
|
||||
page = d.decode("utf-8")
|
||||
# one self-contained response: nothing linked, the token once,
|
||||
# the theme attribute the head script sets
|
||||
self.assertEqual(page.count(self.token), 1)
|
||||
self.assertNotIn("<link ", page)
|
||||
self.assertNotIn("<script src=", page)
|
||||
self.assertIn("data-bs-theme", page)
|
||||
st, d = self.call("GET", "/state", headers={"Host": "evil.example.net"})
|
||||
self.assertEqual(st, 403)
|
||||
st, d = self.call("GET", "/state", headers={"Origin": "http://evil.example.net"})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "d97cb35a270d46ef1348abb001abb64c2a275e30"
|
||||
SRCREV = "9d1f6f2727955fcffc8ec76b8d55105bec88bf74"
|
||||
PV = "0.1.0"
|
||||
|
||||
@@ -36,6 +36,14 @@ do_install() {
|
||||
install -Dm 0644 ${WORKDIR}/forgetest/forgetest/$f \
|
||||
${D}${PYTHON_SITEPACKAGES_DIR}/forgetest/$f
|
||||
done
|
||||
# the page's sources (ui/), gzipped: page.py inflates them once at
|
||||
# first request, and bytes here are bytes on the ext4 rootfs
|
||||
for f in $(cd ${WORKDIR}/forgetest/forgetest/ui && find . -type f); do
|
||||
install -d ${D}${PYTHON_SITEPACKAGES_DIR}/forgetest/ui/$(dirname $f)
|
||||
gzip -9 -n -c ${WORKDIR}/forgetest/forgetest/ui/$f \
|
||||
> ${D}${PYTHON_SITEPACKAGES_DIR}/forgetest/ui/$f.gz
|
||||
chmod 0644 ${D}${PYTHON_SITEPACKAGES_DIR}/forgetest/ui/$f.gz
|
||||
done
|
||||
# the bench scripts the #bench tab drives
|
||||
install -d ${D}${datadir}/forgetest/bench
|
||||
for f in ${WORKDIR}/scripts/bench/*.py; do
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The files forgetest's page shares with forgectrl's panel, byte for byte.
|
||||
|
||||
theme.css (the OpenGlow theme on Bootstrap) and the vendored Bootstrap
|
||||
files live in both repos so each page is self-contained; they are meant
|
||||
to be identical, and this is the check. forgetest's copies are compared
|
||||
against forgectrl's at the revision the recipe pins (fetched from GitHub
|
||||
by default) or in a local checkout (--forgectrl PATH). Exit status 1 on
|
||||
any difference or missing file.
|
||||
|
||||
python3 scripts/check-ui-vendor.py
|
||||
python3 scripts/check-ui-vendor.py --forgectrl ../forgectrl
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.normpath(os.path.join(HERE, ".."))
|
||||
SHARED = ("theme.css", "vendor/bootstrap.min.css", "vendor/bootstrap.bundle.min.js",
|
||||
"vendor/LICENSE")
|
||||
FORGETEST_UI = os.path.join(REPO, "forgetest", "forgetest", "ui")
|
||||
PIN = os.path.join(REPO, "meta-forgefirm", "recipes-forgefirm", "forgectrl", "forgectrl-pin.inc")
|
||||
RAW = "https://raw.githubusercontent.com/ScottW514/forgectrl/%s/src/ui/%s"
|
||||
|
||||
|
||||
def pinned_rev():
|
||||
with open(PIN, encoding="utf-8") as f:
|
||||
m = re.search(r'^SRCREV\s*=\s*"([0-9a-f]{7,40})"', f.read(), re.M)
|
||||
if not m:
|
||||
sys.exit("check-ui-vendor: no SRCREV in %s" % PIN)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def sha(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def theirs(name, local, rev):
|
||||
if local:
|
||||
path = os.path.join(local, "src", "ui", *name.split("/"))
|
||||
with open(path, "rb") as f:
|
||||
return f.read(), path
|
||||
url = RAW % (rev, name)
|
||||
with urllib.request.urlopen(url, timeout=30) as r:
|
||||
return r.read(), url
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--forgectrl", help="a local forgectrl checkout instead of the pinned revision")
|
||||
args = ap.parse_args()
|
||||
rev = None if args.forgectrl else pinned_rev()
|
||||
print("forgectrl: %s" % (args.forgectrl or ("pinned %s" % rev)))
|
||||
bad = 0
|
||||
for name in SHARED:
|
||||
ours_path = os.path.join(FORGETEST_UI, *name.split("/"))
|
||||
try:
|
||||
with open(ours_path, "rb") as f:
|
||||
ours = f.read()
|
||||
except OSError as e:
|
||||
print(" MISSING forgetest copy of %s (%s)" % (name, e))
|
||||
bad += 1
|
||||
continue
|
||||
try:
|
||||
other, where = theirs(name, args.forgectrl, rev)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(" MISSING forgectrl copy of %s (%s)" % (name, e))
|
||||
bad += 1
|
||||
continue
|
||||
if sha(ours) == sha(other):
|
||||
print(" ok %s (%d bytes, %s)" % (name, len(ours), sha(ours)[:12]))
|
||||
else:
|
||||
print(" DIFFERS %s: forgetest %s, forgectrl %s (%s)"
|
||||
% (name, sha(ours)[:12], sha(other)[:12], where))
|
||||
bad += 1
|
||||
if bad:
|
||||
print("check-ui-vendor: %d shared file(s) out of step; copy from forgectrl/src/ui/ "
|
||||
"(or push forgectrl and bump its pin)" % bad)
|
||||
return 1
|
||||
print("check-ui-vendor: the shared UI files are identical")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user