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.
106 lines
4.9 KiB
JavaScript
106 lines
4.9 KiB
JavaScript
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=10';
|
|
|
|
const addZone = QuickModal({
|
|
title: 'Add Zone',
|
|
fields: [
|
|
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
|
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
|
],
|
|
submit: {
|
|
url: '/api/firewall/zones',
|
|
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
|
|
validate: (b) => !b.name ? 'Zone name required' : null,
|
|
successMsg: 'Zone created',
|
|
},
|
|
refresh: 'firewall',
|
|
});
|
|
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
firewall: getModel('firewall'),
|
|
};
|
|
},
|
|
render(state) {
|
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
|
if (guard) return guard;
|
|
|
|
const zones = state.firewall.data?.zones?.available || [];
|
|
const activeZones = state.firewall.data?.zones?.active || {};
|
|
const zoneDetails = {};
|
|
for (const name of zones) {
|
|
const activeIfaces = activeZones[name];
|
|
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
|
|
}
|
|
|
|
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
|
|
const z = typeof zdata === 'object' ? zdata : {};
|
|
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
|
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
|
return html`<div class="card" key=${name} style="position:relative">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
<div>
|
|
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
|
|
<div class="text-muted text-sm" style="margin-bottom:10px">
|
|
${z.target ? 'Target: ' + esc(z.target) : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm mb-4">
|
|
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
|
|
${ifacesArr.length
|
|
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
|
|
: html`<span class="text-muted">None</span>`}
|
|
</div>
|
|
<div class="text-sm mb-4">
|
|
<div class="text-muted" style="margin-bottom:4px">Services</div>
|
|
${svcsArr.length
|
|
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
|
|
: html`<span class="text-muted">None</span>`}
|
|
</div>
|
|
<div style="display:flex;gap:6px">
|
|
<button class="btn btn-sm btn-outline"
|
|
onClick=${() => MultiSelectModal({
|
|
title: 'Interfaces: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
|
options: (state.firewall.data?.interfaces || []).map(i => i.name),
|
|
selected: ifacesArr,
|
|
fieldKey: 'interfaces',
|
|
successMsg: 'Interfaces updated',
|
|
refresh: 'firewall',
|
|
})()}>Interfaces</button>
|
|
<button class="btn btn-sm btn-outline"
|
|
onClick=${() => MultiSelectModal({
|
|
title: 'Services: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/services',
|
|
options: state.firewall.data?.services || [],
|
|
selected: svcsArr,
|
|
fieldKey: 'services',
|
|
successMsg: 'Services updated',
|
|
refresh: 'firewall',
|
|
})()}>Services</button>
|
|
<${ConfirmDelete}
|
|
url=${'/api/firewall/zones/' + enc(name)}
|
|
deleteKey=${name}
|
|
message=${'Delete zone ' + name + '?'}
|
|
success=${'Zone ' + name + ' deleted'}
|
|
refresh="firewall"
|
|
label="Delete" />
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'Zones',
|
|
subtitle: 'Firewall zones',
|
|
actions: html`<button class="btn btn-primary"
|
|
onClick=${() => addZone()}>Add Zone</button>`,
|
|
}),
|
|
zoneCards.length
|
|
? html`<div class="card-grid">${zoneCards}</div>`
|
|
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
|
];
|
|
},
|
|
});
|