import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; function addZoneModal(state) { openModal((inner, idx) => { formModal(inner, 'Add Zone', [ { label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' }, { label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Create', cls: 'btn-primary', action: 's', handler: async () => { const name = ($val('zone-name') || '').trim(); if (!name) { toast('Zone name required', 'error'); return; } const target = ($val('zone-target') || '').trim() || 'default'; const r = await apiFetch('/api/firewall/zones', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, target }), }); if (r.ok) { toast('Zone ' + name + ' created', 'success'); closeModal(idx); await load(state); } else { toast(r.error || 'Failed', 'error'); } }, }, ], ); }); } function zoneIfaceModal(zoneName, state) { const zdata = state.zones?.[zoneName] || {}; const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : []; const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : []; openModal((inner, idx) => { formModal(inner, 'Interfaces: ' + zoneName, [ { label: 'Interfaces', id: 'z-iface-select', tag: 'select', options: allIfaces.map(i => [i, current.includes(i)]), }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { const sel = document.getElementById('z-iface-select'); const selected = Array.from(sel.selectedOptions).map(o => o.value); const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interfaces: selected }), }); if (r.ok) { toast('Interfaces updated', 'success'); closeModal(idx); await load(state); } else { toast(r.error || 'Failed', 'error'); } }, }, ], ); }); } function zoneSvcModal(zoneName, state) { const zdata = state.zones?.[zoneName] || {}; const current = Array.isArray(zdata.services) ? zdata.services : []; const all = Array.isArray(state.services) ? state.services : []; openModal((inner, idx) => { formModal(inner, 'Services: ' + zoneName, [ { label: 'Services', id: 'z-svc-select', tag: 'select', options: all.map(s => [s, current.includes(s)]), }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { const sel = document.getElementById('z-svc-select'); const selected = Array.from(sel.selectedOptions).map(o => o.value); const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ services: selected }), }); if (r.ok) { toast('Services updated', 'success'); closeModal(idx); await load(state); } else { toast(r.error || 'Failed', 'error'); } }, }, ], ); }); } async function load(state, abortController, entry) { if (Object.keys(state.zones || {}).length) state.refreshing = true; else state.loading = true; try { const myId = entry ? entry.requestId : 0; const sig = abortController?.signal; const [zRes, svcRes, ifRes] = await Promise.all([ apiFetch('/api/firewall/zones', { signal: sig }), apiFetch('/api/firewall/services', { signal: sig }), apiFetch('/api/firewall/interfaces', { signal: sig }), ]); if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (zRes.ok) { const data = zRes.data || {}; const activeZones = data.active || {}; const availableZones = data.available || []; const detailPromises = availableZones.map(name => apiFetch('/api/firewall/zones/' + enc(name), { signal: sig }).catch(() => null) ); const detailResults = await Promise.all(detailPromises); if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; const zones = {}; for (let i = 0; i < availableZones.length; i++) { const name = availableZones[i]; const detail = detailResults[i]; if (detail && detail.ok) { zones[name] = detail.data; const activeIfaces = activeZones[name]; if (Array.isArray(activeIfaces)) { zones[name].interfaces = activeIfaces; } } } state.zones = zones; } if (svcRes.ok) state.services = svcRes.data || []; if (ifRes.ok) state.interfaces = ifRes.data || []; } catch (e) { if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; state.refreshing = false; } export default definePage({ init() { return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null }; }, subscribe: ['firewall'], load, render(state) { if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }), h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } if (state.error) { return [ PageHeader({ title: 'Zones' }), h('div', { class: 'card', key: 'error' }, h('div', { class: 'card-body error-msg' }, state.error), ), ]; } 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': () => zoneIfaceModal(name, state) }, 'Interfaces'), h('button', { class: 'btn btn-sm btn-outline', 'on:click': () => zoneSvcModal(name, state) }, 'Services'), h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;', 'on:click': async () => { if (!confirm('Delete zone ' + name + '?')) return; const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' }); if (r.ok) { toast('Zone ' + name + ' deleted', 'success'); await load(state); } else { toast(r.error || 'Failed', 'error'); } }}, 'Delete'), ), ); }); return [ PageHeader({ title: 'Zones', subtitle: 'Firewall zones', actions: h('button', { class: 'btn btn-primary', 'on:click': () => addZoneModal(state) }, 'Add Zone'), }), zoneCards.length ? h('div', { class: 'card-grid' }, ...zoneCards) : Empty({ text: 'No zones configured. Add a zone to get started.' }), ]; }, });