Fine tune the admin UI.
This commit is contained in:
BIN
__pycache__/build_ota_package.cpython-313.pyc
Normal file
BIN
__pycache__/build_ota_package.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/upload_device.cpython-313.pyc
Normal file
BIN
__pycache__/upload_device.cpython-313.pyc
Normal file
Binary file not shown.
181
data/admin.html
181
data/admin.html
@@ -1,181 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>ESP32-C3 Admin</title>
|
|
||||||
<style>
|
|
||||||
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
|
|
||||||
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
|
|
||||||
main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
|
|
||||||
section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
|
|
||||||
h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
|
||||||
input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
|
|
||||||
textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
|
||||||
button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}}
|
|
||||||
button.secondary{background:#607d8b}button.danger{background:#b3261e}
|
|
||||||
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
|
|
||||||
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px}
|
|
||||||
.tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto}
|
|
||||||
.tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap}
|
|
||||||
.tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block}
|
|
||||||
.subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block}
|
|
||||||
.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
|
|
||||||
.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed}
|
|
||||||
.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238}
|
|
||||||
.checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto}
|
|
||||||
.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
|
|
||||||
.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
|
|
||||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
|
|
||||||
.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="top">ESP32-C3 Admin</div>
|
|
||||||
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
|
|
||||||
<main>
|
|
||||||
<div id="loginPanel" class="box">
|
|
||||||
<h2>Login</h2>
|
|
||||||
<label>User</label><input id="user" value="admin">
|
|
||||||
<label>Password</label><input id="pass" type="password">
|
|
||||||
<button onclick="doLogin()">Login</button>
|
|
||||||
<pre id="loginOut"></pre>
|
|
||||||
</div>
|
|
||||||
<div id="app" class="hidden">
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" data-tab="dashboard" onclick="showTab('dashboard')">Dashboard</button>
|
|
||||||
<button class="tab" data-tab="access" onclick="showTab('access')">Access</button>
|
|
||||||
<button class="tab" data-tab="network" onclick="showTab('network')">Networking</button>
|
|
||||||
<button class="tab" data-tab="ops" onclick="showTab('ops')">System</button>
|
|
||||||
</div>
|
|
||||||
<div id="dashboard" class="panel active"><div class="grid">
|
|
||||||
<section><h3>Status</h3><button onclick="call('GET','/api/ping')">Ping</button><button class="secondary" onclick="loadMe()">Me</button><pre id="statusOut"></pre></section>
|
|
||||||
<section><h3>LED</h3><label>Brightness</label><div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0" oninput="ledVal.value=value"><output id="ledVal">0</output></div><button onclick="setLed()">Apply</button><pre id="ledOut"></pre></section>
|
|
||||||
<section><h3>Add API</h3><div class="row"><div><label>A</label><input id="a" type="number" value="1"></div><div><label>B</label><input id="b" type="number" value="2"></div></div><button onclick="addNums()">Add</button><pre id="addOut"></pre></section>
|
|
||||||
</div></div>
|
|
||||||
<div id="access" class="panel">
|
|
||||||
<div class="tabs subtabs">
|
|
||||||
<button class="tab subtab active" data-subtab="users" onclick="showAccessTab('users')">User Management</button>
|
|
||||||
<button class="tab subtab" data-subtab="roles" onclick="showAccessTab('roles')">Role Management</button>
|
|
||||||
<button class="tab subtab" data-subtab="apis" onclick="showAccessTab('apis')">API Management</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="users" class="subpanel active">
|
|
||||||
<section id="usersOverview">
|
|
||||||
<div class="toolbar"><h3>User Overview</h3><button onclick="loadUsers()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div>
|
|
||||||
<div id="userList" class="list"></div>
|
|
||||||
</section>
|
|
||||||
<section id="userDetail" class="hidden">
|
|
||||||
<div class="toolbar"><button class="secondary" onclick="showUserOverview()">Back</button><h3 id="userTitle" class="detailTitle">User Detail</h3></div>
|
|
||||||
<label>Name</label><input id="userName">
|
|
||||||
<label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged">
|
|
||||||
<label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label>
|
|
||||||
<label>Roles</label><div id="userRoles" class="checkGrid"></div>
|
|
||||||
<div class="detailActions"><button onclick="saveSelectedUser()">Save user</button></div>
|
|
||||||
<pre id="usersOut"></pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="roles" class="subpanel">
|
|
||||||
<section id="rolesOverview">
|
|
||||||
<div class="toolbar"><h3>Role Overview</h3><button onclick="loadRoles()">Refresh</button><button class="secondary" onclick="newRoleRecord()">New</button></div>
|
|
||||||
<div id="rolesOut" class="list"></div>
|
|
||||||
</section>
|
|
||||||
<section id="roleDetail" class="hidden">
|
|
||||||
<div class="toolbar"><button class="secondary" onclick="showRoleOverview()">Back</button><h3 id="roleTitle" class="detailTitle">Role Detail</h3></div>
|
|
||||||
<label>Name</label><input id="roleName">
|
|
||||||
<div id="roleMeta" class="meta"></div>
|
|
||||||
<div class="detailActions"><button id="roleSave" onclick="addRole()">Save role</button><button id="roleDelete" class="danger" onclick="deleteSelectedRole()">Delete role</button></div>
|
|
||||||
<pre id="roleMsg"></pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="apis" class="subpanel">
|
|
||||||
<section id="apisOverview">
|
|
||||||
<div class="toolbar"><h3>API Overview</h3><button onclick="loadApis()">Refresh</button></div>
|
|
||||||
<div id="apiList" class="list"></div>
|
|
||||||
</section>
|
|
||||||
<section id="apiDetail" class="hidden">
|
|
||||||
<div class="toolbar"><button class="secondary" onclick="showApiOverview()">Back</button><h3 id="apiTitle" class="detailTitle">API Detail</h3></div>
|
|
||||||
<label>Path</label><input id="apiPath" readonly>
|
|
||||||
<label>Method</label><input id="apiMethod" readonly>
|
|
||||||
<label>Required role</label><select id="apiRole"></select>
|
|
||||||
<button onclick="saveSelectedApi()">Save ACL</button>
|
|
||||||
<pre id="apisOut"></pre>
|
|
||||||
<h3>Test</h3>
|
|
||||||
<label>JSON body</label><textarea id="apiTestBody" placeholder='{"key":"value"}'></textarea>
|
|
||||||
<div class="detailActions"><button onclick="testSelectedApi()">Run test</button><button class="secondary" onclick="clearApiTestBody()">Clear body</button></div>
|
|
||||||
<pre id="apiTestOut"></pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="network" class="panel"><div class="grid">
|
|
||||||
<section><h3>Network</h3><label>Host name</label><input id="netHost" placeholder="Default host name"><label>Address mode</label><select id="netDhcp" onchange="syncNetworkMode()"><option value="true">DHCP</option><option value="false">Static IPv4</option></select><div id="netCurrent" class="muted"></div><label>Static IP</label><input id="netIp" inputmode="decimal" placeholder="192.168.1.50"><label>Gateway</label><input id="netGateway" inputmode="decimal" placeholder="192.168.1.1"><label>Subnet mask</label><input id="netSubnet" inputmode="decimal" placeholder="255.255.255.0"><div class="row"><div><label>DNS 1</label><input id="netDns1" inputmode="decimal" placeholder="192.168.1.1"></div><div><label>DNS 2</label><input id="netDns2" inputmode="decimal" placeholder="8.8.8.8"></div></div><button id="networkSaveBtn" onclick="saveNetwork()">Save network</button><pre id="networkOut"></pre></section>
|
|
||||||
<section><h3>HTTPS Certificate</h3><label>Certificate file</label><input id="certFile" type="file" accept=".pem,.cer,.crt,.txt,application/x-pem-file,application/pkix-cert"><button id="certSaveBtn" onclick="saveCertFile()">Save certificate</button><button class="secondary" onclick="loadCert()">Load current</button><pre id="certOut"></pre></section>
|
|
||||||
</div></div>
|
|
||||||
<div id="ops" class="panel"><div class="grid">
|
|
||||||
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button onclick="saveSettings()">Save</button><pre id="settingsOut"></pre></section>
|
|
||||||
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn" onclick="uploadPkg()">Upload package</button><button class="secondary" onclick="checkOta()">Check URL</button><button class="danger" onclick="runOta()">Install from URL</button><pre id="fwOut"></pre></section>
|
|
||||||
<section><h3>Maintenance</h3><button onclick="downloadConfig()">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary" onclick="restoreConfig()">Restore config</button><button class="danger" onclick="factoryResetDevice()">Factory reset</button><pre id="maintOut"></pre></section>
|
|
||||||
<section class="fullWidth"><h3>Files</h3><button onclick="loadFiles()">Refresh</button><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
|
|
||||||
<section class="fullWidth"><h3>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
let token='';const el=id=>document.getElementById(id);
|
|
||||||
let busyCount=0;function setGlobalBusy(on){busyCount+=on?1:-1;if(busyCount<0)busyCount=0;el('busyChip').classList.toggle('hidden',busyCount==0)}
|
|
||||||
async function tracked(p){setGlobalBusy(true);try{return await p()}finally{setGlobalBusy(false)}}
|
|
||||||
async function apiFetch(u,o){return await tracked(()=>fetch(u,o))}
|
|
||||||
async function req(m,u,b){let o={method:m,headers:{'Content-Type':'application/json'}};if(token)o.headers.Authorization='Bearer '+token;if(b!==undefined&&m!='GET')o.body=JSON.stringify(b);let r=await apiFetch(u,o);return await r.text()}
|
|
||||||
function showTab(id){document.querySelectorAll('.tab[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='network')loadSettings()}
|
|
||||||
function showAccessTab(id){document.querySelectorAll('.subtab').forEach(b=>b.classList.toggle('active',b.dataset.subtab==id));document.querySelectorAll('.subpanel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='users')showUserOverview();if(id=='roles')showRoleOverview();if(id=='apis')showApiOverview()}
|
|
||||||
let roles=[],users=[],apis=[],selectedUser='',selectedRole='',selectedApi=-1;
|
|
||||||
async function doLogin(){let t=await req('POST','/api/login',{username:el('user').value,password:el('pass').value});el('loginOut').textContent=t;let j=JSON.parse(t);if(j.success){token=j.token;el('loginPanel').classList.add('hidden');el('app').classList.remove('hidden');loadMe();loadSettings();loadAccess()}}
|
|
||||||
async function call(m,u){el('statusOut').textContent=await req(m,u)}async function loadMe(){el('statusOut').textContent=await req('GET','/api/me')}
|
|
||||||
async function setLed(){el('ledOut').textContent=await req('POST','/api/led',{brightness:+el('brightness').value})}async function addNums(){el('addOut').textContent=await req('POST','/api/add',{a:+el('a').value,b:+el('b').value})}
|
|
||||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
||||||
function roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+esc(r)+'" '+(selected.includes(r)?'checked':'')+'> '+esc(r)+'</label>').join('')}
|
|
||||||
function selectedRoles(){return [...el('userRoles').querySelectorAll('input:checked')].map(x=>x.value)}
|
|
||||||
function showUserOverview(){el('usersOverview').classList.remove('hidden');el('userDetail').classList.add('hidden')}
|
|
||||||
function showRoleOverview(){el('rolesOverview').classList.remove('hidden');el('roleDetail').classList.add('hidden')}
|
|
||||||
function showApiOverview(){el('apisOverview').classList.remove('hidden');el('apiDetail').classList.add('hidden')}
|
|
||||||
function renderRoles(){el('rolesOut').innerHTML=roles.map((r,i)=>'<button class=item onclick="selectRole('+i+')"><strong>'+esc(r.name)+'</strong><span class=meta><span class=badge>'+(r.system?'System':'Custom')+'</span></span></button>').join('')}
|
|
||||||
async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
|
|
||||||
function newRoleRecord(){selectedRole='';el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent='New role';el('roleName').value='';el('roleName').readOnly=false;el('roleMeta').innerHTML='';el('roleSave').classList.remove('hidden');el('roleDelete').classList.add('hidden');el('roleMsg').textContent=''}
|
|
||||||
function selectRole(i){let r=roles[i];if(!r)return;selectedRole=r.name;el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent=r.name;el('roleName').value=r.name;el('roleName').readOnly=true;el('roleMeta').innerHTML='<span class=badge>'+(r.system?'System role':'Custom role')+'</span>';el('roleSave').classList.add('hidden');el('roleDelete').classList.toggle('hidden',r.system);el('roleMsg').textContent=''}
|
|
||||||
async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;await loadAccess();showRoleOverview()}
|
|
||||||
async function deleteSelectedRole(){if(!selectedRole)return;let t=await req('DELETE','/api/roles',{role:selectedRole});el('roleMsg').textContent=t;selectedRole='';await loadAccess();showRoleOverview()}
|
|
||||||
function jsArg(s){return JSON.stringify(String(s)).replace(/"/g,'"')}
|
|
||||||
function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class=item onclick="selectUser('+jsArg(u.username)+')"><strong>'+esc(u.username)+'</strong><span class=meta><span class=badge>'+(u.active?'Active':'Inactive')+'</span><span class=badge>'+esc(u.roles||'No roles')+'</span></span></button>').join('')}
|
|
||||||
async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
|
|
||||||
function newUserRecord(){selectedUser='';el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[]);el('usersOut').textContent=''}
|
|
||||||
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent=name;el('userName').value=name;el('userName').readOnly=true;el('userPass').value='';el('userActive').checked=u.active;roleChecks('userRoles',(u.roles||'').split('|'));el('usersOut').textContent=''}
|
|
||||||
async function saveSelectedUser(){let t=await req('POST','/api/users',{username:el('userName').value,password:el('userPass').value,active:el('userActive').checked,roles:selectedRoles()});el('usersOut').textContent=t;selectedUser=el('userName').value;await loadUsers();showUserOverview()}
|
|
||||||
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+esc(r)+'</option>').join('');el('apiRole').innerHTML=opts}
|
|
||||||
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class=item onclick="selectApi('+i+')"><strong>'+esc(a.method+' '+a.path)+'</strong><span class=meta><span class=badge>'+esc(a.role)+'</span></span></button>').join('')}
|
|
||||||
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0&&el('apiDetail').classList.contains('hidden')==false)selectApi(selectedApi)}}
|
|
||||||
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;el('apisOverview').classList.add('hidden');el('apiDetail').classList.remove('hidden');el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role;el('apisOut').textContent='';el('apiTestOut').textContent=''}
|
|
||||||
async function saveSelectedApi(){if(selectedApi<0)return;let t=await req('POST','/api/apis',{path:el('apiPath').value,method:el('apiMethod').value,role:el('apiRole').value});el('apisOut').textContent=t;await loadApis()}
|
|
||||||
function clearApiTestBody(){el('apiTestBody').value=''}
|
|
||||||
async function testSelectedApi(){if(selectedApi<0)return;let a=apis[selectedApi],body=el('apiTestBody').value.trim(),payload=undefined;if(body){try{payload=JSON.parse(body)}catch(e){el('apiTestOut').textContent='Invalid JSON body';return}}el('apiTestOut').textContent=await req(a.method,a.path,payload)}
|
|
||||||
async function loadAccess(){await loadRoles();await loadUsers();await loadApis()}
|
|
||||||
function fileName(p){let i=String(p).lastIndexOf('/');return i>=0?p.substring(i+1):p}
|
|
||||||
function renderFiles(files){el('fileList').innerHTML=files.map(f=>'<div class=item><div class=detailActions><button onclick="downloadFile('+jsArg(f.path)+')">Download</button><div><strong>'+esc(f.path)+'</strong><span class=meta><span class=badge>'+f.size+' bytes</span></span></div></div></div>').join('')}
|
|
||||||
async function loadFiles(){let t=await req('GET','/api/files');el('filesOut').textContent='';try{let j=JSON.parse(t);if(j.success){renderFiles(j.files||[]);if(!(j.files||[]).length)el('filesOut').textContent='No files found'}else{el('filesOut').textContent=t}}catch(e){el('filesOut').textContent=t}}
|
|
||||||
async function downloadFile(path){let r=await apiFetch('/api/files/download?path='+encodeURIComponent(path),{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('filesOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=fileName(path)||'download';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove()}
|
|
||||||
async function downloadConfig(){let r=await apiFetch('/api/config/export',{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('maintOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='config-backup.json';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove();el('maintOut').textContent='Configuration backup downloaded'}
|
|
||||||
async function restoreConfig(){if(!el('configFile').files.length){el('maintOut').textContent='Select a configuration JSON file first';return}if(!confirm('Restore configuration from this file? WiFi settings will not change.'))return;setBusy('restoreConfigBtn',true,'Restoring');try{await tracked(async()=>{let body=await el('configFile').files[0].text();let r=await fetch('/api/config/import',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body});el('maintOut').textContent=await r.text();if(r.ok){loadSettings();loadAccess()}})}catch(e){el('maintOut').textContent='Restore failed: '+e.message}finally{setBusy('restoreConfigBtn',false,'Restore config')}}
|
|
||||||
async function loadCert(){let t=await req('GET','/api/cert');try{let j=JSON.parse(t);el('certOut').textContent=j.success?(j.certificate||'No certificate stored'):t}catch(e){el('certOut').textContent=t}}
|
|
||||||
async function saveCertFile(){if(!el('certFile').files.length){el('certOut').textContent='Select a PEM/CER/CRT file first';return}setBusy('certSaveBtn',true,'Saving');try{await tracked(async()=>{let certificate=await el('certFile').files[0].text();el('certOut').textContent=await req('POST','/api/cert',{certificate})})}catch(e){el('certOut').textContent='Certificate save failed: '+e.message}finally{setBusy('certSaveBtn',false,'Save certificate')}}
|
|
||||||
async function factoryResetDevice(){if(!confirm('Factory reset this device and restart?'))return;el('maintOut').textContent=await req('POST','/api/factory-reset')}
|
|
||||||
async function loadLogs(){let t=await req('GET','/api/logs');try{let j=JSON.parse(t);el('logsOut').textContent=j.success?(j.logs||''):t}catch(e){el('logsOut').textContent=t}}async function clearLogs(){let t=await req('POST','/api/logs/clear');el('logsOut').textContent=t;try{let j=JSON.parse(t);if(j.success)el('logsOut').textContent='Logs cleared'}catch(e){}}
|
|
||||||
function syncNetworkMode(){let s=el('netDhcp').value=='false';['netIp','netGateway','netSubnet','netDns1','netDns2'].forEach(id=>el(id).disabled=!s)}
|
|
||||||
async function loadSettings(){let t=await req('GET','/api/settings');el('settingsOut').textContent=t;let j=JSON.parse(t);if(j.success){el('logLevel').value=j.settings.logLevel;el('logMax').value=j.settings.maxLogBytes;el('upd').value=j.settings.updateUrl;el('brightness').value=j.settings.ledBrightness;el('ledVal').value=j.settings.ledBrightness;el('netHost').value=j.settings.hostname||'';el('netHost').placeholder=j.settings.defaultHostname||'Default host name';el('netDhcp').value=String(j.settings.dhcp!==false);el('netIp').value=j.settings.ip||'';el('netGateway').value=j.settings.gateway||'';el('netSubnet').value=j.settings.subnet||'';el('netDns1').value=j.settings.dns1||'';el('netDns2').value=j.settings.dns2||'';el('netCurrent').textContent='Current IP: '+(j.settings.currentIp||'not connected');syncNetworkMode()}}
|
|
||||||
async function saveSettings(){el('settingsOut').textContent=await req('POST','/api/settings',{logLevel:+el('logLevel').value,maxLogBytes:+el('logMax').value,updateUrl:el('upd').value,ledBrightness:+el('brightness').value})}
|
|
||||||
async function saveNetwork(){setBusy('networkSaveBtn',true,'Saving');try{let body={hostname:el('netHost').value,dhcp:el('netDhcp').value=='true',ip:el('netIp').value,gateway:el('netGateway').value,subnet:el('netSubnet').value,dns1:el('netDns1').value,dns2:el('netDns2').value};let t=await req('POST','/api/settings',body);el('networkOut').textContent=t+'\\nNetwork changes apply on the next WiFi reconnect or reboot.';if(JSON.parse(t).success)await loadSettings()}catch(e){el('networkOut').textContent='Network save failed: '+e.message}finally{setBusy('networkSaveBtn',false,'Save network')}}
|
|
||||||
function setBusy(id,busy,label){let b=el(id);if(!b)return;b.disabled=busy;b.innerHTML=busy?'<span class=spinner></span>'+label:label}
|
|
||||||
async function uploadPkg(){if(!el('pkg').files.length){el('fwOut').textContent='Select an update package first';return}setBusy('uploadPkgBtn',true,'Uploading');el('fwOut').textContent='Uploading update package...';try{let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await apiFetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()}catch(e){el('fwOut').textContent='Upload failed: '+e.message}finally{setBusy('uploadPkgBtn',false,'Upload package')}}
|
|
||||||
async function checkOta(){el('fwOut').textContent=await req('POST','/api/ota/check')}async function runOta(){el('fwOut').textContent=await req('POST','/api/ota/run')}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>ESP32-C3 first-run setup</title>
|
|
||||||
<style>
|
|
||||||
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
|
|
||||||
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
|
|
||||||
main{max-width:1040px;margin:0 auto;padding:18px}
|
|
||||||
section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
|
|
||||||
label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
|
||||||
input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
|
|
||||||
button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
|
||||||
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="top">ESP32-C3 first-run setup</div>
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<h2>Network and admin setup</h2>
|
|
||||||
<label>WiFi network</label>
|
|
||||||
<select id="ssid"></select>
|
|
||||||
<label>WiFi password</label>
|
|
||||||
<input id="wifiPass" type="password" autocomplete="off">
|
|
||||||
<label>Admin username</label>
|
|
||||||
<input id="admin" value="admin">
|
|
||||||
<label>Admin password</label>
|
|
||||||
<input id="adminPass" type="password" autocomplete="new-password">
|
|
||||||
<button onclick="save()">Save and restart</button>
|
|
||||||
<pre id="out"></pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
async function scan(){
|
|
||||||
let r=await fetch('/api/wifi/scan'),j=await r.json();
|
|
||||||
ssid.innerHTML=j.networks.map(n=>`<option>${n.ssid}</option>`).join('');
|
|
||||||
}
|
|
||||||
async function save(){
|
|
||||||
let body={ssid:ssid.value,wifiPass:wifiPass.value,admin:admin.value,adminPass:adminPass.value};
|
|
||||||
let r=await fetch('/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
||||||
out.textContent=await r.text();
|
|
||||||
}
|
|
||||||
scan();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
108
data/www/admin.html
Normal file
108
data/www/admin.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>ESP32-C3 Admin</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
<script defer src="/js/app.js"></script>
|
||||||
|
<script type="module" src="/components/status-card.js"></script>
|
||||||
|
<script type="module" src="/components/uptime-card.js"></script>
|
||||||
|
<script type="module" src="/components/led-card.js"></script>
|
||||||
|
<script type="module" src="/components/add-card.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="appTitle" class="top">ESP32-C3 Admin</div>
|
||||||
|
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
|
||||||
|
<main>
|
||||||
|
<div id="loginPanel" class="box">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<label>User</label><input id="user" value="admin">
|
||||||
|
<label>Password</label><input id="pass" type="password">
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<pre id="loginOut"></pre>
|
||||||
|
</div>
|
||||||
|
<div id="app" class="hidden">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="dashboard">Dashboard</button>
|
||||||
|
<button class="tab" data-tab="access">Access</button>
|
||||||
|
<button class="tab" data-tab="network">Networking</button>
|
||||||
|
<button class="tab" data-tab="ops">System</button>
|
||||||
|
</div>
|
||||||
|
<div id="dashboard" class="panel active"><div class="grid">
|
||||||
|
<status-card></status-card>
|
||||||
|
<uptime-card></uptime-card>
|
||||||
|
<led-card></led-card>
|
||||||
|
<add-card></add-card>
|
||||||
|
</div></div>
|
||||||
|
<div id="access" class="panel">
|
||||||
|
<div class="tabs subtabs">
|
||||||
|
<button class="tab subtab active" data-subtab="users">User Management</button>
|
||||||
|
<button class="tab subtab" data-subtab="roles">Role Management</button>
|
||||||
|
<button class="tab subtab" data-subtab="apis">API Management</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="users" class="subpanel active">
|
||||||
|
<section id="usersOverview">
|
||||||
|
<div class="toolbar"><h3>User Overview</h3><button id="usersRefresh">Refresh</button><button id="userNew" class="secondary">New</button></div>
|
||||||
|
<div id="userList" class="list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="userDetail" class="hidden">
|
||||||
|
<div class="toolbar"><button id="userBack" class="secondary">Back</button><h3 id="userTitle" class="detailTitle">User Detail</h3></div>
|
||||||
|
<label>Name</label><input id="userName">
|
||||||
|
<label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged">
|
||||||
|
<label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label>
|
||||||
|
<label>Roles</label><div id="userRoles" class="checkGrid"></div>
|
||||||
|
<div class="detailActions"><button id="userSave">Save user</button></div>
|
||||||
|
<pre id="usersOut"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="roles" class="subpanel">
|
||||||
|
<section id="rolesOverview">
|
||||||
|
<div class="toolbar"><h3>Role Overview</h3><button id="rolesRefresh">Refresh</button><button id="roleNew" class="secondary">New</button></div>
|
||||||
|
<div id="rolesOut" class="list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="roleDetail" class="hidden">
|
||||||
|
<div class="toolbar"><button id="roleBack" class="secondary">Back</button><h3 id="roleTitle" class="detailTitle">Role Detail</h3></div>
|
||||||
|
<label>Name</label><input id="roleName">
|
||||||
|
<div id="roleMeta" class="meta"></div>
|
||||||
|
<div id="roleUsage" class="usageGrid"></div>
|
||||||
|
<div class="detailActions"><button id="roleSave">Save role</button><button id="roleDelete" class="danger">Delete role</button></div>
|
||||||
|
<pre id="roleMsg"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="apis" class="subpanel">
|
||||||
|
<section id="apisOverview">
|
||||||
|
<div class="toolbar"><h3>API Overview</h3></div>
|
||||||
|
<div id="apiList" class="list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="apiDetail" class="hidden">
|
||||||
|
<div class="toolbar"><button id="apiBack" class="secondary">Back</button><h3 id="apiTitle" class="detailTitle">API Detail</h3></div>
|
||||||
|
<label>Path</label><input id="apiPath" readonly>
|
||||||
|
<label>Method</label><input id="apiMethod" readonly>
|
||||||
|
<label>Required role</label><select id="apiRole"></select>
|
||||||
|
<button id="apiSave">Save ACL</button>
|
||||||
|
<pre id="apisOut"></pre>
|
||||||
|
<h3>Test</h3>
|
||||||
|
<label>JSON body</label><textarea id="apiTestBody" placeholder='{"key":"value"}'></textarea>
|
||||||
|
<div class="detailActions"><button id="apiTest">Run test</button><button id="apiTestClear" class="secondary">Clear body</button></div>
|
||||||
|
<pre id="apiTestOut"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="network" class="panel"><div class="grid">
|
||||||
|
<section><h3>Network</h3><label>Host name</label><input id="netHost" placeholder="Default host name"><label>Address mode</label><select id="netDhcp"><option value="true">DHCP</option><option value="false">Static IPv4</option></select><div id="netCurrent" class="muted"></div><label>Static IP</label><input id="netIp" inputmode="decimal" placeholder="192.168.1.50"><label>Gateway</label><input id="netGateway" inputmode="decimal" placeholder="192.168.1.1"><label>Subnet mask</label><input id="netSubnet" inputmode="decimal" placeholder="255.255.255.0"><div class="row"><div><label>DNS 1</label><input id="netDns1" inputmode="decimal" placeholder="192.168.1.1"></div><div><label>DNS 2</label><input id="netDns2" inputmode="decimal" placeholder="8.8.8.8"></div></div><button id="networkSaveBtn">Save network</button><pre id="networkOut"></pre></section>
|
||||||
|
<section><h3>HTTPS Certificate</h3><label>Certificate file</label><input id="certFile" type="file" accept=".pem,.cer,.crt,.txt,application/x-pem-file,application/pkix-cert"><button id="certSaveBtn">Save certificate</button><button id="certLoad" class="secondary">Load current</button><pre id="certOut"></pre></section>
|
||||||
|
</div></div>
|
||||||
|
<div id="ops" class="panel"><div class="grid">
|
||||||
|
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button id="settingsSave">Save</button><pre id="settingsOut"></pre></section>
|
||||||
|
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn">Upload package</button><button id="otaCheck" class="secondary">Check URL</button><button id="otaRun" class="danger">Install from URL</button><pre id="fwOut"></pre></section>
|
||||||
|
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
|
||||||
|
<section class="fullWidth"><div class="toolbar"><h3>Files</h3><span id="filesPath" class="filePathChip">/</span></div><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
|
||||||
|
<section class="fullWidth"><h3>Logs</h3><div class="row"><button id="logsLoad">Load</button><button id="logsClear" class="danger">Clear</button></div><pre id="logsOut"></pre></section>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
data/www/components/add-card.js
Normal file
26
data/www/components/add-card.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class AddCard extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host{display:block}section{height:100%;box-sizing:border-box}.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}
|
||||||
|
</style>
|
||||||
|
<section>
|
||||||
|
<h3>Add API</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>A</label><input id="a" type="number" value="1"></div>
|
||||||
|
<div><label>B</label><input id="b" type="number" value="2"></div>
|
||||||
|
</div>
|
||||||
|
<button id="add">Add</button>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
</section>`;
|
||||||
|
this.querySelector('#add').addEventListener('click', () => this.addNums());
|
||||||
|
}
|
||||||
|
|
||||||
|
async addNums() {
|
||||||
|
const a = +this.querySelector('#a').value;
|
||||||
|
const b = +this.querySelector('#b').value;
|
||||||
|
this.querySelector('#out').textContent = await window.App.req('POST', '/api/add', {a, b});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('add-card', AddCard);
|
||||||
32
data/www/components/led-card.js
Normal file
32
data/www/components/led-card.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
class LedCard extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host{display:block}section{height:100%;box-sizing:border-box}.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
|
||||||
|
</style>
|
||||||
|
<section>
|
||||||
|
<h3>LED</h3>
|
||||||
|
<label>Brightness</label>
|
||||||
|
<div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0"><output id="value">0</output></div>
|
||||||
|
<button id="apply">Apply</button>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
</section>`;
|
||||||
|
this.brightness = this.querySelector('#brightness');
|
||||||
|
this.value = this.querySelector('#value');
|
||||||
|
this.brightness.addEventListener('input', () => this.value.value = this.brightness.value);
|
||||||
|
this.querySelector('#apply').addEventListener('click', () => this.setLed());
|
||||||
|
window.addEventListener('app:settings-loaded', event => this.applySettings(event.detail.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
applySettings(settings) {
|
||||||
|
if (!settings || settings.ledBrightness === undefined) return;
|
||||||
|
this.brightness.value = settings.ledBrightness;
|
||||||
|
this.value.value = settings.ledBrightness;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLed() {
|
||||||
|
this.querySelector('#out').textContent = await window.App.req('POST', '/api/led', {brightness: +this.brightness.value});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('led-card', LedCard);
|
||||||
26
data/www/components/status-card.js
Normal file
26
data/www/components/status-card.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class StatusCard extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host{display:block}section{height:100%;box-sizing:border-box}button+button{margin-left:6px}
|
||||||
|
</style>
|
||||||
|
<section>
|
||||||
|
<h3>Status</h3>
|
||||||
|
<button id="ping">Ping</button>
|
||||||
|
<button id="me" class="secondary">Me</button>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
</section>`;
|
||||||
|
this.querySelector('#ping').addEventListener('click', () => this.call('GET', '/api/ping'));
|
||||||
|
this.querySelector('#me').addEventListener('click', () => this.loadMe());
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(method, url) {
|
||||||
|
this.querySelector('#out').textContent = await window.App.req(method, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMe() {
|
||||||
|
await this.call('GET', '/api/me');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('status-card', StatusCard);
|
||||||
59
data/www/components/uptime-card.js
Normal file
59
data/www/components/uptime-card.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
class UptimeCard extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host{display:block}section{height:100%;box-sizing:border-box}.liveValue{font-size:34px;font-weight:700;line-height:1.15;margin-top:10px}.state{display:inline-flex;align-items:center;gap:6px;margin-top:6px}.dot{width:9px;height:9px;border-radius:50%;background:#b3261e}.dot.on{background:#188038}
|
||||||
|
</style>
|
||||||
|
<section>
|
||||||
|
<h3>Live uptime</h3>
|
||||||
|
<div class="muted">Server-Sent Events</div>
|
||||||
|
<div class="liveValue"><span id="seconds">--</span>s</div>
|
||||||
|
<div class="muted state"><span id="dot" class="dot"></span><span id="state">Disconnected</span></div>
|
||||||
|
</section>`;
|
||||||
|
window.addEventListener('app:login', event => this.start(event.detail.token));
|
||||||
|
window.addEventListener('beforeunload', () => this.stop());
|
||||||
|
if (window.App && window.App.token()) this.start(window.App.token());
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(text, connected) {
|
||||||
|
this.querySelector('#state').textContent = text;
|
||||||
|
this.querySelector('#dot').classList.toggle('on', !!connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(token) {
|
||||||
|
this.stop();
|
||||||
|
if (!token || !window.EventSource) {
|
||||||
|
this.setState('Unavailable', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState('Connecting', false);
|
||||||
|
this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token));
|
||||||
|
this.source.addEventListener('open', () => this.setState('Connected', true));
|
||||||
|
this.source.addEventListener('uptime', event => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.data);
|
||||||
|
this.querySelector('#seconds').textContent = json.uptimeSeconds;
|
||||||
|
this.setState('Connected', true);
|
||||||
|
} catch (error) {
|
||||||
|
this.setState('Invalid event', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.source.addEventListener('error', () => this.setState('Reconnecting', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.source) this.source.close();
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('uptime-card', UptimeCard);
|
||||||
26
data/www/css/app.css
Normal file
26
data/www/css/app.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
|
||||||
|
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
|
||||||
|
main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
|
||||||
|
section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
|
||||||
|
h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
||||||
|
input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
|
||||||
|
textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
||||||
|
button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
button.secondary{background:#607d8b}button.danger{background:#b3261e}
|
||||||
|
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
|
||||||
|
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px}
|
||||||
|
.tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto}
|
||||||
|
.tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap}
|
||||||
|
.tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block}
|
||||||
|
.subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block}
|
||||||
|
.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
|
||||||
|
.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed}
|
||||||
|
.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238}
|
||||||
|
.checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto}
|
||||||
|
.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
|
||||||
|
.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
|
||||||
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
|
||||||
|
.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
|
||||||
|
.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
|
||||||
|
.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
|
||||||
|
.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||||
7
data/www/css/setup.css
Normal file
7
data/www/css/setup.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
|
||||||
|
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
|
||||||
|
main{max-width:520px;margin:0 auto;padding:18px}.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
|
||||||
|
label{display:block;font-size:13px;margin:10px 0 4px}input,button{font:inherit}
|
||||||
|
input{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
|
||||||
|
button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
||||||
|
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:220px;overflow:auto}
|
||||||
599
data/www/js/app.js
Normal file
599
data/www/js/app.js
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
let token = '';
|
||||||
|
let roles = [];
|
||||||
|
let users = [];
|
||||||
|
let apis = [];
|
||||||
|
let selectedUser = '';
|
||||||
|
let selectedRole = '';
|
||||||
|
let selectedApi = -1;
|
||||||
|
let currentFilePath = '/';
|
||||||
|
let filesLoaded = false;
|
||||||
|
let busyCount = 0;
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
function setGlobalBusy(on) {
|
||||||
|
busyCount += on ? 1 : -1;
|
||||||
|
if (busyCount < 0) busyCount = 0;
|
||||||
|
el('busyChip').classList.toggle('hidden', busyCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tracked(fn) {
|
||||||
|
setGlobalBusy(true);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
setGlobalBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, options) {
|
||||||
|
return await tracked(() => fetch(url, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(method, url, body) {
|
||||||
|
const options = {method, headers: {'Content-Type': 'application/json'}};
|
||||||
|
if (token) options.headers.Authorization = 'Bearer ' + token;
|
||||||
|
if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
|
||||||
|
const response = await apiFetch(url, options);
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.App = {
|
||||||
|
apiFetch,
|
||||||
|
req,
|
||||||
|
token: () => token
|
||||||
|
};
|
||||||
|
|
||||||
|
function showTab(id) {
|
||||||
|
document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
|
||||||
|
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
|
||||||
|
if (id == 'network') loadSettings();
|
||||||
|
if (id == 'ops' && !filesLoaded) loadFiles('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAccessTab(id) {
|
||||||
|
document.querySelectorAll('.subtab').forEach(button => button.classList.toggle('active', button.dataset.subtab == id));
|
||||||
|
document.querySelectorAll('.subpanel').forEach(panel => panel.classList.toggle('active', panel.id == id));
|
||||||
|
if (id == 'users') showUserOverview();
|
||||||
|
if (id == 'roles') showRoleOverview();
|
||||||
|
if (id == 'apis') showApiOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const text = await req('POST', '/api/login', {username: el('user').value, password: el('pass').value});
|
||||||
|
el('loginOut').textContent = text;
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (!json.success) return;
|
||||||
|
token = json.token;
|
||||||
|
el('loginPanel').classList.add('hidden');
|
||||||
|
el('app').classList.remove('hidden');
|
||||||
|
window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
|
||||||
|
loadSettings();
|
||||||
|
loadAccess();
|
||||||
|
loadFiles('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleNames() {
|
||||||
|
return roles.map(role => role.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userHasRole(user, role) {
|
||||||
|
return (user.roles || '').split('|').includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleChecks(id, selected) {
|
||||||
|
el(id).innerHTML = roleNames().map(role => '<label><input type=checkbox value="' + esc(role) + '" ' + (selected.includes(role) ? 'checked' : '') + '> ' + esc(role) + '</label>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRoles() {
|
||||||
|
return [...el('userRoles').querySelectorAll('input:checked')].map(input => input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUserOverview() {
|
||||||
|
el('usersOverview').classList.remove('hidden');
|
||||||
|
el('userDetail').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRoleOverview() {
|
||||||
|
el('rolesOverview').classList.remove('hidden');
|
||||||
|
el('roleDetail').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApiOverview() {
|
||||||
|
el('apisOverview').classList.remove('hidden');
|
||||||
|
el('apiDetail').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoles() {
|
||||||
|
el('rolesOut').innerHTML = roles.map((role, index) => '<button class=item data-role-index="' + index + '"><strong>' + esc(role.name) + '</strong><span class=meta><span class=badge>' + (role.system ? 'System' : 'Custom') + '</span></span></button>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleUsage(role) {
|
||||||
|
const roleUsers = users.filter(user => userHasRole(user, role.name)).sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
const roleApis = apis.filter(api => api.role == role.name).sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
||||||
|
const usersHtml = roleUsers.length
|
||||||
|
? roleUsers.map(user => '<div class="usageItem"><strong>' + esc(user.username) + '</strong><span class="badge">' + (user.active ? 'Active' : 'Inactive') + '</span></div>').join('')
|
||||||
|
: '<div class="muted">No users</div>';
|
||||||
|
const apisHtml = roleApis.length
|
||||||
|
? roleApis.map(api => '<div class="usageItem"><span class="badge">' + esc(api.method) + '</span><code>' + esc(api.path) + '</code></div>').join('')
|
||||||
|
: '<div class="muted">No API endpoints</div>';
|
||||||
|
el('roleUsage').innerHTML = '<div><h3>Users</h3><div class="usageList">' + usersHtml + '</div></div><div><h3>API endpoints</h3><div class="usageList">' + apisHtml + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
const json = JSON.parse(await req('GET', '/api/roles'));
|
||||||
|
if (!json.success) return;
|
||||||
|
roles = json.roles;
|
||||||
|
renderRoles();
|
||||||
|
roleOptions();
|
||||||
|
if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRoleRecord() {
|
||||||
|
selectedRole = '';
|
||||||
|
el('rolesOverview').classList.add('hidden');
|
||||||
|
el('roleDetail').classList.remove('hidden');
|
||||||
|
el('roleTitle').textContent = 'New role';
|
||||||
|
el('roleName').value = '';
|
||||||
|
el('roleName').readOnly = false;
|
||||||
|
el('roleMeta').innerHTML = '';
|
||||||
|
el('roleUsage').innerHTML = '';
|
||||||
|
el('roleSave').classList.remove('hidden');
|
||||||
|
el('roleDelete').classList.add('hidden');
|
||||||
|
el('roleMsg').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRole(index) {
|
||||||
|
const role = roles[index];
|
||||||
|
if (!role) return;
|
||||||
|
selectedRole = role.name;
|
||||||
|
el('rolesOverview').classList.add('hidden');
|
||||||
|
el('roleDetail').classList.remove('hidden');
|
||||||
|
el('roleTitle').textContent = role.name;
|
||||||
|
el('roleName').value = role.name;
|
||||||
|
el('roleName').readOnly = true;
|
||||||
|
el('roleMeta').innerHTML = '<span class=badge>' + (role.system ? 'System role' : 'Custom role') + '</span>';
|
||||||
|
renderRoleUsage(role);
|
||||||
|
el('roleSave').classList.add('hidden');
|
||||||
|
el('roleDelete').classList.toggle('hidden', role.system);
|
||||||
|
el('roleMsg').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRole() {
|
||||||
|
const text = await req('POST', '/api/roles', {role: el('roleName').value});
|
||||||
|
el('roleMsg').textContent = text;
|
||||||
|
await loadAccess();
|
||||||
|
showRoleOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedRole() {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
const text = await req('DELETE', '/api/roles', {role: selectedRole});
|
||||||
|
el('roleMsg').textContent = text;
|
||||||
|
selectedRole = '';
|
||||||
|
await loadAccess();
|
||||||
|
showRoleOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
el('userList').innerHTML = users.map(user => '<button class=item data-user="' + esc(user.username) + '"><strong>' + esc(user.username) + '</strong><span class=meta><span class=badge>' + (user.active ? 'Active' : 'Inactive') + '</span><span class=badge>' + esc(user.roles || 'No roles') + '</span></span></button>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const json = JSON.parse(await req('GET', '/api/users'));
|
||||||
|
if (!json.success) return;
|
||||||
|
users = json.users;
|
||||||
|
renderUsers();
|
||||||
|
if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUserRecord() {
|
||||||
|
selectedUser = '';
|
||||||
|
el('usersOverview').classList.add('hidden');
|
||||||
|
el('userDetail').classList.remove('hidden');
|
||||||
|
el('userTitle').textContent = 'New user';
|
||||||
|
el('userName').value = '';
|
||||||
|
el('userName').readOnly = false;
|
||||||
|
el('userPass').value = '';
|
||||||
|
el('userActive').checked = true;
|
||||||
|
roleChecks('userRoles', []);
|
||||||
|
el('usersOut').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(name) {
|
||||||
|
selectedUser = name;
|
||||||
|
const user = users.find(item => item.username == name);
|
||||||
|
if (!user) return;
|
||||||
|
el('usersOverview').classList.add('hidden');
|
||||||
|
el('userDetail').classList.remove('hidden');
|
||||||
|
el('userTitle').textContent = name;
|
||||||
|
el('userName').value = name;
|
||||||
|
el('userName').readOnly = true;
|
||||||
|
el('userPass').value = '';
|
||||||
|
el('userActive').checked = user.active;
|
||||||
|
roleChecks('userRoles', (user.roles || '').split('|'));
|
||||||
|
el('usersOut').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedUser() {
|
||||||
|
const text = await req('POST', '/api/users', {username: el('userName').value, password: el('userPass').value, active: el('userActive').checked, roles: selectedRoles()});
|
||||||
|
el('usersOut').textContent = text;
|
||||||
|
selectedUser = el('userName').value;
|
||||||
|
await loadUsers();
|
||||||
|
showUserOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleOptions() {
|
||||||
|
const options = '<option>PUBLIC</option>' + roleNames().map(role => '<option>' + esc(role) + '</option>').join('');
|
||||||
|
el('apiRole').innerHTML = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApis() {
|
||||||
|
const groups = new Map();
|
||||||
|
apis.forEach((api, index) => {
|
||||||
|
if (!groups.has(api.path)) groups.set(api.path, []);
|
||||||
|
groups.get(api.path).push({...api, index});
|
||||||
|
});
|
||||||
|
const methodOrder = {GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5};
|
||||||
|
el('apiList').innerHTML = [...groups.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([path, items]) => {
|
||||||
|
items.sort((a, b) => (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99) || a.method.localeCompare(b.method));
|
||||||
|
const verbs = items.map(api => '<button class="verbBadge" data-api-index="' + api.index + '"><strong>' + esc(api.method) + '</strong><span>' + esc(api.role) + '</span></button>').join('');
|
||||||
|
return '<div class="apiRow"><div class="apiPath" title="' + esc(path) + '">' + esc(path) + '</div><div class="apiVerbs">' + verbs + '</div></div>';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApis() {
|
||||||
|
const json = JSON.parse(await req('GET', '/api/apis'));
|
||||||
|
if (!json.success) return;
|
||||||
|
apis = json.apis;
|
||||||
|
renderApis();
|
||||||
|
if (selectedApi >= 0 && !el('apiDetail').classList.contains('hidden')) selectApi(selectedApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectApi(index) {
|
||||||
|
selectedApi = index;
|
||||||
|
const api = apis[index];
|
||||||
|
if (!api) return;
|
||||||
|
el('apisOverview').classList.add('hidden');
|
||||||
|
el('apiDetail').classList.remove('hidden');
|
||||||
|
el('apiTitle').textContent = api.method + ' ' + api.path;
|
||||||
|
el('apiPath').value = api.path;
|
||||||
|
el('apiMethod').value = api.method;
|
||||||
|
roleOptions();
|
||||||
|
el('apiRole').value = api.role;
|
||||||
|
el('apisOut').textContent = '';
|
||||||
|
el('apiTestOut').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedApi() {
|
||||||
|
if (selectedApi < 0) return;
|
||||||
|
const text = await req('POST', '/api/apis', {path: el('apiPath').value, method: el('apiMethod').value, role: el('apiRole').value});
|
||||||
|
el('apisOut').textContent = text;
|
||||||
|
await loadApis();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearApiTestBody() {
|
||||||
|
el('apiTestBody').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSelectedApi() {
|
||||||
|
if (selectedApi < 0) return;
|
||||||
|
const api = apis[selectedApi];
|
||||||
|
const body = el('apiTestBody').value.trim();
|
||||||
|
let payload;
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body);
|
||||||
|
} catch (error) {
|
||||||
|
el('apiTestOut').textContent = 'Invalid JSON body';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el('apiTestOut').textContent = await req(api.method, api.path, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccess() {
|
||||||
|
await loadRoles();
|
||||||
|
await loadUsers();
|
||||||
|
await loadApis();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileName(path) {
|
||||||
|
const index = String(path).lastIndexOf('/');
|
||||||
|
return index >= 0 ? path.substring(index + 1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(file) {
|
||||||
|
if (file.directory) return '<span class="folderIcon" title="Folder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h4"/></svg></span>';
|
||||||
|
return '<button class="iconBtn" data-download="' + esc(file.path) + '" title="Download" aria-label="Download ' + esc(file.path) + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSize(file) {
|
||||||
|
if (file.directory) return 'Folder';
|
||||||
|
return file.size + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path) {
|
||||||
|
if (!path || path == '/') return '/';
|
||||||
|
const end = path.lastIndexOf('/');
|
||||||
|
return end <= 0 ? '/' : path.substring(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(files) {
|
||||||
|
el('fileList').classList.add('fileList');
|
||||||
|
files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path));
|
||||||
|
const rows = currentFilePath == '/' ? [] : [{name: '..', path: parentPath(currentFilePath), directory: true, parent: true}];
|
||||||
|
rows.push(...files);
|
||||||
|
el('fileList').innerHTML = rows.map(file => '<div class="fileRow" ' + (file.directory ? 'data-path="' + esc(file.path) + '"' : '') + '>' + fileIcon(file) + '<div class="filePath" title="' + esc(file.path) + '">' + esc(file.name || file.path) + '</div><div class="fileSize">' + (file.parent ? '' : fileSize(file)) + '</div></div>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(path = currentFilePath) {
|
||||||
|
const text = await req('GET', '/api/files?path=' + encodeURIComponent(path));
|
||||||
|
el('filesOut').textContent = '';
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (json.success) {
|
||||||
|
currentFilePath = json.path || path || '/';
|
||||||
|
filesLoaded = true;
|
||||||
|
el('filesPath').textContent = currentFilePath;
|
||||||
|
renderFiles(json.files || []);
|
||||||
|
if (!(json.files || []).length) el('filesOut').textContent = 'No files found';
|
||||||
|
} else {
|
||||||
|
el('filesOut').textContent = text;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
el('filesOut').textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(path) {
|
||||||
|
const response = await apiFetch('/api/files/download?path=' + encodeURIComponent(path), {headers: {Authorization: 'Bearer ' + token}});
|
||||||
|
if (!response.ok) {
|
||||||
|
el('filesOut').textContent = await response.text();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = fileName(path) || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadConfig() {
|
||||||
|
const response = await apiFetch('/api/config/export', {headers: {Authorization: 'Bearer ' + token}});
|
||||||
|
if (!response.ok) {
|
||||||
|
el('maintOut').textContent = await response.text();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = 'config-backup.json';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
link.remove();
|
||||||
|
el('maintOut').textContent = 'Configuration backup downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreConfig() {
|
||||||
|
if (!el('configFile').files.length) {
|
||||||
|
el('maintOut').textContent = 'Select a configuration JSON file first';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('Restore configuration from this file? WiFi settings will not change.')) return;
|
||||||
|
setBusy('restoreConfigBtn', true, 'Restoring');
|
||||||
|
try {
|
||||||
|
await tracked(async () => {
|
||||||
|
const body = await el('configFile').files[0].text();
|
||||||
|
const response = await fetch('/api/config/import', {method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer ' + token}, body});
|
||||||
|
el('maintOut').textContent = await response.text();
|
||||||
|
if (response.ok) {
|
||||||
|
loadSettings();
|
||||||
|
loadAccess();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
el('maintOut').textContent = 'Restore failed: ' + error.message;
|
||||||
|
} finally {
|
||||||
|
setBusy('restoreConfigBtn', false, 'Restore config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCert() {
|
||||||
|
const text = await req('GET', '/api/cert');
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
el('certOut').textContent = json.success ? (json.certificate || 'No certificate stored') : text;
|
||||||
|
} catch (error) {
|
||||||
|
el('certOut').textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCertFile() {
|
||||||
|
if (!el('certFile').files.length) {
|
||||||
|
el('certOut').textContent = 'Select a PEM/CER/CRT file first';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('certSaveBtn', true, 'Saving');
|
||||||
|
try {
|
||||||
|
await tracked(async () => {
|
||||||
|
const certificate = await el('certFile').files[0].text();
|
||||||
|
el('certOut').textContent = await req('POST', '/api/cert', {certificate});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
el('certOut').textContent = 'Certificate save failed: ' + error.message;
|
||||||
|
} finally {
|
||||||
|
setBusy('certSaveBtn', false, 'Save certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function factoryResetDevice() {
|
||||||
|
if (!confirm('Factory reset this device and restart?')) return;
|
||||||
|
el('maintOut').textContent = await req('POST', '/api/factory-reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const text = await req('GET', '/api/logs');
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
el('logsOut').textContent = json.success ? (json.logs || '') : text;
|
||||||
|
} catch (error) {
|
||||||
|
el('logsOut').textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLogs() {
|
||||||
|
const text = await req('POST', '/api/logs/clear');
|
||||||
|
el('logsOut').textContent = text;
|
||||||
|
try {
|
||||||
|
if (JSON.parse(text).success) el('logsOut').textContent = 'Logs cleared';
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNetworkMode() {
|
||||||
|
const staticMode = el('netDhcp').value == 'false';
|
||||||
|
['netIp', 'netGateway', 'netSubnet', 'netDns1', 'netDns2'].forEach(id => el(id).disabled = !staticMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const text = await req('GET', '/api/settings');
|
||||||
|
el('settingsOut').textContent = text;
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (!json.success) return;
|
||||||
|
const settings = json.settings;
|
||||||
|
const projectName = settings.projectName || 'ESP32-C3';
|
||||||
|
el('appTitle').textContent = projectName + ' Admin';
|
||||||
|
document.title = projectName + ' Admin';
|
||||||
|
el('logLevel').value = settings.logLevel;
|
||||||
|
el('logMax').value = settings.maxLogBytes;
|
||||||
|
el('upd').value = settings.updateUrl;
|
||||||
|
el('netHost').value = settings.hostname || '';
|
||||||
|
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
|
||||||
|
el('netDhcp').value = String(settings.dhcp !== false);
|
||||||
|
el('netIp').value = settings.ip || '';
|
||||||
|
el('netGateway').value = settings.gateway || '';
|
||||||
|
el('netSubnet').value = settings.subnet || '';
|
||||||
|
el('netDns1').value = settings.dns1 || '';
|
||||||
|
el('netDns2').value = settings.dns2 || '';
|
||||||
|
el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
|
||||||
|
syncNetworkMode();
|
||||||
|
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
el('settingsOut').textContent = await req('POST', '/api/settings', {logLevel: +el('logLevel').value, maxLogBytes: +el('logMax').value, updateUrl: el('upd').value});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNetwork() {
|
||||||
|
setBusy('networkSaveBtn', true, 'Saving');
|
||||||
|
try {
|
||||||
|
const body = {hostname: el('netHost').value, dhcp: el('netDhcp').value == 'true', ip: el('netIp').value, gateway: el('netGateway').value, subnet: el('netSubnet').value, dns1: el('netDns1').value, dns2: el('netDns2').value};
|
||||||
|
const text = await req('POST', '/api/settings', body);
|
||||||
|
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
|
||||||
|
if (JSON.parse(text).success) await loadSettings();
|
||||||
|
} catch (error) {
|
||||||
|
el('networkOut').textContent = 'Network save failed: ' + error.message;
|
||||||
|
} finally {
|
||||||
|
setBusy('networkSaveBtn', false, 'Save network');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(id, busy, label) {
|
||||||
|
const button = el(id);
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = busy;
|
||||||
|
button.innerHTML = busy ? '<span class=spinner></span>' + label : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPkg() {
|
||||||
|
if (!el('pkg').files.length) {
|
||||||
|
el('fwOut').textContent = 'Select an update package first';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('uploadPkgBtn', true, 'Uploading');
|
||||||
|
el('fwOut').textContent = 'Uploading update package...';
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('package', el('pkg').files[0]);
|
||||||
|
const response = await apiFetch('/api/update', {method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: formData});
|
||||||
|
el('fwOut').textContent = await response.text();
|
||||||
|
} catch (error) {
|
||||||
|
el('fwOut').textContent = 'Upload failed: ' + error.message;
|
||||||
|
} finally {
|
||||||
|
setBusy('uploadPkgBtn', false, 'Upload package');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOta() {
|
||||||
|
el('fwOut').textContent = await req('POST', '/api/ota/check');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOta() {
|
||||||
|
el('fwOut').textContent = await req('POST', '/api/ota/run');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
el('loginBtn').addEventListener('click', doLogin);
|
||||||
|
document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
|
||||||
|
document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
|
||||||
|
el('usersRefresh').addEventListener('click', loadUsers);
|
||||||
|
el('userNew').addEventListener('click', newUserRecord);
|
||||||
|
el('userBack').addEventListener('click', showUserOverview);
|
||||||
|
el('userSave').addEventListener('click', saveSelectedUser);
|
||||||
|
el('rolesRefresh').addEventListener('click', loadRoles);
|
||||||
|
el('roleNew').addEventListener('click', newRoleRecord);
|
||||||
|
el('roleBack').addEventListener('click', showRoleOverview);
|
||||||
|
el('roleSave').addEventListener('click', addRole);
|
||||||
|
el('roleDelete').addEventListener('click', deleteSelectedRole);
|
||||||
|
el('apiBack').addEventListener('click', showApiOverview);
|
||||||
|
el('apiSave').addEventListener('click', saveSelectedApi);
|
||||||
|
el('apiTest').addEventListener('click', testSelectedApi);
|
||||||
|
el('apiTestClear').addEventListener('click', clearApiTestBody);
|
||||||
|
el('rolesOut').addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-role-index]');
|
||||||
|
if (button) selectRole(+button.dataset.roleIndex);
|
||||||
|
});
|
||||||
|
el('userList').addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-user]');
|
||||||
|
if (button) selectUser(button.dataset.user);
|
||||||
|
});
|
||||||
|
el('apiList').addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-api-index]');
|
||||||
|
if (button) selectApi(+button.dataset.apiIndex);
|
||||||
|
});
|
||||||
|
el('netDhcp').addEventListener('change', syncNetworkMode);
|
||||||
|
el('networkSaveBtn').addEventListener('click', saveNetwork);
|
||||||
|
el('certSaveBtn').addEventListener('click', saveCertFile);
|
||||||
|
el('certLoad').addEventListener('click', loadCert);
|
||||||
|
el('settingsSave').addEventListener('click', saveSettings);
|
||||||
|
el('uploadPkgBtn').addEventListener('click', uploadPkg);
|
||||||
|
el('otaCheck').addEventListener('click', checkOta);
|
||||||
|
el('otaRun').addEventListener('click', runOta);
|
||||||
|
el('configDownload').addEventListener('click', downloadConfig);
|
||||||
|
el('restoreConfigBtn').addEventListener('click', restoreConfig);
|
||||||
|
el('factoryReset').addEventListener('click', factoryResetDevice);
|
||||||
|
el('fileList').addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-download]');
|
||||||
|
if (button) {
|
||||||
|
downloadFile(button.dataset.download);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directory = event.target.closest('.fileRow[data-path]');
|
||||||
|
if (directory) loadFiles(directory.dataset.path);
|
||||||
|
});
|
||||||
|
el('logsLoad').addEventListener('click', loadLogs);
|
||||||
|
el('logsClear').addEventListener('click', clearLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
20
data/www/js/setup.js
Normal file
20
data/www/js/setup.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const ssid = document.getElementById('ssid');
|
||||||
|
const wifiPass = document.getElementById('wifiPass');
|
||||||
|
const admin = document.getElementById('admin');
|
||||||
|
const adminPass = document.getElementById('adminPass');
|
||||||
|
const out = document.getElementById('out');
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
const response = await fetch('/api/wifi/scan');
|
||||||
|
const json = await response.json();
|
||||||
|
ssid.innerHTML = json.networks.map(network => `<option>${network.ssid}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const body = {ssid: ssid.value, wifiPass: wifiPass.value, admin: admin.value, adminPass: adminPass.value};
|
||||||
|
const response = await fetch('/api/setup', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||||
|
out.textContent = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('saveSetup').addEventListener('click', save);
|
||||||
|
scan();
|
||||||
27
data/www/setup.html
Normal file
27
data/www/setup.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>ESP32-C3 first-run setup</title>
|
||||||
|
<link rel="stylesheet" href="/css/setup.css">
|
||||||
|
<script defer src="/js/setup.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">ESP32-C3 first-run setup</div>
|
||||||
|
<main>
|
||||||
|
<section class="box">
|
||||||
|
<h2>Network and admin setup</h2>
|
||||||
|
<label>WiFi network</label>
|
||||||
|
<select id="ssid"></select>
|
||||||
|
<label>WiFi password</label>
|
||||||
|
<input id="wifiPass" type="password" autocomplete="off">
|
||||||
|
<label>Admin username</label>
|
||||||
|
<input id="admin" value="admin">
|
||||||
|
<label>Admin password</label>
|
||||||
|
<input id="adminPass" type="password" autocomplete="new-password">
|
||||||
|
<button id="saveSetup">Save and restart</button>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg
vendored
Normal file
BIN
dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg
vendored
Normal file
Binary file not shown.
BIN
dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg
vendored
Normal file
BIN
dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg
vendored
Normal file
Binary file not shown.
@@ -34,6 +34,9 @@ extern const byte DNS_PORT;
|
|||||||
extern const IPAddress SETUP_AP_IP;
|
extern const IPAddress SETUP_AP_IP;
|
||||||
extern const IPAddress SETUP_AP_GATEWAY;
|
extern const IPAddress SETUP_AP_GATEWAY;
|
||||||
extern const IPAddress SETUP_AP_SUBNET;
|
extern const IPAddress SETUP_AP_SUBNET;
|
||||||
|
extern const char *WWW_ROOT;
|
||||||
|
extern const char *LOG_DIR;
|
||||||
|
extern const char *LOG_FILE_PATH;
|
||||||
|
|
||||||
enum LogLevel : uint8_t {
|
enum LogLevel : uint8_t {
|
||||||
LOG_ERROR = 0,
|
LOG_ERROR = 0,
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
void handlePing();
|
void handlePing();
|
||||||
void handleAdd();
|
void handleAdd();
|
||||||
void handleLed();
|
void handleLed();
|
||||||
|
void handleUptime();
|
||||||
|
void handleUptimeEvents();
|
||||||
|
|||||||
BIN
scripts/__pycache__/build_ota_package.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/build_ota_package.cpython-313.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/build_upload_device.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/build_upload_device.cpython-313.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/upload_device.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/upload_device.cpython-313.pyc
Normal file
Binary file not shown.
84
scripts/build_ota_package.py
Normal file
84
scripts/build_ota_package.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENV = "seeed_xiao_esp32c3"
|
||||||
|
|
||||||
|
|
||||||
|
def find_pio():
|
||||||
|
pio = shutil.which("pio")
|
||||||
|
if pio:
|
||||||
|
return pio
|
||||||
|
candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return "pio"
|
||||||
|
|
||||||
|
|
||||||
|
def run(command, cwd):
|
||||||
|
print("+ " + " ".join(command), flush=True)
|
||||||
|
subprocess.run(command, check=True, cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def project_name(root):
|
||||||
|
app_h = root / "include" / "app.h"
|
||||||
|
if app_h.exists():
|
||||||
|
match = re.search(r'^\s*#define\s+PROJECT_NAME\s+"([^"]+)"', app_h.read_text(encoding="utf-8"), re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return root.name
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename_part(value):
|
||||||
|
value = value.strip().replace(" ", "-")
|
||||||
|
return re.sub(r"[^A-Za-z0-9._+-]+", "-", value).strip("-") or "project"
|
||||||
|
|
||||||
|
|
||||||
|
def package_timestamp():
|
||||||
|
return datetime.now().astimezone().isoformat(timespec="seconds").replace(":", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then create a timestamped OTA update package.")
|
||||||
|
parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build.")
|
||||||
|
parser.add_argument("-o", "--output-dir", default="dist", help="Directory for the generated OTA package.")
|
||||||
|
parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
build_dir = root / ".pio" / "build" / args.environment
|
||||||
|
firmware = build_dir / "firmware.bin"
|
||||||
|
filesystem = build_dir / "littlefs.bin"
|
||||||
|
output_dir = (root / args.output_dir).resolve()
|
||||||
|
output = output_dir / f"{safe_filename_part(project_name(root))}_{package_timestamp()}.tslpkg"
|
||||||
|
|
||||||
|
try:
|
||||||
|
run([args.pio, "run", "-e", args.environment, "-t", "buildfs"], root)
|
||||||
|
run([args.pio, "run", "-e", args.environment], root)
|
||||||
|
run([
|
||||||
|
sys.executable,
|
||||||
|
str(Path(__file__).resolve().parent / "create_update_package.py"),
|
||||||
|
"--firmware",
|
||||||
|
str(firmware),
|
||||||
|
"--filesystem",
|
||||||
|
str(filesystem),
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
], root)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
sys.exit(error.returncode)
|
||||||
|
|
||||||
|
print(f"OTA update file: {output.resolve()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
57
scripts/build_upload_device.py
Normal file
57
scripts/build_upload_device.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ENV = "seeed_xiao_esp32c3"
|
||||||
|
|
||||||
|
|
||||||
|
def find_pio():
|
||||||
|
pio = shutil.which("pio")
|
||||||
|
if pio:
|
||||||
|
return pio
|
||||||
|
candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return "pio"
|
||||||
|
|
||||||
|
|
||||||
|
def run(command, cwd):
|
||||||
|
print("+ " + " ".join(command), flush=True)
|
||||||
|
subprocess.run(command, check=True, cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def pio_command(args, pio, environment, upload_port):
|
||||||
|
command = [pio, "run", "-e", environment] + args
|
||||||
|
if upload_port:
|
||||||
|
command += ["--upload-port", upload_port]
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then upload both to a connected device.")
|
||||||
|
parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build and upload.")
|
||||||
|
parser.add_argument("-p", "--upload-port", help="Upload port, for example COM3.")
|
||||||
|
parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
try:
|
||||||
|
run(pio_command(["-t", "buildfs"], args.pio, args.environment, None), root)
|
||||||
|
run(pio_command([], args.pio, args.environment, None), root)
|
||||||
|
run(pio_command(["-t", "uploadfs"], args.pio, args.environment, args.upload_port), root)
|
||||||
|
run(pio_command(["-t", "upload"], args.pio, args.environment, args.upload_port), root)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
sys.exit(error.returncode)
|
||||||
|
|
||||||
|
print(f"Uploaded filesystem and firmware for {args.environment} from {root}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,6 +5,8 @@ ApiDef customApiDefs[] = {
|
|||||||
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
||||||
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
||||||
{"/api/led", "POST", "", true, handleLed, nullptr},
|
{"/api/led", "POST", "", true, handleLed, nullptr},
|
||||||
|
{"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr},
|
||||||
|
{"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);
|
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ void applyLed() {
|
|||||||
|
|
||||||
void factoryReset() {
|
void factoryReset() {
|
||||||
prefs.clear();
|
prefs.clear();
|
||||||
LittleFS.remove("/logs.txt");
|
LittleFS.remove(LOG_FILE_PATH);
|
||||||
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
|
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ void appendLog(LogLevel level, const String &message) {
|
|||||||
if (level > currentLogLevel) return;
|
if (level > currentLogLevel) return;
|
||||||
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
|
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
|
||||||
String line = timestamp() + " " + names[level] + " " + message + "\n";
|
String line = timestamp() + " " + names[level] + " " + message + "\n";
|
||||||
File f = LittleFS.open("/logs.txt", "a");
|
if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
|
||||||
|
File f = LittleFS.open(LOG_FILE_PATH, "a");
|
||||||
if (f) {
|
if (f) {
|
||||||
f.print(line);
|
f.print(line);
|
||||||
f.close();
|
f.close();
|
||||||
}
|
}
|
||||||
f = LittleFS.open("/logs.txt", "r");
|
f = LittleFS.open(LOG_FILE_PATH, "r");
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
size_t size = f.size();
|
size_t size = f.size();
|
||||||
if (size <= maxLogBytes) {
|
if (size <= maxLogBytes) {
|
||||||
@@ -34,7 +35,7 @@ void appendLog(LogLevel level, const String &message) {
|
|||||||
f.close();
|
f.close();
|
||||||
int newline = tail.indexOf('\n');
|
int newline = tail.indexOf('\n');
|
||||||
if (newline >= 0) tail = tail.substring(newline + 1);
|
if (newline >= 0) tail = tail.substring(newline + 1);
|
||||||
f = LittleFS.open("/logs.txt", "w");
|
f = LittleFS.open(LOG_FILE_PATH, "w");
|
||||||
if (f) {
|
if (f) {
|
||||||
f.print(tail);
|
f.print(tail);
|
||||||
f.close();
|
f.close();
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const byte DNS_PORT = 53;
|
|||||||
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
|
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
|
||||||
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
|
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
|
||||||
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
|
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
|
||||||
|
const char *WWW_ROOT = "/www";
|
||||||
|
const char *LOG_DIR = "/log";
|
||||||
|
const char *LOG_FILE_PATH = "/log/logs.txt";
|
||||||
|
|
||||||
WebServer server(80);
|
WebServer server(80);
|
||||||
DNSServer dnsServer;
|
DNSServer dnsServer;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ static bool validIpValue(const String &value) {
|
|||||||
|
|
||||||
void handleSettingsGet() {
|
void handleSettingsGet() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
|
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
|
||||||
sendJson(200, jsonOk(out));
|
sendJson(200, jsonOk(out));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ void handleSettingsPost() {
|
|||||||
|
|
||||||
void handleLogsGet() {
|
void handleLogsGet() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
File f = LittleFS.open("/logs.txt", "r");
|
File f = LittleFS.open(LOG_FILE_PATH, "r");
|
||||||
String logs = f ? f.readString() : "";
|
String logs = f ? f.readString() : "";
|
||||||
if (f) f.close();
|
if (f) f.close();
|
||||||
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
|
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
|
||||||
@@ -91,37 +91,41 @@ void handleLogsGet() {
|
|||||||
|
|
||||||
void handleLogsClear() {
|
void handleLogsClear() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
LittleFS.remove("/logs.txt");
|
LittleFS.remove(LOG_FILE_PATH);
|
||||||
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
|
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
|
||||||
sendJson(200, jsonOk());
|
sendJson(200, jsonOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool validFsPath(const String &path) {
|
static bool validFsPath(const String &path) {
|
||||||
return path.length() && path.startsWith("/") && path.indexOf("..") < 0;
|
return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String fsPathArg(const String &fallback = "/") {
|
static String fsPathArg(const String &fallback = "/") {
|
||||||
String path = server.arg("path");
|
String path = server.arg("path");
|
||||||
if (!path.length()) path = fallback;
|
if (!path.length()) path = fallback;
|
||||||
|
if (!path.startsWith("/")) path = "/" + path;
|
||||||
|
while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void appendFileJson(String &out, bool &first, File &file) {
|
static String joinedFsPath(const String &parent, const String &name) {
|
||||||
|
if (name.startsWith("/")) return name;
|
||||||
|
if (parent == "/") return "/" + name;
|
||||||
|
return parent + "/" + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) {
|
||||||
String path = file.name();
|
String path = file.name();
|
||||||
if (!path.startsWith("/")) path = "/" + path;
|
path = joinedFsPath(parent, path);
|
||||||
if (!first) out += ",";
|
if (!first) out += ",";
|
||||||
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}";
|
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}";
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void appendFilesJson(String &out, bool &first, File &dir) {
|
static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) {
|
||||||
File file = dir.openNextFile();
|
File file = dir.openNextFile();
|
||||||
while (file) {
|
while (file) {
|
||||||
if (file.isDirectory()) {
|
appendFsEntryJson(out, first, path, file);
|
||||||
appendFilesJson(out, first, file);
|
|
||||||
} else {
|
|
||||||
appendFileJson(out, first, file);
|
|
||||||
}
|
|
||||||
file.close();
|
file.close();
|
||||||
file = dir.openNextFile();
|
file = dir.openNextFile();
|
||||||
}
|
}
|
||||||
@@ -129,13 +133,20 @@ static void appendFilesJson(String &out, bool &first, File &dir) {
|
|||||||
|
|
||||||
void handleFilesList() {
|
void handleFilesList() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
File root = LittleFS.open("/", "r");
|
String path = fsPathArg("/");
|
||||||
if (!root) return sendJson(500, jsonError("Filesystem root not available"));
|
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
|
||||||
|
File dir = LittleFS.open(path, "r");
|
||||||
|
if (!dir && path != "/") dir = LittleFS.open(path + "/", "r");
|
||||||
|
if (!dir) return sendJson(404, jsonError("Directory not found"));
|
||||||
|
if (!dir.isDirectory()) {
|
||||||
|
dir.close();
|
||||||
|
return sendJson(400, jsonError("Path is not a directory"));
|
||||||
|
}
|
||||||
|
|
||||||
String out = "\"files\":[";
|
String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":[";
|
||||||
bool first = true;
|
bool first = true;
|
||||||
appendFilesJson(out, first, root);
|
appendDirectoryJson(out, first, path, dir);
|
||||||
root.close();
|
dir.close();
|
||||||
out += "]";
|
out += "]";
|
||||||
sendJson(200, jsonOk(out));
|
sendJson(200, jsonOk(out));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "custom_api.h"
|
#include "custom_api.h"
|
||||||
|
|
||||||
|
static String uptimeJsonFields() {
|
||||||
|
return "\"uptimeSeconds\":" + String(millis() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
void handlePing() {
|
void handlePing() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
||||||
@@ -22,3 +26,18 @@ void handleLed() {
|
|||||||
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
|
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
|
||||||
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
|
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleUptime() {
|
||||||
|
if (!authorize()) return;
|
||||||
|
sendJson(200, jsonOk(uptimeJsonFields()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleUptimeEvents() {
|
||||||
|
if (!authorize()) return;
|
||||||
|
String payload = "retry: 1000\n";
|
||||||
|
payload += "event: uptime\n";
|
||||||
|
payload += "data: {" + uptimeJsonFields() + "}\n\n";
|
||||||
|
server.sendHeader("Cache-Control", "no-store");
|
||||||
|
server.sendHeader("Connection", "close");
|
||||||
|
server.send(200, "text/event-stream", payload);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,46 @@
|
|||||||
|
|
||||||
#include <LittleFS.h>
|
#include <LittleFS.h>
|
||||||
|
|
||||||
static bool serveHtmlFile(const String &path) {
|
static bool validPublicPath(const String &path) {
|
||||||
if (!path.startsWith("/") || path.indexOf("..") >= 0 || !path.endsWith(".html")) return false;
|
return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
|
||||||
File file = LittleFS.open(path, "r");
|
}
|
||||||
if (!file) return false;
|
|
||||||
server.streamFile(file, "text/html");
|
static String contentTypeFor(const String &path) {
|
||||||
|
if (path.endsWith(".html")) return "text/html";
|
||||||
|
if (path.endsWith(".css")) return "text/css";
|
||||||
|
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
|
||||||
|
if (path.endsWith(".json")) return "application/json";
|
||||||
|
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
if (path.endsWith(".png")) return "image/png";
|
||||||
|
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (path.endsWith(".ico")) return "image/x-icon";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool serveWwwFile(const String &path) {
|
||||||
|
if (!validPublicPath(path)) return false;
|
||||||
|
String fsPath = String(WWW_ROOT) + path;
|
||||||
|
File file = LittleFS.open(fsPath, "r");
|
||||||
|
if (!file || file.isDirectory()) {
|
||||||
|
if (file) file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
|
||||||
|
server.streamFile(file, contentTypeFor(path));
|
||||||
file.close();
|
file.close();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleSetupPage() {
|
void handleSetupPage() {
|
||||||
if (!serveHtmlFile("/setup.html")) {
|
if (!serveWwwFile("/setup.html")) {
|
||||||
sendJson(500, jsonError("Missing /setup.html in LittleFS"));
|
sendJson(500, jsonError("Missing /www/setup.html in LittleFS"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool handleHtmlFileRequest() {
|
bool handleHtmlFileRequest() {
|
||||||
String path = server.uri();
|
String path = server.uri();
|
||||||
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
|
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
|
||||||
return serveHtmlFile(path);
|
return serveWwwFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
void redirectToSetupPage() {
|
void redirectToSetupPage() {
|
||||||
@@ -38,11 +59,11 @@ void handleCaptiveProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleFavicon() {
|
void handleFavicon() {
|
||||||
server.send(204, "image/x-icon", "");
|
if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleAdminPage() {
|
void handleAdminPage() {
|
||||||
if (!serveHtmlFile("/admin.html")) {
|
if (!serveWwwFile("/admin.html")) {
|
||||||
sendJson(500, jsonError("Missing /admin.html in LittleFS"));
|
sendJson(500, jsonError("Missing /www/admin.html in LittleFS"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user