Various improvements for usability and consistency and inclusion of PWA handling.

This commit is contained in:
2026-07-09 20:03:41 -05:00
parent 941ce78bfd
commit 0a400e3039
23 changed files with 439 additions and 133 deletions

View File

@@ -7,47 +7,103 @@ let selectedRole = '';
let selectedApi = -1;
let currentFilePath = '/';
let filesLoaded = false;
let busyCount = 0;
let uiBusyCount = 0;
let serverBusyCount = 0;
let currentUserRoles = [];
const el = id => document.getElementById(id);
const hasCurrentRole = role => currentUserRoles.includes(role);
function setGlobalBusy(on) {
busyCount += on ? 1 : -1;
if (busyCount < 0) busyCount = 0;
el('busyChip').classList.toggle('hidden', busyCount == 0);
function setProjectName(projectName) {
if (!projectName) return;
document.title = projectName;
el('appTitle').textContent = projectName;
}
async function tracked(fn) {
setGlobalBusy(true);
async function loadAppInfo() {
try {
const response = await fetch('/api/app-info');
const json = await response.json();
setProjectName(json.projectName || '');
} catch (error) {}
}
function setActivityBusy(kind, on) {
if (kind == 'server') {
serverBusyCount += on ? 1 : -1;
if (serverBusyCount < 0) serverBusyCount = 0;
el('serverBusyChip').classList.toggle('hidden', serverBusyCount == 0);
return;
}
uiBusyCount += on ? 1 : -1;
if (uiBusyCount < 0) uiBusyCount = 0;
el('uiBusyChip').classList.toggle('hidden', uiBusyCount == 0);
}
async function tracked(fn, kind = 'ui') {
setActivityBusy(kind, true);
try {
return await fn();
} finally {
setGlobalBusy(false);
setActivityBusy(kind, false);
}
}
async function apiFetch(url, options) {
return await tracked(() => fetch(url, options));
async function apiFetch(url, options, activity = 'ui') {
return await tracked(() => fetch(url, options), activity);
}
async function req(method, url, body) {
async function req(method, url, body, activity = 'ui') {
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);
const response = await apiFetch(url, options, activity);
return await response.text();
}
async function serverReq(method, url, body) {
return await req(method, url, body, 'server');
}
window.App = {
apiFetch,
req,
token: () => token
serverReq,
setActivityBusy,
token: () => token,
roles: () => [...currentUserRoles]
};
const topLevelTabs = [
{id: 'dashboard', role: 'WebUIConnect'},
{id: 'access', role: 'AccessAdmin'},
{id: 'network', role: 'NetworkAdmin'},
{id: 'ops', role: 'SystemAdmin'}
];
function visibleTopLevelTabs() {
return topLevelTabs.filter(tab => hasCurrentRole(tab.role)).map(tab => tab.id);
}
function applyRoleVisibility() {
const visible = visibleTopLevelTabs();
topLevelTabs.forEach(tab => {
const allowed = visible.includes(tab.id);
document.querySelectorAll('[data-tab="' + tab.id + '"]').forEach(button => button.classList.toggle('hidden', !allowed));
const panel = el(tab.id);
if (panel) panel.classList.toggle('hidden', !allowed);
});
const topTabs = document.querySelector('#app > .tabs');
if (topTabs) topTabs.classList.toggle('hidden', visible.length <= 1);
if (visible.length) showTab(visible[0]);
return visible;
}
function showTab(id) {
if (!visibleTopLevelTabs().includes(id)) return;
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 == 'network') loadNetworkSettings();
if (id == 'ops' && !filesLoaded) loadFiles('/');
}
@@ -65,12 +121,40 @@ async function doLogin() {
const json = JSON.parse(text);
if (!json.success) return;
token = json.token;
currentUserRoles = (json.roles || '').split('|').filter(Boolean);
el('loginPanel').classList.add('hidden');
el('app').classList.remove('hidden');
window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
loadSettings();
loadAccess();
loadFiles('/');
el('logoutBtn').classList.remove('hidden');
window.dispatchEvent(new CustomEvent('app:login', {detail: {token, roles: currentUserRoles}}));
const visible = applyRoleVisibility();
if (hasCurrentRole('SystemAdmin')) loadSettings();
if (hasCurrentRole('AccessAdmin')) loadAccess();
if (hasCurrentRole('NetworkAdmin')) loadNetworkSettings();
if (visible.includes('ops')) loadFiles('/');
}
async function doLogout() {
if (token) {
try {
await req('POST', '/api/logout');
} catch (error) {}
}
token = '';
currentUserRoles = [];
roles = [];
users = [];
apis = [];
selectedUser = '';
selectedRole = '';
selectedApi = -1;
filesLoaded = false;
currentFilePath = '/';
el('app').classList.add('hidden');
el('loginPanel').classList.remove('hidden');
el('logoutBtn').classList.add('hidden');
el('pass').value = '';
el('loginOut').textContent = '';
window.dispatchEvent(new CustomEvent('app:logout'));
}
function esc(value) {
@@ -443,6 +527,16 @@ async function factoryResetDevice() {
el('maintOut').textContent = await req('POST', '/api/factory-reset');
}
async function restartDevice() {
if (!confirm('Restart this device now?')) return;
setBusy('restartDevice', true, 'Restarting');
try {
el('maintOut').textContent = await req('POST', '/api/restart');
} finally {
setBusy('restartDevice', false, 'Restart');
}
}
async function loadLogs() {
const text = await req('GET', '/api/logs');
try {
@@ -472,12 +566,20 @@ async function loadSettings() {
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';
setProjectName(settings.projectName);
el('logLevel').value = settings.logLevel;
el('logMax').value = settings.maxLogBytes;
el('upd').value = settings.updateUrl;
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
}
async function loadNetworkSettings() {
const text = await req('GET', '/api/network');
el('networkOut').textContent = text;
const json = JSON.parse(text);
if (!json.success) return;
const settings = json.settings;
setProjectName(settings.projectName);
el('netHost').value = settings.hostname || '';
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
el('netDhcp').value = String(settings.dhcp !== false);
@@ -488,7 +590,6 @@ async function loadSettings() {
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() {
@@ -499,9 +600,9 @@ 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);
const text = await req('POST', '/api/network', body);
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
if (JSON.parse(text).success) await loadSettings();
if (JSON.parse(text).success) await loadNetworkSettings();
} catch (error) {
el('networkOut').textContent = 'Network save failed: ' + error.message;
} finally {
@@ -545,13 +646,12 @@ async function runOta() {
function bindEvents() {
el('loginBtn').addEventListener('click', doLogin);
el('logoutBtn').addEventListener('click', doLogout);
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);
@@ -582,6 +682,7 @@ function bindEvents() {
el('otaRun').addEventListener('click', runOta);
el('configDownload').addEventListener('click', downloadConfig);
el('restoreConfigBtn').addEventListener('click', restoreConfig);
el('restartDevice').addEventListener('click', restartDevice);
el('factoryReset').addEventListener('click', factoryResetDevice);
el('fileList').addEventListener('click', event => {
const button = event.target.closest('[data-download]');
@@ -596,4 +697,5 @@ function bindEvents() {
el('logsClear').addEventListener('click', clearLogs);
}
loadAppInfo();
bindEvents();

9
data/www/js/pwa.js Normal file
View File

@@ -0,0 +1,9 @@
(function () {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
console.warn('PWA service worker registration failed:', error);
});
});
})();

View File

@@ -4,6 +4,16 @@ const admin = document.getElementById('admin');
const adminPass = document.getElementById('adminPass');
const out = document.getElementById('out');
async function loadAppInfo() {
try {
const response = await fetch('/api/app-info');
const json = await response.json();
const projectName = json.projectName || '';
document.title = projectName;
document.getElementById('setupTitle').textContent = projectName;
} catch (error) {}
}
async function scan() {
const response = await fetch('/api/wifi/scan');
const json = await response.json();
@@ -17,4 +27,5 @@ async function save() {
}
document.getElementById('saveSetup').addEventListener('click', save);
loadAppInfo();
scan();