Files
vacuum-wall/webui/static/pages/nat.js
T
mteehan 633505e7dc refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
2026-06-21 04:29:27 +00:00

163 lines
7.1 KiB
JavaScript

import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6';
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: (s) => ({
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',
},
reload: (s) => load(s._s),
});
async function load(state, abortController, entry) {
await refactorLoad(state,
s => Object.keys(s.config || {}).length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/firewall/config', { signal: sig });
if (isAborted()) return;
if (r.ok) s.config = r.data || {};
else s.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (isAborted()) return;
if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {});
else if (!s.error) s.error = zr.error;
const sr = await apiFetch('/api/firewall/state', { signal: sig });
if (isAborted()) return;
if (sr.ok) s.stateData = sr.data;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, activeZones: [], stateData: null };
},
subscribe: ['firewall'],
load,
render(state) {
const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
if (guard) return guard;
const cfg = state.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.stateData || {}).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,
reload: () => load(state),
}),
),
);
});
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',
reload: () => load(state),
}),
),
));
});
});
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: state.activeZones, _s: state }) }, 'Add Forward'),
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
rows: fwRows,
emptyText: 'No port forwarding rules',
wrapCard: false,
}),
]}),
];
},
});