633505e7dc
- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
155 lines
7.0 KiB
JavaScript
155 lines
7.0 KiB
JavaScript
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
|
|
|
|
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',
|
|
},
|
|
reload: (s) => load(s),
|
|
});
|
|
|
|
async function load(state, abortController, entry) {
|
|
await refactorLoad(state,
|
|
s => Object.keys(s.zones || {}).length,
|
|
async (s, sig, isAborted) => {
|
|
const [zRes, svcRes, ifRes] = await Promise.allSettled([
|
|
apiFetch('/api/firewall/zones', { signal: sig }),
|
|
apiFetch('/api/firewall/services', { signal: sig }),
|
|
apiFetch('/api/firewall/interfaces', { signal: sig }),
|
|
]);
|
|
if (isAborted()) return;
|
|
|
|
const errors = [];
|
|
if (zRes.status === 'rejected') errors.push(zRes.reason?.message || 'Failed');
|
|
else if (!zRes.value.ok) errors.push(zRes.value.error || 'Failed');
|
|
if (svcRes.status === 'rejected') errors.push(svcRes.reason?.message || 'Failed');
|
|
else if (!svcRes.value.ok) errors.push(svcRes.value.error || 'Failed');
|
|
if (ifRes.status === 'rejected') errors.push(ifRes.reason?.message || 'Failed');
|
|
else if (!ifRes.value.ok) errors.push(ifRes.value.error || 'Failed');
|
|
if (errors.length) {
|
|
s.error = errors[0];
|
|
return;
|
|
}
|
|
|
|
const data = zRes.value.data || {};
|
|
const activeZones = data.active || {};
|
|
const availableZones = data.available || [];
|
|
|
|
const detailPromises = availableZones.map(name =>
|
|
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig })
|
|
);
|
|
const detailResults = await Promise.allSettled(detailPromises);
|
|
|
|
if (isAborted()) return;
|
|
|
|
const zones = {};
|
|
for (let i = 0; i < availableZones.length; i++) {
|
|
const name = availableZones[i];
|
|
const res = detailResults[i];
|
|
const detail = res.status === 'fulfilled' ? res.value : null;
|
|
if (detail && detail.ok) {
|
|
zones[name] = detail.data;
|
|
const activeIfaces = activeZones[name];
|
|
if (Array.isArray(activeIfaces)) {
|
|
zones[name].interfaces = activeIfaces;
|
|
}
|
|
}
|
|
}
|
|
s.zones = zones;
|
|
s.services = svcRes.value.data || [];
|
|
s.interfaces = ifRes.value.data || [];
|
|
},
|
|
{ entry, abortController },
|
|
);
|
|
}
|
|
|
|
export default definePage({
|
|
init() {
|
|
return { zones: {}, services: [], interfaces: [] };
|
|
},
|
|
subscribe: ['firewall'],
|
|
load,
|
|
render(state) {
|
|
const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
|
|
if (guard) return guard;
|
|
|
|
const zoneCards = Object.entries(state.zones || []).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 h('div', { class: 'card', key: name, style: 'position:relative;' },
|
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' },
|
|
h('div', null,
|
|
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name),
|
|
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' },
|
|
z.target ? 'Target: ' + esc(z.target) : '',
|
|
),
|
|
),
|
|
),
|
|
h('div', { class: 'text-sm mb-4' },
|
|
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'),
|
|
ifacesArr.length
|
|
? ifacesArr.map(i => Badge({ text: esc(i) }))
|
|
: h('span', { class: 'text-muted' }, 'None'),
|
|
),
|
|
h('div', { class: 'text-sm mb-4' },
|
|
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'),
|
|
svcsArr.length
|
|
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' }))
|
|
: h('span', { class: 'text-muted' }, 'None'),
|
|
),
|
|
h('div', { style: 'display:flex;gap:6px;' },
|
|
h('button', { class: 'btn btn-sm btn-outline',
|
|
'on:click': () => MultiSelectModal({
|
|
title: 'Interfaces: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
|
options: state.interfaces,
|
|
selected: ifacesArr,
|
|
fieldKey: 'interfaces',
|
|
successMsg: 'Interfaces updated',
|
|
reload: () => load(state),
|
|
})(),
|
|
}, 'Interfaces'),
|
|
h('button', { class: 'btn btn-sm btn-outline',
|
|
'on:click': () => MultiSelectModal({
|
|
title: 'Services: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/services',
|
|
options: state.services,
|
|
selected: svcsArr,
|
|
fieldKey: 'services',
|
|
successMsg: 'Services updated',
|
|
reload: () => load(state),
|
|
})(),
|
|
}, 'Services'),
|
|
ConfirmDelete({
|
|
url: '/api/firewall/zones/' + enc(name),
|
|
message: 'Delete zone ' + name + '?',
|
|
success: 'Zone ' + name + ' deleted',
|
|
reload: () => load(state),
|
|
label: 'Delete',
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'Zones',
|
|
subtitle: 'Firewall zones',
|
|
actions: h('button', { class: 'btn btn-primary',
|
|
'on:click': () => addZone(state), }, 'Add Zone'),
|
|
}),
|
|
zoneCards.length
|
|
? h('div', { class: 'card-grid' }, ...zoneCards)
|
|
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
|
];
|
|
},
|
|
});
|