Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ access-point password`. To also join a 2.4 GHz network, open
and password in a local, ignored `sdkconfig`. Credentials entered on the setup
page persist in NVS and take precedence over Kconfig.

The dashboard at `/` is read-only. `http://192.168.4.1/run` starts and aborts
workloads and shows the device's run state: READY, RUNNING, ABORTING, PASS,
FAIL or ABORTED, taken from `/api/v1/status`. It shows STARTING only while its
own start request is in flight, and STALE or DISCONNECTED when the device stops
answering. It offers Start only for workloads whose request it knows how to
build; the device still validates every request and refuses a second run while
one is active.

Each board advertises its own mDNS name on both interfaces, derived from the
same MAC suffix as its access point: `DragonBench-D685F0` answers as
`http://dragonbench-d685f0.local/`. Several boards can share one network
Expand Down
239 changes: 239 additions & 0 deletions firmware/targets/esp32s3/main/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,8 @@ static const char landing[] =
"details{border-top:1px solid var(--border);padding-top:8px;margin-top:8px}"
"summary{cursor:pointer;color:var(--muted-fg);font-size:.78rem;width:fit-content;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}"
"pre{margin:8px 0 0;max-height:320px;overflow:auto;background:var(--code);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;font:.72rem/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre}"
"a{color:var(--primary)}"
".panel-link{margin:8px 0 0}"
"footer{padding:16px 0 24px;color:var(--muted-fg);text-align:center;font-size:.76rem}"
"@media (max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}"
"@media (max-width:620px){.app-header{flex-direction:column;align-items:start}.api-status{justify-items:start}.metric-grid{grid-template-columns:1fr}.sensor-row{flex-direction:column;align-items:start}}"
Expand Down Expand Up @@ -684,6 +686,7 @@ static const char landing[] =
" <p class=\"endpoint-note\" data-role=\"workloads-error\" hidden></p>"
" </div>"
" <div class=\"workload-list\" data-role=\"workload-fields\"></div>"
" <p class=\"panel-link\"><a href=\"/run\">Start and watch runs</a></p>"
" <details><summary>Raw /api/v1/workloads</summary><pre data-role=\"raw-workloads\">unavailable</pre></details>"
" </section>"
"</main>"
Expand Down Expand Up @@ -906,6 +909,241 @@ static const char setup_page[] =

static esp_err_t setup_get(httpd_req_t *req) { return send_page(req, setup_page); }

