Files
vacuum-wall/webui/static/pages/rules.js
T
mteehan 89b64960f3 ui: amber pending-edit markers for unapplied config changes
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards
edited (saved to config) but not yet applied, consuming the pending
state the daemon already streams — status.pending_diff for hash
subsystems, firewall pending zone+type for firewalld. Visual language
is amber (.config-dirty + PendingDot), distinct from the red
.pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard
peers) on their container table. Wired into the backends, dhcp,
interfaces, nat, proxy, rules, wireguard, and zones pages; Card and
Table gain cls/title props. Covered by 27 node tests
(tests/test-dirty.js).
2026-09-01 20:17:15 +00:00

81 lines
3.4 KiB
JavaScript

import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addRule = QuickModal({
title: 'Add Rich Rule',
fields: (d) => [
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
],
submit: {
url: '/api/firewall/rich-rules',
body: () => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
successMsg: 'Rule added',
},
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {};
const zoneRules = {};
Object.entries(zoneData).forEach(([zname, zcfg]) => {
const rr = zcfg.rich_rules || [];
if (rr.length) zoneRules[zname] = rr;
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
const info = fwInfo(fw, zone, 'rich_rules');
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
return html`<tr key=${i}>
<td class="text-muted">${i + 1}</td>
<td class="mono-text td-fullwidth"><${MonoText} text=${ruleText} /></td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
deleteKey=${zone + '-' + (ruleId || i)}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed" />
</td>
</tr>`;
});
return Card({
header: info.dirty
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
: 'Zone: ' + esc(zone),
key: zone,
cls: info.class || undefined,
title: info.title || undefined,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: ruleRows,
emptyText: 'No rules',
wrapCard: false,
})],
});
});
return [
PageHeader({
title: 'Rules',
subtitle: 'Firewall rich rules',
actions: html`<button class="btn btn-primary"
onClick=${() => addRule({ zones })}>Add Rule</button>`,
}),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
];
},
});