Redesign embedded dashboard with tabs

This commit is contained in:
2026-06-04 20:27:05 -06:00
parent 83254494fb
commit c70c730c05
@@ -19,144 +19,208 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Overland Controller</title>
<style>
:root{--bg:#0b0f14;--card:#141b24;--muted:#7f8da3;--text:#eef4ff;--good:#48d17a;--warn:#ffd166;--bad:#ff5c7a;--line:#223044;--btn:#233249}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif}
.wrap{max-width:980px;margin:0 auto;padding:18px}
.top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:14px}
h1{font-size:22px;margin:0}.sub{color:var(--muted);font-size:13px;margin-top:4px}.pill{border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:var(--muted);font-size:12px}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 10px 28px rgba(0,0,0,.22)}
.span6{grid-column:span 6}.span4{grid-column:span 4}.span12{grid-column:span 12}
.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.big{font-size:42px;font-weight:750;margin:8px 0}.row{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding-top:10px;margin-top:10px}
.v{font-weight:650}.good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}
.bar{height:10px;background:#0e141c;border-radius:999px;overflow:hidden;border:1px solid var(--line)}.fill{height:100%;background:linear-gradient(90deg,var(--good),#9be15d);width:0%}
button{width:100%;border:0;border-radius:12px;padding:12px;background:var(--btn);color:var(--text);font-weight:700;font-size:15px}
input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;background:#0f151d;color:var(--text);font-size:15px}
button.on{background:#17462a;color:#dfffea}button.off{background:#46202a;color:#ffe2e8}.relayBtns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
.tempList,.alarmList{display:grid;gap:8px;margin-top:12px}.item{display:flex;justify-content:space-between;gap:8px;background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
@media(max-width:760px){.span6,.span4{grid-column:span 12}.big{font-size:36px}.wrap{padding:12px}}
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Overland Controller</title>
<style>
:root{--bg:#0b0f14;--card:#141b24;--muted:#7f8da3;--text:#eef4ff;--good:#48d17a;--warn:#ffd166;--bad:#ff5c7a;--line:#223044;--btn:#233249}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif}
.wrap{max-width:980px;margin:0 auto;padding:14px 14px 90px}
.top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}
h1{font-size:21px;margin:0}.sub{color:var(--muted);font-size:13px;margin-top:4px}
.pill{border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:var(--muted);font-size:12px;white-space:nowrap}
.tabs{position:fixed;left:0;right:0;bottom:0;background:#080c11;border-top:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);z-index:10}
.tabbtn{border:0;border-radius:0;background:#080c11;color:var(--muted);padding:13px 8px;font-weight:800}
.tabbtn.active{color:var(--text);background:#111923}
.page{display:none}.page.active{display:block}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 10px 28px rgba(0,0,0,.22)}
.span4{grid-column:span 4}.span6{grid-column:span 6}.span12{grid-column:span 12}
.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}
.big{font-size:42px;font-weight:800;margin:8px 0}
.medium{font-size:25px;font-weight:800;margin:6px 0}
.row{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding-top:10px;margin-top:10px}
.v{font-weight:700}.good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}
.bar{height:10px;background:#0e141c;border-radius:999px;overflow:hidden;border:1px solid var(--line)}
.fill{height:100%;background:linear-gradient(90deg,var(--good),#9be15d);width:0%}
button{width:100%;border:0;border-radius:12px;padding:12px;background:var(--btn);color:var(--text);font-weight:800;font-size:15px}
button.on{background:#17462a;color:#dfffea}button.off{background:#46202a;color:#ffe2e8}
input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;background:#0f151d;color:var(--text);font-size:15px}
.list{display:grid;gap:8px;margin-top:12px}
.item{display:flex;justify-content:space-between;gap:8px;background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
.btnrow{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
.kpi{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px}
.kpi div{background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
.kpi .num{font-size:20px;font-weight:800;margin-top:4px}
@media(max-width:760px){.span4,.span6{grid-column:span 12}.big{font-size:36px}.wrap{padding:12px 12px 82px}.kpi{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div>
<h1 id="device">Overland Controller</h1>
<div class="sub">Local camp dashboard</div>
</div>
<div class="pill" id="conn">Connecting...</div>
<div class="wrap">
<div class="top">
<div>
<h1 id="device">Overland Controller</h1>
<div class="sub" id="subtitle">Local camp dashboard</div>
</div>
<div class="pill" id="conn">Connecting...</div>
</div>
<div id="overviewPage" class="page active">
<div class="grid">
<section class="card span6">
<div class="label">Battery</div>
<div class="big"><span id="soc">--</span>%</div>
<div class="bar"><div class="fill" id="socbar"></div></div>
<div class="row"><span class="muted">Voltage</span><span class="v" id="voltage">--</span></div>
<div class="row"><span class="muted">Current</span><span class="v" id="current">--</span></div>
<div class="row"><span class="muted">Remaining</span><span class="v" id="ah">--</span></div>
</section>
<section class="card span6">
<div class="label">BMS</div>
<div class="big" id="bmsState">--</div>
<div class="row"><span class="muted">Battery temp</span><span class="v" id="battTemp">--</span></div>
<div class="row"><span class="muted">Cell delta</span><span class="v" id="delta">--</span></div>
<div class="row"><span class="muted">Cycles</span><span class="v" id="cycles">--</span></div>
<div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div>
<div class="relayBtns">
<button onclick="setBmsEnabled(true)">Enable BMS</button>
<button class="off" onclick="setBmsEnabled(false)">Disable BMS</button>
<div class="kpi">
<div><div class="muted">Voltage</div><div class="num" id="voltage">--</div></div>
<div><div class="muted">Current</div><div class="num" id="current">--</div></div>
<div><div class="muted">Runtime</div><div class="num" id="runtime">--</div></div>
</div>
</section>
<section class="card span6">
<div class="label">Temperatures</div>
<div class="tempList" id="temps"></div>
</section>
<section class="card span6">
<div class="label">Relays</div>
<div class="tempList" id="relays"></div>
<div class="list" id="relays"></div>
</section>
<section class="card span12">
<div class="label">WiFi</div>
<div class="sub">AP stays available at 192.168.4.1. STA networks are tried in order.</div>
<div class="tempList" style="margin-top:12px">
<input id="w1s" placeholder="Network 1 SSID">
<input id="w1p" placeholder="Network 1 password" type="password">
<input id="w1r" placeholder="Network 1 priority, lower = first" inputmode="numeric">
<input id="w2s" placeholder="Network 2 SSID">
<input id="w2p" placeholder="Network 2 password" type="password">
<input id="w2r" placeholder="Network 2 priority, lower = first" inputmode="numeric">
<input id="w3s" placeholder="Network 3 SSID">
<input id="w3p" placeholder="Network 3 password" type="password">
<input id="w3r" placeholder="Network 3 priority, lower = first" inputmode="numeric">
<button onclick="saveWifi()">Save WiFi</button>
<button onclick="connectWifi()">Connect Now</button>
</div>
<section class="card span6">
<div class="label">Temperatures</div>
<div class="list" id="temps"></div>
</section>
<section class="card span6">
<div class="label">Network / Alarms</div>
<div class="row"><span class="muted">STA</span><span class="v" id="wifiSta">--</span></div>
<div class="row"><span class="muted">STA IP</span><span class="v" id="wifiIp">--</span></div>
</section>
<section class="card span12">
<div class="label">Alarms / System</div>
<div class="alarmList" id="alarms"></div>
<div class="row"><span class="muted">Firmware</span><span class="v" id="fw">--</span></div>
<div class="row"><span class="muted">Uptime</span><span class="v" id="uptime">--</span></div>
<div class="list" id="alarmSummary"></div>
</section>
</div>
</div>
<div id="batteryPage" class="page">
<div class="grid">
<section class="card span6">
<div class="label">Battery Detail</div>
<div class="medium" id="bmsState">--</div>
<div class="row"><span class="muted">Source</span><span class="v" id="battSource">--</span></div>
<div class="row"><span class="muted">Remaining</span><span class="v" id="ah">--</span></div>
<div class="row"><span class="muted">Capacity</span><span class="v" id="capacity">--</span></div>
<div class="row"><span class="muted">Battery temp</span><span class="v" id="battTemp">--</span></div>
<div class="row"><span class="muted">Cycles</span><span class="v" id="cycles">--</span></div>
</section>
<section class="card span6">
<div class="label">Cells</div>
<div class="row"><span class="muted">Cell count</span><span class="v" id="cellCount">--</span></div>
<div class="row"><span class="muted">Cell delta</span><span class="v" id="delta">--</span></div>
<div class="list" id="cells"></div>
</section>
<section class="card span12">
<div class="label">BMS Control</div>
<div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div>
<div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button>
<button class="off" onclick="setBmsEnabled(false)">Disable BMS</button>
</div>
</section>
</div>
</div>
<div id="configPage" class="page">
<div class="grid">
<section class="card span12">
<div class="label">WiFi Config</div>
<div class="sub">AP remains available at 192.168.4.1. Lower priority number is tried first.</div>
<div class="list">
<input id="w1s" placeholder="Network 1 SSID">
<input id="w1p" placeholder="Network 1 password" type="password">
<input id="w1r" placeholder="Network 1 priority" inputmode="numeric">
<input id="w2s" placeholder="Network 2 SSID">
<input id="w2p" placeholder="Network 2 password" type="password">
<input id="w2r" placeholder="Network 2 priority" inputmode="numeric">
<input id="w3s" placeholder="Network 3 SSID">
<input id="w3p" placeholder="Network 3 password" type="password">
<input id="w3r" placeholder="Network 3 priority" inputmode="numeric">
<button onclick="saveWifi()">Save WiFi</button>
<button onclick="connectWifi()">Connect Now</button>
</div>
</section>
<section class="card span12">
<div class="label">System</div>
<div class="row"><span class="muted">Firmware</span><span class="v" id="fw">--</span></div>
<div class="row"><span class="muted">Uptime</span><span class="v" id="uptime">--</span></div>
<div class="row"><span class="muted">AP IP</span><span class="v" id="apIp">--</span></div>
</section>
</div>
</div>
</div>
<div class="tabs">
<button class="tabbtn active" onclick="showTab('overview')">Overview</button>
<button class="tabbtn" onclick="showTab('battery')">Battery</button>
<button class="tabbtn" onclick="showTab('config')">Config</button>
</div>
<script>
const $=id=>document.getElementById(id);
function fmt(n,d=1){return typeof n==="number"?n.toFixed(d):"--"}
function temp(v){return typeof v==="number"?fmt(v,1)+"°F":"Offline"}
function clsAlarm(v){return v?"bad":"good"}
function runtimeText(b){
if(!b.connected) return "--";
if(typeof b.runtime_hours==="number" && b.runtime_hours>0) return fmt(b.runtime_hours,1)+"h";
if(typeof b.current==="number" && b.current<-.1 && typeof b.remaining_ah==="number"){
return fmt(b.remaining_ah/Math.abs(b.current),1)+"h";
}
return "Idle";
}
function showTab(name){
["overview","battery","config"].forEach(t=>{
$(t+"Page").classList.toggle("active",t===name);
});
document.querySelectorAll(".tabbtn").forEach((b,i)=>{
b.classList.toggle("active",["overview","battery","config"][i]===name);
});
}
async function setBmsEnabled(enabled){
try{
await fetch("/config/bms",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({enabled:enabled})
});
await fetch("/config/save",{method:"POST"});
await load();
}catch(e){}
await fetch("/config/bms",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled})});
await fetch("/config/save",{method:"POST"});
await load();
}
async function relay(id,on){
try{
await fetch("/relay/set",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id,state:on})
});
await load();
}catch(e){}
await fetch("/relay/set",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id,state:on})});
await load();
}
function render(data){
$("conn").textContent="Live";
$("conn").className="pill good";
$("device").textContent=data.config?.device_name||"Overland Controller";
const b=data.battery||{};
$("soc").textContent=fmt(b.soc,0);
$("socbar").style.width=Math.max(0,Math.min(100,b.soc||0))+"%";
$("voltage").textContent=fmt(b.voltage,2)+" V";
$("current").textContent=fmt(b.current,2)+" A";
$("runtime").textContent=runtimeText(b);
$("bmsState").textContent=b.connected?"BMS Online":"BMS Offline";
$("bmsState").className=b.connected?"medium good":"medium bad";
$("battSource").textContent=b.source||"--";
$("ah").textContent=fmt(b.remaining_ah,1)+" Ah";
$("bmsState").textContent=b.connected?"Online":"Offline";
$("bmsState").className=b.connected?"big good":"big bad";
$("capacity").textContent=fmt(b.capacity_ah,1)+" Ah";
$("battTemp").textContent=temp(b.temperature_f);
$("delta").textContent=(typeof b.cell_delta_mv==="number"?b.cell_delta_mv:"--")+" mV";
$("cycles").textContent=b.cycle_count??"--";
$("cellCount").textContent=b.cell_count??"--";
$("delta").textContent=(typeof b.cell_delta_mv==="number"?b.cell_delta_mv:"--")+" mV";
const bmsCfg=data.config?.bms||{};
$("bmsEnabledText").textContent=bmsCfg.enabled ? "Enabled" : "Disabled";
$("cells").innerHTML=(b.cell_voltages||[]).map((v,i)=>`
<div class="item"><span>Cell ${i+1}</span><strong>${fmt(v,3)} V</strong></div>`).join("") ||
`<div class="item"><span>No cell data</span><strong class="muted">--</strong></div>`;
$("temps").innerHTML=(data.temps||[]).map(t=>`
<div class="item">
<span>${t.name||t.id}</span>
@@ -165,21 +229,27 @@ function render(data){
$("relays").innerHTML=(data.relays||[]).map(r=>`
<div class="item" style="display:block">
<div style="display:flex;justify-content:space-between"><span>${r.name||r.id}</span><strong class="${r.state?"good":"muted"}">${r.state?"ON":"OFF"}</strong></div>
<div class="relayBtns">
<div style="display:flex;justify-content:space-between">
<span>${r.name||r.id}</span>
<strong class="${r.state?"good":"muted"}">${r.state?"ON":"OFF"}</strong>
</div>
<div class="btnrow">
<button class="on" onclick="relay('${r.id}',true)">ON</button>
<button class="off" onclick="relay('${r.id}',false)">OFF</button>
</div>
</div>`).join("");
const alarms=data.alarms||{};
$("alarms").innerHTML=Object.keys(alarms).map(k=>`
<div class="item"><span>${k.replaceAll("_"," ")}</span><strong class="${clsAlarm(alarms[k])}">${alarms[k]?"ALARM":"OK"}</strong></div>`).join("");
const active=Object.keys(alarms).filter(k=>alarms[k]);
$("alarmSummary").innerHTML=active.length
? active.map(k=>`<div class="item"><span>${k.replaceAll("_"," ")}</span><strong class="bad">ALARM</strong></div>`).join("")
: `<div class="item"><span>Alarms</span><strong class="good">OK</strong></div>`;
const s=data.system||{};
const n=data.network||{};
$("wifiSta").textContent=n.sta_connected ? (n.sta_ssid||"connected") : "Not connected";
$("wifiIp").textContent=n.sta_ip||"--";
$("apIp").textContent=n.ap_ip||"--";
$("fw").textContent=(s.firmware_name||"--")+" "+(s.firmware_version||"");
$("uptime").textContent=(s.uptime_seconds||0)+" sec";
}
@@ -190,10 +260,8 @@ async function loadWifiConfig(){
const nets=d.wifi?.networks||[];
for(let i=0;i<3;i++){
const n=nets[i]||{};
const s=$("w"+(i+1)+"s");
if(s) s.value=n.ssid||"";
const pr=$("w"+(i+1)+"r");
if(pr) pr.value=n.priority||i+1;
$("w"+(i+1)+"s").value=n.ssid||"";
$("w"+(i+1)+"r").value=n.priority||i+1;
}
}catch(e){}
}
@@ -205,11 +273,7 @@ async function saveWifi(){
const priority=parseInt($("w"+i+"r").value||i,10);
if(ssid) networks.push({ssid,password,priority});
}
await fetch("/config/wifi",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({networks})
});
await fetch("/config/wifi",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({networks})});
await loadWifiConfig();
await load();
}