// Operator page for starting and watching runs. The dashboard at / stays
// read-only; this page is the one place in the UI that POSTs to /api/v1/runs.
static const char run_page[] =
"<!doctype html>"
"<html lang=\"en\">"
"<head>"
"<meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<meta name=\"color-scheme\" content=\"light dark\">"
"<title>DragonBench workloads</title>"
"<style>"
":root{color-scheme:light dark;--bg:light-dark(#fff,#181818);--fg:light-dark(#1a1c1f,#fff);"
"--card:color-mix(in oklab, var(--fg) 5%, transparent);--muted-fg:light-dark(rgba(26,28,31,.58),rgba(255,255,255,.58));"
"--border:light-dark(rgba(26,28,31,.1),rgba(255,255,255,.1));--primary:light-dark(#339cff,#83c3ff);"
"--ok:light-dark(#2d9a4f,#74d58b);--warn:light-dark(#cc671a,#f59a56);--bad:light-dark(#c43827,#ff8549);--radius:6px;--radius-sm:4px}"
"*{box-sizing:border-box}"
"[hidden]{display:none!important}"
"body{margin:0;min-height:100vh;color:var(--fg);background:var(--bg);font:14px/1.4 -apple-system,system-ui,\"Segoe UI\",Roboto,sans-serif;padding:0 16px}"
"main{width:min(720px,100%);margin:0 auto;padding:20px 0;display:grid;gap:12px}"
".head{display:flex;justify-content:space-between;align-items:end;gap:12px;flex-wrap:wrap}"
"h1{margin:0;font-size:1.6rem;letter-spacing:-.02em}"
"h2{margin:0;font-size:1.05rem}"
"p{margin:0;color:var(--muted-fg)}"
"a{color:var(--primary)}"
".panel{border:1px solid var(--border);border-radius:var(--radius);background:var(--card);padding:14px;display:grid;gap:10px}"
".state{--status-color:var(--muted-fg);font:700 1.5rem ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--status-color);overflow-wrap:anywhere}"
"[data-status=\"ready\"],[data-status=\"pass\"]{--status-color:var(--ok)}"
"[data-status=\"starting\"],[data-status=\"running\"]{--status-color:var(--primary)}"
"[data-status=\"aborting\"],[data-status=\"aborted\"],[data-status=\"stale\"],[data-status=\"unknown\"]{--status-color:var(--warn)}"
"[data-status=\"fail\"],[data-status=\"disconnected\"]{--status-color:var(--bad)}"
"dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0}"
"dt{color:var(--muted-fg)}dd{margin:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere}"
"fieldset{border:1px solid var(--border);border-radius:var(--radius-sm);margin:0;padding:10px}"
"legend{color:var(--muted-fg);font-size:.8rem}"
".fields{display:flex;flex-wrap:wrap;gap:10px}"
"label{display:grid;gap:4px;color:var(--muted-fg);font-size:.8rem}"
"input,button{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 10px}"
"button{cursor:pointer;border-color:var(--primary);color:var(--primary);font-weight:700}"
"button:disabled{cursor:not-allowed;opacity:.45}"
".workloads{display:grid;gap:7px}"
".workload{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:7px 10px;border:1px solid var(--border);border-radius:var(--radius-sm)}"
".workload strong{font:700 .85rem ui-monospace,SFMono-Regular,Consolas,monospace}"
".workload span{color:var(--muted-fg);font-size:.8rem}"
"</style>"
"</head>"
"<body>"
"<main>"
" <div class=\"head\">"
" <h1>Run workloads</h1>"
" <a href=\"/\">Back to the dashboard</a>"
" </div>"
" <section class=\"panel\" aria-label=\"Run state\">"
" <strong class=\"state\" data-role=\"run-state\" data-status=\"unknown\" aria-live=\"polite\">CONNECTING&hellip;</strong>"
" <dl>"
" <dt>Workload</dt><dd data-role=\"run-workload\">&mdash;</dd>"
" <dt>Run ID</dt><dd data-role=\"run-id\">&mdash;</dd>"
" <dt>Device reports</dt><dd data-role=\"run-device\">&mdash;</dd>"
" <dt>Connection</dt><dd data-role=\"run-freshness\">Waiting for the device&hellip;</dd>"
" </dl>"
" <p data-role=\"run-message\"></p>"
" <button type=\"button\" data-role=\"run-abort\" hidden>Abort this run</button>"
" </section>"
" <section class=\"panel\" aria-label=\"Start a workload\">"
" <h2>Start a workload</h2>"
" <p>One run at a time. The device checks every request and refuses a second run while one is active; these controls only mirror that.</p>"
" <div class=\"fields\">"
" <label>Duration (s)<input data-role=\"run-duration\" type=\"number\" min=\"0.001\" max=\"3600\" step=\"any\" value=\"30\"></label>"
" </div>"
" <fieldset>"
" <legend>Traffic peer (NET_TX, NET_RX, NET_BIDIRECTIONAL only)</legend>"
" <div class=\"fields\">"
" <label>Host<input data-role=\"run-host\" maxlength=\"63\" autocomplete=\"off\"></label>"
" <label>Port<input data-role=\"run-port\" type=\"number\" min=\"1\" max=\"65535\" step=\"1\"></label>"
" <label>Rate (bit/s, 0 = unthrottled)<input data-role=\"run-rate\" type=\"number\" min=\"0\" step=\"1\" value=\"0\"></label>"
" </div>"
" </fieldset>"
" <div class=\"workloads\" data-role=\"run-workloads\"><p>Loading workloads&hellip;</p></div>"
" </section>"
"</main>"
"<script>"
"(function(){"
" /* Request shape per workload this page knows how to build. Anything else the"
" device lists is shown without a Start button rather than guessed at. */"
" var KINDS={BOOT:'timed',IDLE:'timed',WIFI_ASSOCIATED_IDLE:'timed',CPU_STRESS:'timed',FLASH_WRITE:'timed',"
" NVS_WRITE:'timed',OTA_PARTITION_WRITE:'timed',CONTROLLED_REBOOT:'timed',"
" NET_TX:'network',NET_RX:'network',NET_BIDIRECTIONAL:'network'};"
" var STALE_MS=5000,LOST_MS=15000;"
" var status=null,lastOk=0,tried=false,workloads=null,starting=null,accepted=null,aborting=false,inFlight=false,again=false;"
" var buttons=[];"
" function role(name){return document.querySelector('[data-role=\"'+name+'\"]')}"
" function text(value){return value===undefined||value===null||value===''?'—':String(value)}"
" function clear(el){while(el.firstChild)el.removeChild(el.firstChild)}"
" function say(message){role('run-message').textContent=message||''}"
" function connection(){"
" if(!lastOk)return tried?'disconnected':'connecting';"
" var age=Date.now()-lastOk;"
" return age>LOST_MS?'disconnected':age>STALE_MS?'stale':'live';"
" }"
" /* Everything shown comes from the last /api/v1/status the device sent, except"
" STARTING, which only means this page's start request is in flight. */"
" function indicator(conn){"
" if(starting)return {status:'starting',label:'STARTING… '+starting};"
" if(conn==='connecting')return {status:'unknown',label:'CONNECTING…'};"
" if(conn==='disconnected')return {status:'disconnected',label:'DISCONNECTED'};"
" if(conn==='stale')return {status:'stale',label:'STALE'};"
" var s=status.run_state,w=status.workload;"
" if(s==='idle')return {status:'ready',label:'READY'};"
" if(s==='running')return {status:'running',label:'RUNNING — '+w};"
" if(s==='aborting')return {status:'aborting',label:'ABORTING — '+w};"
" if(s==='complete'){"
" if(status.result==='pass')return {status:'pass',label:'PASS — '+w};"
" if(status.result==='fail')return {status:'fail',label:'FAIL — '+w};"
" if(status.result==='aborted')return {status:'aborted',label:'ABORTED — '+w};"
" }"
" return {status:'unknown',label:'UNKNOWN — device reports '+text(s)+' / '+text(status.result)};"
" }"
" function canStart(conn){"
" return !starting&&conn==='live'&&(status.run_state==='idle'||status.run_state==='complete');"
" }"
" function render(){"
" var conn=connection(),ind=indicator(conn),el=role('run-state');"
" el.textContent=ind.label;el.dataset.status=ind.status;"
" var active=status&&status.run_state!=='idle';"
" role('run-workload').textContent=active?text(status.workload):'—';"
" role('run-id').textContent=active?text(status.run_id):'—';"
" role('run-device').textContent=status?text(status.run_state)+' / result '+text(status.result)+"
" (status.previous_reboot_run_id?' / rebooted by run '+status.previous_reboot_run_id:''):'—';"
" role('run-freshness').textContent=!lastOk?(tried?'No response from the device':'Waiting for the device…'):"
" conn==='live'?'Live':'No response for '+Math.round((Date.now()-lastOk)/1000)+' s; everything above is the last report';"
" var abort=role('run-abort');"
" abort.hidden=!(conn==='live'&&status.run_state==='running');"
" abort.disabled=aborting;"
" var allowed=canStart(conn);"
" buttons.forEach(function(b){b.disabled=!allowed});"
" if(accepted&&conn==='live'&&status.run_id===accepted){accepted=null;say('')}"
" }"
" function renderWorkloads(list){"
" var box=role('run-workloads');clear(box);buttons=[];"
" list.forEach(function(w){"
" var row=document.createElement('div');row.className='workload';"
" var name=document.createElement('strong');name.textContent=text(w.id);row.appendChild(name);"
" var kind=KINDS[w.id];"
" if(w.status!=='available'){"
" var off=document.createElement('span');off.textContent='not supported by this image';row.appendChild(off);"
" }else if(!kind){"
" var unknown=document.createElement('span');unknown.textContent='available; no controls on this page yet';row.appendChild(unknown);"
" }else{"
" if(w.id==='CONTROLLED_REBOOT'){"
" var warn=document.createElement('span');warn.textContent='reboots the device when it passes; this page shows DISCONNECTED until it is back';row.appendChild(warn);"
" }"
" var b=document.createElement('button');b.type='button';b.textContent='Start';b.dataset.workload=w.id;b.disabled=true;"
" b.addEventListener('click',function(){start(w.id,kind)});"
" row.appendChild(b);buttons.push(b);"
" }"
" box.appendChild(row);"
" });"
" }"
" function request(id,kind){"
" var seconds=Number(role('run-duration').value);"
" if(!(seconds>0&&seconds<=3600))return {error:'Duration must be more than 0 and at most 3600 s.'};"
" var body={workload:id,duration_ms:Math.max(1,Math.round(seconds*1000))};"
" if(kind==='network'){"
" var host=role('run-host').value.trim(),port=Number(role('run-port').value),rate=role('run-rate').value;"
" if(!host)return {error:id+' needs a traffic peer host.'};"
" if(!(port>=1&&port<=65535&&Math.floor(port)===port))return {error:id+' needs a traffic peer port from 1 to 65535.'};"
" var r=rate===''?0:Number(rate);"
" if(!(r>=0&&Math.floor(r)===r))return {error:'Rate must be a whole number of bit/s (0 = unthrottled).'};"
" body.host=host;body.port=port;body.rate_bps=r;"
" }"
" return {body:body};"
" }"
" /* The headers, and so the HTTP status, arrived but the body did not: the"
" request timeout aborted it or the connection dropped. A body that arrived"
" but is not JSON is a SyntaxError and keeps the empty-payload fallback. */"
" function bodyLost(err){return !(err&&err.name==='SyntaxError')}"
" function start(id,kind){"
" if(starting||!canStart(connection()))return;"
" var req=request(id,kind);"
" if(req.error){say(req.error);return}"
" starting=id;accepted=null;say('');render();"
" var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);"
" fetch('/api/v1/runs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req.body),signal:ctl.signal})"
" .then(function(res){return res.json().then(function(data){return {code:res.status,data:data}},function(err){return {code:res.status,data:{},lost:bodyLost(err)}})})"
" .then(function(r){"
" if(r.lost&&r.code===201)say('Start acknowledged by the device (HTTP 201), but its reply did not arrive, so the run ID is unknown. The state above comes from the device.');"
" else if(r.lost)say('Refused by the device (HTTP '+r.code+'); its reply did not arrive, so the reason is unavailable.');"
" else if(r.code===201&&r.data.run_id){accepted=r.data.run_id;say('Run '+accepted+' accepted; waiting for the device to report it.')}"
" else say('Refused by the device (HTTP '+r.code+'): '+(r.data.error||'no reason given'));"
" })"
" .catch(function(){say('No response to the start request. The state above comes from the device; check it before retrying.')})"
" .then(function(){clearTimeout(timer);starting=null;render();poll()});"
" }"
" role('run-abort').addEventListener('click',function(){"
" if(aborting||!status||status.run_state!=='running')return;"
" var id=status.run_id;aborting=true;render();"
" var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);"
" fetch('/api/v1/runs/'+encodeURIComponent(id)+'/abort',{method:'POST',signal:ctl.signal})"
" .then(function(res){return res.json().then(function(data){return {ok:res.ok,code:res.status,data:data}},function(err){return {ok:res.ok,code:res.status,data:{},lost:bodyLost(err)}})})"
" .then(function(r){"
" if(r.lost&&r.ok)say('Abort acknowledged by the device (HTTP '+r.code+') for run '+id+', but its reply did not arrive. The state above comes from the device.');"
" else if(r.lost)say('Abort refused (HTTP '+r.code+'); its reply did not arrive, so the reason is unavailable.');"
" else say(r.ok?'Abort requested for run '+id+'.':'Abort refused (HTTP '+r.code+'): '+(r.data.error||'no reason given'));"
" })"
" .catch(function(){say('No response to the abort request.')})"
" .then(function(){clearTimeout(timer);aborting=false;render();poll()});"
" });"
" function getJson(path,signal){"
" return fetch(path,{cache:'no-store',signal:signal}).then(function(res){"
" if(!res.ok)throw new Error('HTTP '+res.status);"
" return res.json();"
" });"
" }"
" function poll(){"
" if(inFlight){again=true;return}"
" inFlight=true;again=false;"
" var ctl=new AbortController(),timer=setTimeout(function(){ctl.abort()},4000);"
" var work=getJson('/api/v1/status',ctl.signal).then(function(data){"
" status=data;lastOk=Date.now();"
" if(!workloads)return getJson('/api/v1/workloads',ctl.signal).then(function(w){workloads=w.workloads||[];renderWorkloads(workloads)});"
" });"
" work.catch(function(){}).then(function(){"
" clearTimeout(timer);inFlight=false;tried=true;render();"
" if(again)poll();"
" });"
" }"
" poll();"
" setInterval(poll,2000);"
" setInterval(render,1000);"
"})();"
"</script>"
"</body>"
"</html>";

