Files
vacuum-wall/webui/static/pages/nat.js
T
mteehan b673e87c9b refactor: introduce model layer for centralized data synchronization
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
2026-06-22 22:54:29 +00:00

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