ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=11';
|
|
|
|
import DashboardPage from '/static/pages/dashboard.js?v=11';
|
|
import InterfacesPage from '/static/pages/interfaces.js?v=9';
|
|
import ZonesPage from '/static/pages/zones.js?v=9';
|
|
import RulesPage from '/static/pages/rules.js?v=9';
|
|
import NatPage from '/static/pages/nat.js?v=9';
|
|
import DhcpPage from '/static/pages/dhcp.js?v=9';
|
|
import ProxyPage from '/static/pages/proxy.js?v=10';
|
|
import BackendsPage from '/static/pages/backends.js?v=10';
|
|
import CertsPage from '/static/pages/certs.js?v=9';
|
|
import WireguardPage from '/static/pages/wireguard.js?v=9';
|
|
import LogsPage from '/static/pages/logs.js?v=9';
|
|
import NotFoundPage from '/static/pages/notfound.js?v=9';
|
|
import LoginPage from '/static/pages/login.js';
|
|
import PasskeysPage from '/static/pages/passkeys.js';
|
|
import UsersPage from '/static/pages/users.js';
|
|
|
|
/* ── Navigation items ──────────────────────────────────────── */
|
|
const _NavBase = [
|
|
{ path: '/dashboard', label: 'Dashboard' },
|
|
{ path: '/interfaces', label: 'Interfaces' },
|
|
{ path: '/zones', label: 'Zones' },
|
|
{ path: '/rules', label: 'Rules' },
|
|
{ path: '/nat', label: 'NAT' },
|
|
{ path: '/dhcp', label: 'DHCP' },
|
|
{ path: '/proxy', label: 'Proxy' },
|
|
{ path: '/backends', label: 'Backends' },
|
|
{ path: '/certs', label: 'Certs' },
|
|
{ path: '/wireguard', label: 'WireGuard' },
|
|
{ path: '/logs', label: 'Logs' },
|
|
];
|
|
|
|
function getNav() {
|
|
const nav = [..._NavBase];
|
|
const perms = JSON.parse(sessionStorage.getItem('vw:permissions') || 'null');
|
|
if (perms && perms.auth === 'rw') {
|
|
nav.push({ path: '/users', label: 'Users' });
|
|
}
|
|
return nav;
|
|
}
|
|
|
|
modelRegister('firewall', {
|
|
subsystem: 'firewall',
|
|
fetch: async () => {
|
|
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
|
apiFetch('/api/firewall/config'),
|
|
apiFetch('/api/firewall/zones'),
|
|
apiFetch('/api/firewall/services'),
|
|
apiFetch('/api/firewall/interfaces'),
|
|
apiFetch('/api/firewall/state'),
|
|
]);
|
|
const result = {};
|
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
|
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
|
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
|
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
|
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
|
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
|
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
|
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('network', {
|
|
subsystem: 'networkd',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/network/interfaces');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.data || { interfaces: {} };
|
|
},
|
|
});
|
|
|
|
modelRegister('dnsmasq', {
|
|
subsystem: 'dnsmasq',
|
|
fetch: async () => {
|
|
const [cfg, status, leases] = await Promise.allSettled([
|
|
apiFetch('/api/dhcp/config'),
|
|
apiFetch('/api/dhcp/status'),
|
|
apiFetch('/api/dhcp/leases'),
|
|
]);
|
|
const result = {};
|
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
|
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
|
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
|
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
|
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('nginx', {
|
|
subsystem: 'nginx',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/proxy/domains');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { domains: r.data || [] };
|
|
},
|
|
});
|
|
|
|
modelRegister('backends', {
|
|
subsystem: 'nginx',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/proxy/backends');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.data || {};
|
|
},
|
|
});
|
|
|
|
modelRegister('acme', {
|
|
subsystem: 'acme',
|
|
fetch: async () => {
|
|
const [listR, acctR] = await Promise.allSettled([
|
|
apiFetch('/api/certs/list'),
|
|
apiFetch('/api/certs/account'),
|
|
]);
|
|
const result = {};
|
|
if (listR.status === 'fulfilled' && listR.value.ok) {
|
|
result.certs = listR.value.data || [];
|
|
} else if (listR.status === 'rejected' || !listR.value.ok) {
|
|
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
|
|
}
|
|
if (acctR.status === 'fulfilled' && acctR.value.ok) {
|
|
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('wireguard', {
|
|
subsystem: 'wireguard',
|
|
fetch: async () => {
|
|
const [stR, pR, cfgR] = await Promise.allSettled([
|
|
apiFetch('/api/wireguard/status'),
|
|
apiFetch('/api/wireguard/peers'),
|
|
apiFetch('/api/wireguard/config'),
|
|
]);
|
|
const result = {};
|
|
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
|
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
|
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
|
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
|
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const LOG_TABS = {
|
|
journal: '/api/logs/journal',
|
|
'nginx-access': '/api/logs/nginx/access',
|
|
'nginx-error': '/api/logs/nginx/error',
|
|
dnsmasq: '/api/logs/dnsmasq',
|
|
app: '/api/logs/app',
|
|
};
|
|
|
|
modelRegister('logs', {
|
|
subsystem: '*',
|
|
fetch: async (signal, tab) => {
|
|
const tabKey = tab || 'journal';
|
|
const url = LOG_TABS[tabKey];
|
|
if (!url) throw new Error('Unknown log tab: ' + tabKey);
|
|
const r = await apiFetch(url, { signal });
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
|
|
},
|
|
});
|
|
|
|
modelRegister('status', {
|
|
subsystem: '*',
|
|
fetch: async () => {
|
|
const [pendingR, metricsR] = await Promise.allSettled([
|
|
apiFetch('/api/status/pending'),
|
|
apiFetch('/api/status/system-metrics'),
|
|
]);
|
|
const result = {};
|
|
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
|
|
result.pending = pendingR.value.data || {};
|
|
}
|
|
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
|
|
result.metrics = metricsR.value.data || {};
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
/* ── Initial fetch (after auth check) ───────────────────────── */
|
|
function fetchInitialData() {
|
|
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
|
modelFetch(name);
|
|
}
|
|
modelFetch('logs', 'journal');
|
|
}
|
|
|
|
/* ── Page map ──────────────────────────────────────────────── */
|
|
const Pages = {
|
|
login: LoginPage,
|
|
dashboard: DashboardPage,
|
|
interfaces: InterfacesPage,
|
|
zones: ZonesPage,
|
|
rules: RulesPage,
|
|
nat: NatPage,
|
|
dhcp: DhcpPage,
|
|
proxy: ProxyPage,
|
|
backends: BackendsPage,
|
|
certs: CertsPage,
|
|
wireguard: WireguardPage,
|
|
logs: LogsPage,
|
|
passkeys: PasskeysPage,
|
|
users: UsersPage,
|
|
};
|
|
|
|
/* ── Router ────────────────────────────────────────────────── */
|
|
const router = {
|
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
|
isAuthenticated: false,
|
|
component() {
|
|
const name = this.state.path.replace(/^\//, '');
|
|
const page = Pages[name] || NotFoundPage;
|
|
return hComp(page, this.state.path);
|
|
},
|
|
};
|
|
|
|
window.location.hash || (window.location.hash = router.state.path);
|
|
window.addEventListener('hashchange', () => {
|
|
router.state.path = location.hash.slice(1) || '/dashboard';
|
|
});
|
|
|
|
/* ── Sidebar render root ───────────────────────────────────── */
|
|
function Sidebar() {
|
|
const current = router.state.path;
|
|
const nav = getNav();
|
|
return h('div', { class: 'sidebar' },
|
|
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
|
h('nav', null,
|
|
nav.map(item =>
|
|
Link({
|
|
path: item.path,
|
|
class: current === item.path ? 'active' : '',
|
|
children: [item.label],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/* ── Main content render root ──────────────────────────────── */
|
|
function MainContent() {
|
|
return [
|
|
router.component(),
|
|
ToastContainer(),
|
|
];
|
|
}
|
|
|
|
/* ── Init ──────────────────────────────────────────────────── */
|
|
export async function initApp() {
|
|
const sidebarEl = document.getElementById('sidebar');
|
|
const mainEl = document.getElementById('main');
|
|
if (sidebarEl && mainEl) {
|
|
render(sidebarEl, Sidebar);
|
|
render(mainEl, MainContent);
|
|
}
|
|
|
|
// Check auth state before connecting WS
|
|
const ok = await initAuth();
|
|
if (ok) {
|
|
router.isAuthenticated = true;
|
|
fetchInitialData();
|
|
setTimeout(connect, 0);
|
|
} else {
|
|
// No valid session — redirect to login
|
|
if (router.state.path !== '/login') {
|
|
window.location.hash = '/login';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initApp);
|
|
} else {
|
|
initApp();
|
|
}
|