static esp_err_t run_page_get(httpd_req_t *req) { return send_page(req, run_page); }

#define WIFI_NVS_NAMESPACE "db_wifi"

static bool nvs_load_sta(char *ssid, size_t ssid_size, char *password, size_t password_size) {
Expand Down Expand Up @@ -1033,6 +1271,7 @@ static esp_err_t network_sta_post(httpd_req_t *req) {
static void start_http(void) {
const httpd_uri_t routes[] = {
{.uri="/",.method=HTTP_GET,.handler=landing_get}, {.uri="/setup",.method=HTTP_GET,.handler=setup_get},
{.uri="/run",.method=HTTP_GET,.handler=run_page_get},
{.uri="/api/v1/device",.method=HTTP_GET,.handler=device_get},
{.uri="/api/v1/status",.method=HTTP_GET,.handler=status_get}, {.uri="/api/v1/sensors",.method=HTTP_GET,.handler=sensors_get},
{.uri="/api/v1/workloads",.method=HTTP_GET,.handler=workloads_get}, {.uri="/api/v1/runs",.method=HTTP_POST,.handler=runs_post},
Expand Down
20 changes: 20 additions & 0 deletions tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ def test_landing_page_has_no_write_controls(self):
for token in forbidden_ci:
self.assertNotIn(token, lowered)

def test_run_page_is_served_and_linked_from_the_read_only_landing(self):
source = (ROOT / "firmware/targets/esp32s3/main/main.c").read_text()
self.assertIn('{.uri="/run",.method=HTTP_GET,.handler=run_page_get}', source)
web = (ROOT / "web/index.html").read_text()
for page in (web, _firmware_page("landing")):
self.assertIn('<a href="/run">', page)
self.assertIn('<a href="/">', _firmware_page("run_page"))

def test_run_page_has_no_external_dependency_or_innerhtml(self):
page = _firmware_page("run_page")
for token in ("http://", "https://", "cdn.", "//fonts.", "innerHTML"):
self.assertNotIn(token, page)

def test_run_page_writes_only_through_the_run_api(self):
page = _firmware_page("run_page")
self.assertIn("fetch('/api/v1/runs',{method:'POST'", page)
self.assertIn("'/abort',{method:'POST'", page)
self.assertEqual(page.count("method:'POST'"), 2)
self.assertNotIn("/api/v1/network", page)

def test_actuator_exclusion(self):
source = "\n".join(p.read_text(errors="ignore") for p in (ROOT / "firmware").rglob("*.[ch]"))
for forbidden in ("HEATER_GPIO", "FAN_GPIO", "heater_set", "fan_set"):
Expand Down
Loading
Loading