More work on the ESP32C3 Boilerplate
This commit is contained in:
159
data/admin.html
159
data/admin.html
@@ -8,24 +8,30 @@
|
||||
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}
|
||||
label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
||||
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}
|
||||
button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
||||
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}.muted{color:#607d8b;font-size:13px}
|
||||
.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}.item.active{background:#1261a6;color:white}
|
||||
.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>
|
||||
@@ -38,56 +44,137 @@
|
||||
<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="ops" onclick="showTab('ops')">Operations</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></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="grid">
|
||||
<section><h3>Roles</h3><div class="row"><div><label>Custom role</label><input id="roleName"></div><div><button onclick="addRole()">Add</button></div></div><div id="rolesOut"></div><pre id="roleMsg"></pre></section>
|
||||
<section><h3>Users</h3><div class="row"><button onclick="loadAccess()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div><div id="userList" class="list"></div></section>
|
||||
<section><h3>User Details</h3><h4 id="userTitle" class="detailTitle">Select a user</h4><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><button onclick="saveSelectedUser()">Save user</button><pre id="usersOut"></pre></section>
|
||||
<section><h3>API Security</h3><button onclick="loadApis()">Refresh</button><div id="apiList" class="list"></div></section>
|
||||
<section><h3>API Details</h3><h4 id="apiTitle" class="detailTitle">Select an API</h4><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></section>
|
||||
<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>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section>
|
||||
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2" selected>Info</option><option value="3">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 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>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);
|
||||
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)o.body=JSON.stringify(b);let r=await fetch(u,o);return await r.text()}
|
||||
function showTab(id){document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id))}
|
||||
let roles=[],users=[],apis=[],selectedUser='',selectedApi=-1;
|
||||
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('statusOut').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 roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+r+'" '+(selected.includes(r)?'checked':'')+'> '+r+'</label>').join('')}
|
||||
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 renderRoles(){el('rolesOut').innerHTML=roles.map(r=>'<span class=pill>'+r.name+(r.system?'':' <button onclick="deleteRole(\''+r.name+'\')">Delete</button>')+'</span>').join('')}
|
||||
async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser)selectUser(selectedUser)}}
|
||||
async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;el('roleName').value='';await loadAccess()}
|
||||
async function deleteRole(r){let t=await req('DELETE','/api/roles',{role:r});el('roleMsg').textContent=t;await loadAccess()}
|
||||
function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class="item '+(u.username==selectedUser?'active':'')+'" onclick="selectUser(\''+u.username+'\')">'+u.username+(u.active?'':' (inactive)')+'</button>').join('')}
|
||||
async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser)selectUser(selectedUser)}}
|
||||
function newUserRecord(){selectedUser='';renderUsers();el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[])}
|
||||
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;renderUsers();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('|'))}
|
||||
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()}
|
||||
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+r+'</option>').join('');el('apiRole').innerHTML=opts}
|
||||
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class="item '+(i==selectedApi?'active':'')+'" onclick="selectApi('+i+')">'+a.method+' '+a.path+'</button>').join('')}
|
||||
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0)selectApi(selectedApi)}}
|
||||
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;renderApis();el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role}
|
||||
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()}
|
||||
async function loadLogs(){el('logsOut').textContent=await req('GET','/api/logs')}async function clearLogs(){el('logsOut').textContent=await req('POST','/api/logs/clear')}
|
||||
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}}
|
||||
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 uploadPkg(){let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await fetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user