ws: migrate push stream to data streaming

- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
This commit is contained in:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+4 -4
View File
@@ -239,8 +239,8 @@ export function openBackendModal(state, backend) {
if (res.ok) {
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
closeModal();
await modelFetch('backends');
modelFetch('nginx');
await modelFetch('backends'); // not state-store-backed — explicit fetch
// No nginx modelFetch — daemon broadcasts nginx via WS delta.
} else {
toast(res.error || 'Failed', 'error');
}
@@ -290,7 +290,7 @@ export default definePage({
deleteKey=${name}
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
onComplete=${() => modelFetch('backends')}
label="Delete" />`
}
</td>
@@ -303,7 +303,7 @@ export default definePage({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
refresh: ['backends', 'nginx'],
onSuccess: () => modelFetch('backends'),
}),
);
+5 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js';
function _accountCard(account) {
@@ -56,7 +56,7 @@ function registerAccountModal() {
if (!resp.ok) throw resp.error || 'Registration failed';
toast('ACME account registered', 'success');
closeModal();
modelFetch('acme');
// No modelFetch — WS delta updates the acme model.
}),
},
],
@@ -100,7 +100,7 @@ function settingsModal(account) {
if (!resp.ok) throw resp.error || 'Failed';
toast('Email updated', 'success');
closeModal(idx);
modelFetch('acme');
// No modelFetch — WS delta updates the acme model.
}),
);
@@ -111,7 +111,7 @@ function settingsModal(account) {
if (!resp.ok) throw resp.error || 'Failed';
toast('Account deactivated', 'success');
closeModal(idx);
modelFetch('acme');
// No modelFetch — WS delta updates the acme model.
}),
);
});
@@ -262,7 +262,7 @@ async function pollCertIssue(rid) {
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued for ' + (d.domain || rid), 'success');
modelFetch('acme');
// No modelFetch — WS delta updates the acme model.
},
onError: (d) => {
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
@@ -299,7 +299,6 @@ export default definePage({
removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess="Certificate removed"
removeRefresh="acme"
deleteKey=${c.domain} />
</tr>`;
});
+20 -20
View File
@@ -9,13 +9,13 @@ export default definePage({
wireguard: getModel('wireguard'),
acme: getModel('acme'),
nginx: getModel('nginx'),
status: getModel('status'),
system: getModel('system'),
};
},
render(state) {
const guard = renderGuardMulti('Dashboard', 'System overview',
state.firewall, state.network, state.dnsmasq, state.wireguard,
state.acme, state.nginx, state.status);
state.acme, state.nginx, state.system);
if (guard) return guard;
// Extract data
@@ -33,19 +33,22 @@ export default definePage({
const allCerts = state.acme.data?.certs || [];
const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30));
// System metrics from status model
const sysMetrics = state.status.data?.metrics || {};
const sysLoad = sysMetrics.load || {};
const sysMem = sysMetrics.memory || {};
const sysSwap = sysMetrics.swap || {};
const sysTraffic = sysMetrics.traffic || {};
// System metrics from the system model
const sysLoad = state.system.data?.load || {};
const sysMem = state.system.data?.memory || {};
const sysSwap = state.system.data?.swap || {};
const sysTraffic = state.system.data?.traffic || {};
// Pending changes
const pend = state.status.data?.pending || {};
const totalChanges = pend.total_changes || 0;
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k =>
k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes)
);
// Pending changes — derived from the config-backed subsystem models.
// Firewall uses pending.needs_apply (config_pending() output); all
// others use status.pending_changes. `system` is metrics-only.
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => {
const model = getModel(k === 'networkd' ? 'network' : k);
const d = model.data || {};
if (k === 'firewall') return !!d.pending?.needs_apply;
return !!d.status?.pending_changes;
});
const totalChanges = pendKeys.length;
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
// Build merged interface list
@@ -53,13 +56,10 @@ export default definePage({
const ifaces = allNames.map(name => {
const fw = fwIfaces.find(f => f.name === name);
const netEntry = netIfaces[name] || {};
// /api/network/interfaces returns {config, runtime} per interface —
// state fields (state, addresses, mac) live under runtime.
const runtime = netEntry.runtime || {};
const traffic = sysTraffic[name] || {};
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
const addrs = runtime.addresses || [];
const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s));
const addrs = netEntry.addresses || [];
const isUp = ['routable', 'degraded', 'carrier'].some(s => (netEntry.state || '').startsWith(s));
return {
name,
mac: fw?.mac || null,
@@ -91,7 +91,7 @@ export default definePage({
<div class="card-body">
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
successMsg="All changes applied" refresh="status"
successMsg="All changes applied"
cls="btn btn-sm btn-primary" />
</div>
</div>`
+7 -14
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
function makeAddRange(activeZones, interfaces) {
const opts = [
@@ -70,7 +70,6 @@ function makeAddRange(activeZones, interfaces) {
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
successMsg: 'Range added',
},
refresh: 'dnsmasq',
});
}
@@ -91,7 +90,6 @@ const addLease = QuickModal({
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
successMsg: 'Lease added',
},
refresh: 'dnsmasq',
});
const addDns = QuickModal({
@@ -106,7 +104,6 @@ const addDns = QuickModal({
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
successMsg: 'DNS record added',
},
refresh: 'dnsmasq',
});
export default definePage({
@@ -140,8 +137,7 @@ export default definePage({
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed"
refresh="dnsmasq" />
success="Range removed" />
</td>
</tr>`);
@@ -154,8 +150,7 @@ export default definePage({
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
deleteKey=${l.mac}
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed"
refresh="dnsmasq" />
success="Lease removed" />
</td>
</tr>`);
@@ -167,8 +162,7 @@ export default definePage({
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
deleteKey=${rec.name || 'unnamed'}
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed"
refresh="dnsmasq" />
success="Record removed" />
</td>
</tr>`);
@@ -179,7 +173,7 @@ export default definePage({
});
if (res.ok) {
toast('DNS domain updated', 'success');
modelFetch('dnsmasq');
// No modelFetch — WS delta updates the dnsmasq model.
} else {
toast(res.error || 'Failed to update', 'error');
}
@@ -198,7 +192,7 @@ export default definePage({
const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.active_zones, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
(() => {
@@ -213,10 +207,9 @@ export default definePage({
let msg = 'dnsmasq applied';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
modelFetch('dnsmasq');
// No modelFetch — WS delta updates the dnsmasq model.
} else {
toast(res.error || 'Apply failed', 'error');
}
+11 -10
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -7,8 +7,7 @@ async function changeZone(name, zone, state) {
});
if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success');
modelFetch('firewall');
modelFetch('network');
// No modelFetch — daemon broadcasts both subsystems via WS delta.
} else {
toast(r.error || 'Failed', 'error');
}
@@ -33,7 +32,6 @@ const cfgModalFn = QuickModal({
}),
successMsg: 'Config saved',
},
refresh: ['firewall', 'network'],
});
export default definePage({
@@ -49,8 +47,11 @@ export default definePage({
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = fwZones.available || [];
const activeZones = fwZones.active || {};
const zones = Object.keys(fwZones);
const activeZones = state.firewall.data?.active_zones || {};
// Per-interface config lives in the top-level config (flat runtime
// entries carry no per-interface config).
const netCfgIfaces = state.network.data?.config?.interfaces || {};
// Loopback has no networkd config to manage — show real NICs only.
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
@@ -63,11 +64,11 @@ export default definePage({
}
return {
name,
mac: entry?.runtime?.mac || null,
ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])],
state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down',
mac: entry?.mac || null,
ips: [...(netCfgIfaces[name]?.addresses || []), ...(entry?.addresses || [])],
state: (entry?.state || '').startsWith('routable') || (entry?.state || '').startsWith('carrier') ? 'up' : 'down',
zone,
config: entry?.config || {},
config: netCfgIfaces[name] || {},
};
});
+4 -7
View File
@@ -1,4 +1,4 @@
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';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -21,7 +21,6 @@ const addFwd = QuickModal({
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
successMsg: 'Forward rule added',
},
refresh: 'firewall',
});
export default definePage({
@@ -37,7 +36,7 @@ export default definePage({
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.firewall.data?.state || {}).interfaces || [];
const sIface = state.firewall.data?.interfaces || [];
// With nftables, masquerade is propagated to the public zone at runtime for
// POSTROUTING to work. The config-side masquerade flag indicates which
// zones source NAT traffic (LAN / internal), not where traffic exits (WAN).
@@ -92,8 +91,7 @@ export default definePage({
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" />
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} />
</td>
</tr>`;
});
@@ -115,8 +113,7 @@ export default definePage({
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
deleteKey=${zone + '/' + port + '/' + proto}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
success="Rule removed" />
</td>
</tr>`);
});
+2 -5
View File
@@ -104,7 +104,6 @@ function addDomain(state, preselectedBackend) {
!b.backend ? 'Backend is required' : null,
successMsg: 'Domain added',
},
refresh: ['nginx', 'acme'],
postRender: (inner) => {
if (preselectedBackend) {
const backendSelect = inner.querySelector('#p-backend');
@@ -161,7 +160,6 @@ function editDomain(d, state) {
validate: (b) => !b.cert ? 'Cert is required' : null,
successMsg: 'Domain updated',
},
refresh: ['nginx', 'acme'],
postRender: (inner) => {
const certSelect = inner.querySelector('#pe-cert');
if (certSelect) certSelect.value = selectedCert;
@@ -213,7 +211,6 @@ function domainRow(domainName, domainPaths, state) {
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
removeMessage=${'Remove ' + enc(domainName) + '?'}
removeSuccess="Domain removed"
removeRefresh=["nginx", "acme"]
removeLabel="Delete" />
</td>
</tr>`;
@@ -231,7 +228,7 @@ function backendSection(section, state) {
deleteKey=${backendName}
message=${'Remove backend ' + enc(backendName) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
onComplete=${() => modelFetch('backends')}
label="Delete" />`);
}
}
@@ -267,7 +264,7 @@ export default definePage({
const sectionVNodes = sections.map(s => backendSection(s, state));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
);
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
+3 -5
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -12,7 +12,6 @@ const addRule = QuickModal({
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
successMsg: 'Rule added',
},
refresh: 'firewall',
});
export default definePage({
@@ -26,7 +25,7 @@ export default definePage({
if (guard) return guard;
const cfg = state.firewall.data?.config || {};
const zones = state.firewall.data?.zones?.available || [];
const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {};
const zoneRules = {};
Object.entries(zoneData).forEach(([zname, zcfg]) => {
@@ -46,8 +45,7 @@ export default definePage({
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
deleteKey=${zone + '-' + (ruleId || i)}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed"
refresh="firewall" />
success="Rule removed" />
</td>
</tr>`;
});
+8 -13
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
/* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() {
@@ -76,7 +76,6 @@ const addPeer = QuickModal({
!b.access_class ? 'Access Class is required' : null,
successMsg: 'Peer added',
},
refresh: 'wireguard',
postRender: (inner, data) => {
const presetEl = document.getElementById('wg-allowed-preset');
if (presetEl) {
@@ -256,7 +255,7 @@ function settingsModal(wireguardData, state) {
if (!resp.ok) throw resp.error || 'Failed to save';
toast('Settings saved', 'success');
closeModal(idx);
modelFetch('wireguard');
// No modelFetch — WS delta updates the wireguard model.
}),
},
],
@@ -291,7 +290,6 @@ const addClass = QuickModal({
!b.listen_port ? 'Listen port is required' : null,
successMsg: 'Class added',
},
refresh: 'wireguard',
postRender: (inner) => {
const sel = document.getElementById('wc-lan');
if (sel) {
@@ -330,7 +328,7 @@ function editClassModal(key, cls, peerCount) {
if (!resp.ok) throw resp.error || 'Failed';
toast('Class updated', 'success');
closeModal(idx);
modelFetch('wireguard');
// No modelFetch — WS delta updates the wireguard model.
}),
},
],
@@ -374,17 +372,17 @@ function renderAccessClasses(config, status) {
<td>
${!hasKeys
? html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Keys"
cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} refresh="wireguard" />`
cls="btn btn-sm btn-warning" successMsg=${'Keys generated for ' + esc(k)} />`
: ''}
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
cls="btn btn-sm btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} refresh="wireguard" />
successMsg=${isUp ? 'Tunnel stopped' : 'Tunnel started'} />
${(pCount > 0)
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
: html`<${ConfirmDelete} url=${'/api/wireguard/classes'} body=${{ key: k }}
deleteKey=${k} message=${'Delete access class ' + esc(k) + '?'} success="Class deleted"
refresh="wireguard" label="Delete" />`}
label="Delete" />`}
</td>
</tr>`;
});
@@ -469,7 +467,6 @@ export default definePage({
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed"
removeRefresh="wireguard"
deleteKey=${p.name} />
</tr>`;
});
@@ -492,12 +489,12 @@ export default definePage({
<div class="d-flex justify-content-between">
<span>Subnet: ${esc(v.subnet || '-')}</span>
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} refresh="wireguard" />`}</span>
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<${ActionButton} url=${'/api/wireguard/classes/keys/' + enc(k)} label="Generate" cls="btn btn-xs btn-warning" successMsg=${'Keys generated'} />`}</span>
</div>
<div style="margin-top: 4px;">
<${ActionButton} url=${'/api/wireguard/classes/' + enc(k) + '/' + (isUp ? 'down' : 'up')}
cls="btn btn-xs btn-outline" labelOn="Stop" labelOff="Start" condition=${isUp}
successMsg=${isUp ? 'Stopped' : 'Started'} refresh="wireguard" />
successMsg=${isUp ? 'Stopped' : 'Started'} />
</div>
</div>
</div>`;
@@ -512,12 +509,10 @@ export default definePage({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
refresh: 'wireguard',
}),
ApplyConfirm({
pending: st.pending_changes || false,
successMsg: 'WireGuard applied',
refresh: ['wireguard', 'firewall'],
}),
);
+14 -7
View File
@@ -12,7 +12,6 @@ const addZone = QuickModal({
validate: (b) => !b.name ? 'Zone name required' : null,
successMsg: 'Zone created',
},
refresh: 'firewall',
});
export default definePage({
@@ -25,8 +24,8 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const zones = state.firewall.data?.zones?.available || [];
const activeZones = state.firewall.data?.zones?.active || {};
const zones = Object.keys(state.firewall.data?.zones || {});
const activeZones = state.firewall.data?.active_zones || {};
const zoneDetails = {};
for (const name of zones) {
const activeIfaces = activeZones[name];
@@ -67,24 +66,32 @@ export default definePage({
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
refresh: 'firewall',
})()}>Interfaces</button>
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.services || [],
options: state.firewall.data?.available_services || [],
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
refresh: 'firewall',
confirm: (b) => {
const svcs = (b && b.services) || [];
const isDefault = name === state.firewall.data?.default_zone;
if (isDefault && !svcs.includes('https') && !svcs.includes('ssh')) {
return 'This removes both HTTPS and SSH from the default zone ' +
"'" + name + "'. Management access and remote recovery " +
'through this zone will be blocked until you reach the ' +
'appliance via console or another route.\n\nRemove them anyway?';
}
return null;
},
})()}>Services</button>
<${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)}
deleteKey=${name}
message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'}
refresh="firewall"
label="Delete" />
</div>
</div>`;