244 lines
11 KiB
JavaScript
244 lines
11 KiB
JavaScript
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.' }),
|
|
];
|
|
},
|
|
});
|