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:
@@ -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'),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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] || {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>`);
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user