5ba0f31767
- lib/state.py: per-subsystem collectors with versioned state store - daemon/server.py: state refresh on request, batch routing updates - webui/static/hoover/html.js: new html tag template helper via htm.js - webui/static/hoover/websocket.js: real-time state change notifications - webui/static/hoover/vdom.js: VDOM improvements for keyed diff - All frontend pages refactored to use html templates - Add tests for state management and polling - Update docs and AGENTS.md
139 lines
6.1 KiB
JavaScript
139 lines
6.1 KiB
JavaScript
import { html, 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) => html`<tr key=${'ii-' + iface.name}>
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
|
|
<strong>${iface.name}</strong>
|
|
</div>
|
|
</td>
|
|
<td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
|
|
<td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
|
|
<td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
|
|
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
|
|
</tr>`);
|
|
|
|
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
|
const masq = !!zcfg.masquerade;
|
|
return html`<tr key=${'m-' + zone}>
|
|
<td><strong>${zone}</strong></td>
|
|
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
|
|
<td>
|
|
<${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" />
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
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(html`<tr key=${'f-' + zone + '-' + i}>
|
|
<td><strong>${zone}</strong></td>
|
|
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
|
|
<td>${port}</td>
|
|
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
|
|
<td>${fwd['to-port'] || fwd.toport || '-'}</td>
|
|
<td>
|
|
<${ConfirmDelete}
|
|
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
|
|
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
|
|
success="Rule removed"
|
|
refresh="firewall" />
|
|
</td>
|
|
</tr>`);
|
|
});
|
|
});
|
|
|
|
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(
|
|
html`<button class="btn btn-sm btn-primary"
|
|
onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
|
|
),
|
|
Table({
|
|
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
|
rows: fwRows,
|
|
emptyText: 'No port forwarding rules',
|
|
wrapCard: false,
|
|
}),
|
|
]}),
|
|
];
|
|
},
|
|
});
|