b673e87c9b
Add hoover model.js as a central reactive store per subsystem, replacing per-component data fetching with a single source of truth. - Add hoover/model.js with modelRegister, modelFetch, and WS invalidation - Refactor websocket.js to route messages to model refresh (drop per-component subscribe/unsubscribe) - Simplify component.js by removing WS subscription management - Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort - Rewrite all pages to use getModel() instead of inline data fetching - Bootstrap model registrations in app.js - Add GET /api/firewall/state endpoint - Fix restart-services.sh restart order and add service health verification - Update hoover.md docs with model layer architecture
143 lines
6.3 KiB
JavaScript
143 lines
6.3 KiB
JavaScript
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
|
|
|
|
const addFwd = QuickModal({
|
|
title: 'Add Port Forward',
|
|
fields: (d) => [
|
|
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
|
|
{ label: 'Port', id: 'fwd-port', type: 'number' },
|
|
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
|
|
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
|
|
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
|
|
],
|
|
submit: {
|
|
url: '/api/firewall/forward-port',
|
|
body: () => ({
|
|
zone: $val('fwd-zone'),
|
|
port: parseInt($val('fwd-port')),
|
|
proto: ($val('fwd-proto') || 'tcp').trim(),
|
|
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
|
|
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
|
|
}),
|
|
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
|
successMsg: 'Forward rule added',
|
|
},
|
|
refresh: 'firewall',
|
|
});
|
|
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
firewall: getModel('firewall'),
|
|
};
|
|
},
|
|
render(state) {
|
|
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
|
if (guard) return guard;
|
|
|
|
const cfg = state.firewall.data?.config || {};
|
|
const zoneData = cfg.zones || {};
|
|
|
|
const sIface = (state.firewall.data?.state || {}).interfaces || [];
|
|
const masqZones = new Set(
|
|
Object.entries(zoneData)
|
|
.filter(([, zcfg]) => !!zcfg.masquerade)
|
|
.map(([z]) => z)
|
|
);
|
|
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
|
|
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
|
|
|
|
const ifaceRows = (ifaces) =>
|
|
ifaces.map((iface) =>
|
|
h('tr', { key: 'ii-' + iface.name },
|
|
h('td', null,
|
|
h('div', { class: 'd-flex align-items-center gap-2' },
|
|
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
|
|
h('strong', null, iface.name),
|
|
),
|
|
),
|
|
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
|
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
|
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
|
|
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
|
|
)
|
|
);
|
|
|
|
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
|
const masq = !!zcfg.masquerade;
|
|
return h('tr', { key: 'm-' + zone },
|
|
h('td', null, h('strong', null, zone)),
|
|
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
|
|
h('td', null,
|
|
ActionButton({
|
|
url: '/api/firewall/masquerade',
|
|
cls: 'btn btn-sm btn-outline',
|
|
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
|
|
body: () => ({ zone, enable: !masq }),
|
|
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
|
|
refresh: 'firewall',
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
const fwRows = [];
|
|
Object.entries(zoneData).forEach(([zone, zcfg]) => {
|
|
const forwards = zcfg.forward_ports || [];
|
|
forwards.forEach((fwd, i) => {
|
|
const port = fwd.port;
|
|
const proto = fwd['proxy-protocol'] || fwd.proto;
|
|
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
|
|
h('td', null, h('strong', null, zone)),
|
|
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
|
|
h('td', null, port),
|
|
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
|
|
h('td', null, fwd['to-port'] || fwd.toport || '-'),
|
|
h('td', null,
|
|
ConfirmDelete({
|
|
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
|
|
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
|
|
success: 'Rule removed',
|
|
refresh: 'firewall',
|
|
}),
|
|
),
|
|
));
|
|
});
|
|
});
|
|
|
|
return [
|
|
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
|
DataTableSection({
|
|
title: 'WAN / External',
|
|
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
|
rows: ifaceRows(wanIface),
|
|
emptyText: 'No WAN interfaces with masquerade enabled',
|
|
}),
|
|
DataTableSection({
|
|
title: 'Internal / LAN',
|
|
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
|
rows: ifaceRows(lanIface),
|
|
emptyText: 'No internal interfaces',
|
|
}),
|
|
DataTableSection({
|
|
title: 'Masquerade',
|
|
columns: ['Zone', 'Status', 'Action'],
|
|
rows: masqRows,
|
|
emptyText: 'No zones',
|
|
}),
|
|
SectionTitle({ title: 'Port Forwarding' }),
|
|
Card({ children: [
|
|
ActionGroup(
|
|
h('button', { class: 'btn btn-sm btn-primary',
|
|
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
|
|
}, 'Add Forward'),
|
|
),
|
|
Table({
|
|
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
|
rows: fwRows,
|
|
emptyText: 'No port forwarding rules',
|
|
wrapCard: false,
|
|
}),
|
|
]}),
|
|
];
|
|
},
|
|
}); |