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:
+51
-116
@@ -1,4 +1,5 @@
|
||||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||||
import { SUBSYSTEMS } from '/static/hoover/schema.js';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js';
|
||||
import InterfacesPage from '/static/pages/interfaces.js';
|
||||
@@ -44,66 +45,47 @@ function getNav() {
|
||||
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
||||
modelRegister('auth', createAuthModel());
|
||||
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
fetch: async () => {
|
||||
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
||||
apiFetch('/api/firewall/config'),
|
||||
apiFetch('/api/firewall/zones'),
|
||||
apiFetch('/api/firewall/services'),
|
||||
apiFetch('/api/firewall/interfaces'),
|
||||
apiFetch('/api/firewall/state'),
|
||||
]);
|
||||
const result = {};
|
||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
||||
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
||||
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
||||
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
||||
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
||||
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
||||
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('network', {
|
||||
subsystem: 'networkd',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/network/interfaces');
|
||||
/* ── State-backed models ──────────────────────────────────── */
|
||||
/* All state-backed models stream over the WS (snapshot on connect,
|
||||
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
|
||||
* POST /api/status/refresh with a subsystem filter and returns the
|
||||
* subsystem state verbatim — the exact shape the state store holds. */
|
||||
function _stateModelFetch(subsystem) {
|
||||
return async () => {
|
||||
const r = await apiFetch('/api/status/refresh', {
|
||||
method: 'POST',
|
||||
body: { subsystems: [subsystem] },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data || { interfaces: {} };
|
||||
},
|
||||
});
|
||||
const payload = r.data?.[subsystem];
|
||||
// Collector failure: the daemon returns null for that subsystem.
|
||||
// Throw instead of returning {} so modelFetch keeps the current
|
||||
// data (schema defaults) and sets model.error rather than
|
||||
// clobbering it with an empty object.
|
||||
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
modelRegister('dnsmasq', {
|
||||
subsystem: 'dnsmasq',
|
||||
fetch: async () => {
|
||||
const [cfg, status, leases] = await Promise.allSettled([
|
||||
apiFetch('/api/dhcp/config'),
|
||||
apiFetch('/api/dhcp/status'),
|
||||
apiFetch('/api/dhcp/leases'),
|
||||
]);
|
||||
const result = {};
|
||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
||||
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
||||
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
||||
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
||||
return result;
|
||||
},
|
||||
});
|
||||
// Each maps to one subsystem in the state store. Model name may differ
|
||||
// from subsystem name (e.g. `network` → `networkd`).
|
||||
const STATE_MODELS = [
|
||||
{ name: 'firewall', subsystem: 'firewall' },
|
||||
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||
{ name: 'nginx', subsystem: 'nginx' },
|
||||
{ name: 'acme', subsystem: 'acme' },
|
||||
{ name: 'wireguard', subsystem: 'wireguard' },
|
||||
{ name: 'network', subsystem: 'networkd' },
|
||||
{ name: 'system', subsystem: 'system' },
|
||||
];
|
||||
|
||||
modelRegister('nginx', {
|
||||
subsystem: 'nginx',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/proxy/domains');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { domains: r.data || [] };
|
||||
},
|
||||
});
|
||||
for (const { name, subsystem } of STATE_MODELS) {
|
||||
modelRegister(name, {
|
||||
subsystem,
|
||||
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||
fetch: _stateModelFetch(subsystem),
|
||||
});
|
||||
}
|
||||
|
||||
modelRegister('backends', {
|
||||
subsystem: 'nginx',
|
||||
@@ -114,44 +96,6 @@ modelRegister('backends', {
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('acme', {
|
||||
subsystem: 'acme',
|
||||
fetch: async () => {
|
||||
const [listR, acctR] = await Promise.allSettled([
|
||||
apiFetch('/api/certs/list'),
|
||||
apiFetch('/api/certs/account'),
|
||||
]);
|
||||
const result = {};
|
||||
if (listR.status === 'fulfilled' && listR.value.ok) {
|
||||
result.certs = listR.value.data || [];
|
||||
} else if (listR.status === 'rejected' || !listR.value.ok) {
|
||||
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
|
||||
}
|
||||
if (acctR.status === 'fulfilled' && acctR.value.ok) {
|
||||
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('wireguard', {
|
||||
subsystem: 'wireguard',
|
||||
fetch: async () => {
|
||||
const [stR, pR, cfgR] = await Promise.allSettled([
|
||||
apiFetch('/api/wireguard/status'),
|
||||
apiFetch('/api/wireguard/peers'),
|
||||
apiFetch('/api/wireguard/config'),
|
||||
]);
|
||||
const result = {};
|
||||
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
||||
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
||||
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
||||
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
||||
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const LOG_TABS = {
|
||||
journal: '/api/logs/journal',
|
||||
'nginx-access': '/api/logs/nginx/access',
|
||||
@@ -172,29 +116,20 @@ modelRegister('logs', {
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('status', {
|
||||
subsystem: '*',
|
||||
fetch: async () => {
|
||||
const [pendingR, metricsR] = await Promise.allSettled([
|
||||
apiFetch('/api/status/pending'),
|
||||
apiFetch('/api/status/system-metrics'),
|
||||
]);
|
||||
const result = {};
|
||||
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
|
||||
result.pending = pendingR.value.data || {};
|
||||
}
|
||||
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
|
||||
result.metrics = metricsR.value.data || {};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Initial fetch (after auth check) ───────────────────────── */
|
||||
function fetchInitialData() {
|
||||
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
||||
modelFetch(name);
|
||||
// State-backed models: first data arrives via the WS snapshot.
|
||||
// If WS hasn't delivered data within 3s, fall back to HTTP.
|
||||
for (const { name } of STATE_MODELS) {
|
||||
setTimeout(() => {
|
||||
const model = getModel(name);
|
||||
if (model.loading) { // snapshot (or a prior fetch) hasn't completed
|
||||
modelFetch(name);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
// Non-state models fetch immediately
|
||||
modelFetch('backends');
|
||||
modelFetch('logs', 'journal');
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,11 @@ export function formAction(fn) {
|
||||
* @param {string} [opts.method] - HTTP method (default: 'POST')
|
||||
* @param {function} [opts.body] - () => object, body builder
|
||||
* @param {function} [opts.validate] - (body) => string|null, validation function
|
||||
* @param {function} [opts.confirm] - (body) => string|null; if a message is
|
||||
* returned, a native confirm() dialog gates
|
||||
* the submit; on approval the body gains
|
||||
* force=true (server-side guard override)
|
||||
* @param {string} [opts.successMsg] - Success toast message
|
||||
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||
* @returns {object[]} Array of action descriptors
|
||||
*/
|
||||
@@ -279,8 +282,8 @@ export function apiSubmit(opts) {
|
||||
method = 'POST',
|
||||
body,
|
||||
validate,
|
||||
confirm,
|
||||
successMsg = 'Saved',
|
||||
refresh,
|
||||
submitText = 'Submit',
|
||||
closeModal,
|
||||
} = opts;
|
||||
@@ -300,6 +303,13 @@ export function apiSubmit(opts) {
|
||||
const err = validate(b);
|
||||
if (err) { toast(err, 'error'); return; }
|
||||
}
|
||||
if (confirm) {
|
||||
const msg = confirm(b);
|
||||
if (msg) {
|
||||
if (!window.confirm(msg)) return;
|
||||
b.force = true;
|
||||
}
|
||||
}
|
||||
refreshModals();
|
||||
const res = await apiFetch(url, { method, body: b });
|
||||
if (res.ok) {
|
||||
@@ -307,14 +317,10 @@ export function apiSubmit(opts) {
|
||||
let msg = successMsg;
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
toast(msg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (refresh) {
|
||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||
await Promise.all(models.map(m => modelFetch(m)));
|
||||
}
|
||||
// No modelFetch — WS delta updates all affected subsystems.
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { h } from '../vdom.js';
|
||||
import { html } from '../html.js';
|
||||
import { reactive } from '../reactivity.js';
|
||||
import { apiFetch, toast } from '../api.js';
|
||||
import { modelFetch } from '../model.js';
|
||||
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
|
||||
|
||||
export const SUBSYSTEM_LIST = [
|
||||
@@ -56,9 +55,10 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
|
||||
}
|
||||
|
||||
/**
|
||||
* POST apply-all, toast result, close modal, refresh models.
|
||||
* POST apply-all, toast result, close modal. State-store models update from
|
||||
* the daemon's WS delta — no explicit refresh.
|
||||
*/
|
||||
async function doApply(successMsg, refreshTargets) {
|
||||
async function doApply(successMsg) {
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
@@ -66,10 +66,7 @@ async function doApply(successMsg, refreshTargets) {
|
||||
if (resp.ok) {
|
||||
toast(successMsg, 'success');
|
||||
closeModal();
|
||||
if (refreshTargets) {
|
||||
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
// No modelFetch — WS delta updates all affected subsystems.
|
||||
} else {
|
||||
toast(resp.error || 'Apply failed', 'error');
|
||||
}
|
||||
@@ -82,7 +79,7 @@ async function doApply(successMsg, refreshTargets) {
|
||||
/**
|
||||
* Fetch pending state, then open the confirmation modal.
|
||||
*/
|
||||
async function openApplyModal(successMsg, refreshTargets) {
|
||||
async function openApplyModal(successMsg) {
|
||||
const pendingResp = await apiFetch('/api/status/pending');
|
||||
if (!pendingResp.ok) {
|
||||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||||
@@ -109,7 +106,7 @@ async function openApplyModal(successMsg, refreshTargets) {
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||||
<div class="modal-body">${rows}</div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, refreshTargets)}">Apply All</button></div>
|
||||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
|
||||
</div>`);
|
||||
});
|
||||
}
|
||||
@@ -123,7 +120,7 @@ async function openApplyModal(successMsg, refreshTargets) {
|
||||
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
|
||||
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
*/
|
||||
export function ApplyConfirm(props = {}) {
|
||||
const label = props.label || 'Apply';
|
||||
@@ -139,7 +136,7 @@ export function ApplyConfirm(props = {}) {
|
||||
toast(successMsg || 'All synced', 'info');
|
||||
return;
|
||||
}
|
||||
openApplyModal(successMsg, props.refresh);
|
||||
openApplyModal(successMsg);
|
||||
},
|
||||
}, props.pending ? label : syncedLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { h } from '../vdom.js';
|
||||
import { esc } from '../helpers.js';
|
||||
import { apiFetch, toast } from '../api.js';
|
||||
import { modelFetch } from '../model.js';
|
||||
import { requestUpdate } from '../reactivity.js';
|
||||
|
||||
const _actionPending = new Map();
|
||||
@@ -69,16 +68,16 @@ export function Card(props = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
|
||||
* When the response includes a ``synced`` array (list of subsystem names
|
||||
* that were auto-updated), shows a secondary toast and refreshes those
|
||||
* models.
|
||||
* A Remove button that confirms, deletes via API, and toasts. State-store
|
||||
* models update from the daemon's WS delta — no explicit refresh. When the
|
||||
* response includes a ``synced`` array (list of subsystem names that were
|
||||
* auto-updated), appends them to the success toast.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API DELETE URL
|
||||
* @param {string} props.message - Confirmation prompt text
|
||||
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
|
||||
@@ -104,31 +103,20 @@ export function ConfirmDelete(props = {}) {
|
||||
let msg = props.success || 'Removed';
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
toast(msg, 'success');
|
||||
|
||||
if (props.deleteKey) {
|
||||
_deleting.add(props.deleteKey);
|
||||
// The WS delta (~50ms) removes the deleted item from
|
||||
// model.data and re-renders the row away. This timeout
|
||||
// purges _deleting if the delta is slow or the row was
|
||||
// already unmounted.
|
||||
setTimeout(() => _deleting.delete(props.deleteKey), 2000);
|
||||
}
|
||||
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
const promises = names.map(n => modelFetch(n));
|
||||
|
||||
if (props.deleteKey && promises.length) {
|
||||
Promise.all(promises).finally(() => {
|
||||
_deleting.delete(props.deleteKey);
|
||||
if (props.onComplete) props.onComplete();
|
||||
});
|
||||
}
|
||||
} else if (props.deleteKey) {
|
||||
_deleting.delete(props.deleteKey);
|
||||
}
|
||||
|
||||
if (props.onComplete && !props.refresh) {
|
||||
props.onComplete();
|
||||
}
|
||||
if (props.onComplete) props.onComplete();
|
||||
// No modelFetch — WS delta updates state store models.
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
@@ -141,11 +129,11 @@ export function ConfirmDelete(props = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* An action button that POSTs to an API endpoint, toasts on result,
|
||||
* and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||
* When the response includes a ``synced`` array (list of subsystem names
|
||||
* that were auto-updated), shows a secondary toast and refreshes those
|
||||
* models.
|
||||
* An action button that POSTs to an API endpoint and toasts on result.
|
||||
* Supports toggle labels for on/off buttons. State-store models update from
|
||||
* the daemon's WS delta — no explicit refresh. When the response includes a
|
||||
* ``synced`` array (list of subsystem names that were auto-updated), appends
|
||||
* them to the success toast.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API URL
|
||||
@@ -157,7 +145,8 @@ export function ConfirmDelete(props = {}) {
|
||||
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {function} [props.onSuccess] - Callback after the success toast
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||
* @param {boolean} [props.disabled] - Disabled state
|
||||
*/
|
||||
@@ -187,13 +176,10 @@ export function ActionButton(props = {}) {
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
if (props.onSuccess) props.onSuccess();
|
||||
// No modelFetch — WS delta updates state store models.
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
@@ -324,7 +310,7 @@ export function ServiceStatus(props = {}) {
|
||||
* @param {string} props.removeUrl - API DELETE URL
|
||||
* @param {string} props.removeMessage - Confirmation prompt text
|
||||
* @param {string} [props.removeSuccess] - Success toast message
|
||||
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
|
||||
* @param {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
||||
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
||||
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
||||
|
||||
@@ -217,7 +217,8 @@ export function formModal(inner, title, fields, actions) {
|
||||
* @param {string[]} props.selected - Currently selected values
|
||||
* @param {string} props.fieldKey - JSON key for the field
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
|
||||
* @returns {function} () => void, calls openModal
|
||||
*/
|
||||
export function MultiSelectModal(props = {}) {
|
||||
@@ -242,6 +243,7 @@ export function MultiSelectModal(props = {}) {
|
||||
}),
|
||||
successMsg: props.successMsg || 'Updated',
|
||||
refresh: props.refresh,
|
||||
confirm: props.confirm,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
],
|
||||
@@ -263,7 +265,7 @@ export function MultiSelectModal(props = {}) {
|
||||
* @param {function} [props.submit.body] - (data) => object
|
||||
* @param {function} [props.submit.validate] - (body) => string|null
|
||||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||||
* @returns {function} (data) => void, calls openModal
|
||||
|
||||
@@ -23,7 +23,7 @@ export { definePage, hComp } from './component.js';
|
||||
export { createRouter, Link } from './router.js';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage, disconnect } from './websocket.js';
|
||||
export { connect, disconnect } from './websocket.js';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction }
|
||||
@@ -38,7 +38,7 @@ export { createAuthModel, getAuthToken, isAuthenticated, refreshAuth, getAuthDat
|
||||
from './auth_model.js';
|
||||
|
||||
/* ── Model ───────────────────────────────────────────────────── */
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js';
|
||||
export { modelRegister, getModel, modelFetch, modelSet, collectLoadingModels } from './model.js';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js';
|
||||
|
||||
@@ -135,6 +135,23 @@ export function modelFetch(name, signalOrParam, signal) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model data from WebSocket. Bypasses fetch cycle, no refreshing flag.
|
||||
* Directly assigns to reactive proxy → triggers re-render.
|
||||
* Clears loading unconditionally on arrival of real data.
|
||||
*
|
||||
* @param {string} name - Model name
|
||||
* @param {any} data - State payload from the WS snapshot/delta
|
||||
*/
|
||||
export function modelSet(name, data) {
|
||||
const entry = _models.get(name);
|
||||
if (!entry) return;
|
||||
const model = entry.model;
|
||||
if (model.loading) model.loading = false; // real data ends the initial load
|
||||
model.data = data;
|
||||
model.error = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all models whose subsystem topic matches the given topic.
|
||||
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Hoover — schema.js
|
||||
*
|
||||
* State-store schema defaults — one module per subsystem.
|
||||
*
|
||||
* `defaults` initializes model.data so pages don't need null guards
|
||||
* during the first render (before the WS snapshot or HTTP fallback
|
||||
* delivers real data). The shapes match the daemon state store
|
||||
* (docs/state-model.md); the WebSocket streams these exact shapes.
|
||||
*
|
||||
* `POLL_INTERVALS` is client-side awareness of the daemon's expected
|
||||
* refresh cadence per subsystem (for "last updated" displays).
|
||||
*/
|
||||
|
||||
export const SUBSYSTEMS = {
|
||||
firewall: {
|
||||
defaults: {
|
||||
config: {},
|
||||
active_zones: {},
|
||||
interfaces: [],
|
||||
available_services: [],
|
||||
zones: {},
|
||||
rich_rules: {},
|
||||
pending: {},
|
||||
},
|
||||
},
|
||||
dnsmasq: {
|
||||
defaults: {
|
||||
config: {},
|
||||
status: { service_active: false, config_file_exists: false, active_leases: 0, pending_changes: false },
|
||||
leases: [],
|
||||
},
|
||||
},
|
||||
nginx: {
|
||||
defaults: {
|
||||
config: {},
|
||||
domains: [],
|
||||
status: { pending_changes: false },
|
||||
},
|
||||
},
|
||||
acme: {
|
||||
defaults: {
|
||||
certs: [],
|
||||
email: '',
|
||||
account: { registered: false, email: '', ca: '', key_length: null },
|
||||
},
|
||||
},
|
||||
wireguard: {
|
||||
defaults: {
|
||||
config: {},
|
||||
status: { up: false, interface: {}, peers: [], classes: {}, pending_changes: false },
|
||||
peers: [],
|
||||
},
|
||||
},
|
||||
networkd: {
|
||||
defaults: {
|
||||
config: {},
|
||||
interfaces: {},
|
||||
status: { pending_changes: false },
|
||||
},
|
||||
},
|
||||
system: {
|
||||
defaults: {
|
||||
load: { load1: 0, load5: 0, load15: 0 },
|
||||
memory: { total: 0, available: 0, used: 0, used_pct: 0 },
|
||||
swap: { total: 0, used: 0, used_pct: 0 },
|
||||
traffic: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const POLL_INTERVALS = {
|
||||
firewall: 30,
|
||||
wireguard: 10,
|
||||
dnsmasq: 10,
|
||||
networkd: 10,
|
||||
system: 1, // Phase 5: 30 → 1 (real-time metrics)
|
||||
nginx: 60, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
|
||||
acme: 300, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Hoover — websocket.js
|
||||
*
|
||||
* WebSocket connection manager with auto-reconnect. WS messages are routed
|
||||
* to model-based refresh and direct onMessage handlers.
|
||||
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
||||
* WebSocket connection manager with auto-reconnect. WS messages carry
|
||||
* state data directly: a full snapshot on connect, then per-subsystem
|
||||
* deltas. handleMessage patches the matching models in place via
|
||||
* modelSet — no HTTP round-trip for auto-refresh.
|
||||
*
|
||||
* The JWT is read from the auth model (single source of truth). After 3
|
||||
* failed close attempts a token refresh is triggered through the auth
|
||||
@@ -19,9 +20,21 @@
|
||||
* reloaded; the UI keeps working via the REST API.
|
||||
*/
|
||||
|
||||
import { refreshByTopic } from './model.js';
|
||||
import { modelSet } from './model.js';
|
||||
import { refreshAuth, getAuthToken } from './auth_model.js';
|
||||
|
||||
// Maps subsystem name → registered model name.
|
||||
// Most subsystems use the same name. `networkd` maps to `network`.
|
||||
const _SUBSYSTEM_TO_MODEL = {
|
||||
firewall: 'firewall',
|
||||
dnsmasq: 'dnsmasq',
|
||||
nginx: 'nginx',
|
||||
acme: 'acme',
|
||||
wireguard: 'wireguard',
|
||||
networkd: 'network',
|
||||
system: 'system',
|
||||
};
|
||||
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
let _wsFailCount = 0;
|
||||
@@ -32,9 +45,6 @@ let _wsRefreshStreak = 0;
|
||||
let _wsGivingUp = false;
|
||||
let _wsClosingHandled = false;
|
||||
|
||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL from the current origin. nginx proxies /ws to
|
||||
* the daemon's WebSocket port.
|
||||
@@ -126,55 +136,41 @@ function _wsConnect() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an incoming WS message to model refresh and direct handlers.
|
||||
* Patch models in place from a data-carrying WS message.
|
||||
*
|
||||
* Expected message shapes:
|
||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
|
||||
* { type: 'notify', topic: 'firewall' }
|
||||
* { type: 'status', topic: 'firewall', … }
|
||||
* Expected message shapes (daemon → client):
|
||||
* { type: 'snapshot', data: {subsystem: state|null, …} } // on connect
|
||||
* { type: 'versions', subsystem: 'firewall', data: state } // structural change
|
||||
* { type: 'tick', subsystem: 'system', data: state } // volatile change
|
||||
*
|
||||
* The daemon only sends these three types after the WS push-stream
|
||||
* migration; unknown / retired types (refresh/notify/status, legacy
|
||||
* versions.updated, tick.subsystems) are ignored — no backward compat.
|
||||
*/
|
||||
function handleMessage(msg) {
|
||||
const topics = [];
|
||||
|
||||
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
|
||||
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
|
||||
} else if (msg.type === 'notify') {
|
||||
topics.push(msg.topic);
|
||||
} else if (msg.type === 'status') {
|
||||
topics.push(msg.topic || '*');
|
||||
}
|
||||
|
||||
// Refresh models for each topic
|
||||
for (const topic of topics) {
|
||||
refreshByTopic(topic);
|
||||
}
|
||||
|
||||
// Notify direct onMessage handlers
|
||||
for (const h of _directHandlers) {
|
||||
if (h.unsubscribed) continue;
|
||||
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
||||
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
|
||||
if (msg.type === 'snapshot') {
|
||||
// Full state on connect — set all models (null = collector failed, skip)
|
||||
for (const [subsystem, data] of Object.entries(msg.data)) {
|
||||
if (data !== null) {
|
||||
const modelName = _SUBSYSTEM_TO_MODEL[subsystem] || subsystem;
|
||||
modelSet(modelName, data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
||||
* Handler receives the raw parsed message when a matching topic arrives.
|
||||
* @param {string|string[]} topics
|
||||
* @param {function} handler
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
export function onMessage(topics, handler) {
|
||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
||||
const entry = { topics: tArray, handler, unsubscribed: false };
|
||||
_directHandlers.push(entry);
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
const idx = _directHandlers.indexOf(entry);
|
||||
if (idx !== -1) _directHandlers.splice(idx, 1);
|
||||
};
|
||||
if ((msg.type === 'versions' || msg.type === 'tick')
|
||||
&& msg.subsystem && msg.data != null) {
|
||||
// Delta for one subsystem — patch the corresponding model.
|
||||
// Guard is `!= null` (not `!== undefined`): a null payload means the
|
||||
// collector failed — never overwrite good model data (defense in depth;
|
||||
// the daemon skips null broadcasts).
|
||||
const modelName = _SUBSYSTEM_TO_MODEL[msg.subsystem] || msg.subsystem;
|
||||
modelSet(modelName, msg.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else is unknown / retired — ignored.
|
||||
}
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
|
||||
@@ -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