files added
This commit is contained in:
+378
@@ -0,0 +1,378 @@
|
||||
#pragma once
|
||||
|
||||
const char ESP_DASHBOARD_HTML[] PROGMEM = R"HTML(
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SASA ESP RFID</title>
|
||||
<style>
|
||||
:root{--bg:#131416;--top:#282B2F;--panel:#181A1D;--panel3:#202328;--line:#2A2D32;--line2:#343942;--text:#E6EBF2;--text2:#C6CED8;--muted:#8D96A3;--blue:#4797FF;--blue2:#006EFF;--blueHover:#05254D;--blueSoft:rgba(71,151,255,.12);--green:#2FA252;--greenSoft:rgba(47,162,82,.13);--accent:#9568B8;--red:#EE6368;--redDark:#5C1F21;--yellow:#FFB84C;--r:6px;--r2:8px;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
|
||||
*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font);font-size:12px;line-height:1.35;font-weight:400;overflow-x:hidden;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,textarea,select{font-family:inherit;font-size:12px}::selection{background:rgba(71,151,255,.32)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:#30343A;border-radius:4px;border:2px solid var(--bg)}::-webkit-scrollbar-thumb:hover{background:#3B414A}
|
||||
.app{min-height:100vh;display:grid;grid-template-rows:auto 1fr;background:var(--bg)}.topbar{height:44px;display:grid;grid-template-columns:300px minmax(0,1fr) auto;align-items:center;gap:12px;padding:0 12px;background:var(--top);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:9px;min-width:0}.mark{width:22px;height:22px;display:grid;place-items:center;border-radius:var(--r);background:var(--blue2);color:white;font-weight:560;font-size:12px;letter-spacing:-.03em;flex:0 0 auto}.brandText{min-width:0}.brandText h1{margin:0;font-size:13px;line-height:1.1;color:#F3F6FA;font-weight:520;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.brandText .sub{margin-top:2px;font-size:11px;color:#AAB2BD;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tabs{display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;overflow:auto;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab{height:30px;padding:0 13px;border:0;border-radius:var(--r);background:transparent;color:#C7CDD6;cursor:pointer;font-weight:450;white-space:nowrap}.tab:hover{background:rgba(5,37,77,.75);color:#fff}.tab.active{background:#34383E;color:#fff}.topMeta{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}.stamp{color:#B5BDC8;font-size:11px;white-space:nowrap}.status{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 9px;border-radius:var(--r);border:1px solid #464B53;background:rgba(19,20,22,.62);color:#E5EAF1;font-size:11px;font-weight:450;white-space:nowrap;text-transform:capitalize}.dot{width:7px;height:7px;border-radius:50%;background:var(--yellow)}.ok{color:#D9FFE8;background:rgba(47,162,82,.16);border-color:rgba(47,162,82,.38)}.ok .dot{background:var(--green)}.bad{color:#FFD8DF;background:rgba(92,31,33,.75);border-color:rgba(238,99,104,.42)}.bad .dot{background:var(--red)}
|
||||
.stream{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 8px;border-radius:var(--r);border:1px solid #464B53;background:rgba(19,20,22,.48);color:#B5BDC8;font-size:11px;font-weight:450;white-space:nowrap}.streamDot{width:6px;height:6px;border-radius:50%;background:var(--yellow)}.stream.live{color:#D9FFE8;border-color:rgba(47,162,82,.28);background:rgba(47,162,82,.09)}.stream.live .streamDot{background:var(--green);animation:pulse 1.35s ease-in-out infinite}.stream.bad{color:#FFD8DF;border-color:rgba(238,99,104,.32);background:rgba(92,31,33,.36)}.stream.bad .streamDot{background:var(--red)}@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.72)}}
|
||||
.layout{width:100%;min-height:calc(100vh - 44px);display:grid;grid-template-columns:300px minmax(0,1fr);background:var(--bg)}.side{min-width:0;background:var(--bg);padding:10px}.sideInner{position:sticky;top:54px;display:grid;gap:10px}.panel,.card{background:transparent;border:1px solid var(--line);border-radius:var(--r2);overflow:hidden;min-width:0}.readerHead{padding:11px;display:flex;align-items:center;gap:10px;background:transparent}.readerIcon{width:32px;height:32px;border-radius:var(--r);background:#20242A;border:1px solid #303640;color:var(--blue);display:grid;place-items:center;flex:0 0 auto}.readerIcon svg{width:19px;height:19px}.readerTitle{min-width:0;flex:1}.readerTitle strong{display:block;color:#F3F6FA;font-size:13px;font-weight:520;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.readerTitle span{display:block;margin-top:2px;color:var(--muted);font-size:11px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.readerRows,.sideSection{padding:10px 11px;display:grid;gap:8px}.kv{display:grid;grid-template-columns:1fr auto;align-items:center;gap:12px;min-width:0}.kv .k{color:#EEF2F7;font-weight:450;min-width:0}.kv .v{color:var(--text2);text-align:right;max-width:154px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:400}.v.blue{color:var(--blue)}.v.green{color:var(--green)}.v.red{color:var(--red)}.v.yellow{color:#DFA446}.v.purple{color:var(--accent)}.sideDivider{height:1px;background:var(--line);margin:2px 0}.sideActions{display:grid;gap:7px;padding:0 11px 11px}.sideActions button{width:100%}.sideLabel{display:flex;align-items:center;justify-content:space-between;color:#EEF2F7;font-weight:520;font-size:12px;padding-bottom:2px}.sideLabel span:last-child{color:var(--muted);font-size:11px;font-weight:400}
|
||||
.main{min-width:0;padding:10px;display:grid;align-content:start;gap:10px;background:var(--bg)}.notice{border-radius:var(--r);padding:9px 11px;border:1px solid var(--line2);background:transparent;color:#DCE5EF;font-weight:400}.notice.info{background:var(--blueSoft);border-color:rgba(71,151,255,.28);color:#CFE8FF}.notice.error{background:var(--redDark);border-color:rgba(238,99,104,.42);color:#FFD7DF}.notice.success{background:var(--greenSoft);border-color:rgba(47,162,82,.36);color:#D9FFE8}.hidden{display:none!important}.contentGrid{display:grid;grid-template-columns:minmax(0,.88fr) minmax(460px,1.12fr);gap:10px;align-items:start}.fullGrid{display:grid;grid-template-columns:1fr;gap:10px}#dash{display:grid;gap:18px}.cardHead{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;background:transparent}.cardHead h3{margin:0;color:#F3F6FA;font-size:12px;font-weight:500}.cardHead .hint{color:var(--muted);font-size:11px;font-weight:400;white-space:nowrap}.tableWrap{overflow:auto}.table{width:100%;border-collapse:collapse;font-size:12px}.table th,.table td{text-align:left;padding:8px 12px;border-bottom:1px solid rgba(42,45,50,.72);vertical-align:middle;height:32px}.table tr:last-child th,.table tr:last-child td{border-bottom:0}.table th{width:34%;color:#AEB6C2;font-size:11px;font-weight:450;background:transparent;white-space:nowrap}.table td{color:var(--text2);font-weight:400;word-break:break-word}.table tr:hover td,.table tr:hover th{background:rgba(255,255,255,.018)}.compactTable th{width:auto}.compactTable td,.compactTable th{white-space:nowrap}.actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap;padding:10px 11px;border-top:1px solid var(--line);background:transparent}
|
||||
.table tr.flash td,.table tr.flash th{animation:rowFlash .85s ease-out}@keyframes rowFlash{0%{background:rgba(71,151,255,.16)}100%{background:transparent}}
|
||||
button{border:1px solid rgba(71,151,255,.72);background:transparent;color:var(--blue);border-radius:var(--r);padding:7px 10px;min-height:30px;cursor:pointer;font-weight:450;transition:background .12s ease,border-color .12s ease,color .12s ease}button:hover{background:var(--blueHover);border-color:var(--blue);color:#DCEBFF}button.primary{background:transparent;border-color:rgba(71,151,255,.86);color:var(--blue)}button.primary:hover{background:var(--blueHover);border-color:var(--blue);color:#fff}button.danger{background:transparent;border-color:rgba(238,99,104,.68);color:#FFA2A6}button.danger:hover{background:var(--redDark);border-color:var(--red);color:#FFE2E4}button:disabled{opacity:.55;cursor:not-allowed}.logoutBtn{min-height:28px;padding:0 9px;border-color:#464B53;color:#C7CDD6}.logoutBtn:hover{background:rgba(255,255,255,.05);border-color:#5A626D;color:#fff}
|
||||
.formBody{padding:11px}.formGrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}label{display:block;color:#E6ECF4;font-weight:450;font-size:12px;margin-bottom:10px}label .labelText{display:block;margin-bottom:5px}input,textarea,select{width:100%;border:1px solid #343942;background:#111214;color:#E9EEF6;border-radius:var(--r);padding:8px 9px;min-height:32px;outline:none;font-weight:400}input:focus,textarea:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(71,151,255,.12)}textarea{min-height:76px;resize:vertical}.checkGrid{display:grid;grid-template-columns:repeat(2,minmax(190px,1fr));gap:8px;margin-top:2px}.row{margin:0;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--line);background:transparent;border-radius:var(--r);padding:9px 10px;color:#D7E0EC;font-weight:400;cursor:pointer;user-select:none}.row input[type="checkbox"]{appearance:none;-webkit-appearance:none;width:34px;height:18px;min-height:18px;flex:0 0 34px;margin:0;padding:0;border:1px solid #41474F;border-radius:999px;background:#252A30;cursor:pointer;position:relative;transition:background .14s ease,border-color .14s ease}.row input[type="checkbox"]::before{content:"";position:absolute;width:14px;height:14px;left:1px;top:1px;border-radius:50%;background:#9AA3AE;transition:transform .14s ease,background .14s ease}.row input[type="checkbox"]:checked{background:var(--blue2);border-color:var(--blue)}.row input[type="checkbox"]:checked::before{transform:translateX(16px);background:#fff}.muted{color:var(--muted)}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#DCEFFF;word-break:break-all}
|
||||
@media(max-width:1180px){.topbar{grid-template-columns:260px minmax(0,1fr) auto}.layout{grid-template-columns:260px minmax(0,1fr)}.contentGrid{grid-template-columns:1fr}}@media(max-width:860px){.topbar{height:auto;min-height:44px;grid-template-columns:1fr;gap:8px;padding:9px 10px;position:relative}.tabs{justify-content:flex-start;order:3}.topMeta{justify-content:flex-start;flex-wrap:wrap}.layout{display:block}.sideInner{position:static}.formGrid,.checkGrid{grid-template-columns:1fr}}@media(max-width:560px){.table th,.table td{padding:9px 10px}.table th{width:42%}.actions{display:grid}.actions button{width:100%}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar"><div class="brand"><div class="mark">S</div><div class="brandText"><h1>SASA ESP RFID</h1><div class="sub" id="subtitle">Loading reader state...</div></div></div><nav class="tabs"><button class="tab active" data-tab="dash">Dashboard</button><button class="tab" data-tab="setup">Setup</button></nav><div class="topMeta"><div id="updatedAt" class="stamp">Not synced yet</div><div id="streamPill" class="stream"><span class="streamDot"></span><span>Connecting</span></div><div id="statePill" class="status"><span class="dot"></span><span>Loading</span></div><button class="logoutBtn" onclick="logout()">Logout</button></div></header>
|
||||
<div class="layout"><aside class="side"><div class="sideInner"><div class="panel"><div class="readerHead"><div class="readerIcon"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="6" y="3.5" width="12" height="17" rx="2.5" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><path d="M9 7h6M9 17h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div><div class="readerTitle"><strong id="sideName">SASA Reader</strong><span id="sideId">Loading device...</span></div></div><div class="readerRows"><div class="kv"><span class="k">Status</span><span id="sideStatus" class="v blue">-</span></div><div class="kv"><span class="k">WiFi</span><span id="sideWifi" class="v">-</span></div><div class="kv"><span class="k">IP Address</span><span id="sideIp" class="v blue">-</span></div><div class="kv"><span class="k">RSSI</span><span id="sideRssi" class="v">-</span></div><div class="sideDivider"></div><div class="kv"><span class="k">Provisioning</span><span id="sideProv" class="v">-</span></div><div class="kv"><span class="k">Mode</span><span id="sideMode" class="v">-</span></div><div class="kv"><span class="k">Clock</span><span id="sideClock" class="v">-</span></div><div class="kv"><span class="k">Heartbeat</span><span id="sideBeat" class="v">-</span></div><div class="sideDivider"></div><div class="kv"><span class="k">Last UID</span><span id="sideUid" class="v purple">-</span></div><div class="kv"><span class="k">Write Job</span><span id="sideJob" class="v">-</span></div><div class="kv"><span class="k">Writing</span><span id="sideWriting" class="v">-</span></div></div><div class="sideActions"><button class="primary" onclick="apiPost('/api/register')">Register / Re-register</button><button onclick="apiPost('/api/sync-time')">Sync Time</button><button onclick="apiPost('/api/poll-job')">Poll Write Job</button></div></div><div class="panel"><div class="sideSection"><div class="sideLabel"><span>Reader</span><span>Local</span></div><div class="kv"><span class="k">Type</span><span id="sideType" class="v">-</span></div><div class="kv"><span class="k">Firmware</span><span id="sideFirmware" class="v">-</span></div><div class="kv"><span class="k">Location</span><span id="sideLocation" class="v">-</span></div></div></div><div class="panel"><div class="sideSection"><div class="sideLabel"><span>Backend</span><span>API</span></div><div class="kv"><span class="k">Server</span><span id="sideServer" class="v blue">-</span></div><div class="kv"><span class="k">TLS</span><span id="sideTls" class="v">-</span></div></div></div></div></aside>
|
||||
<main class="main"><div id="notice" class="notice info hidden"></div>
|
||||
<section id="dash" class="tabPanel"><div class="contentGrid"><div class="card"><div class="cardHead"><h3>Reader Status</h3><span class="hint" id="statusHint">Current state</span></div><div class="tableWrap"><table class="table"><tbody id="statusRows"></tbody></table></div></div><div class="card"><div class="cardHead"><h3>Current Work</h3><span class="hint" id="workHint">RFID activity</span></div><div class="tableWrap"><table class="table"><tbody id="workRows"></tbody></table></div><div class="actions"><button class="primary" onclick="apiPost('/api/register')">Register / Re-register</button><button onclick="apiPost('/api/sync-time')">Sync Time Now</button></div></div></div><div class="fullGrid"><div class="card"><div class="cardHead"><h3>Active Write Job</h3><span class="hint">Queue</span></div><div class="tableWrap"><table class="table compactTable"><thead><tr><th>Waiting</th><th>Job ID</th><th>Label</th><th>User</th><th>Payload</th><th>Claimed</th></tr></thead><tbody id="jobRows"></tbody></table></div><div class="actions"><button onclick="apiPost('/api/poll-job')">Poll Now</button><button class="danger" onclick="apiPost('/api/cancel-local-job')">Cancel Local Job</button></div></div><div class="card"><div class="cardHead"><h3>Last RFID Activity</h3><span class="hint">Last 10 card events</span></div><div class="tableWrap"><table class="table compactTable"><thead><tr><th>When</th><th>UID</th><th>Action</th><th>Result</th></tr></thead><tbody id="rfidRows"></tbody></table></div></div></div></section>
|
||||
<section id="setup" class="tabPanel hidden"><form id="configForm" class="card"><div class="cardHead"><h3>Configuration</h3><span class="hint">Saved on ESP</span></div><div class="formBody"><div class="formGrid"><div><label><span class="labelText">Backend Base URL</span><input name="apiBaseUrl" required></label><label><span class="labelText">Device ID</span><input name="deviceId" required></label><label><span class="labelText">Reader Type</span><input name="readerType" required></label><label><span class="labelText">Reader Name</span><input name="readerName"></label><label><span class="labelText">Location</span><input name="readerLocation"></label></div><div><label><span class="labelText">WiFi SSID <span class="muted">(loaded only on request)</span></span><input name="wifiSsid" placeholder="Click Load WiFi Settings"></label><label><span class="labelText">WiFi Password <span class="muted">(leave blank to keep current)</span></span><input name="wifiPassword" type="password" placeholder="Not shown"></label><label><span class="labelText">Dashboard Username <span class="muted">(leave blank to keep current)</span></span><input name="dashboardUser" autocomplete="off" placeholder="Not shown"></label><label><span class="labelText">Dashboard Password <span class="muted">(leave blank to keep current)</span></span><input name="dashboardPassword" type="password" autocomplete="new-password" placeholder="Not shown"></label><label><span class="labelText">API Key <span class="muted">(leave blank to keep current/provisioned key)</span></span><input name="apiKey" type="password" autocomplete="off" placeholder="Not shown"></label></div></div><label><span class="labelText">Notes</span><textarea name="notes"></textarea></label><div class="checkGrid"><label class="row">Can write RFID cards <input type="checkbox" name="canWriteCards"></label><label class="row">Use TLS <input type="checkbox" name="useTls"></label><label class="row">Allow insecure TLS/self-signed certificate <input type="checkbox" name="tlsInsecure"></label><label class="row">Send tapped_at from synced clock <input type="checkbox" name="sendTappedAt"></label></div></div><div class="actions"><button class="primary" type="submit">Save Configuration</button><button type="button" onclick="loadWifiConfig()">Load WiFi Settings</button><button type="button" onclick="apiPost('/api/register')">Register Reader</button><button type="button" onclick="apiPost('/api/clear-provisioning')">Clear Provisioning</button><button class="danger" type="button" onclick="apiPost('/api/reboot')">Reboot</button></div></form></section>
|
||||
</main></div></div>
|
||||
<script>
|
||||
let latest=null,statusBusy=false,statusTimer=null,activeTab='dash',consecutiveStatusFailures=0,wifiConfigLoaded=false;
|
||||
let eventSource=null,streamConnected=false,fallbackPolling=true,lastStatusAt=0,lastHistoryKey='';
|
||||
const ACTIVE_REFRESH_MS=3500,HIDDEN_REFRESH_MS=9000,STATUS_TIMEOUT_MS=3200,ACTION_TIMEOUT_MS=14000;
|
||||
|
||||
function $(id){return document.getElementById(id)}
|
||||
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function row(k,v){return `<tr><th>${esc(k)}</th><td>${esc(v||'-')}</td></tr>`}
|
||||
function notice(text,type='info'){const n=$('notice');n.className='notice '+type;n.textContent=text;n.classList.remove('hidden');clearTimeout(notice._t);notice._t=setTimeout(()=>n.classList.add('hidden'),5000)}
|
||||
function setUpdated(source=''){const suffix=streamConnected?' · live':'';$('updatedAt').textContent=(source?source+' ':'')+new Date().toLocaleTimeString()+suffix}
|
||||
async function requestWithTimeout(path,options={},timeoutMs=STATUS_TIMEOUT_MS){const controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),timeoutMs);try{return await fetch(path,{cache:'no-store',...options,signal:controller.signal})}finally{clearTimeout(timeout)}}
|
||||
function yesNo(v){return v?'Yes':'No'}
|
||||
function enabledDisabled(v){return v?'Enabled':'Disabled'}
|
||||
|
||||
function setStream(text,tone=''){
|
||||
const p=$('streamPill');
|
||||
p.className='stream '+tone;
|
||||
p.querySelector('span:last-child').textContent=text;
|
||||
}
|
||||
|
||||
function relLocal(localMs){
|
||||
if(!(latest?.runtime?.deviceMillis>=0)||!(localMs>=0))return 'Not available';
|
||||
const eventClientMs=Date.now()-(latest.runtime.deviceMillis-localMs);
|
||||
const diff=Math.max(0,Math.round((Date.now()-eventClientMs)/1000));
|
||||
if(diff<5)return 'just now';
|
||||
if(diff<60)return `${diff}s ago`;
|
||||
if(diff<3600)return `${Math.round(diff/60)}m ago`;
|
||||
return `${Math.round(diff/3600)}h ago`;
|
||||
}
|
||||
|
||||
function formatClock(s){
|
||||
if(!s.time?.synced)return 'Not synced';
|
||||
return `In sync · ${relLocal(s.time.lastSyncLocalMs)}`;
|
||||
}
|
||||
|
||||
function formatHeartbeat(s){
|
||||
if(!(s.heartbeat?.lastOkMs>=0)||!s.heartbeat?.lastOk)return 'Waiting for first heartbeat';
|
||||
return `Connected ${relLocal(s.heartbeat.lastOkMs)}`;
|
||||
}
|
||||
|
||||
function headlineStatus(s){
|
||||
const prov=s.provisioning?.status||'unregistered';
|
||||
if(prov==='unregistered')return{tone:'bad',label:'Not registered',detail:'Complete setup and register this reader.'};
|
||||
if(prov==='pending')return{tone:'',label:'Awaiting approval',detail:'Registration sent. Waiting for approval from the main dashboard.'};
|
||||
if(prov==='rejected')return{tone:'bad',label:'Registration rejected',detail:'Clear provisioning and register again after review.'};
|
||||
if(!s.wifi?.connected)return{tone:'bad',label:'Offline',detail:'Reader is not connected to WiFi.'};
|
||||
if(s.writeJob?.active)return{tone:'ok',label:'Ready to write',detail:`Waiting for card to write ${s.writeJob.label||'the queued job'}.`};
|
||||
return{tone:'ok',label:'Ready',detail:'Reader is online and ready for taps.'};
|
||||
}
|
||||
|
||||
function modeDetail(s){
|
||||
const prov=s.provisioning?.status||'unregistered';
|
||||
if(prov==='unregistered')return 'Not registered';
|
||||
if(prov==='pending')return 'Awaiting approval';
|
||||
if(prov==='rejected')return 'Rejected';
|
||||
if(!s.wifi?.connected)return 'Offline';
|
||||
if(s.writeJob?.active)return 'Card writing';
|
||||
return 'Operational';
|
||||
}
|
||||
|
||||
function lastActivity(s){
|
||||
const h=s.rfid?.history||[];
|
||||
if(h.length){
|
||||
const e=h[0];
|
||||
return [e.result,e.action,e.uid].filter(Boolean).join(' · ');
|
||||
}
|
||||
return 'No recent card activity';
|
||||
}
|
||||
|
||||
function setTone(el,tone){
|
||||
el.classList.remove('green','red','yellow','blue','purple');
|
||||
if(tone)el.classList.add(tone);
|
||||
}
|
||||
|
||||
function refreshDelay(){return document.hidden?HIDDEN_REFRESH_MS:ACTIVE_REFRESH_MS}
|
||||
|
||||
function scheduleStatus(){
|
||||
clearTimeout(statusTimer);
|
||||
if(!fallbackPolling)return;
|
||||
statusTimer=setTimeout(loadStatus,refreshDelay());
|
||||
}
|
||||
|
||||
function applyStatus(data,source=''){
|
||||
latest=data;
|
||||
lastStatusAt=Date.now();
|
||||
consecutiveStatusFailures=0;
|
||||
render(latest);
|
||||
setUpdated(source);
|
||||
}
|
||||
|
||||
async function loadStatus(){
|
||||
if(statusBusy){scheduleStatus();return}
|
||||
statusBusy=true;
|
||||
try{
|
||||
const r=await requestWithTimeout('/api/status',{},STATUS_TIMEOUT_MS);
|
||||
if(r.status===401){window.location.href='/login';return}
|
||||
if(!r.ok)throw new Error(await r.text());
|
||||
applyStatus(await r.json(),fallbackPolling?'polled':'');
|
||||
}catch(e){
|
||||
consecutiveStatusFailures++;
|
||||
if(consecutiveStatusFailures>=3){
|
||||
setStream('Offline','bad');
|
||||
notice(e.name==='AbortError'?'Status failed: request timed out':('Status failed: '+e.message),'error');
|
||||
}
|
||||
}finally{
|
||||
statusBusy=false;
|
||||
scheduleStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function addRfidEvent(item){
|
||||
if(!latest){
|
||||
loadStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!latest.rfid)latest.rfid={history:[]};
|
||||
if(!Array.isArray(latest.rfid.history))latest.rfid.history=[];
|
||||
|
||||
const exists=latest.rfid.history.some(e=>
|
||||
e.localMs===item.localMs &&
|
||||
e.uid===item.uid &&
|
||||
e.action===item.action &&
|
||||
e.result===item.result
|
||||
);
|
||||
|
||||
if(!exists){
|
||||
latest.rfid.history.unshift(item);
|
||||
latest.rfid.history=latest.rfid.history.slice(0,10);
|
||||
}
|
||||
|
||||
latest.rfid.lastInteractionMs=item.localMs;
|
||||
lastStatusAt=Date.now();
|
||||
render(latest);
|
||||
setUpdated('card');
|
||||
}
|
||||
|
||||
function render(s){
|
||||
const status=headlineStatus(s),readerName=s.config.readerName||'SASA Reader',deviceId=s.config.deviceId||'Unknown device',wifiText=s.wifi.connected?'Online':'Offline',wifiDetail=s.wifi.connected?`${s.wifi.ip||'-'} · ${s.wifi.rssi??'-'} dBm`:'Disconnected',writeJobText=s.writeJob.active?'#'+s.writeJob.id+' · '+(s.writeJob.label||'Write job'):'None';
|
||||
|
||||
$('subtitle').textContent=`${readerName} · ${deviceId}`;
|
||||
|
||||
const pill=$('statePill');
|
||||
pill.className='status '+status.tone;
|
||||
pill.querySelector('span:last-child').textContent=status.label;
|
||||
|
||||
$('sideName').textContent=readerName;
|
||||
$('sideId').textContent=deviceId;
|
||||
$('sideStatus').textContent=status.label;
|
||||
$('sideWifi').textContent=wifiText;
|
||||
$('sideIp').textContent=s.wifi.connected?(s.wifi.ip||'-'):(s.wifi.apStarted&&s.wifi.apIp?s.wifi.apIp:'-');
|
||||
$('sideRssi').textContent=s.wifi.connected?((s.wifi.rssi??'-')+' dBm'):'-';
|
||||
$('sideProv').textContent=s.provisioning?.status||'unregistered';
|
||||
$('sideMode').textContent=modeDetail(s);
|
||||
$('sideClock').textContent=s.time?.synced?'Synced':'Not synced';
|
||||
$('sideBeat').textContent=formatHeartbeat(s);
|
||||
$('sideUid').textContent=s.rfid?.history?.[0]?.uid||'None';
|
||||
$('sideJob').textContent=s.writeJob?.active?('#'+s.writeJob.id):'None';
|
||||
$('sideWriting').textContent=enabledDisabled(s.config?.canWriteCards);
|
||||
$('sideType').textContent=s.config?.readerType||'-';
|
||||
$('sideFirmware').textContent=s.config?.firmwareVersion||'-';
|
||||
$('sideLocation').textContent=s.config?.readerLocation||'-';
|
||||
$('sideServer').textContent=s.config?.apiBaseUrl||'-';
|
||||
$('sideTls').textContent=s.config?.useTls?(s.config?.tlsInsecure?'TLS insecure':'TLS enabled'):'Disabled';
|
||||
|
||||
setTone($('sideStatus'),status.tone==='ok'?'green':status.tone==='bad'?'red':'yellow');
|
||||
setTone($('sideWifi'),s.wifi.connected?'green':'red');
|
||||
setTone($('sideProv'),status.tone==='ok'?'green':status.tone==='bad'?'red':'yellow');
|
||||
setTone($('sideClock'),s.time?.synced?'green':'yellow');
|
||||
setTone($('sideWriting'),s.config?.canWriteCards?'green':'yellow');
|
||||
|
||||
$('statusHint').textContent=status.label;
|
||||
$('workHint').textContent=s.writeJob?.active?'Write job active':'No queued job';
|
||||
|
||||
$('statusRows').innerHTML=[
|
||||
row('Status',status.detail),
|
||||
row('Setup AP',s.wifi?.apStarted?`${s.wifi.apSsid||'Started'} · ${s.wifi.apIp||'-'}`:'Off'),
|
||||
row('Server',s.config.apiBaseUrl),
|
||||
row('Clock',formatClock(s)),
|
||||
row('Free heap',s.runtime?.freeHeap?`${s.runtime.freeHeap} bytes`:'-'),
|
||||
row('Card writing enabled',yesNo(s.config.canWriteCards)),
|
||||
row('API key stored',yesNo(s.provisioning.hasApiKey))
|
||||
].join('');
|
||||
|
||||
$('workRows').innerHTML=[
|
||||
row('Reader state',modeDetail(s)),
|
||||
row('Mode message',s.runtime?.message||'-'),
|
||||
row('Queued write job',writeJobText),
|
||||
row('Last card seen',s.rfid?.history?.[0]?.uid||'None'),
|
||||
row('Last activity',lastActivity(s))
|
||||
].join('');
|
||||
|
||||
$('jobRows').innerHTML=`<tr><td>${s.writeJob.active?'Yes':'No'}</td><td>${esc(s.writeJob.id||'None')}</td><td>${esc(s.writeJob.label||'None')}</td><td>${esc(s.writeJob.userName||'None')}</td><td>${s.writeJob.payloadLength?esc(s.writeJob.payloadLength+' bytes'):'None'}</td><td>${s.writeJob.claimedMs?esc(relLocal(s.writeJob.claimedMs)):'None'}</td></tr>`;
|
||||
|
||||
const hist=s.rfid?.history||[];
|
||||
const historyKey=hist.map(e=>`${e.localMs}:${e.uid}:${e.action}:${e.result}`).join('|');
|
||||
const flashNewest=historyKey&&lastHistoryKey&&historyKey!==lastHistoryKey;
|
||||
lastHistoryKey=historyKey;
|
||||
|
||||
$('rfidRows').innerHTML=hist.length?hist.map((e,i)=>`<tr class="${flashNewest&&i===0?'flash':''}"><td>${esc(relLocal(e.localMs))}</td><td><code>${esc(e.uid||'-')}</code></td><td>${esc(e.action||'-')}</td><td>${esc(e.result||'-')}</td></tr>`).join(''):`<tr><td colspan="4" class="muted">No RFID activity yet</td></tr>`;
|
||||
|
||||
const f=document.forms.configForm;
|
||||
if(!f.dataset.loaded){
|
||||
for(const [k,v]of Object.entries(s.config)){
|
||||
if(f.elements[k]&&typeof v!=='boolean')f.elements[k].value=v??'';
|
||||
}
|
||||
['canWriteCards','useTls','tlsInsecure','sendTappedAt'].forEach(k=>{
|
||||
if(f.elements[k])f.elements[k].checked=!!s.config[k];
|
||||
});
|
||||
f.elements.wifiSsid.value='';
|
||||
f.dataset.loaded='1';
|
||||
}
|
||||
}
|
||||
|
||||
async function apiPost(path,body={}){
|
||||
try{
|
||||
const r=await requestWithTimeout(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)},ACTION_TIMEOUT_MS);
|
||||
const t=await r.text();
|
||||
if(r.status===401){window.location.href='/login';return}
|
||||
if(!r.ok)throw new Error(t);
|
||||
notice(t||'Done','success');
|
||||
await loadStatus();
|
||||
}catch(e){
|
||||
notice(e.name==='AbortError'?'Request timed out':e.message,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWifiConfig(){
|
||||
try{
|
||||
const r=await requestWithTimeout('/api/wifi-config',{},ACTION_TIMEOUT_MS);
|
||||
if(r.status===401){window.location.href='/login';return}
|
||||
if(!r.ok)throw new Error(await r.text());
|
||||
const data=await r.json();
|
||||
const f=document.forms.configForm;
|
||||
f.elements.wifiSsid.value=data.wifiSsid||'';
|
||||
wifiConfigLoaded=true;
|
||||
notice(data.hasWifiPassword?'WiFi SSID loaded. Password is stored but not shown.':'WiFi SSID loaded. No password is stored.','success');
|
||||
}catch(e){
|
||||
notice(e.name==='AbortError'?'Request timed out':e.message,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(){
|
||||
try{
|
||||
await requestWithTimeout('/api/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'},ACTION_TIMEOUT_MS);
|
||||
}catch(e){}
|
||||
window.location.href='/login';
|
||||
}
|
||||
|
||||
function connectEvents(){
|
||||
if(!window.EventSource){
|
||||
fallbackPolling=true;
|
||||
setStream('Polling');
|
||||
loadStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventUrl=`http://${location.hostname}:81/events`;
|
||||
|
||||
try{
|
||||
eventSource=new EventSource(eventUrl);
|
||||
}catch(e){
|
||||
fallbackPolling=true;
|
||||
setStream('Polling');
|
||||
loadStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
eventSource.addEventListener('open',()=>{
|
||||
streamConnected=true;
|
||||
fallbackPolling=false;
|
||||
clearTimeout(statusTimer);
|
||||
setStream('Live','live');
|
||||
setUpdated('live');
|
||||
});
|
||||
|
||||
eventSource.addEventListener('status',event=>{
|
||||
try{
|
||||
streamConnected=true;
|
||||
fallbackPolling=false;
|
||||
clearTimeout(statusTimer);
|
||||
setStream('Live','live');
|
||||
applyStatus(JSON.parse(event.data),'live');
|
||||
}catch(e){
|
||||
console.warn('Bad status event',e);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('rfid',event=>{
|
||||
try{
|
||||
streamConnected=true;
|
||||
fallbackPolling=false;
|
||||
clearTimeout(statusTimer);
|
||||
setStream('Live','live');
|
||||
addRfidEvent(JSON.parse(event.data));
|
||||
}catch(e){
|
||||
console.warn('Bad RFID event',e);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror=()=>{
|
||||
streamConnected=false;
|
||||
setStream('Reconnecting');
|
||||
if(Date.now()-lastStatusAt>6000){
|
||||
fallbackPolling=true;
|
||||
scheduleStatus();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(()=>{
|
||||
if(!latest){
|
||||
fallbackPolling=true;
|
||||
setStream('Polling');
|
||||
loadStatus();
|
||||
}
|
||||
},1800);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
|
||||
activeTab=b.dataset.tab;
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
document.querySelectorAll('.tabPanel').forEach(p=>p.classList.add('hidden'));
|
||||
$(activeTab).classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange',scheduleStatus);
|
||||
|
||||
document.forms.configForm.onsubmit=async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target);
|
||||
const data=Object.fromEntries(fd.entries());
|
||||
['canWriteCards','useTls','tlsInsecure','sendTappedAt'].forEach(k=>data[k]=fd.has(k));
|
||||
await apiPost('/api/config',data);
|
||||
};
|
||||
|
||||
setInterval(()=>{
|
||||
if(latest)render(latest);
|
||||
if(latest&&!fallbackPolling&&Date.now()-lastStatusAt>10000){
|
||||
setStream('Reconnecting');
|
||||
}
|
||||
},1000);
|
||||
|
||||
connectEvents();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)HTML";
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#pragma once
|
||||
|
||||
const char ESP_LOGIN_HTML[] PROGMEM = R"HTML(
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SASA ESP RFID Login</title>
|
||||
<style>
|
||||
:root{--bg:#131416;--top:#282B2F;--line:#2A2D32;--line2:#343942;--text:#E6EBF2;--muted:#8D96A3;--blue:#4797FF;--blue2:#006EFF;--blueHover:#05254D;--green:#2FA252;--greenSoft:rgba(47,162,82,.13);--red:#EE6368;--redDark:#5C1F21;--blueSoft:rgba(71,151,255,.12);--r:6px;--r2:8px;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
|
||||
*{box-sizing:border-box}
|
||||
html,body{min-height:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font);font-size:12px;line-height:1.35;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
button,input{font-family:inherit;font-size:12px}
|
||||
|
||||
.app{min-height:100vh;display:grid;grid-template-rows:auto 1fr;background:var(--bg)}
|
||||
|
||||
.topbar{height:44px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;background:var(--top)}
|
||||
.brand{display:flex;align-items:center;gap:9px;min-width:0}
|
||||
.mark{width:22px;height:22px;display:grid;place-items:center;border-radius:var(--r);background:var(--blue2);color:#fff;font-weight:560;font-size:12px;letter-spacing:-.03em;flex:0 0 auto}
|
||||
.brandText{min-width:0}
|
||||
.brandText h1{margin:0;font-size:13px;line-height:1.1;color:#F3F6FA;font-weight:520;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.brandText .sub{margin-top:2px;font-size:11px;color:#AAB2BD;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
.shell{min-height:calc(100vh - 44px);display:grid;place-items:center;padding:10px}
|
||||
.card{width:min(400px,100%);border:1px solid var(--line);border-radius:var(--r2);background:transparent;overflow:hidden}
|
||||
|
||||
.cardHead{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;border-bottom:1px solid var(--line)}
|
||||
.cardHead h2{margin:0;color:#F3F6FA;font-size:12px;font-weight:500}
|
||||
.cardHead .hint{color:var(--muted);font-size:11px;font-weight:400;white-space:nowrap}
|
||||
|
||||
form{display:grid}
|
||||
.formBody{padding:11px;display:grid;gap:12px}
|
||||
label{display:block;color:#E6ECF4;font-weight:450;font-size:12px}
|
||||
.labelText{display:block;margin-bottom:5px}
|
||||
|
||||
input{width:100%;border:1px solid #343942;background:#111214;color:#E9EEF6;border-radius:var(--r);padding:8px 9px;min-height:32px;outline:none;font-weight:400}
|
||||
input:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(71,151,255,.12)}
|
||||
|
||||
button{border:1px solid rgba(71,151,255,.86);background:transparent;color:var(--blue);border-radius:var(--r);padding:7px 10px;min-height:30px;cursor:pointer;font-weight:450;transition:background .12s ease,border-color .12s ease,color .12s ease}
|
||||
button:hover{background:var(--blueHover);border-color:var(--blue);color:#fff}
|
||||
button:disabled{opacity:.55;cursor:not-allowed}
|
||||
|
||||
.actions{padding:10px 11px;border-top:1px solid var(--line)}
|
||||
.actions button{width:100%}
|
||||
|
||||
.notice{border-radius:var(--r);padding:9px 11px;border:1px solid var(--line2);background:transparent;color:#DCE5EF;font-weight:400;display:none}
|
||||
.notice.error{display:block;background:var(--redDark);border-color:rgba(238,99,104,.42);color:#FFD7DF}
|
||||
.notice.success{display:block;background:var(--greenSoft);border-color:rgba(47,162,82,.36);color:#D9FFE8}
|
||||
|
||||
@media(max-width:520px){
|
||||
.topbar{height:auto;min-height:44px;padding:9px 10px}
|
||||
.shell{place-items:start center}
|
||||
.cardHead{padding:10px 11px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="mark">S</div>
|
||||
<div class="brandText">
|
||||
<h1>SASA ESP RFID</h1>
|
||||
<div class="sub">Local reader dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<section class="card">
|
||||
<div class="cardHead">
|
||||
<h2>Sign In</h2>
|
||||
<span class="hint">ESP local session</span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="formBody">
|
||||
<div id="notice" class="notice"></div>
|
||||
|
||||
<label>
|
||||
<span class="labelText">Username</span>
|
||||
<input name="username" autocomplete="username" autofocus required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span class="labelText">Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="loginButton" type="submit">Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form=document.getElementById('loginForm'),notice=document.getElementById('notice'),button=document.getElementById('loginButton');
|
||||
|
||||
function showNotice(text,type='error'){
|
||||
notice.className='notice '+type;
|
||||
notice.textContent=text;
|
||||
}
|
||||
|
||||
form.onsubmit=async e=>{
|
||||
e.preventDefault();
|
||||
|
||||
const data=Object.fromEntries(new FormData(form).entries());
|
||||
|
||||
button.disabled=true;
|
||||
button.textContent='Signing in...';
|
||||
notice.className='notice';
|
||||
notice.textContent='';
|
||||
|
||||
try{
|
||||
const r=await fetch('/api/login',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(data),
|
||||
cache:'no-store'
|
||||
});
|
||||
|
||||
const text=await r.text();
|
||||
|
||||
if(!r.ok)throw new Error(text||'Login failed');
|
||||
|
||||
showNotice('Signed in. Opening dashboard...','success');
|
||||
window.location.href='/';
|
||||
}catch(err){
|
||||
showNotice(err.message||'Login failed','error');
|
||||
}finally{
|
||||
button.disabled=false;
|
||||
button.textContent='Sign In';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)HTML";
|
||||
+2000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
// WiFi defaults (can be changed from ESP config web UI)
|
||||
const char *WIFI_SSID = "Office";
|
||||
const char *WIFI_PASSWORD = "Nathan1@3$!";
|
||||
|
||||
// Backend defaults (can be changed from ESP config web UI)
|
||||
const char *API_BASE_URL = "http://172.30.42.13:8057";
|
||||
|
||||
// Reader defaults from Admin -> ESP RFID (can be changed from ESP config web UI)
|
||||
const char *ESP_DEVICE_ID = "";
|
||||
const char *ESP_API_KEY = "";
|
||||
const char *ESP_READER_TYPE = "checkin_checkout";
|
||||
|
||||
// RFID reader pins
|
||||
const uint8_t RFID_SS_PIN = 5; // SDA/SS pin on MFRC522
|
||||
const uint8_t RFID_RST_PIN = 22; // RST pin on MFRC522
|
||||
|
||||
// Set true only if you use non-default SPI pins on ESP32
|
||||
const bool RFID_USE_CUSTOM_SPI_PINS = true;
|
||||
const uint8_t RFID_SCK_PIN = 18;
|
||||
const uint8_t RFID_MISO_PIN = 19;
|
||||
const uint8_t RFID_MOSI_PIN = 21;
|
||||
|
||||
// Runtime behavior
|
||||
const unsigned long WIFI_RECONNECT_INTERVAL_MS = 15000;
|
||||
const unsigned long HTTP_TIMEOUT_MS = 12000;
|
||||
const unsigned long RFID_DUPLICATE_SUPPRESS_MS = 1500;
|
||||
const bool ESP_SEND_TAPPED_AT = true;
|
||||
|
||||
// TLS settings
|
||||
// If your device cannot validate your server cert chain yet, set ESP_TLS_INSECURE = true.
|
||||
const bool ESP_USE_TLS = true;
|
||||
const bool ESP_TLS_INSECURE = true;
|
||||
const char *ESP_TLS_ROOT_CA = "";
|
||||
|
||||
// Local provisioning AP + config page auth
|
||||
const char *CONFIG_AP_SSID_PREFIX = "RFID-Setup-";
|
||||
const char *CONFIG_AP_PASSWORD = "espconfig123";
|
||||
const char *CONFIG_WEB_USERNAME = "admin";
|
||||
const char *CONFIG_WEB_PASSWORD = "admin";
|
||||
Reference in New Issue
Block a user