ui: amber pending-edit markers for unapplied config changes
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards edited (saved to config) but not yet applied, consuming the pending state the daemon already streams — status.pending_diff for hash subsystems, firewall pending zone+type for firewalld. Visual language is amber (.config-dirty + PendingDot), distinct from the red .pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard peers) on their container table. Wired into the backends, dhcp, interfaces, nat, proxy, rules, wireguard, and zones pages; Card and Table gain cls/title props. Covered by 27 node tests (tests/test-dirty.js).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js';
|
||||
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
|
||||
import { _deleting } from '/static/hoover/components/data.js';
|
||||
|
||||
@@ -260,18 +260,22 @@ export default definePage({
|
||||
return {
|
||||
backends: getModel('backends'),
|
||||
dnsmasq: getModel('dnsmasq'),
|
||||
nginx: getModel('nginx'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
|
||||
if (guard) return guard;
|
||||
|
||||
const set = dirtySet(state.nginx.data?.status);
|
||||
const backends = state.backends.data || {};
|
||||
const entries = Object.entries(backends);
|
||||
|
||||
const rows = entries.map(([name, b]) =>
|
||||
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
const rows = entries.map(([name, b]) => {
|
||||
const info = dirtyInfo(set, 'backends.' + name);
|
||||
const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
|
||||
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(name)}</strong></td>
|
||||
<td>${esc(b.label || name)}</td>
|
||||
<td>${Object.keys(b.paths || {}).length}</td>
|
||||
<td>
|
||||
@@ -291,11 +295,11 @@ export default definePage({
|
||||
message=${'Remove backend ' + enc(name) + '?'}
|
||||
success="Backend removed"
|
||||
onComplete=${() => modelFetch('backends')}
|
||||
label="Delete" />`
|
||||
label="Delete" />`}
|
||||
}
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
|
||||
|
||||
+22
-11
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||
|
||||
function makeAddRange(activeZones, interfaces) {
|
||||
const opts = [
|
||||
@@ -118,6 +118,7 @@ export default definePage({
|
||||
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
|
||||
if (guard) return guard;
|
||||
|
||||
const set = dirtySet(state.dnsmasq.data?.status);
|
||||
const cfg = state.dnsmasq.data?.config || {};
|
||||
const dhcpCfg = cfg.dhcp || {};
|
||||
const dnsCfg = cfg.dns || {};
|
||||
@@ -126,8 +127,10 @@ export default definePage({
|
||||
const dnsRecords = dnsCfg.custom_records || [];
|
||||
const status = state.dnsmasq.data?.status || {};
|
||||
|
||||
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
|
||||
<td>${r.interface || '(global)'}</td>
|
||||
const rangesRows = ranges.map((r, i) => {
|
||||
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
|
||||
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'}</td>
|
||||
<td>${esc(r.start)}</td>
|
||||
<td>${esc(r.end)}</td>
|
||||
<td>${esc(r.lease_time || '12h')}</td>
|
||||
@@ -139,10 +142,13 @@ export default definePage({
|
||||
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
||||
success="Range removed" />
|
||||
</td>
|
||||
</tr>`);
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
|
||||
<td>${esc(l.mac)}</td>
|
||||
const leaseRows = staticLeases.map((l, i) => {
|
||||
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
|
||||
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}${esc(l.mac)}</td>
|
||||
<td>${esc(l.ip)}</td>
|
||||
<td>${l.hostname || '-'}</td>
|
||||
<td>
|
||||
@@ -152,10 +158,13 @@ export default definePage({
|
||||
message=${'Remove lease ' + l.mac + '?'}
|
||||
success="Lease removed" />
|
||||
</td>
|
||||
</tr>`);
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
|
||||
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
|
||||
const dnsRows = dnsRecords.map((rec, i) => {
|
||||
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
|
||||
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(rec.name || 'unnamed')}</strong></td>
|
||||
<td class="text-sm">${esc(rec.address || '-')}</td>
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
@@ -164,7 +173,8 @@ export default definePage({
|
||||
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
||||
success="Record removed" />
|
||||
</td>
|
||||
</tr>`);
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const _setDomain = async (domain) => {
|
||||
const res = await apiFetch('/api/dhcp/domain', {
|
||||
@@ -180,7 +190,8 @@ export default definePage({
|
||||
};
|
||||
|
||||
const currentDomain = dnsCfg.domain || null;
|
||||
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
|
||||
const domainInfo = dirtyInfo(set, 'dns.domain');
|
||||
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600;">Search Domain</label>
|
||||
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
|
||||
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
|
||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||
|
||||
async function changeZone(name, zone, state) {
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||
@@ -45,6 +45,7 @@ export default definePage({
|
||||
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
|
||||
if (guard) return guard;
|
||||
|
||||
const set = dirtySet(state.network.data?.status);
|
||||
const fwZones = state.firewall.data?.zones || {};
|
||||
const netData = state.network.data?.interfaces || {};
|
||||
const zones = Object.keys(fwZones);
|
||||
@@ -72,9 +73,10 @@ export default definePage({
|
||||
};
|
||||
});
|
||||
|
||||
const rows = ifaces.map(iface =>
|
||||
html`<tr key=${iface.name}>
|
||||
<td><strong>${iface.name}</strong></td>
|
||||
const rows = ifaces.map(iface => {
|
||||
const info = dirtyInfo(set, 'interfaces.' + iface.name);
|
||||
return html`<tr key=${iface.name} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${iface.name}</strong></td>
|
||||
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
|
||||
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
|
||||
<td><${StatusText} status=${iface.state} /></td>
|
||||
@@ -84,8 +86,8 @@ export default definePage({
|
||||
<button class="btn btn-sm btn-outline" style="margin-left:8px"
|
||||
onClick=${() => cfgModalFn(iface)}>Config</button>
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
|
||||
|
||||
const addFwd = QuickModal({
|
||||
title: 'Add Port Forward',
|
||||
@@ -33,6 +33,7 @@ export default definePage({
|
||||
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const fw = fwDirty(state.firewall.data?.pending);
|
||||
const cfg = state.firewall.data?.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
|
||||
@@ -82,8 +83,9 @@ export default definePage({
|
||||
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
|
||||
</tr>`;
|
||||
}
|
||||
return html`<tr key=${'m-' + zone}>
|
||||
<td><strong>${zone}</strong></td>
|
||||
const info = fwInfo(fw, zone, 'masquerade');
|
||||
return html`<tr key=${'m-' + zone} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
|
||||
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
|
||||
<td>
|
||||
<${ActionButton}
|
||||
@@ -102,8 +104,9 @@ export default definePage({
|
||||
forwards.forEach((fwd, i) => {
|
||||
const port = fwd.port;
|
||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
|
||||
<td><strong>${zone}</strong></td>
|
||||
const info = fwInfo(fw, zone, 'forward_ports');
|
||||
fwRows.push(html`<tr key=${'f-' + zone + '-' + i} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
|
||||
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
|
||||
<td>${port}</td>
|
||||
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
||||
import { openBackendModal } from '/static/pages/backends.js';
|
||||
|
||||
function certLookup(acmeData) {
|
||||
@@ -172,8 +172,9 @@ function editDomain(d, state) {
|
||||
modal({});
|
||||
}
|
||||
|
||||
function domainRow(domainName, domainPaths, state) {
|
||||
function domainRow(domainName, domainPaths, state, set) {
|
||||
const d = domainPaths[0];
|
||||
const info = dirtyInfo(set, 'domains.' + domainName);
|
||||
const certMap = certLookup(state.acme ? state.acme.data : null);
|
||||
const cert = certMap[d.domain];
|
||||
let certBadge, certTitle;
|
||||
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
|
||||
if (flags.length) parts.push(flags.join(', '));
|
||||
return parts.join(' → ');
|
||||
});
|
||||
return html`<tr key=${domainName} class="domain-row">
|
||||
<td><strong>${esc(domainName)}</strong></td>
|
||||
return html`<tr key=${domainName} class="domain-row ${info.class}" title=${info.title || undefined}>
|
||||
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(domainName)}</strong></td>
|
||||
<td>${pathSummaries}</td>
|
||||
<td title=${certTitle}>${certBadge}</td>
|
||||
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
|
||||
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function backendSection(section, state) {
|
||||
function backendSection(section, state, set) {
|
||||
const { backendName, backend, domains } = section;
|
||||
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
|
||||
const info = dirtyInfo(set, 'backends.' + backendName);
|
||||
const rows = domains.map(d => domainRow(d.domain, d.paths, state, set));
|
||||
const sectionActions = [];
|
||||
if (!backend.builtin) {
|
||||
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
|
||||
@@ -233,7 +235,7 @@ function backendSection(section, state) {
|
||||
}
|
||||
}
|
||||
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
|
||||
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
|
||||
return html`<div class="backend-section ${info.class}" title=${info.title || undefined} key=${backendName} style="margin-bottom:24px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
|
||||
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
|
||||
<${Badge} text=${esc(backendName)} variant="primary" />
|
||||
@@ -258,10 +260,11 @@ export default definePage({
|
||||
render(state) {
|
||||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
|
||||
if (guard) return guard;
|
||||
const set = dirtySet(state.nginx.data?.status);
|
||||
const domains = state.nginx.data.domains || [];
|
||||
const backends = state.backends.data || {};
|
||||
const sections = _groupByBackend(domains, backends);
|
||||
const sectionVNodes = sections.map(s => backendSection(s, state));
|
||||
const sectionVNodes = sections.map(s => backendSection(s, state, set));
|
||||
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' }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
|
||||
import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
|
||||
|
||||
const addRule = QuickModal({
|
||||
title: 'Add Rich Rule',
|
||||
@@ -24,6 +24,7 @@ export default definePage({
|
||||
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const fw = fwDirty(state.firewall.data?.pending);
|
||||
const cfg = state.firewall.data?.config || {};
|
||||
const zones = Object.keys(state.firewall.data?.zones || {});
|
||||
const zoneData = cfg.zones || {};
|
||||
@@ -34,6 +35,7 @@ export default definePage({
|
||||
});
|
||||
|
||||
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
||||
const info = fwInfo(fw, zone, 'rich_rules');
|
||||
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||
@@ -50,8 +52,12 @@ export default definePage({
|
||||
</tr>`;
|
||||
});
|
||||
return Card({
|
||||
header: 'Zone: ' + esc(zone),
|
||||
header: info.dirty
|
||||
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
|
||||
: 'Zone: ' + esc(zone),
|
||||
key: zone,
|
||||
cls: info.class || undefined,
|
||||
title: info.title || undefined,
|
||||
children: [Table({
|
||||
columns: ['#', 'Rule', 'Action'],
|
||||
rows: ruleRows,
|
||||
|
||||
@@ -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, 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, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
|
||||
|
||||
/* ── LAN detection helper ────────────────────────────────────── */
|
||||
function getLanSubnets() {
|
||||
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
|
||||
}
|
||||
|
||||
const classStatuses = status?.classes || {};
|
||||
const set = dirtySet(status);
|
||||
|
||||
const rows = entries.map(([k, v]) => {
|
||||
const pCount = peerCountMap[k] || 0;
|
||||
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
|
||||
const isUp = clsStatus.up;
|
||||
const hasKeys = classHasKeys(v);
|
||||
const color = classColor(k);
|
||||
return html`<tr key=${k}>
|
||||
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
|
||||
const info = dirtyInfo(set, 'access_classes.' + k);
|
||||
return html`<tr key=${k} class=${info.class || undefined} title=${info.title || undefined}>
|
||||
<td style="border-left: 3px solid ${color}">${info.dirty ? PendingDot({}) : ''}<strong>${esc(k)}</strong></td>
|
||||
<td>${esc(v.name || k)}</td>
|
||||
<td class="text-sm">${esc(v.description || '-')}</td>
|
||||
<td class="text-sm">${esc(v.subnet || '-')}</td>
|
||||
@@ -418,12 +420,16 @@ export default definePage({
|
||||
const wgData = state.wireguard.data;
|
||||
const st = wgData?.status || {};
|
||||
const config = wgData?.config || {};
|
||||
const set = dirtySet(st);
|
||||
const isUp = st.up || false;
|
||||
const listenPort = config.interface?.listen_port || '-';
|
||||
const serverEndpoint = config.interface?.server_endpoint || '';
|
||||
|
||||
// Build merged peer rows: configured peers + live status
|
||||
const configuredPeers = wgData?.peers || [];
|
||||
// A removed peer leaves a `peers.<name>` pending path with no live row
|
||||
// to attach a per-row marker to; surface it on the table itself.
|
||||
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
|
||||
const statusPeersMap = {};
|
||||
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
|
||||
for (const sp of (cSt.peers || [])) {
|
||||
@@ -442,9 +448,11 @@ export default definePage({
|
||||
const isConnected = sp && !!sp.latest_handshake;
|
||||
const accessClass = p.access_class;
|
||||
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
|
||||
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
|
||||
return html`<tr key=${p.name}${borderColor}>
|
||||
const info = dirtyInfo(set, 'peers.' + p.name);
|
||||
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
|
||||
return html`<tr key=${p.name} class=${info.class || undefined} title=${info.title || undefined} style=${style}>
|
||||
<td>
|
||||
${info.dirty ? PendingDot({}) : ''}
|
||||
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
|
||||
<strong>${esc(p.name || 'unnamed')}</strong>
|
||||
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
|
||||
@@ -480,7 +488,8 @@ export default definePage({
|
||||
const isUp = cSt.up;
|
||||
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
|
||||
const color = classColor(k);
|
||||
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
|
||||
const info = dirtyInfo(set, 'access_classes.' + k);
|
||||
return html`<div key=${k} class="card ${info.class}" title=${info.title || undefined} style="border-left: 3px solid ${color}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
|
||||
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
|
||||
@@ -502,9 +511,10 @@ export default definePage({
|
||||
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
|
||||
}
|
||||
|
||||
const ifaceInfo = dirtyInfo(set, 'interface');
|
||||
const actions = ActionGroup(
|
||||
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
|
||||
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
|
||||
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title=${'Interface Settings' + (ifaceInfo.dirty ? ' — ' + ifaceInfo.title : '')}>${ifaceInfo.dirty ? PendingDot({}) : ''}\u{1F527}</button>`,
|
||||
ActionButton({
|
||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
||||
@@ -531,6 +541,8 @@ export default definePage({
|
||||
? Table({
|
||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
|
||||
rows: peerRows,
|
||||
cls: peersOrphan.class || undefined,
|
||||
title: peersOrphan.title || undefined,
|
||||
})
|
||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||
renderAccessClasses(config, st),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
|
||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
|
||||
|
||||
// Services shown by default in the service picker. Everything else is only
|
||||
// visible with the "Show all options" toggle (or while it is already
|
||||
@@ -33,6 +33,8 @@ export default definePage({
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
|
||||
const fw = fwDirty(state.firewall.data?.pending);
|
||||
|
||||
// Live zone data (parsed `--list-all-zones`): carries interfaces,
|
||||
// services, target, and masquerade for every defined zone.
|
||||
const liveZones = state.firewall.data?.zones || {};
|
||||
@@ -45,22 +47,26 @@ export default definePage({
|
||||
const z = typeof zdata === 'object' ? zdata : {};
|
||||
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
||||
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
||||
return html`<div class="card" key=${name} style="position:relative">
|
||||
const info = fwInfo(fw, name);
|
||||
const ifTitle = fwTitle(fw, name, 'interfaces');
|
||||
const svcTitle = fwTitle(fw, name, 'services');
|
||||
const tgtTitle = fwTitle(fw, name, 'target');
|
||||
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
|
||||
<div class="text-muted text-sm" style="margin-bottom:10px">
|
||||
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
|
||||
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
|
||||
${z.target ? 'Target: ' + esc(z.target) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-sm mb-4" title=${ifTitle || undefined}>
|
||||
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
|
||||
${ifacesArr.length
|
||||
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
|
||||
: html`<span class="text-muted">None</span>`}
|
||||
</div>
|
||||
<div class="text-sm mb-4">
|
||||
<div class="text-sm mb-4" title=${svcTitle || undefined}>
|
||||
<div class="text-muted" style="margin-bottom:4px">Services</div>
|
||||
${svcsArr.length
|
||||
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
|
||||
|
||||
Reference in New Issue
Block a user