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.' }), ]; }, });