refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
+12
-12
@@ -1,16 +1,16 @@
|
||||
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4';
|
||||
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=6';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=4';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=4';
|
||||
import ZonesPage from '/static/pages/zones.js?v=4';
|
||||
import RulesPage from '/static/pages/rules.js?v=4';
|
||||
import NatPage from '/static/pages/nat.js?v=4';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=4';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=4';
|
||||
import CertsPage from '/static/pages/certs.js?v=4';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=4';
|
||||
import LogsPage from '/static/pages/logs.js?v=4';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=4';
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=6';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=6';
|
||||
import ZonesPage from '/static/pages/zones.js?v=6';
|
||||
import RulesPage from '/static/pages/rules.js?v=6';
|
||||
import NatPage from '/static/pages/nat.js?v=6';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=6';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=6';
|
||||
import CertsPage from '/static/pages/certs.js?v=6';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=6';
|
||||
import LogsPage from '/static/pages/logs.js?v=6';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=6';
|
||||
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
|
||||
+148
-1
@@ -6,7 +6,7 @@
|
||||
* ToastContainer component for rendering queued toasts.
|
||||
*/
|
||||
|
||||
import { h } from './vdom.js';
|
||||
import { h } from './vdom.js?v=6';
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
@@ -96,3 +96,150 @@ export function ToastContainer() {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abort-checking function from an AbortController.
|
||||
*
|
||||
* @param {AbortController} ac
|
||||
* @returns {function} () => boolean
|
||||
*/
|
||||
export function checkAbort(ac) {
|
||||
return () => ac?.signal?.aborted || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard data loading wrapper with state management and abort handling.
|
||||
*
|
||||
* Sets loading=true before, loading=false after, tracks errors.
|
||||
*
|
||||
* @param {object} state - Reactive state object
|
||||
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
|
||||
* @param {function} fetchFn - (state, signal, isAborted) => Promise
|
||||
* @param {object} [opts] - Additional options
|
||||
* @param {object} [opts.entry] - Component entry for requestId tracking
|
||||
* @param {AbortController} [opts.abortController] - Fresh abort controller
|
||||
*/
|
||||
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
|
||||
const entry = opts.entry;
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const ab = opts.abortController;
|
||||
const isAborted = ab ? checkAbort(ab) : () => false;
|
||||
const signal = ab ? ab.signal : null;
|
||||
|
||||
if (entry) {
|
||||
if (dataKey(state) !== undefined) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
}
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
await fetchFn(state, signal, isAborted);
|
||||
} catch (e) {
|
||||
if (!isAborted()) state.error = e.message || 'Request failed';
|
||||
} finally {
|
||||
if (!isAborted()) {
|
||||
if (entry) {
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a URL until success or error condition is met.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.url - URL to poll
|
||||
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
|
||||
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
|
||||
* @param {function} [opts.onComplete] - (data) => void, called on success
|
||||
* @param {function} [opts.onError] - (data) => void, called on failure
|
||||
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
|
||||
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
|
||||
*/
|
||||
export async function poll(opts) {
|
||||
const {
|
||||
url,
|
||||
successKey,
|
||||
onErrorKey,
|
||||
onComplete,
|
||||
onError,
|
||||
interval = 3000,
|
||||
timeout = 60000,
|
||||
} = opts;
|
||||
|
||||
const start = Date.now();
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeout) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(null);
|
||||
return;
|
||||
}
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(res);
|
||||
return;
|
||||
}
|
||||
if (successKey(res.data)) {
|
||||
clearInterval(timer);
|
||||
if (onComplete) onComplete(res.data);
|
||||
} else if (onErrorKey(res.data)) {
|
||||
clearInterval(timer);
|
||||
if (onError) onError(res.data);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action button descriptors for modal form submission.
|
||||
*
|
||||
* Returns an array of action descriptors that can be spread into the
|
||||
* actions array passed to formModal. First item is the submit button.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.url - API URL to POST/PUT to
|
||||
* @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 {string} [opts.successMsg] - Success toast message
|
||||
* @param {function} [opts.reload] - () => Promise, data reload function
|
||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||
* @returns {object[]} Array of action descriptors
|
||||
*/
|
||||
export function apiSubmit(opts) {
|
||||
const {
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
validate,
|
||||
successMsg = 'Saved',
|
||||
reload,
|
||||
submitText = 'Submit',
|
||||
closeModal,
|
||||
} = opts;
|
||||
|
||||
return [
|
||||
{
|
||||
label: submitText,
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: async () => {
|
||||
const b = body ? body() : {};
|
||||
if (validate) {
|
||||
const err = validate(b);
|
||||
if (err) { toast(err, 'error'); return; }
|
||||
}
|
||||
const res = await apiFetch(url, { method, body: b });
|
||||
if (res.ok) {
|
||||
toast(successMsg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (reload) await reload();
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js';
|
||||
import { h } from './vdom.js';
|
||||
import { _compExpandedCache } from './render.js';
|
||||
import { reactive } from './reactivity.js?v=6';
|
||||
import { h } from './vdom.js?v=6';
|
||||
import { _compExpandedCache } from './render.js?v=6';
|
||||
|
||||
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
|
||||
const _mounted = new Map();
|
||||
@@ -32,6 +32,15 @@ export function isComponentStateMounted(state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get the full mounted entry for a state object.
|
||||
* Used by websocket.js to abort in-flight loads before triggering a refresh. */
|
||||
export function getComponentEntry(state) {
|
||||
for (const entry of _mounted.values()) {
|
||||
if (entry.state === state) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** External subscribe function from websocket.js.
|
||||
* Set via setSubscribeFn() when the websocket module initializes.
|
||||
*/
|
||||
@@ -79,12 +88,11 @@ export function mountComponent(key, renderer) {
|
||||
let entry = _mounted.get(key);
|
||||
|
||||
if (entry) {
|
||||
// Re-mount of an already-mounted page: restart load with fresh AbortController
|
||||
if (entry.loadAbort) {
|
||||
entry.loadAbort.abort();
|
||||
}
|
||||
entry.requestId++;
|
||||
entry.loadAbort = null;
|
||||
// Re-mount: component already exists with its data and subscriptions.
|
||||
// Don't abort or restart loads — that re-render was triggered by a
|
||||
// state change (load completion, reactive update, etc). Let existing
|
||||
// in-flight loads complete naturally. WS handles auto-refresh.
|
||||
return;
|
||||
} else {
|
||||
// Fresh mount
|
||||
entry = {
|
||||
@@ -101,6 +109,7 @@ export function mountComponent(key, renderer) {
|
||||
|
||||
// Fire load with fresh AbortController
|
||||
if (pd.load) {
|
||||
if (entry.isLoading) return;
|
||||
const abortController = new AbortController();
|
||||
entry.loadAbort = abortController;
|
||||
entry.requestId++;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { esc } from '../helpers.js?v=6';
|
||||
import { apiFetch, toast } from '../api.js?v=6';
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
@@ -49,11 +51,259 @@ export function Empty(props = {}) {
|
||||
* @param {VNode[]} [props.children]
|
||||
*/
|
||||
export function Card(props = {}) {
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
if (props.header) {
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card', ...key },
|
||||
h('div', { class: 'card-header' }, props.header),
|
||||
h('div', { class: 'card-body' }, props.children || []),
|
||||
);
|
||||
}
|
||||
return h('div', { class: 'card' }, props.children || []);
|
||||
return h('div', { class: 'card', ...key }, props.children || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Remove button that confirms, deletes via API, toasts, and reloads.
|
||||
*
|
||||
* @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 {function} [props.reload] - Function to call on success (e.g., load)
|
||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||
*/
|
||||
export function ConfirmDelete(props = {}) {
|
||||
const opts = { method: 'DELETE' };
|
||||
if (props.body) opts.body = props.body;
|
||||
return h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm(props.message)) return;
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
toast(props.success || 'Removed', 'success');
|
||||
if (props.reload) await props.reload();
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, props.label || 'Remove');
|
||||
}
|
||||
|
||||
/**
|
||||
* An action button that POSTs to an API endpoint, toasts on result,
|
||||
* and optionally reloads state. Supports toggle labels for on/off buttons.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API URL
|
||||
* @param {string} [props.method] - HTTP method (default: 'POST')
|
||||
* @param {function} [props.body] - () => body, or undefined for no body
|
||||
* @param {string} [props.label] - Button text
|
||||
* @param {string} [props.labelOn] - Label when condition is true (toggle)
|
||||
* @param {string} [props.labelOff] - Label when condition is false (toggle)
|
||||
* @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 {function} [props.reload] - () => Promise, called on success
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||
* @param {boolean} [props.disabled] - Disabled state
|
||||
*/
|
||||
export function ActionButton(props = {}) {
|
||||
const label = props.label !== undefined ? props.label :
|
||||
(props.labelOn !== undefined && props.labelOff !== undefined
|
||||
? (props.condition ? props.labelOn : props.labelOff)
|
||||
: 'Action');
|
||||
const cls = props.cls || 'btn btn-outline';
|
||||
return h('button', {
|
||||
class: cls,
|
||||
disabled: props.disabled,
|
||||
'on:click': async () => {
|
||||
const body = props.body ? props.body() : undefined;
|
||||
const opts = { method: props.method || 'POST' };
|
||||
if (body !== undefined) opts.body = body;
|
||||
const resp = await apiFetch(props.url, opts);
|
||||
if (resp.ok) {
|
||||
if (props.successMsg) toast(props.successMsg, 'success');
|
||||
if (props.reload) await props.reload();
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
}
|
||||
}, label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table wrapper with header, body, and empty-state row.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string[]} props.columns - Column header labels
|
||||
* @param {VNode[]} props.rows - Body row vnodes
|
||||
* @param {string} [props.emptyText] - Empty-state message
|
||||
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
|
||||
* @param {string} [props.key] - VNode key
|
||||
*/
|
||||
export function Table(props = {}) {
|
||||
const cols = props.columns || [];
|
||||
const ths = cols.map(c => h('th', null, c));
|
||||
const table = h('table', { class: 'table' },
|
||||
h('thead', null, h('tr', null, ...ths)),
|
||||
h('tbody', null,
|
||||
props.rows.length ? props.rows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: cols.length, class: 'text-muted text-sm' },
|
||||
props.emptyText || 'No data'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
if (props.wrapCard !== false) {
|
||||
return h('div', { class: 'card', ...key }, table);
|
||||
}
|
||||
return h('div', key, table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard stat card.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.label
|
||||
* @param {*} props.value
|
||||
* @param {*} [props.meta]
|
||||
*/
|
||||
export function StatCard(props = {}) {
|
||||
return h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, props.label),
|
||||
h('div', { class: 'value' }, props.value),
|
||||
props.meta ? h('div', { class: 'meta' }, props.meta) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot + human-readable label.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.status
|
||||
*/
|
||||
export function StatusText(props = {}) {
|
||||
const status = props.status || 'down';
|
||||
const label = status === 'up' ? 'Up' : status === 'pending' ? 'Pending' : 'Down';
|
||||
return [StatusDot({ status }), ' ', label];
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge for certificate status based on expiry data.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {number} [props.daysRemaining] - Days until expiry
|
||||
* @param {boolean} [props.expired] - Explicitly expired flag
|
||||
* @param {string} [props.certStatus] - Status string (e.g. 'valid', 'active', 'expired')
|
||||
*/
|
||||
export function certStatusBadge(props = {}) {
|
||||
const { daysRemaining, expired, certStatus } = props;
|
||||
if (certStatus === 'valid' || certStatus === 'active')
|
||||
return Badge({ text: 'Valid', variant: 'success' });
|
||||
if (expired || certStatus === 'expired' || (daysRemaining !== undefined && daysRemaining <= 0))
|
||||
return Badge({ text: 'Expired', variant: 'danger' });
|
||||
if (daysRemaining !== undefined && daysRemaining <= 30)
|
||||
return Badge({ text: daysRemaining + 'd left', variant: 'warning' });
|
||||
if (daysRemaining !== undefined)
|
||||
return Badge({ text: daysRemaining + 'd left', variant: 'success' });
|
||||
return Badge({ text: certStatus || 'N/A', variant: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot + Badge pair for a service state string.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.state - Service state (e.g. 'up', 'down')
|
||||
*/
|
||||
export function serviceStatusBadge(props = {}) {
|
||||
const state = props.state || 'down';
|
||||
const isUp = state === 'up';
|
||||
return [
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' ',
|
||||
Badge({ text: state, variant: isUp ? 'success' : 'danger' }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStatusBadge + label in a single vnode.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.state - Service state string
|
||||
* @param {string} [props.label] - Optional label text after the badge
|
||||
*/
|
||||
export function ServiceStatus(props = {}) {
|
||||
return h('span', { class: 'service-status' },
|
||||
...serviceStatusBadge({ state: props.state }),
|
||||
props.label ? ' ' + props.label : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionCell — standardizes "action button + ConfirmDelete" in a table cell.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.editLabel - First button text
|
||||
* @param {function} props.editClick - First button click handler
|
||||
* @param {string} props.removeUrl - API DELETE URL
|
||||
* @param {string} props.removeMessage - Confirmation prompt text
|
||||
* @param {string} [props.removeSuccess] - Success toast message
|
||||
* @param {function} [props.removeReload] - Reload function
|
||||
* @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')
|
||||
*/
|
||||
export function ActionCell(props = {}) {
|
||||
return h('td', null,
|
||||
h('button', {
|
||||
class: props.editCls || 'btn btn-sm btn-outline',
|
||||
style: 'margin-right:4px;',
|
||||
'on:click': props.editClick,
|
||||
}, props.editLabel),
|
||||
ConfirmDelete({
|
||||
url: props.removeUrl,
|
||||
message: props.removeMessage,
|
||||
success: props.removeSuccess,
|
||||
reload: props.removeReload,
|
||||
label: props.removeLabel || 'Remove',
|
||||
body: props.removeBody,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monospace text with optional truncation.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.text
|
||||
* @param {number} [props.maxLength] - Truncate with "..." if longer
|
||||
*/
|
||||
export function MonoText(props = {}) {
|
||||
const text = String(props.text || '');
|
||||
const display = props.maxLength && text.length > props.maxLength
|
||||
? text.substring(0, props.maxLength) + '...'
|
||||
: text;
|
||||
return h('span', { class: 'mono-text' }, esc(display));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown to select a firewall zone.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string[]} props.zones - Available zone names
|
||||
* @param {string} [props.value] - Currently selected zone
|
||||
* @param {function} [props.onChange] - (zone) => void
|
||||
* @param {string} [props.placeholder]
|
||||
*/
|
||||
export function ZoneSelect(props = {}) {
|
||||
return h('select', {
|
||||
class: 'form-select',
|
||||
'on:change': (e) => props.onChange?.(e.target.value),
|
||||
},
|
||||
props.placeholder ? h('option', { value: '' }, props.placeholder) : null,
|
||||
props.zones.map(z =>
|
||||
h('option', { value: z, selected: z === props.value }, z),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Hoover — components/layout.js
|
||||
*
|
||||
* Layout components: PageHeader for page titles with optional subtitles
|
||||
* and action buttons.
|
||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { Table } from './data.js?v=6';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
@@ -24,3 +24,111 @@ export function PageHeader(props = {}) {
|
||||
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle loading/error/no-data states and return early if applicable.
|
||||
* Returns null when data is ready for the page to render its content.
|
||||
*
|
||||
* @param {object} state - Page state with loading/error flags
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {*} [data] - Data presence check for "no data" state
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuard(state, title, subtitle, data) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' },
|
||||
state.refreshing ? 'Refreshing...' : 'Loading...',
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
if ((data === undefined || data === null) && !state.loading) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
h('div', { class: 'card-body loading' }, 'No data available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar component. Writes to state[prop] on tab click.
|
||||
* The caller is responsible for rendering tab body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {object} props.state - Reactive state object
|
||||
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
|
||||
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
|
||||
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
|
||||
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
|
||||
*/
|
||||
export function Tabs(props = {}) {
|
||||
const tabKeys = props.tabs || [];
|
||||
const prop = props.prop || 'activeTab';
|
||||
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
|
||||
return h('div', { class: 'tabs' },
|
||||
tabKeys.map(t => h('span', {
|
||||
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
|
||||
'on:click': () => {
|
||||
props.state[prop] = t;
|
||||
if (props.onTabClick) props.onTabClick(t);
|
||||
},
|
||||
style: 'cursor:pointer;',
|
||||
}, formatLabel(t))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
*/
|
||||
export function SectionTitle(props = {}) {
|
||||
return h('h3', { class: 'section-title' }, props.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex button container with 8px gap.
|
||||
*
|
||||
* @param {VNode[]} children
|
||||
*/
|
||||
export function ActionGroup(...children) {
|
||||
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableSection — SectionTitle heading followed by a Table.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Section heading
|
||||
* @param {string[]} props.columns
|
||||
* @param {VNode[]} props.rows
|
||||
* @param {string} [props.emptyText]
|
||||
* @param {string} [props.key]
|
||||
*/
|
||||
export function DataTableSection(props = {}) {
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
return h('div', { class: 'data-table-section', ...key },
|
||||
SectionTitle({ title: props.title }),
|
||||
Table({
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
emptyText: props.emptyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* avoid fighting with the main render cycle.
|
||||
*/
|
||||
|
||||
import { esc } from '../helpers.js';
|
||||
import { att_esc } from '../helpers.js';
|
||||
import { esc } from '../helpers.js?v=6';
|
||||
import { att_esc } from '../helpers.js?v=6';
|
||||
import { apiSubmit } from '../api.js?v=6';
|
||||
|
||||
const _modalQueue = [];
|
||||
|
||||
@@ -78,7 +79,8 @@ export function formModal(inner, title, fields, actions) {
|
||||
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
|
||||
+ fields.map(f => {
|
||||
if (f.tag === 'select')
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
|
||||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
|
||||
+ (f.multiple ? ' multiple' : '') + '>'
|
||||
+ (f.options || []).map(o =>
|
||||
typeof o === 'string'
|
||||
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
|
||||
@@ -101,3 +103,103 @@ export function formModal(inner, title, fields, actions) {
|
||||
if (btn) btn.addEventListener('click', a.handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that returns a function to open a multi-select modal.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Modal title
|
||||
* @param {string} props.url - API POST URL
|
||||
* @param {string[]} props.options - All selectable options
|
||||
* @param {string[]} props.selected - Currently selected values
|
||||
* @param {string} props.fieldKey - JSON key for the field
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {function} [props.reload] - () => Promise, called on success
|
||||
* @returns {function} () => void, calls openModal
|
||||
*/
|
||||
export function MultiSelectModal(props = {}) {
|
||||
return () => {
|
||||
const selectId = 'ms-' + props.fieldKey;
|
||||
openModal((inner) => {
|
||||
formModal(inner, props.title,
|
||||
[{
|
||||
label: props.fieldKey,
|
||||
id: selectId,
|
||||
tag: 'select',
|
||||
multiple: true,
|
||||
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
|
||||
}],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
...apiSubmit({
|
||||
url: props.url,
|
||||
body: () => ({
|
||||
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
|
||||
.map(o => o.value),
|
||||
}),
|
||||
successMsg: props.successMsg || 'Updated',
|
||||
reload: props.reload,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that returns a function to open a modal with form fields and apiSubmit.
|
||||
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string|function} props.title - Modal title or (data) => string
|
||||
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
|
||||
* @param {object} props.submit - Submit configuration
|
||||
* @param {string|function} props.submit.url - API URL or (data) => string
|
||||
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
|
||||
* @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 {function} [props.reload] - (data) => Promise, called on success with the data argument
|
||||
* @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
|
||||
*/
|
||||
export function QuickModal(props = {}) {
|
||||
return (data) => {
|
||||
const title = typeof props.title === 'function' ? props.title(data) : props.title;
|
||||
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
|
||||
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
|
||||
|
||||
openModal((inner) => {
|
||||
let actions;
|
||||
if (props.handler) {
|
||||
actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
{
|
||||
label: props.submitLabel || 'Submit',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: () => props.handler(data, () => closeModal()),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
actions = [
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
...apiSubmit({
|
||||
url,
|
||||
method: props.submit.method || 'POST',
|
||||
body: props.submit.body ? () => props.submit.body(data) : undefined,
|
||||
validate: props.submit.validate,
|
||||
successMsg: typeof props.submit.successMsg === 'function'
|
||||
? props.submit.successMsg(data)
|
||||
: (props.submit.successMsg || 'Done'),
|
||||
reload: props.reload ? () => props.reload(data) : undefined,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
formModal(inner, title, fields, actions);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { _toasts, dismissToast } from '../api.js';
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { _toasts, dismissToast } from '../api.js?v=6';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
|
||||
@@ -49,3 +49,20 @@ export function parseZones(data) {
|
||||
z = Object.values(z).map(i => i?.name || i);
|
||||
return Array.isArray(z) ? z : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser file download from a Blob.
|
||||
*
|
||||
* @param {Blob} blob
|
||||
* @param {string} filename
|
||||
*/
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -5,37 +5,37 @@
|
||||
*/
|
||||
|
||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||
export { reactive, requestUpdate } from './reactivity.js';
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=6';
|
||||
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js';
|
||||
export { h } from './vdom.js?v=6';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js';
|
||||
export { render } from './render.js?v=6';
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
export { definePage, hComp } from './component.js';
|
||||
export { definePage, hComp } from './component.js?v=6';
|
||||
|
||||
/* ── Router ──────────────────────────────────────────────────── */
|
||||
export { createRouter, Link } from './router.js';
|
||||
export { createRouter, Link } from './router.js?v=6';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js';
|
||||
export { connect, onMessage } from './websocket.js?v=6';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast } from './api.js';
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, refactorLoad, checkAbort, poll } from './api.js?v=6';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones } from './helpers.js';
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=6';
|
||||
|
||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||
export { PageHeader } from './components/layout.js';
|
||||
export { PageHeader, renderGuard, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=6';
|
||||
|
||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||
export { Badge, StatusDot, Empty, Card } from './components/data.js';
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=6';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, formModal } from './components/modal.js';
|
||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=6';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js';
|
||||
export { ToastContainer } from './components/toast.js?v=6';
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* batched re-render loop integration with reactivity.js.
|
||||
*/
|
||||
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js';
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=6';
|
||||
import {
|
||||
_vnodeDom, createDom, getDom, patchNode,
|
||||
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
||||
setMountFn, setUnmountFn,
|
||||
} from './vdom.js';
|
||||
import { mountComponent, unmountComponent } from './component.js';
|
||||
} from './vdom.js?v=6';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=6';
|
||||
|
||||
/** Container → previous root vnodes */
|
||||
export const _renderSlots = new Map();
|
||||
@@ -21,6 +21,9 @@ export const _renderFns = new Map();
|
||||
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
|
||||
export const _compExpandedCache = new Map();
|
||||
|
||||
/** Component key → renderer function (survives normalization that expands #comp) */
|
||||
const _compRegistry = new Map();
|
||||
|
||||
/**
|
||||
* Set up lifecycle callback hooks from vdom.js.
|
||||
* Called once during render initialization.
|
||||
@@ -87,7 +90,7 @@ function commit(container) {
|
||||
* and manage component lifecycle based on key changes.
|
||||
*/
|
||||
function normalizeVNodesWithLifecycle(result, prevVnodes) {
|
||||
const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : [];
|
||||
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
|
||||
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
|
||||
const newEntries = [];
|
||||
|
||||
@@ -104,6 +107,16 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
|
||||
// so collectCompEntries always returns [] after the first render)
|
||||
const newKeySet = new Set(newEntries.map(e => e.key));
|
||||
for (const [key] of _compRegistry) {
|
||||
if (!newKeySet.has(key)) _compRegistry.delete(key);
|
||||
}
|
||||
for (const entry of newEntries) {
|
||||
_compRegistry.set(entry.key, entry.renderer);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -199,6 +212,7 @@ function diffContainer(container, prev, vnodes) {
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -214,15 +228,25 @@ function diffContainer(container, prev, vnodes) {
|
||||
if (oldDom && oldV.tag === newV.tag) {
|
||||
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
} else {
|
||||
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
} else if (oldDom && !oldDom.parentNode) {
|
||||
// oldDom exists in _vnodeDom but detached from the tree
|
||||
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
if (oldDom?.parentNode) {
|
||||
oldDom.parentNode.replaceChild(nd, oldDom);
|
||||
} else if (nd.parentNode !== container) {
|
||||
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
||||
}
|
||||
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = nd;
|
||||
} else if (oldDom && oldV.tag !== newV.tag) {
|
||||
// tag mismatch — replace old DOM with new
|
||||
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
oldDom.parentNode.replaceChild(nd, oldDom);
|
||||
lastDom = nd;
|
||||
} else {
|
||||
// oldDom is null — create and insert new DOM
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = nd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* navigation). Link component for client-side navigation.
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js';
|
||||
import { h } from './vdom.js';
|
||||
import { reactive } from './reactivity.js?v=6';
|
||||
import { h } from './vdom.js?v=6';
|
||||
|
||||
/**
|
||||
* Hash-based router.
|
||||
|
||||
@@ -198,6 +198,7 @@ export function patchUnkeyed(parent, oldCh, newCh) {
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
lastDom = i > 0 ? getDom(oldCh[i - 1]) : null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* auto-refresh messages from the backend can trigger page reloads.
|
||||
*/
|
||||
|
||||
import { setSubscribeFn, isComponentStateMounted } from './component.js';
|
||||
import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
|
||||
|
||||
const _wsSubs = new Map();
|
||||
let _wsConn = null;
|
||||
@@ -52,6 +52,43 @@ function _wsConnect() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-state debounce timer (shared across all subscriptions for that state). */
|
||||
const _wsDebounceTimers = new Map();
|
||||
|
||||
/**
|
||||
* Fire the debounced load for a component state.
|
||||
*
|
||||
* Only one load fires per state regardless of how many subscriptions
|
||||
* matched. Passes the mount entry so refactorLoad can toggle
|
||||
* loading / refreshing flags correctly.
|
||||
*/
|
||||
function debouncedLoad(state, entry) {
|
||||
if (!isComponentStateMounted(state)) return;
|
||||
// Abort any in-flight load for this component
|
||||
if (entry && entry.loadAbort) entry.loadAbort.abort();
|
||||
const ac = new AbortController();
|
||||
const firstSub = [..._wsSubs.values()]
|
||||
.find(s => !s.unsubscribed && s.state === state);
|
||||
if (firstSub) {
|
||||
firstSub.loadFn(state, ac, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce helper: coalesces all matching subscriptions for the same
|
||||
* component state into a single reload, keyed by state object.
|
||||
*/
|
||||
function scheduleReload(state) {
|
||||
if (_wsDebounceTimers.has(state)) {
|
||||
clearTimeout(_wsDebounceTimers.get(state));
|
||||
}
|
||||
_wsDebounceTimers.set(state, setTimeout(() => {
|
||||
_wsDebounceTimers.delete(state);
|
||||
const entry = getComponentEntry(state);
|
||||
debouncedLoad(state, entry);
|
||||
}, 300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an incoming WS message to subscribed components.
|
||||
*
|
||||
@@ -61,6 +98,11 @@ function _wsConnect() {
|
||||
* { type: 'status', topic: 'firewall', … }
|
||||
*
|
||||
* Components subscribed to wildcard ('*') match every topic.
|
||||
*
|
||||
* Uses per-component-state debouncing (300ms) to prevent a burst of WS
|
||||
* messages or multiple matching topics from triggering overlapping
|
||||
* loads. All subscriptions that share the same state object are
|
||||
* coalesced into a single debounced reload.
|
||||
*/
|
||||
function handleMessage(msg) {
|
||||
const topics = [];
|
||||
@@ -73,13 +115,20 @@ function handleMessage(msg) {
|
||||
topics.push(msg.topic || '*');
|
||||
}
|
||||
|
||||
// Track which states have already been scheduled to avoid
|
||||
// double-scheduling when multiple subscriptions of the same
|
||||
// component match the same message.
|
||||
const scheduled = new Set();
|
||||
|
||||
for (const s of _wsSubs.values()) {
|
||||
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
|
||||
if (s.topic === '*') {
|
||||
s.loadFn(s.state);
|
||||
} else if (topics.some(t => t === s.topic || t === '*')) {
|
||||
s.loadFn(s.state);
|
||||
}
|
||||
|
||||
const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*');
|
||||
if (!matched) continue;
|
||||
|
||||
if (scheduled.has(s.state)) continue;
|
||||
scheduled.add(s.state);
|
||||
scheduleReload(s.state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +138,9 @@ function handleMessage(msg) {
|
||||
* Called by component.js on mount. Returns an unsubscribe function
|
||||
* called by component.js on unmount.
|
||||
*
|
||||
* Key is `componentFn + ':' + topic` so a component can subscribe to
|
||||
* multiple topics without overwriting previous subscriptions.
|
||||
*
|
||||
* @param {function} componentFn – The page renderer function (used as map key)
|
||||
* @param {string} topic – Topic to listen for ('*' = all)
|
||||
* @param {function} loadFn – Function to call when topic updates
|
||||
@@ -96,12 +148,21 @@ function handleMessage(msg) {
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
function subscribe(componentFn, topic, loadFn, state) {
|
||||
const key = componentFn + ':' + topic;
|
||||
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
|
||||
_wsSubs.set(componentFn, entry);
|
||||
_wsSubs.set(key, entry);
|
||||
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
_wsSubs.delete(componentFn);
|
||||
// Clear per-state debounce timer if this was the last active
|
||||
// subscription for that state
|
||||
const remaining = [..._wsSubs.values()]
|
||||
.some(s => !s.unsubscribed && s.state === entry.state);
|
||||
if (!remaining && _wsDebounceTimers.has(entry.state)) {
|
||||
clearTimeout(_wsDebounceTimers.get(entry.state));
|
||||
_wsDebounceTimers.delete(entry.state);
|
||||
}
|
||||
_wsSubs.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +189,14 @@ export function onMessage(topics, handler) {
|
||||
unsubscribed: false
|
||||
};
|
||||
_wsSubs.set(handler + ':' + t, entry);
|
||||
fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); });
|
||||
fns.push(() => {
|
||||
entry.unsubscribed = true;
|
||||
if (_wsDebounceTimers.has(entry.state)) {
|
||||
clearTimeout(_wsDebounceTimers.get(entry.state));
|
||||
_wsDebounceTimers.delete(entry.state);
|
||||
}
|
||||
_wsSubs.delete(handler + ':' + t);
|
||||
});
|
||||
}
|
||||
return () => fns.forEach(f => f());
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||
<script type="module" src="/static/app.js?v=4"></script>
|
||||
<script type="module" src="/static/app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+45
-97
@@ -1,4 +1,4 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, refactorLoad, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function issueCertModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
@@ -13,14 +13,10 @@ function issueCertModal(state) {
|
||||
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
const body = {
|
||||
domain,
|
||||
email: ($val('ic-email') || '').trim() || undefined,
|
||||
};
|
||||
const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
|
||||
const resp = await apiFetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body,
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Issuance started for ' + domain, 'success');
|
||||
@@ -38,103 +34,63 @@ function issueCertModal(state) {
|
||||
}
|
||||
|
||||
async function pollCertIssue(rid, state) {
|
||||
let done = false;
|
||||
const timer = setInterval(async () => {
|
||||
if (done) return clearInterval(timer);
|
||||
const r = await apiFetch('/api/certs/issue/' + enc(rid));
|
||||
if (r.ok && r.data) {
|
||||
if (r.data.status === 'completed') {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
toast('Certificate issued for ' + (r.data.domain || rid), 'success');
|
||||
await load(state);
|
||||
} else if (r.data.status === 'failed') {
|
||||
done = true;
|
||||
clearInterval(timer);
|
||||
toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
poll({
|
||||
url: '/api/certs/issue/' + enc(rid),
|
||||
successKey: (d) => d.status === 'completed',
|
||||
onErrorKey: (d) => d.status === 'failed',
|
||||
onComplete: (d) => {
|
||||
toast('Certificate issued for ' + (d.domain || rid), 'success');
|
||||
load(state);
|
||||
},
|
||||
onError: (d) => {
|
||||
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (state.certs?.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const r = await apiFetch('/api/certs/list', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (r.ok) state.certs = r.data || [];
|
||||
else state.error = r.error;
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => s.certs?.length,
|
||||
async (s, sig, isAborted) => {
|
||||
const r = await apiFetch('/api/certs/list', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (r.ok) s.certs = r.data || [];
|
||||
else s.error = r.error;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { certs: [], loading: true, refreshing: false, error: null };
|
||||
return { certs: [] };
|
||||
},
|
||||
subscribe: ['acme'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Certificates' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Certificates' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
|
||||
if (guard) return guard;
|
||||
|
||||
const rows = state.certs.map(c => {
|
||||
const days = c.days_remaining;
|
||||
let badge;
|
||||
if (c.expired || (days !== undefined && days <= 0)) {
|
||||
badge = Badge({ text: 'Expired', variant: 'danger' });
|
||||
} else if (days !== undefined && days <= 30) {
|
||||
badge = Badge({ text: days + 'd left', variant: 'warning' });
|
||||
} else {
|
||||
badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' });
|
||||
}
|
||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||
|
||||
return h('tr', { key: c.domain },
|
||||
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
|
||||
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
|
||||
h('td', null, esc(c.expiry || 'N/A')),
|
||||
h('td', null, badge),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Renew'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove certificate for ' + c.domain + '?')) return;
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Certificate removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
ActionCell({
|
||||
editLabel: 'Renew',
|
||||
editClick: async () => {
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
},
|
||||
removeUrl: '/api/certs/' + enc(c.domain),
|
||||
removeMessage: 'Remove certificate for ' + c.domain + '?',
|
||||
removeSuccess: 'Certificate removed',
|
||||
removeReload: () => load(state),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -146,18 +102,10 @@ export default definePage({
|
||||
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
|
||||
}),
|
||||
rows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Domain'),
|
||||
h('th', null, 'Issuer'),
|
||||
h('th', null, 'Expiry'),
|
||||
h('th', null, 'Status'),
|
||||
h('th', { style: 'width:120px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...rows),
|
||||
))
|
||||
? Table({
|
||||
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -1,55 +1,27 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, apiFetch, definePage, renderGuard, refactorLoad, ServiceStatus, StatCard } from '/static/hoover/index.js?v=6';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { data: null, loading: true, refreshing: false, error: null };
|
||||
return { data: null };
|
||||
},
|
||||
subscribe: ['*'],
|
||||
subscribe: ['firewall', 'dnsmasq', 'wireguard', 'acme', 'networkd'],
|
||||
async load(state, abortController, entry) {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
if (state.data) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const res = await apiFetch('/api/status/all', { signal: abortController?.signal });
|
||||
if (abortController?.signal.aborted || entry.requestId !== myId) return;
|
||||
if (res.ok) state.data = res.data;
|
||||
else state.error = res.error;
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => s.data,
|
||||
async (s, sig, isAborted) => {
|
||||
const res = await apiFetch('/api/status/all', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (res.ok) s.data = res.data;
|
||||
else s.error = res.error;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
},
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Dashboard', 'System overview', state.data);
|
||||
if (guard) return guard;
|
||||
|
||||
const d = state.data;
|
||||
if (!d) {
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
h('div', { class: 'card-body loading' }, 'No data available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const fwZones = (d.firewall?.zones) || {};
|
||||
const net = d.net || {};
|
||||
const nCount = Object.keys(net).length;
|
||||
@@ -58,49 +30,39 @@ export default definePage({
|
||||
const certs = d.certs || [];
|
||||
const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
|
||||
const dmsk = d.dnsmasq?.status || {};
|
||||
const dmskUp = dmsk.state === 'up';
|
||||
const wUp = (d.wg?.state || 'down') === 'up';
|
||||
const wP = (d.wg || {}).peers || [];
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||
h('div', { class: 'grid grid-4' },
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Active Zones'),
|
||||
h('div', { class: 'value' }, Object.keys(fwZones).length),
|
||||
h('div', { class: 'meta' }, Object.keys(fwZones).join(', ') || 'None'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Interfaces Up'),
|
||||
h('div', { class: 'value' }, upC + '/' + nCount),
|
||||
h('div', { class: 'meta' }, upI.map(i => i.name).join(', ') || 'None up'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'WireGuard'),
|
||||
h('div', { class: 'value' }, String(d.wg?.state || 'unknown')),
|
||||
h('div', { class: 'meta' }, wP.length + ' peers'),
|
||||
),
|
||||
h('div', { class: 'stat-card' },
|
||||
h('div', { class: 'label' }, 'Certificates'),
|
||||
h('div', { class: 'value' }, certs.length),
|
||||
h('div', { class: 'meta' }, certW.length + ' expiring/expired'),
|
||||
),
|
||||
StatCard({
|
||||
label: 'Active Zones',
|
||||
value: Object.keys(fwZones).length,
|
||||
meta: Object.keys(fwZones).join(', ') || 'None',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'Interfaces Up',
|
||||
value: upC + '/' + nCount,
|
||||
meta: upI.map(i => i.name).join(', ') || 'None up',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'WireGuard',
|
||||
value: String(d.wg?.state || 'unknown'),
|
||||
meta: wP.length + ' peers',
|
||||
}),
|
||||
StatCard({
|
||||
label: 'Certificates',
|
||||
value: certs.length,
|
||||
meta: certW.length + ' expiring/expired',
|
||||
}),
|
||||
),
|
||||
h('div', { class: 'grid grid-2' },
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' }, 'Services'),
|
||||
h('div', { class: 'card-body' },
|
||||
h('ul', { class: 'service-list' },
|
||||
h('li', null,
|
||||
StatusDot({ status: dmskUp ? 'success' : 'danger' }),
|
||||
' Dnsmasq ',
|
||||
Badge({ text: dmsk.state || 'down', variant: dmskUp ? 'success' : 'danger' }),
|
||||
),
|
||||
h('li', null,
|
||||
StatusDot({ status: wUp ? 'success' : 'danger' }),
|
||||
' WireGuard ',
|
||||
Badge({ text: String(d.wg?.state || 'down'), variant: wUp ? 'success' : 'danger' }),
|
||||
),
|
||||
h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })),
|
||||
h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+127
-282
@@ -1,335 +1,180 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addRangeModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add DHCP Range',
|
||||
[
|
||||
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
|
||||
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
|
||||
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
|
||||
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
interface: ($val('r-iface') || '').trim() || undefined,
|
||||
start: ($val('r-start') || '').trim(),
|
||||
end: ($val('r-end') || '').trim(),
|
||||
lease_time: ($val('r-lease') || '').trim() || '12h',
|
||||
};
|
||||
if (!body.start || !body.end) {
|
||||
toast('Start and end are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/ranges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Range added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addRange = QuickModal({
|
||||
title: 'Add DHCP Range',
|
||||
fields: [
|
||||
{ label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' },
|
||||
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
|
||||
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
|
||||
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/ranges',
|
||||
body: () => ({
|
||||
interface: ($val('r-iface') || '').trim() || undefined,
|
||||
start: ($val('r-start') || '').trim(),
|
||||
end: ($val('r-end') || '').trim(),
|
||||
lease_time: ($val('r-lease') || '').trim() || '12h',
|
||||
}),
|
||||
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
||||
successMsg: 'Range added',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
function addLeaseModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Static Lease',
|
||||
[
|
||||
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
|
||||
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
|
||||
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
mac: ($val('l-mac') || '').trim(),
|
||||
ip: ($val('l-ip') || '').trim(),
|
||||
hostname: ($val('l-host') || '').trim() || undefined,
|
||||
};
|
||||
if (!body.mac || !body.ip) {
|
||||
toast('MAC and IP are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/static-lease', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Lease added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addLease = QuickModal({
|
||||
title: 'Add Static Lease',
|
||||
fields: [
|
||||
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
|
||||
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
|
||||
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/static-lease',
|
||||
body: () => ({
|
||||
mac: ($val('l-mac') || '').trim(),
|
||||
ip: ($val('l-ip') || '').trim(),
|
||||
hostname: ($val('l-host') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
||||
successMsg: 'Lease added',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
function addDnsModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add DNS Record',
|
||||
[
|
||||
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
|
||||
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
name: ($val('d-name') || '').trim(),
|
||||
address: ($val('d-addr') || '').trim(),
|
||||
};
|
||||
if (!body.name || !body.address) {
|
||||
toast('Name and address are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/dhcp/dns-record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('DNS record added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addDns = QuickModal({
|
||||
title: 'Add DNS Record',
|
||||
fields: [
|
||||
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
|
||||
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/dhcp/dns-record',
|
||||
body: () => ({ name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim() }),
|
||||
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
||||
successMsg: 'DNS record added',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (Object.keys(state.config || {}).length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const cfgR = await apiFetch('/api/dhcp/config', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (cfgR.ok) state.config = cfgR.data || {};
|
||||
const stR = await apiFetch('/api/dhcp/status', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (stR.ok) state.status = stR.data || {};
|
||||
const lsR = await apiFetch('/api/dhcp/leases', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (lsR.ok) state.leases = lsR.data || [];
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => Object.keys(s.config || {}).length,
|
||||
async (s, sig, isAborted) => {
|
||||
const [cfgR, stR, lsR] = await Promise.allSettled([
|
||||
apiFetch('/api/dhcp/config', { signal: sig }),
|
||||
apiFetch('/api/dhcp/status', { signal: sig }),
|
||||
apiFetch('/api/dhcp/leases', { signal: sig }),
|
||||
]);
|
||||
if (isAborted()) return;
|
||||
const errors = [];
|
||||
if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
|
||||
else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
|
||||
if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
|
||||
else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
|
||||
if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
|
||||
else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
|
||||
if (errors.length) {
|
||||
s.error = errors[0];
|
||||
return;
|
||||
}
|
||||
s.config = cfgR.value.data || {};
|
||||
s.status = stR.value.data || {};
|
||||
s.leases = lsR.value.data || [];
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, status: {}, leases: [], loading: true, refreshing: false, error: null, activeTab: 'ranges' };
|
||||
return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
|
||||
},
|
||||
subscribe: ['dnsmasq'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.config || {};
|
||||
const ranges = cfg.ranges || [];
|
||||
const staticLeases = cfg.static_leases || [];
|
||||
const dnsRecords = cfg.dns_records || [];
|
||||
const statusUp = state.status || {};
|
||||
const isUp = statusUp.state === 'up';
|
||||
|
||||
const rangesRows = ranges.map((r, i) => h('tr', { key: i },
|
||||
const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
|
||||
h('td', null, r.interface || '(global)'),
|
||||
h('td', null, esc(r.start)),
|
||||
h('td', null, esc(r.end)),
|
||||
h('td', null, esc(r.lease_time || '12h')),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove range ' + r.start + ' - ' + r.end + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/ranges', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interface: r.interface || '', start: r.start, end: r.end }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Range removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/ranges',
|
||||
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
|
||||
body: { interface: r.interface || '', start: r.start, end: r.end },
|
||||
success: 'Range removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: i },
|
||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
|
||||
h('td', null, esc(l.mac)),
|
||||
h('td', null, esc(l.ip)),
|
||||
h('td', null, l.hostname || '-'),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove lease ' + l.mac + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/static-lease/' + enc(l.mac), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Lease removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/static-lease/' + enc(l.mac),
|
||||
message: 'Remove lease ' + l.mac + '?',
|
||||
success: 'Lease removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i },
|
||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
|
||||
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
|
||||
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove DNS record ' + (rec.name || 'unnamed') + '?')) return;
|
||||
const resp = await apiFetch('/api/dhcp/dns-record/' + enc(rec.name || ''), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Record removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
|
||||
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
|
||||
success: 'Record removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addRangeModal(state) }, 'Add Range'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addLeaseModal(state) }, 'Static Lease'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addDnsModal(state) }, 'DNS Record'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/dhcp/apply', { method: 'POST' });
|
||||
if (resp.ok) toast('dnsmasq applied', 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Apply'),
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
|
||||
ActionButton({
|
||||
url: '/api/dhcp/apply',
|
||||
successMsg: 'dnsmasq applied',
|
||||
label: 'Apply',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
||||
h('div', null,
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' Dnsmasq ',
|
||||
Badge({ text: statusUp.state || 'unknown', variant: isUp ? 'success' : 'danger' }),
|
||||
),
|
||||
h('div', { class: 'tabs' },
|
||||
tabNames.map(t => h('span', {
|
||||
class: 'tab ' + (state.activeTab === t ? 'active' : ''),
|
||||
'on:click': () => { state.activeTab = t; },
|
||||
style: 'cursor:pointer;',
|
||||
}, t.charAt(0).toUpperCase() + t.slice(1))),
|
||||
),
|
||||
ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
|
||||
Tabs({ state, tabs: tabNames }),
|
||||
state.activeTab === 'ranges'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Interface'),
|
||||
h('th', null, 'Start'),
|
||||
h('th', null, 'End'),
|
||||
h('th', null, 'Lease'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(rangesRows.length ? rangesRows : [
|
||||
h('tr', null, h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No DHCP ranges')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
|
||||
state.activeTab === 'leases'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IP'),
|
||||
h('th', null, 'Hostname'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(leaseRows.length ? leaseRows : [
|
||||
h('tr', null, h('td', { colspan: 4, class: 'text-muted text-sm' }, 'No static leases')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
|
||||
state.activeTab === 'dns'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Name'),
|
||||
h('th', null, 'Address'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(dnsRows.length ? dnsRows : [
|
||||
h('tr', null, h('td', { colspan: 3, class: 'text-muted text-sm' }, 'No custom DNS records')),
|
||||
]),
|
||||
),
|
||||
)) : null,
|
||||
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
|
||||
state.activeTab === 'active'
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IP'),
|
||||
h('th', null, 'Hostname'),
|
||||
h('th', null, 'Expires'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
(state.leases || []).map((l, i) => h('tr', { key: i },
|
||||
h('td', null, esc(l.mac || '-')),
|
||||
h('td', null, esc(l.ip || '-')),
|
||||
h('td', null, esc(l.hostname || '-')),
|
||||
h('td', null, esc(l.expires || '-')),
|
||||
)),
|
||||
),
|
||||
)) : null,
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.leases || []).map((l, i) => h('tr', { key: l.mac || i },
|
||||
h('td', null, esc(l.mac || '-')),
|
||||
h('td', null, esc(l.ip || '-')),
|
||||
h('td', null, esc(l.hostname || '-')),
|
||||
h('td', null, esc(l.expires || '-')),
|
||||
)), emptyText: 'No active leases' }) : null,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=6';
|
||||
|
||||
async function changeZone(name, zone, state) {
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interfaces: [name] }),
|
||||
body: { interfaces: [name] },
|
||||
});
|
||||
if (r.ok) {
|
||||
toast(name + ' \u2192 ' + zone, 'success');
|
||||
@@ -14,126 +13,87 @@ async function changeZone(name, zone, state) {
|
||||
}
|
||||
}
|
||||
|
||||
function cfgModal(name, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Config: ' + name,
|
||||
[
|
||||
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', placeholder: '192.168.1.1/24' },
|
||||
{ label: 'Gateway', id: 'cfg-gw' },
|
||||
{ label: 'DNS (comma-separated)', id: 'cfg-dns', placeholder: '1.1.1.1, 8.8.8.8' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
gateway: ($val('cfg-gw') || '').trim() || undefined,
|
||||
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
const r = await apiFetch('/api/network/interfaces/' + enc(name), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Config saved', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const cfgModalFn = QuickModal({
|
||||
title: (d) => 'Config: ' + d.name,
|
||||
fields: (d) => {
|
||||
const cfg = d.config || {};
|
||||
return [
|
||||
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
|
||||
{ label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
|
||||
{ label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
|
||||
];
|
||||
},
|
||||
submit: {
|
||||
url: (d) => '/api/network/interfaces/' + enc(d.name),
|
||||
body: () => ({
|
||||
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
gateway: ($val('cfg-gw') || '').trim() || undefined,
|
||||
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
}),
|
||||
successMsg: 'Config saved',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (state.ifaces?.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const [fw, net] = await Promise.all([
|
||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
||||
apiFetch('/api/network/interfaces', { signal: sig }),
|
||||
]);
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
// Extract zone names from available zones (for the dropdown)
|
||||
state.zones = fw.ok ? (fw.data?.available || []) : [];
|
||||
if (net.ok) {
|
||||
// Build reverse zone map: interface name → zone name, from active zones
|
||||
const ifaceZone = {};
|
||||
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
|
||||
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
|
||||
await refactorLoad(state,
|
||||
s => s.ifaces?.length,
|
||||
async (s, sig, isAborted) => {
|
||||
const [fw, net] = await Promise.all([
|
||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
||||
apiFetch('/api/network/interfaces', { signal: sig }),
|
||||
]);
|
||||
if (isAborted()) return;
|
||||
if (fw.ok) s.zones = fw.data?.available || [];
|
||||
else s.error = fw.error;
|
||||
if (net.ok) {
|
||||
const ifaceZone = {};
|
||||
for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) {
|
||||
for (const name of (ifaces || [])) ifaceZone[name] = zoneName;
|
||||
}
|
||||
const ifacesObj = net.data?.interfaces || {};
|
||||
s.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
|
||||
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',
|
||||
zone: ifaceZone[name] || null,
|
||||
config: entry?.config || {},
|
||||
}));
|
||||
} else if (!s.error) {
|
||||
s.error = net.error;
|
||||
}
|
||||
// Transform { interfaces: { name: { config, runtime } }, timestamp }
|
||||
// → array of { name, mac, ips, state, zone }
|
||||
const ifacesObj = net.data?.interfaces || {};
|
||||
state.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({
|
||||
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',
|
||||
zone: ifaceZone[name] || null,
|
||||
}));
|
||||
} else {
|
||||
state.error = net.error;
|
||||
}
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { ifaces: [], zones: [], loading: true, refreshing: false, error: null };
|
||||
return { ifaces: [], zones: [] };
|
||||
},
|
||||
subscribe: ['firewall', 'networkd'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Interfaces', 'Network interface management', state.ifaces.length);
|
||||
if (guard) return guard;
|
||||
|
||||
const rows = state.ifaces.map(iface => {
|
||||
return h('tr', { key: iface.name },
|
||||
h('td', null, h('strong', null, iface.name)),
|
||||
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
|
||||
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
|
||||
h('td', null, StatusText({ status: iface.state })),
|
||||
h('td', null,
|
||||
StatusDot({ status: iface.state }),
|
||||
' ' + (iface.state === 'up' ? 'Up' : 'Down'),
|
||||
),
|
||||
h('td', null,
|
||||
h('select', {
|
||||
'on:change': (e) => changeZone(iface.name, e.target.value, state),
|
||||
}, state.zones.map(z =>
|
||||
h('option', { value: z, selected: z === iface.zone }, z),
|
||||
)),
|
||||
ZoneSelect({
|
||||
zones: state.zones,
|
||||
value: iface.zone,
|
||||
onChange: (z) => changeZone(iface.name, z, state),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'margin-left:8px',
|
||||
'on:click': () => cfgModal(iface.name, state),
|
||||
'on:click': () => cfgModalFn(iface),
|
||||
}, 'Config'),
|
||||
),
|
||||
);
|
||||
@@ -141,26 +101,11 @@ export default definePage({
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
|
||||
h('div', { class: 'card' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Name'),
|
||||
h('th', null, 'MAC'),
|
||||
h('th', null, 'IPs'),
|
||||
h('th', null, 'State'),
|
||||
h('th', null, 'Zone / Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(rows.length ? rows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No interfaces found'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
Table({
|
||||
columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
|
||||
rows,
|
||||
emptyText: 'No interfaces found',
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
+56
-34
@@ -1,4 +1,4 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Tabs, esc, definePage, refactorLoad } from '/static/hoover/index.js?v=6';
|
||||
|
||||
const logTabs = [
|
||||
{ key: 'journal', label: 'Journal', url: '/api/logs/journal' },
|
||||
@@ -8,38 +8,33 @@ const logTabs = [
|
||||
{ key: 'app', label: 'App', url: '/api/logs/app' },
|
||||
];
|
||||
|
||||
|
||||
async function fetchLog(state, url, signal) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (signal?.aborted) return;
|
||||
const text = await res.text();
|
||||
if (signal?.aborted) return;
|
||||
state.lines = text.split('\n').filter(l => l.length > 0);
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
if (signal?.aborted) return;
|
||||
const res = await fetch(url, { signal });
|
||||
if (signal?.aborted) return;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (signal?.aborted) return;
|
||||
state.lines = text.split('\n').filter(l => l.length > 0);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { activeTab: 'journal', lines: [], loading: false, refreshing: false, error: null };
|
||||
return { activeTab: 'journal', lines: [], loading: false, _abortCtrl: null };
|
||||
},
|
||||
subscribe: [],
|
||||
async load(state, abortController, entry) {
|
||||
if (state.lines?.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
|
||||
await fetchLog(state, tab.url, abortController?.signal);
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
await refactorLoad(state,
|
||||
s => s.lines?.length,
|
||||
async (s, sig, isAborted) => {
|
||||
const tab = logTabs.find(t => t.key === s.activeTab) || logTabs[0];
|
||||
await fetchLog(s, tab.url, sig);
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
},
|
||||
onUnmount(state) {
|
||||
state._abortCtrl?.abort();
|
||||
state.lines = [];
|
||||
},
|
||||
render(state) {
|
||||
@@ -51,16 +46,32 @@ export default definePage({
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Logs', subtitle: 'System & service logs' }),
|
||||
h('div', { class: 'tabs', key: 'log-tabs' },
|
||||
logTabs.map(t => h('span', {
|
||||
class: 'tab ' + (state.activeTab === t.key ? 'active' : ''),
|
||||
'on:click': async () => {
|
||||
state.activeTab = t.key;
|
||||
await fetchLog(state, t.url);
|
||||
},
|
||||
style: 'cursor:pointer;',
|
||||
}, t.label))
|
||||
),
|
||||
Tabs({
|
||||
state,
|
||||
tabs: logTabs.map(t => t.key),
|
||||
formatLabel: (k) => {
|
||||
const tab = logTabs.find(t => t.key === k);
|
||||
return tab ? tab.label : k.charAt(0).toUpperCase() + k.slice(1);
|
||||
},
|
||||
onTabClick: async (key) => {
|
||||
const tab = logTabs.find(t => t.key === key);
|
||||
if (!tab) return;
|
||||
state._abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
state._abortCtrl = ctrl;
|
||||
const tabs = state.lines?.length ? state : null;
|
||||
state.refreshing = !!tabs;
|
||||
if (!tabs) state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
await fetchLog(state, tab.url, ctrl.signal);
|
||||
} catch (e) {
|
||||
if (!ctrl.signal.aborted) state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
},
|
||||
}),
|
||||
h('div', { class: 'card', key: 'log-card' },
|
||||
h('div', { class: 'card-header' },
|
||||
h('span', null, tab.label),
|
||||
@@ -68,7 +79,18 @@ export default definePage({
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'float:right;',
|
||||
'on:click': async () => {
|
||||
await fetchLog(state, tab.url);
|
||||
state._abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
state._abortCtrl = ctrl;
|
||||
state.refreshing = true;
|
||||
state.error = null;
|
||||
try {
|
||||
await fetchLog(state, tab.url, ctrl.signal);
|
||||
} catch (e) {
|
||||
if (!ctrl.signal.aborted) state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
},
|
||||
}, '\u21BB')
|
||||
),
|
||||
|
||||
+116
-148
@@ -1,117 +1,101 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addFwdModal(zones, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Port Forward',
|
||||
[
|
||||
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
|
||||
{ label: 'Port', id: 'fwd-port', type: 'number' },
|
||||
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
|
||||
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
|
||||
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
zone: $val('fwd-zone'),
|
||||
port: parseInt($val('fwd-port')),
|
||||
proto: ($val('fwd-proto') || 'tcp').trim(),
|
||||
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
|
||||
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
|
||||
};
|
||||
if (!body.zone || !body.port || !body.proto) {
|
||||
toast('Zone, port, and proto are required', 'error');
|
||||
return;
|
||||
}
|
||||
const r = await apiFetch('/api/firewall/forward-port', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Forward rule added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addFwd = QuickModal({
|
||||
title: 'Add Port Forward',
|
||||
fields: (d) => [
|
||||
{ label: 'Zone', id: 'fwd-zone', tag: 'select', options: d.zones },
|
||||
{ label: 'Port', id: 'fwd-port', type: 'number' },
|
||||
{ label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' },
|
||||
{ label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' },
|
||||
{ label: 'To Port (optional)', id: 'fwd-toport', type: 'number' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/forward-port',
|
||||
body: (s) => ({
|
||||
zone: $val('fwd-zone'),
|
||||
port: parseInt($val('fwd-port')),
|
||||
proto: ($val('fwd-proto') || 'tcp').trim(),
|
||||
toaddr: ($val('fwd-toaddr') || '').trim() || undefined,
|
||||
toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined,
|
||||
}),
|
||||
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
|
||||
successMsg: 'Forward rule added',
|
||||
},
|
||||
reload: (s) => load(s._s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (Object.keys(state.config || {}).length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (r.ok) state.config = r.data || {};
|
||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {});
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => Object.keys(s.config || {}).length,
|
||||
async (s, sig, isAborted) => {
|
||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (r.ok) s.config = r.data || {};
|
||||
else s.error = r.error;
|
||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {});
|
||||
else if (!s.error) s.error = zr.error;
|
||||
const sr = await apiFetch('/api/firewall/state', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (sr.ok) s.stateData = sr.data;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, activeZones: [], loading: true, refreshing: false, error: null };
|
||||
return { config: {}, activeZones: [], stateData: null };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'NAT' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
|
||||
const sIface = (state.stateData || {}).interfaces || [];
|
||||
const masqZones = new Set(
|
||||
Object.entries(zoneData)
|
||||
.filter(([, zcfg]) => !!zcfg.masquerade)
|
||||
.map(([z]) => z)
|
||||
);
|
||||
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone));
|
||||
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
|
||||
|
||||
const ifaceRows = (ifaces) =>
|
||||
ifaces.map((iface) =>
|
||||
h('tr', { key: 'ii-' + iface.name },
|
||||
h('td', null,
|
||||
h('div', { class: 'd-flex align-items-center gap-2' },
|
||||
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
|
||||
h('strong', null, iface.name),
|
||||
),
|
||||
),
|
||||
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
|
||||
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
|
||||
)
|
||||
);
|
||||
|
||||
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
||||
const masq = !!zcfg.masquerade;
|
||||
return h('tr', { key: 'm-' + zone },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': async () => {
|
||||
const r = await apiFetch('/api/firewall/masquerade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zone, enable: !masq }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, masq ? 'Disable' : 'Enable'),
|
||||
ActionButton({
|
||||
url: '/api/firewall/masquerade',
|
||||
cls: 'btn btn-sm btn-outline',
|
||||
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
|
||||
body: () => ({ zone, enable: !masq }),
|
||||
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -120,25 +104,21 @@ export default definePage({
|
||||
Object.entries(zoneData).forEach(([zone, zcfg]) => {
|
||||
const forwards = zcfg.forward_ports || [];
|
||||
forwards.forEach((fwd, i) => {
|
||||
const port = fwd.port;
|
||||
const proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
|
||||
h('td', null, h('strong', null, zone)),
|
||||
h('td', null, Badge({ text: fwd['proxy-protocol'] || fwd.proto || 'tcp', variant: 'info' })),
|
||||
h('td', null, fwd.port),
|
||||
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
|
||||
h('td', null, port),
|
||||
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
|
||||
h('td', null, fwd['to-port'] || fwd.toport || '-'),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
const port = fwd.port, proto = fwd['proxy-protocol'] || fwd.proto;
|
||||
if (!confirm('Remove forward ' + zone + ':' + port + '/' + proto + '?')) return;
|
||||
const r = await apiFetch('/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Rule removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
|
||||
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
|
||||
success: 'Rule removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
});
|
||||
@@ -146,49 +126,37 @@ export default definePage({
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
|
||||
h('h3', { class: 'section-title' }, 'Masquerade'),
|
||||
h('div', { class: 'card' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Zone'),
|
||||
h('th', null, 'Status'),
|
||||
h('th', { style: 'width:100px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(masqRows.length ? masqRows : [
|
||||
h('tr', null, h('td', { colspan: 3, class: 'text-muted' }, 'No zones')),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
h('h3', { class: 'section-title' }, 'Port Forwarding'),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { style: 'padding:0.75rem;', class: 'flex' },
|
||||
DataTableSection({
|
||||
title: 'WAN / External',
|
||||
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
||||
rows: ifaceRows(wanIface),
|
||||
emptyText: 'No WAN interfaces with masquerade enabled',
|
||||
}),
|
||||
DataTableSection({
|
||||
title: 'Internal / LAN',
|
||||
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
||||
rows: ifaceRows(lanIface),
|
||||
emptyText: 'No internal interfaces',
|
||||
}),
|
||||
DataTableSection({
|
||||
title: 'Masquerade',
|
||||
columns: ['Zone', 'Status', 'Action'],
|
||||
rows: masqRows,
|
||||
emptyText: 'No zones',
|
||||
}),
|
||||
SectionTitle({ title: 'Port Forwarding' }),
|
||||
Card({ children: [
|
||||
ActionGroup(
|
||||
h('button', { class: 'btn btn-sm btn-primary',
|
||||
'on:click': () => addFwdModal(state.activeZones, state) }, 'Add Forward'),
|
||||
'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'),
|
||||
),
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Zone'),
|
||||
h('th', null, 'Proto'),
|
||||
h('th', null, 'Port'),
|
||||
h('th', null, 'To Addr'),
|
||||
h('th', null, 'To Port'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
...(fwRows.length ? fwRows : [
|
||||
h('tr', null,
|
||||
h('td', { colspan: 6, class: 'text-muted text-sm' }, 'No port forwarding rules'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
Table({
|
||||
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
|
||||
rows: fwRows,
|
||||
emptyText: 'No port forwarding rules',
|
||||
wrapCard: false,
|
||||
}),
|
||||
]}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, definePage } from '/static/hoover/index.js?v=6';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
|
||||
+89
-162
@@ -1,143 +1,85 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addDomainModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Proxy Domain',
|
||||
[
|
||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
domain: ($val('p-domain') || '').trim(),
|
||||
backend_host: ($val('p-host') || '').trim(),
|
||||
backend_port: parseInt($val('p-port')),
|
||||
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||
cert: ($val('p-cert') || '').trim() || undefined,
|
||||
};
|
||||
if (!body.domain || !body.backend_host || !body.backend_port) {
|
||||
toast('Domain, host, and port are required', 'error');
|
||||
return;
|
||||
}
|
||||
const resp = await apiFetch('/api/proxy/domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Domain added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addDomain = QuickModal({
|
||||
title: 'Add Proxy Domain',
|
||||
fields: [
|
||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/proxy/domains',
|
||||
body: () => ({
|
||||
domain: ($val('p-domain') || '').trim(),
|
||||
backend_host: ($val('p-host') || '').trim(),
|
||||
backend_port: parseInt($val('p-port')),
|
||||
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||
cert: ($val('p-cert') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
|
||||
successMsg: 'Domain added',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
function editDomainModal(domain, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Edit: ' + domain.domain,
|
||||
[
|
||||
{ label: 'Backend Host', id: 'pe-host', value: domain.backend_host || '' },
|
||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: domain.backend_port || '' },
|
||||
{ label: 'Protocol', id: 'pe-proto', value: domain.backend_proto || domain.protocol || 'http' },
|
||||
{ label: 'Cert (optional)', id: 'pe-cert', value: domain.cert || '' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
backend_host: ($val('pe-host') || '').trim(),
|
||||
backend_port: parseInt($val('pe-port')),
|
||||
backend_proto: ($val('pe-proto') || 'http').trim(),
|
||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
||||
};
|
||||
const resp = await apiFetch('/api/proxy/domains/' + enc(domain.domain), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Domain updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const editDomain = QuickModal({
|
||||
title: (d) => 'Edit: ' + d.domain,
|
||||
fields: (d) => [
|
||||
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
|
||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
|
||||
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
|
||||
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
|
||||
],
|
||||
submit: {
|
||||
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
||||
method: 'PUT',
|
||||
body: (d) => ({
|
||||
backend_host: ($val('pe-host') || '').trim(),
|
||||
backend_port: parseInt($val('pe-port')),
|
||||
backend_proto: ($val('pe-proto') || 'http').trim(),
|
||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
||||
}),
|
||||
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
|
||||
successMsg: 'Domain updated',
|
||||
},
|
||||
reload: (s) => load(s._s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (state.domains?.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (domainsR.ok) state.domains = domainsR.data || [];
|
||||
const certsR = await apiFetch('/api/certs/list', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (certsR.ok) state.certs = certsR.data || [];
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => s.domains?.length,
|
||||
async (s, sig, isAborted) => {
|
||||
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (domainsR.ok) s.domains = domainsR.data || [];
|
||||
else s.error = domainsR.error;
|
||||
const certsR = await apiFetch('/api/certs/list', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (certsR.ok) s.certs = certsR.data || [];
|
||||
else if (!s.error) s.error = certsR.error;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { domains: [], certs: [], loading: true, refreshing: false, error: null };
|
||||
return { domains: [], certs: [] };
|
||||
},
|
||||
subscribe: ['nginx', 'acme'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Proxy' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Proxy' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Proxy', 'Nginx reverse proxy', state.domains);
|
||||
if (guard) return guard;
|
||||
|
||||
const rows = state.domains.map(d => {
|
||||
let certBadge = Badge({ text: 'No cert', variant: 'info' });
|
||||
if (d.cert_status === 'valid' || d.cert_status === 'active') {
|
||||
certBadge = Badge({ text: 'Valid', variant: 'success' });
|
||||
} else if (d.cert_status === 'expired' || (d.days_remaining !== undefined && d.days_remaining <= 0)) {
|
||||
certBadge = Badge({ text: 'Expired', variant: 'danger' });
|
||||
} else if (d.days_remaining !== undefined && d.days_remaining <= 30) {
|
||||
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'warning' });
|
||||
} else if (d.days_remaining !== undefined) {
|
||||
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'success' });
|
||||
}
|
||||
const certBadge = certStatusBadge({
|
||||
certStatus: d.cert_status,
|
||||
daysRemaining: d.days_remaining,
|
||||
expired: d.cert_status === 'expired',
|
||||
});
|
||||
|
||||
return h('tr', { key: d.domain },
|
||||
h('td', null, h('strong', null, esc(d.domain))),
|
||||
@@ -145,50 +87,35 @@ export default definePage({
|
||||
h('td', null, d.backend_port || '-'),
|
||||
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
|
||||
h('td', null, certBadge),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': () => editDomainModal(d, state) }, 'Edit'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove proxy for ' + d.domain + '?')) return;
|
||||
const r = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Domain removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Delete'),
|
||||
),
|
||||
ActionCell({
|
||||
editLabel: 'Edit',
|
||||
editClick: () => editDomain({ ...d, _s: state }),
|
||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||
removeSuccess: 'Domain removed',
|
||||
removeReload: () => load(state),
|
||||
removeLabel: 'Delete',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addDomainModal(state) }, 'Add Domain'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/proxy/apply', { method: 'POST' });
|
||||
if (resp.ok) toast('Nginx applied & reloaded', 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
}}, 'Apply'),
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
|
||||
ActionButton({
|
||||
url: '/api/proxy/apply',
|
||||
successMsg: 'Nginx applied & reloaded',
|
||||
label: 'Apply',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||
rows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Domain'),
|
||||
h('th', null, 'Backend Host'),
|
||||
h('th', null, 'Port'),
|
||||
h('th', null, 'Proto'),
|
||||
h('th', null, 'Cert'),
|
||||
h('th', { style: 'width:140px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...rows),
|
||||
))
|
||||
? Table({
|
||||
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||
];
|
||||
},
|
||||
|
||||
+57
-106
@@ -1,83 +1,46 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, MonoText, QuickModal } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addRuleModal(zones, state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Rich Rule',
|
||||
[
|
||||
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) },
|
||||
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const zone = $val('rule-zone');
|
||||
const rule = ($val('rule-text') || '').trim();
|
||||
if (!zone || !rule) { toast('Zone and rule are required', 'error'); return; }
|
||||
const r = await apiFetch('/api/firewall/rich-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zone, rule }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Rule added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addRule = QuickModal({
|
||||
title: 'Add Rich Rule',
|
||||
fields: (d) => [
|
||||
{ label: 'Zone', id: 'rule-zone', tag: 'select', options: d.zones },
|
||||
{ label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/rich-rules',
|
||||
body: (s) => ({ zone: $val('rule-zone'), rule: ($val('rule-text') || '').trim() }),
|
||||
validate: (b) => !b.zone || !b.rule ? 'Zone and rule are required' : null,
|
||||
successMsg: 'Rule added',
|
||||
},
|
||||
reload: (s) => load(s._s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (Object.keys(state.config || {}).length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (r.ok) state.config = r.data || {};
|
||||
else state.error = r.error;
|
||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (zr.ok) state.zones = Object.keys(zr.data?.active || {});
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => Object.keys(s.config || {}).length,
|
||||
async (s, sig, isAborted) => {
|
||||
const r = await apiFetch('/api/firewall/config', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (r.ok) s.config = r.data || {};
|
||||
else s.error = r.error;
|
||||
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (zr.ok) s.zones = Object.keys(zr.data?.active || {});
|
||||
else if (!s.error) s.error = zr.error;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, loading: true, refreshing: false, error: null, zones: [] };
|
||||
return { config: {}, zones: [] };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Rules' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Rules', 'Firewall rich rules', state.config);
|
||||
if (guard) return guard;
|
||||
|
||||
const cfg = state.config || {};
|
||||
const zoneData = cfg.zones || {};
|
||||
@@ -88,43 +51,31 @@ export default definePage({
|
||||
});
|
||||
|
||||
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
|
||||
return h('div', { class: 'card', key: zone },
|
||||
h('div', { class: 'card-header' }, 'Zone: ' + esc(zone)),
|
||||
h('div', { class: 'card-body' },
|
||||
h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, '#'),
|
||||
h('th', null, 'Rule'),
|
||||
h('th', { style: 'width:80px;' }, 'Action'),
|
||||
return Card({
|
||||
header: 'Zone: ' + esc(zone),
|
||||
key: zone,
|
||||
children: [Table({
|
||||
columns: ['#', 'Rule', 'Action'],
|
||||
rows: (Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||
return h('tr', { key: i },
|
||||
h('td', { class: 'text-muted' }, i + 1),
|
||||
h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })),
|
||||
h('td', null,
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''),
|
||||
message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?',
|
||||
success: 'Rule removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
),
|
||||
h('tbody', null,
|
||||
(Array.isArray(rules) ? rules : []).map((entry, i) => {
|
||||
const ruleId = typeof entry === 'object' ? entry.id : null;
|
||||
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
|
||||
return h('tr', { key: i },
|
||||
h('td', { class: 'text-muted' }, i + 1),
|
||||
h('td', { style: 'font-family:monospace;font-size:12px;word-break:break-all;' }, esc(ruleText)),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove rule: ' + ruleText.substring(0, 40) + '...?')) return;
|
||||
const r = await apiFetch('/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Rule removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}),
|
||||
emptyText: 'No rules',
|
||||
wrapCard: false,
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -132,7 +83,7 @@ export default definePage({
|
||||
title: 'Rules',
|
||||
subtitle: 'Firewall rich rules',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addRuleModal(state.zones, state) }, 'Add Rule'),
|
||||
'on:click': () => addRule({ zones: state.zones, _s: state }), }, 'Add Rule'),
|
||||
}),
|
||||
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
|
||||
];
|
||||
|
||||
+73
-148
@@ -1,43 +1,26 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, apiSubmit, refactorLoad, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addPeerModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add WireGuard Peer',
|
||||
[
|
||||
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
||||
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
||||
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
|
||||
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const body = {
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
};
|
||||
if (!body.name) { toast('Name is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/wireguard/peers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Peer added', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addPeer = QuickModal({
|
||||
title: 'Add WireGuard Peer',
|
||||
fields: [
|
||||
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
||||
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
||||
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
|
||||
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/wireguard/peers',
|
||||
body: () => ({
|
||||
name: ($val('wg-name') || '').trim(),
|
||||
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
||||
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
|
||||
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
||||
}),
|
||||
validate: (b) => !b.name ? 'Name is required' : null,
|
||||
successMsg: 'Peer added',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
function downloadConfigModal(peerName, config, state) {
|
||||
openModal((inner, idx) => {
|
||||
@@ -51,19 +34,10 @@ function downloadConfigModal(peerName, config, state) {
|
||||
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/wireguard/generate-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: peerName, server_endpoint: endpoint }),
|
||||
body: { name: peerName, server_endpoint: endpoint },
|
||||
});
|
||||
if (resp.ok && resp.data?.config) {
|
||||
const blob = new Blob([resp.data.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = peerName + '.conf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
} else {
|
||||
@@ -77,52 +51,35 @@ function downloadConfigModal(peerName, config, state) {
|
||||
}
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (state.peers?.length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (stR.ok) state.status = stR.data || {};
|
||||
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (pR.ok) state.peers = pR.data || [];
|
||||
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (cfgR.ok) state.config = cfgR.data || {};
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => s.peers?.length,
|
||||
async (s, sig, isAborted) => {
|
||||
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (stR.ok) s.status = stR.data || {};
|
||||
else s.error = stR.error;
|
||||
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (pR.ok) s.peers = pR.data || [];
|
||||
else if (!s.error) s.error = pR.error;
|
||||
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
|
||||
if (isAborted()) return;
|
||||
if (cfgR.ok) s.config = cfgR.data || {};
|
||||
else if (!s.error) s.error = cfgR.error;
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null };
|
||||
return { status: {}, peers: [], config: {} };
|
||||
},
|
||||
subscribe: ['wireguard'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'WireGuard' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'WireGuard' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
|
||||
if (guard) return guard;
|
||||
|
||||
const st = state.status || {};
|
||||
const isUp = st.state === 'up';
|
||||
@@ -135,10 +92,7 @@ export default definePage({
|
||||
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
|
||||
h('strong', null, esc(p.name || 'unnamed')),
|
||||
),
|
||||
h('td', { style: 'font-family:monospace;font-size:11px;' },
|
||||
esc((p.public_key || 'N/A').substring(0, 20)) +
|
||||
(p.public_key && p.public_key.length > 20 ? '...' : ''),
|
||||
),
|
||||
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
|
||||
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
|
||||
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
|
||||
@@ -147,46 +101,31 @@ export default definePage({
|
||||
h('br'),
|
||||
'Sent: ' + esc(p.transfer_sent || '0'),
|
||||
),
|
||||
h('td', null,
|
||||
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
||||
'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'),
|
||||
h('button', { class: 'btn btn-sm btn-danger',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Remove peer ' + p.name + '?')) return;
|
||||
const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Peer removed', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Remove'),
|
||||
),
|
||||
ActionCell({
|
||||
editLabel: 'Config',
|
||||
editClick: () => downloadConfigModal(p.name, state.config, state),
|
||||
removeUrl: '/api/wireguard/peers/' + enc(p.name),
|
||||
removeMessage: 'Remove peer ' + p.name + '?',
|
||||
removeSuccess: 'Peer removed',
|
||||
removeReload: () => load(state),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const actions = h('div', { style: 'display:flex;gap:8px;' },
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, isUp ? 'Stop' : 'Start'),
|
||||
h('button', { class: 'btn btn-outline',
|
||||
'on:click': async () => {
|
||||
const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast('Config applied', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Apply'),
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
|
||||
ActionButton({
|
||||
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
||||
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
|
||||
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
||||
reload: () => load(state),
|
||||
}),
|
||||
ActionButton({
|
||||
url: '/api/wireguard/apply',
|
||||
successMsg: 'Config applied',
|
||||
label: 'Apply',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -195,26 +134,12 @@ export default definePage({
|
||||
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
|
||||
actions,
|
||||
}),
|
||||
h('div', null,
|
||||
StatusDot({ status: isUp ? 'success' : 'danger' }),
|
||||
' ',
|
||||
Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }),
|
||||
),
|
||||
ServiceStatus({ state: st.state || 'down' }),
|
||||
peerRows.length
|
||||
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
||||
h('thead', null,
|
||||
h('tr', null,
|
||||
h('th', null, 'Peer'),
|
||||
h('th', null, 'Public Key'),
|
||||
h('th', null, 'Allowed IPs'),
|
||||
h('th', null, 'Endpoint'),
|
||||
h('th', null, 'Handshake'),
|
||||
h('th', null, 'Transfer'),
|
||||
h('th', { style: 'width:120px;' }, 'Actions'),
|
||||
),
|
||||
),
|
||||
h('tbody', null, ...peerRows),
|
||||
))
|
||||
? Table({
|
||||
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
|
||||
rows: peerRows,
|
||||
})
|
||||
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
||||
];
|
||||
},
|
||||
|
||||
+78
-167
@@ -1,142 +1,59 @@
|
||||
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
||||
import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, apiFetch, toast, definePage, refactorLoad, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=6';
|
||||
|
||||
function addZoneModal(state) {
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Add Zone',
|
||||
[
|
||||
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
||||
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Create', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const name = ($val('zone-name') || '').trim();
|
||||
if (!name) { toast('Zone name required', 'error'); return; }
|
||||
const target = ($val('zone-target') || '').trim() || 'default';
|
||||
const r = await apiFetch('/api/firewall/zones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, target }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Zone ' + name + ' created', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function zoneIfaceModal(zoneName, state) {
|
||||
const zdata = state.zones?.[zoneName] || {};
|
||||
const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : [];
|
||||
const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : [];
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Interfaces: ' + zoneName,
|
||||
[
|
||||
{
|
||||
label: 'Interfaces', id: 'z-iface-select', tag: 'select',
|
||||
options: allIfaces.map(i => [i, current.includes(i)]),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const sel = document.getElementById('z-iface-select');
|
||||
const selected = Array.from(sel.selectedOptions).map(o => o.value);
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interfaces: selected }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Interfaces updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function zoneSvcModal(zoneName, state) {
|
||||
const zdata = state.zones?.[zoneName] || {};
|
||||
const current = Array.isArray(zdata.services) ? zdata.services : [];
|
||||
const all = Array.isArray(state.services) ? state.services : [];
|
||||
openModal((inner, idx) => {
|
||||
formModal(inner, 'Services: ' + zoneName,
|
||||
[
|
||||
{
|
||||
label: 'Services', id: 'z-svc-select', tag: 'select',
|
||||
options: all.map(s => [s, current.includes(s)]),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
const sel = document.getElementById('z-svc-select');
|
||||
const selected = Array.from(sel.selectedOptions).map(o => o.value);
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services: selected }),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('Services updated', 'success');
|
||||
closeModal(idx);
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addZone = QuickModal({
|
||||
title: 'Add Zone',
|
||||
fields: [
|
||||
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
||||
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
||||
],
|
||||
submit: {
|
||||
url: '/api/firewall/zones',
|
||||
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
|
||||
validate: (b) => !b.name ? 'Zone name required' : null,
|
||||
successMsg: 'Zone created',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (Object.keys(state.zones || {}).length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const [zRes, svcRes, ifRes] = await Promise.all([
|
||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
||||
apiFetch('/api/firewall/services', { signal: sig }),
|
||||
apiFetch('/api/firewall/interfaces', { signal: sig }),
|
||||
]);
|
||||
await refactorLoad(state,
|
||||
s => Object.keys(s.zones || {}).length,
|
||||
async (s, sig, isAborted) => {
|
||||
const [zRes, svcRes, ifRes] = await Promise.allSettled([
|
||||
apiFetch('/api/firewall/zones', { signal: sig }),
|
||||
apiFetch('/api/firewall/services', { signal: sig }),
|
||||
apiFetch('/api/firewall/interfaces', { signal: sig }),
|
||||
]);
|
||||
if (isAborted()) return;
|
||||
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
const errors = [];
|
||||
if (zRes.status === 'rejected') errors.push(zRes.reason?.message || 'Failed');
|
||||
else if (!zRes.value.ok) errors.push(zRes.value.error || 'Failed');
|
||||
if (svcRes.status === 'rejected') errors.push(svcRes.reason?.message || 'Failed');
|
||||
else if (!svcRes.value.ok) errors.push(svcRes.value.error || 'Failed');
|
||||
if (ifRes.status === 'rejected') errors.push(ifRes.reason?.message || 'Failed');
|
||||
else if (!ifRes.value.ok) errors.push(ifRes.value.error || 'Failed');
|
||||
if (errors.length) {
|
||||
s.error = errors[0];
|
||||
return;
|
||||
}
|
||||
|
||||
if (zRes.ok) {
|
||||
const data = zRes.data || {};
|
||||
const data = zRes.value.data || {};
|
||||
const activeZones = data.active || {};
|
||||
const availableZones = data.available || [];
|
||||
|
||||
const detailPromises = availableZones.map(name =>
|
||||
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig }).catch(() => null)
|
||||
apiFetch('/api/firewall/zones/' + enc(name), { signal: sig })
|
||||
);
|
||||
const detailResults = await Promise.all(detailPromises);
|
||||
const detailResults = await Promise.allSettled(detailPromises);
|
||||
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (isAborted()) return;
|
||||
|
||||
const zones = {};
|
||||
for (let i = 0; i < availableZones.length; i++) {
|
||||
const name = availableZones[i];
|
||||
const detail = detailResults[i];
|
||||
const res = detailResults[i];
|
||||
const detail = res.status === 'fulfilled' ? res.value : null;
|
||||
if (detail && detail.ok) {
|
||||
zones[name] = detail.data;
|
||||
const activeIfaces = activeZones[name];
|
||||
@@ -145,43 +62,23 @@ async function load(state, abortController, entry) {
|
||||
}
|
||||
}
|
||||
}
|
||||
state.zones = zones;
|
||||
}
|
||||
|
||||
if (svcRes.ok) state.services = svcRes.data || [];
|
||||
if (ifRes.ok) state.interfaces = ifRes.data || [];
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
s.zones = zones;
|
||||
s.services = svcRes.value.data || [];
|
||||
s.interfaces = ifRes.value.data || [];
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null };
|
||||
return { zones: {}, services: [], interfaces: [] };
|
||||
},
|
||||
subscribe: ['firewall'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Zones' }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
const guard = renderGuard(state, 'Zones', 'Firewall zone management', Object.keys(state.zones || {}).length);
|
||||
if (guard) return guard;
|
||||
|
||||
const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => {
|
||||
const z = typeof zdata === 'object' ? zdata : {};
|
||||
@@ -210,20 +107,34 @@ export default definePage({
|
||||
),
|
||||
h('div', { style: 'display:flex;gap:6px;' },
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => zoneIfaceModal(name, state) }, 'Interfaces'),
|
||||
'on:click': () => MultiSelectModal({
|
||||
title: 'Interfaces: ' + name,
|
||||
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
||||
options: state.interfaces,
|
||||
selected: ifacesArr,
|
||||
fieldKey: 'interfaces',
|
||||
successMsg: 'Interfaces updated',
|
||||
reload: () => load(state),
|
||||
})(),
|
||||
}, 'Interfaces'),
|
||||
h('button', { class: 'btn btn-sm btn-outline',
|
||||
'on:click': () => zoneSvcModal(name, state) }, 'Services'),
|
||||
h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;',
|
||||
'on:click': async () => {
|
||||
if (!confirm('Delete zone ' + name + '?')) return;
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' });
|
||||
if (r.ok) {
|
||||
toast('Zone ' + name + ' deleted', 'success');
|
||||
await load(state);
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
}}, 'Delete'),
|
||||
'on:click': () => MultiSelectModal({
|
||||
title: 'Services: ' + name,
|
||||
url: '/api/firewall/zones/' + enc(name) + '/services',
|
||||
options: state.services,
|
||||
selected: svcsArr,
|
||||
fieldKey: 'services',
|
||||
successMsg: 'Services updated',
|
||||
reload: () => load(state),
|
||||
})(),
|
||||
}, 'Services'),
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/zones/' + enc(name),
|
||||
message: 'Delete zone ' + name + '?',
|
||||
success: 'Zone ' + name + ' deleted',
|
||||
reload: () => load(state),
|
||||
label: 'Delete',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -233,7 +144,7 @@ export default definePage({
|
||||
title: 'Zones',
|
||||
subtitle: 'Firewall zones',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => addZoneModal(state) }, 'Add Zone'),
|
||||
'on:click': () => addZone(state), }, 'Add Zone'),
|
||||
}),
|
||||
zoneCards.length
|
||||
? h('div', { class: 'card-grid' }, ...zoneCards)
|
||||
|
||||
Reference in New Issue
Block a user