feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages
This commit is contained in:
+2
-2
@@ -6,8 +6,8 @@ import ZonesPage from '/static/pages/zones.js?v=9';
|
||||
import RulesPage from '/static/pages/rules.js?v=9';
|
||||
import NatPage from '/static/pages/nat.js?v=9';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=9';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=9';
|
||||
import BackendsPage from '/static/pages/backends.js?v=9';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=10';
|
||||
import BackendsPage from '/static/pages/backends.js?v=10';
|
||||
import CertsPage from '/static/pages/certs.js?v=9';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=9';
|
||||
import LogsPage from '/static/pages/logs.js?v=9';
|
||||
|
||||
+59
-21
@@ -3,10 +3,12 @@
|
||||
*
|
||||
* JSON-friendly fetch wrapper with automatic header management.
|
||||
* Toast notification system with auto-dismiss.
|
||||
* Modal processing guard for async form submissions.
|
||||
*/
|
||||
|
||||
import { modelFetch } from './model.js?v=8';
|
||||
import { requestUpdate } from './reactivity.js?v=8';
|
||||
import { modelFetch } from './model.js?v=9';
|
||||
import { requestUpdate } from './reactivity.js?v=9';
|
||||
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9';
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
@@ -178,6 +180,33 @@ export async function poll(opts) {
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized handler wrapper that encapsulates processing guard,
|
||||
* processing state, error handling, and modal re-render.
|
||||
*
|
||||
* Used by any handler not using `apiSubmit`. The async function receives
|
||||
* no arguments and should perform validation (via `throw`), API calls,
|
||||
* success/error toasting, modal closing, and data refreshing.
|
||||
*
|
||||
* @param {function} fn – Async handler function
|
||||
* @returns {function} Wrapped handler
|
||||
*/
|
||||
export function formAction(fn) {
|
||||
return async () => {
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
refreshModals();
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed', 'error');
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action button descriptors for modal form submission.
|
||||
*
|
||||
@@ -211,28 +240,37 @@ export function apiSubmit(opts) {
|
||||
label: submitText,
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
processing: true,
|
||||
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) {
|
||||
const synced = res.data?.synced;
|
||||
let msg = successMsg;
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
refreshModals();
|
||||
try {
|
||||
const b = body ? body() : {};
|
||||
if (validate) {
|
||||
const err = validate(b);
|
||||
if (err) { toast(err, 'error'); return; }
|
||||
}
|
||||
toast(msg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (refresh) {
|
||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||
await Promise.all(models.map(m => modelFetch(m)));
|
||||
const res = await apiFetch(url, { method, body: b });
|
||||
if (res.ok) {
|
||||
const synced = res.data?.synced;
|
||||
let msg = successMsg;
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
toast(msg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (refresh) {
|
||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||
await Promise.all(models.map(m => modelFetch(m)));
|
||||
}
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=8';
|
||||
import { h } from './vdom.js?v=8';
|
||||
import { _compExpandedCache } from './render.js?v=8';
|
||||
import { reactive } from './reactivity.js?v=9';
|
||||
import { h } from './vdom.js?v=9';
|
||||
import { _compExpandedCache } from './render.js?v=9';
|
||||
|
||||
/** Registry of mounted components: key → { state } */
|
||||
const _mounted = new Map();
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* expandable modal, then applies all via /api/status/apply-all.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=8';
|
||||
import { html } from '../html.js?v=8';
|
||||
import { reactive } from '../reactivity.js?v=8';
|
||||
import { apiFetch, toast } from '../api.js?v=8';
|
||||
import { modelFetch } from '../model.js?v=8';
|
||||
import { openModal, closeModal, modalVNodes } from './modal.js?v=8';
|
||||
import { h } from '../vdom.js?v=9';
|
||||
import { html } from '../html.js?v=9';
|
||||
import { reactive } from '../reactivity.js?v=9';
|
||||
import { apiFetch, toast } from '../api.js?v=9';
|
||||
import { modelFetch } from '../model.js?v=9';
|
||||
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js?v=9';
|
||||
|
||||
export const SUBSYSTEM_LIST = [
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
@@ -59,16 +59,23 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
|
||||
* POST apply-all, toast result, close modal, refresh models.
|
||||
*/
|
||||
async function doApply(successMsg, refreshTargets) {
|
||||
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast(successMsg, 'success');
|
||||
closeModal();
|
||||
if (refreshTargets) {
|
||||
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
|
||||
names.forEach(n => modelFetch(n));
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
toast(successMsg, 'success');
|
||||
closeModal();
|
||||
if (refreshTargets) {
|
||||
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Apply failed', 'error');
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Apply failed', 'error');
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=8';
|
||||
import { esc } from '../helpers.js?v=8';
|
||||
import { apiFetch, toast } from '../api.js?v=8';
|
||||
import { modelFetch } from '../model.js?v=8';
|
||||
import { h } from '../vdom.js?v=9';
|
||||
import { esc } from '../helpers.js?v=9';
|
||||
import { apiFetch, toast } from '../api.js?v=9';
|
||||
import { modelFetch } from '../model.js?v=9';
|
||||
import { requestUpdate } from '../reactivity.js?v=9';
|
||||
|
||||
const _actionPending = new Map();
|
||||
const _confirmPending = new Map();
|
||||
|
||||
export const _deleting = new Set();
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
@@ -75,30 +81,59 @@ export function Card(props = {}) {
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
|
||||
*/
|
||||
export function ConfirmDelete(props = {}) {
|
||||
const opts = { method: 'DELETE' };
|
||||
if (props.body) opts.body = props.body;
|
||||
return h('button', { class: 'btn btn-sm btn-danger',
|
||||
const deleteKey = props.url + (props.body ? '::' + JSON.stringify(props.body) : '');
|
||||
const pending = _confirmPending.get(deleteKey) || false;
|
||||
|
||||
return h('button', {
|
||||
class: 'btn btn-sm btn-danger',
|
||||
disabled: pending,
|
||||
'on:click': async () => {
|
||||
if (!confirm(props.message)) return;
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
const synced = r.data?.synced;
|
||||
let msg = props.success || 'Removed';
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
_confirmPending.set(deleteKey, true);
|
||||
requestUpdate();
|
||||
try {
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
const synced = r.data?.synced;
|
||||
let msg = props.success || 'Removed';
|
||||
if (synced && synced.length) {
|
||||
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
toast(msg, 'success');
|
||||
|
||||
if (props.deleteKey) {
|
||||
_deleting.add(props.deleteKey);
|
||||
}
|
||||
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
const promises = names.map(n => modelFetch(n));
|
||||
|
||||
if (props.deleteKey && promises.length) {
|
||||
Promise.all(promises).finally(() => {
|
||||
_deleting.delete(props.deleteKey);
|
||||
});
|
||||
}
|
||||
} else if (props.deleteKey) {
|
||||
// Cleanup dimming synchronously on DELETE completion rather than
|
||||
// relying on a heuristic timeout that breaks when tabs are throttled.
|
||||
_deleting.delete(props.deleteKey);
|
||||
}
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
toast(msg, 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
} finally {
|
||||
_confirmPending.delete(deleteKey);
|
||||
requestUpdate();
|
||||
}
|
||||
}}, props.label || 'Remove');
|
||||
}
|
||||
}, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,32 +163,42 @@ export function ActionButton(props = {}) {
|
||||
? (props.condition ? props.labelOn : props.labelOff)
|
||||
: 'Action');
|
||||
const cls = props.cls || 'btn btn-outline';
|
||||
const pending = _actionPending.get(props.url) || false;
|
||||
|
||||
return h('button', {
|
||||
class: cls,
|
||||
disabled: props.disabled,
|
||||
disabled: !!props.disabled || pending,
|
||||
'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) {
|
||||
const synced = resp.data?.synced;
|
||||
let msg = props.successMsg || '';
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
if (pending) return;
|
||||
_actionPending.set(props.url, true);
|
||||
requestUpdate();
|
||||
try {
|
||||
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) {
|
||||
const synced = resp.data?.synced;
|
||||
let msg = props.successMsg || '';
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
synced.forEach(s => modelFetch(s));
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
} finally {
|
||||
_actionPending.delete(props.url);
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}, label);
|
||||
}, pending ? h('span', { class: 'btn-spinner' }) : label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,6 +324,7 @@ export function ServiceStatus(props = {}) {
|
||||
* @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')
|
||||
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
|
||||
*/
|
||||
export function ActionCell(props = {}) {
|
||||
return h('td', null,
|
||||
@@ -294,6 +340,7 @@ export function ActionCell(props = {}) {
|
||||
refresh: props.removeRefresh,
|
||||
label: props.removeLabel || 'Remove',
|
||||
body: props.removeBody,
|
||||
deleteKey: props.deleteKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=8';
|
||||
import { Table } from './data.js?v=8';
|
||||
import { collectLoadingModels } from '../model.js?v=8';
|
||||
import { h } from '../vdom.js?v=9';
|
||||
import { Table } from './data.js?v=9';
|
||||
import { collectLoadingModels } from '../model.js?v=9';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
* avoid fighting with the main render cycle.
|
||||
*/
|
||||
|
||||
import { esc } from '../helpers.js?v=8';
|
||||
import { att_esc } from '../helpers.js?v=8';
|
||||
import { apiSubmit } from '../api.js?v=8';
|
||||
import { createDom } from '../vdom.js?v=8';
|
||||
import { esc, att_esc } from '../helpers.js?v=9';
|
||||
import { apiSubmit } from '../api.js?v=9';
|
||||
import { createDom } from '../vdom.js?v=9';
|
||||
|
||||
/**
|
||||
* Render Hoover VNodes into a modal content element.
|
||||
@@ -33,7 +32,13 @@ function _renderModals() {
|
||||
_modalQueue.forEach((m, idx) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'modal-overlay active';
|
||||
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
|
||||
wrap.onclick = (e) => {
|
||||
if (e.target === wrap) {
|
||||
if (m._processing) return;
|
||||
if (m._hasInputs && !confirm('Discard changes?')) return;
|
||||
closeModal(idx);
|
||||
}
|
||||
};
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal';
|
||||
content.onclick = (e) => e.stopPropagation();
|
||||
@@ -55,12 +60,36 @@ function _renderModals() {
|
||||
*/
|
||||
export function openModal(content) {
|
||||
const entry = typeof content === 'function'
|
||||
? { renderFn: content, id: _modalQueue.length }
|
||||
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
|
||||
? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false }
|
||||
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false };
|
||||
_modalQueue.push(entry);
|
||||
_renderModals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the topmost modal (or specified index) has an active async operation.
|
||||
*
|
||||
* @param {number} [idx] – Modal index (defaults to topmost)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModalProcessing(idx) {
|
||||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||||
if (idx < 0 || idx >= _modalQueue.length) return false;
|
||||
return _modalQueue[idx]._processing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the processing flag on the topmost modal (or specified index).
|
||||
*
|
||||
* @param {boolean} flag – Whether the modal is currently processing
|
||||
* @param {number} [idx] – Modal index (defaults to topmost)
|
||||
*/
|
||||
export function setModalProcessing(flag, idx) {
|
||||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||||
if (idx < 0 || idx >= _modalQueue.length) return;
|
||||
_modalQueue[idx]._processing = flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a modal by index. Closes the topmost modal if index is omitted.
|
||||
*
|
||||
@@ -135,16 +164,39 @@ export function formModal(inner, title, fields, actions) {
|
||||
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
|
||||
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
|
||||
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
|
||||
+ (f.checked ? ' checked' : '')
|
||||
+ '></' + tag + '></div>';
|
||||
}).join('') + '</div><div class="modal-actions">'
|
||||
+ actions.map(a =>
|
||||
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
|
||||
).join('') + '</div>';
|
||||
}).join('') + '</div><div class="modal-actions"></div>';
|
||||
|
||||
actions.forEach(a => {
|
||||
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
|
||||
if (btn) btn.addEventListener('click', a.handler);
|
||||
});
|
||||
// Mark modal as having editable inputs
|
||||
const topEntry = _modalQueue[_modalQueue.length - 1];
|
||||
if (topEntry) topEntry._hasInputs = true;
|
||||
|
||||
// Build action buttons with processing-aware rendering
|
||||
const actionsBar = inner.querySelector('.modal-actions');
|
||||
const actionBtns = [];
|
||||
for (const a of actions) {
|
||||
const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn ' + a.cls;
|
||||
btn.id = actionId;
|
||||
if (a.processing && isModalProcessing(_modalQueue.length - 1)) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="btn-spinner"></span>';
|
||||
} else {
|
||||
btn.appendChild(document.createTextNode(a.label));
|
||||
}
|
||||
// Store reference for later re-binding
|
||||
actionBtns.push({ btn, action: a });
|
||||
if (a.handler) {
|
||||
const origHandler = a.handler;
|
||||
btn.addEventListener('click', () => {
|
||||
refreshModals();
|
||||
origHandler();
|
||||
});
|
||||
}
|
||||
actionsBar.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=8';
|
||||
import { _toasts, dismissToast } from '../api.js?v=8';
|
||||
import { h } from '../vdom.js?v=9';
|
||||
import { _toasts, dismissToast } from '../api.js?v=9';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import htm from '../../vendor/htm.js';
|
||||
import { htmAdapter } from './vdom.js?v=8';
|
||||
import { htmAdapter } from './vdom.js?v=9';
|
||||
|
||||
export const html = htm.bind(htmAdapter);
|
||||
|
||||
@@ -5,46 +5,46 @@
|
||||
*/
|
||||
|
||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=8';
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=9';
|
||||
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js?v=8';
|
||||
export { h } from './vdom.js?v=9';
|
||||
|
||||
/* ── HTM ──────────────────────────────────────────────────────── */
|
||||
export { html } from './html.js?v=8';
|
||||
export { html } from './html.js?v=9';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js?v=8';
|
||||
export { render } from './render.js?v=9';
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
export { definePage, hComp } from './component.js?v=8';
|
||||
export { definePage, hComp } from './component.js?v=9';
|
||||
|
||||
/* ── Router ──────────────────────────────────────────────────── */
|
||||
export { createRouter, Link } from './router.js?v=8';
|
||||
export { createRouter, Link } from './router.js?v=9';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js?v=8';
|
||||
export { connect, onMessage } from './websocket.js?v=9';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=8';
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9';
|
||||
|
||||
/* ── Model ───────────────────────────────────────────────────── */
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=8';
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=8';
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=9';
|
||||
|
||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=8';
|
||||
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=9';
|
||||
|
||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=8';
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8';
|
||||
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9';
|
||||
|
||||
/* ── UI Components: Apply ────────────────────────────────────── */
|
||||
export { ApplyConfirm } from './components/applyconfirm.js?v=8';
|
||||
export { ApplyConfirm } from './components/applyconfirm.js?v=9';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js?v=8';
|
||||
export { ToastContainer } from './components/toast.js?v=9';
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* collectLoadingModels(...models) — combine loading/refreshing/error
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=8';
|
||||
import { reactive } from './reactivity.js?v=9';
|
||||
|
||||
/** Registered models: name → { model, subsystem, fetch } */
|
||||
const _models = new Map();
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* batched re-render loop integration with reactivity.js.
|
||||
*/
|
||||
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=8';
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=9';
|
||||
import {
|
||||
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
||||
setMountFn, setUnmountFn,
|
||||
} from './vdom.js?v=8';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=8';
|
||||
} from './vdom.js?v=9';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=9';
|
||||
|
||||
/** Container → previous root vnodes */
|
||||
export const _renderSlots = new Map();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* navigation). Link component for client-side navigation.
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=8';
|
||||
import { h } from './vdom.js?v=8';
|
||||
import { reactive } from './reactivity.js?v=9';
|
||||
import { h } from './vdom.js?v=9';
|
||||
|
||||
/**
|
||||
* Hash-based router.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
||||
*/
|
||||
|
||||
import { refreshByTopic } from './model.js?v=8';
|
||||
import { refreshByTopic } from './model.js?v=9';
|
||||
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
|
||||
+147
-44
@@ -1,15 +1,77 @@
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch } from '/static/hoover/index.js?v=8';
|
||||
import { openModal, closeModal } from '/static/hoover/components/modal.js?v=8';
|
||||
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js?v=9';
|
||||
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js?v=9';
|
||||
import { _deleting } from '/static/hoover/components/data.js?v=9';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a datalist element from DHCP leases
|
||||
// ---------------------------------------------------------------------------
|
||||
function _buildHostDatalist(leases, uniqueId) {
|
||||
const datalist = document.createElement('datalist');
|
||||
datalist.id = 'hosts-' + uniqueId;
|
||||
for (const lease of leases) {
|
||||
const option = document.createElement('option');
|
||||
option.value = lease.ip;
|
||||
if (lease.hostname) {
|
||||
option.setAttribute('label', lease.hostname);
|
||||
const displayIp = esc(lease.ip);
|
||||
const displayHost = esc(lease.hostname);
|
||||
option.appendChild(document.createTextNode(displayIp + ' (' + displayHost + ')'));
|
||||
} else {
|
||||
option.appendChild(document.createTextNode(esc(lease.ip)));
|
||||
}
|
||||
datalist.appendChild(option);
|
||||
}
|
||||
return datalist;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inject discovered hosts hint section into modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function _injectDiscoveredHosts(modalContent, leases, datalistId) {
|
||||
const hostsHint = document.createElement('div');
|
||||
hostsHint.className = 'form-group';
|
||||
const details = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = leases.length + ' host' + (leases.length !== 1 ? 's' : '') + ' discovered';
|
||||
details.appendChild(summary);
|
||||
|
||||
for (const lease of leases) {
|
||||
const item = document.createElement('div');
|
||||
item.style.cssText = 'cursor:pointer;padding:2px 4px;margin:2px 0;border-radius:4px;font-size:0.875rem;';
|
||||
const hostname = lease.hostname ? lease.hostname + ' → ' : '';
|
||||
item.textContent = hostname + lease.ip;
|
||||
item.style.color = '#0d6efd';
|
||||
item.addEventListener('click', () => {
|
||||
const hostInput = document.querySelector('#' + datalistId + '-host-input');
|
||||
if (hostInput) {
|
||||
hostInput.value = lease.ip;
|
||||
}
|
||||
details.removeAttribute('open');
|
||||
});
|
||||
item.addEventListener('mouseenter', () => { item.style.background = '#e9ecef'; });
|
||||
item.addEventListener('mouseleave', () => { item.style.background = ''; });
|
||||
details.appendChild(item);
|
||||
}
|
||||
|
||||
hostsHint.appendChild(details);
|
||||
const pathsGroup = modalContent.querySelector('#paths-' + datalistId.split('-')[1])?.parentElement;
|
||||
if (pathsGroup) {
|
||||
pathsGroup.parentElement.insertBefore(hostsHint, pathsGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add a path row element to the paths container
|
||||
// ---------------------------------------------------------------------------
|
||||
function _addPathRow(container, data) {
|
||||
function _addPathRow(container, data, datalistId, isFirst) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-row-row';
|
||||
const hostListAttr = datalistId ? ' list="' + datalistId + '"' : '';
|
||||
const hostInputId = isFirst ? ' id="' + datalistId + '-host-input"' : '';
|
||||
row.innerHTML = `
|
||||
<input class="form-input path-field" type="text" placeholder="Path" value="${data ? esc(data.path) : '/'}" />
|
||||
<input class="form-input path-field" type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
|
||||
<input class="form-input path-field"${hostInputId + hostListAttr} type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
|
||||
<button type="button" class="btn btn-sm btn-outline host-picker" title="Pick host from list">?</button>
|
||||
<input class="form-input path-field" type="number" placeholder="Port" value="${data ? (data.backend || {}).port || '' : ''}" />
|
||||
<select class="form-select path-field">
|
||||
<option value="http"${(data && (data.backend || {}).proto === 'http') || !data ? ' selected' : ''}>http</option>
|
||||
@@ -26,6 +88,17 @@ function _addPathRow(container, data) {
|
||||
toast('At least one path required', 'warning');
|
||||
}
|
||||
});
|
||||
if (datalistId) {
|
||||
const picker = row.querySelector('.host-picker');
|
||||
const hostInput = row.querySelector('input[placeholder="Host"]');
|
||||
picker.addEventListener('click', () => {
|
||||
if (hostInput.showPicker) {
|
||||
hostInput.showPicker();
|
||||
} else {
|
||||
hostInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
container.appendChild(row);
|
||||
return row;
|
||||
}
|
||||
@@ -37,11 +110,14 @@ function _collectPaths(container) {
|
||||
const result = {};
|
||||
const seen = new Set();
|
||||
container.querySelectorAll('.path-row-row').forEach(row => {
|
||||
const inputs = row.querySelectorAll('.path-field');
|
||||
const path = (inputs[0].value || '').trim() || '/';
|
||||
const host = (inputs[1].value || '').trim();
|
||||
const port = parseInt(inputs[2].value);
|
||||
const proto = inputs[3].value;
|
||||
const pathField = row.querySelector('input[placeholder="Path"]');
|
||||
const hostField = row.querySelector('input[placeholder="Host"]');
|
||||
const portField = row.querySelector('input[placeholder="Port"]');
|
||||
const protoSelect = row.querySelector('select');
|
||||
const path = (pathField?.value || '').trim() || '/';
|
||||
const host = (hostField?.value || '').trim();
|
||||
const port = parseInt(portField?.value || '');
|
||||
const proto = protoSelect?.value || 'http';
|
||||
if (!host || !port) return;
|
||||
if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; }
|
||||
seen.add(path);
|
||||
@@ -56,12 +132,16 @@ function _collectPaths(container) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open backend form modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function openBackendModal(state, backend) {
|
||||
export function openBackendModal(state, backend) {
|
||||
const isEdit = !!backend;
|
||||
const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend';
|
||||
|
||||
// Extract DHCP leases
|
||||
const leases = state.dnsmasq?.data?.leases || [];
|
||||
|
||||
openModal((modalContent) => {
|
||||
const uniqueId = Date.now();
|
||||
const datalistId = 'hosts-' + uniqueId;
|
||||
const pathsId = 'paths-' + uniqueId;
|
||||
|
||||
// Build form HTML
|
||||
@@ -96,16 +176,24 @@ function openBackendModal(state, backend) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Inject datalist
|
||||
if (leases.length > 0) {
|
||||
const datalist = _buildHostDatalist(leases, uniqueId);
|
||||
modalContent.appendChild(datalist);
|
||||
_injectDiscoveredHosts(modalContent, leases, datalistId);
|
||||
}
|
||||
|
||||
// Add paths container refs
|
||||
const pathsContainer = modalContent.querySelector('#' + pathsId);
|
||||
|
||||
// Add initial path rows
|
||||
if (isEdit && backend.data.paths) {
|
||||
Object.entries(backend.data.paths).forEach(([path, cfg]) => {
|
||||
_addPathRow(pathsContainer, { path, ...cfg });
|
||||
const entries = Object.entries(backend.data.paths);
|
||||
entries.forEach(([path, cfg], i) => {
|
||||
_addPathRow(pathsContainer, { path, ...cfg }, datalistId, i === 0);
|
||||
});
|
||||
} else {
|
||||
_addPathRow(pathsContainer, null);
|
||||
_addPathRow(pathsContainer, null, datalistId, true);
|
||||
}
|
||||
|
||||
// "Add path" button
|
||||
@@ -114,7 +202,7 @@ function openBackendModal(state, backend) {
|
||||
addPathBtn.className = 'btn btn-sm btn-outline';
|
||||
addPathBtn.style.marginTop = '8px';
|
||||
addPathBtn.textContent = '+ Add Path';
|
||||
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null));
|
||||
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null, datalistId, false));
|
||||
pathsContainer.parentNode.querySelector('.form-label')
|
||||
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
|
||||
|
||||
@@ -123,35 +211,42 @@ function openBackendModal(state, backend) {
|
||||
|
||||
// Submit button
|
||||
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
|
||||
const nameInput = modalContent.querySelector('#name-' + uniqueId);
|
||||
const labelInput = modalContent.querySelector('#label-' + uniqueId);
|
||||
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const nameInput = modalContent.querySelector('#name-' + uniqueId);
|
||||
const labelInput = modalContent.querySelector('#label-' + uniqueId);
|
||||
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
|
||||
|
||||
const name = (nameInput.value || '').trim();
|
||||
const label = (labelInput.value || '').trim();
|
||||
const authType = authSelect.value;
|
||||
const paths = _collectPaths(pathsContainer);
|
||||
const name = (nameInput.value || '').trim();
|
||||
const label = (labelInput.value || '').trim();
|
||||
const authType = authSelect.value;
|
||||
const paths = _collectPaths(pathsContainer);
|
||||
|
||||
if (!name) { toast('Name is required', 'error'); return; }
|
||||
if (!label) { toast('Label is required', 'error'); return; }
|
||||
if (!Object.keys(paths).length) { toast('At least one valid path is required', 'error'); return; }
|
||||
if (!name) { toast('Name is required', 'error'); return; }
|
||||
if (!label) { toast('Label is required', 'error'); return; }
|
||||
if (!Object.keys(paths).length) { toast('Please fill in Host and Port for at least one path', 'error'); return; }
|
||||
|
||||
const body = { name, label, paths };
|
||||
if (authType === 'htpasswd') {
|
||||
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
|
||||
} else {
|
||||
body.auth = null;
|
||||
}
|
||||
const body = { name, label, paths };
|
||||
if (authType === 'htpasswd') {
|
||||
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
|
||||
} else {
|
||||
body.auth = null;
|
||||
}
|
||||
|
||||
const method = isEdit ? 'PATCH' : 'POST';
|
||||
const res = await apiFetch('/api/proxy/backends', { method, body });
|
||||
if (res.ok) {
|
||||
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
|
||||
closeModal();
|
||||
await modelFetch('backends');
|
||||
modelFetch('nginx');
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
const method = isEdit ? 'PATCH' : 'POST';
|
||||
const res = await apiFetch('/api/proxy/backends', { method, body });
|
||||
if (res.ok) {
|
||||
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
|
||||
closeModal();
|
||||
await modelFetch('backends');
|
||||
modelFetch('nginx');
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -164,17 +259,18 @@ export default definePage({
|
||||
init() {
|
||||
return {
|
||||
backends: getModel('backends'),
|
||||
dnsmasq: getModel('dnsmasq'),
|
||||
};
|
||||
},
|
||||
render(state) {
|
||||
const guard = renderGuard(state.backends, 'Backends', 'Reusable proxy backend templates', state.backends.data);
|
||||
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
|
||||
if (guard) return guard;
|
||||
|
||||
const backends = state.backends.data || {};
|
||||
const entries = Object.entries(backends);
|
||||
|
||||
const rows = entries.map(([name, b]) =>
|
||||
html`<tr key=${name}>
|
||||
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td>${esc(b.label || name)}</td>
|
||||
<td>${Object.keys(b.paths || {}).length}</td>
|
||||
@@ -191,6 +287,7 @@ export default definePage({
|
||||
? ''
|
||||
: html`<${ConfirmDelete}
|
||||
url=${'/api/proxy/backends/' + enc(name)}
|
||||
deleteKey=${name}
|
||||
message=${'Remove backend ' + enc(name) + '?'}
|
||||
success="Backend removed"
|
||||
refresh=["backends", "nginx"]
|
||||
@@ -200,9 +297,15 @@ export default definePage({
|
||||
</tr>`
|
||||
);
|
||||
|
||||
const actions = html`
|
||||
<button class="btn btn-primary" onClick=${() => openBackendModal(state)}>Add Backend</button>
|
||||
`;
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
|
||||
ActionButton({
|
||||
url: '/api/proxy/apply',
|
||||
successMsg: 'Nginx applied & reloaded',
|
||||
label: 'Apply',
|
||||
refresh: ['backends', 'nginx'],
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }),
|
||||
|
||||
+67
-43
@@ -1,4 +1,5 @@
|
||||
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js?v=9';
|
||||
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
|
||||
|
||||
function _accountCard(account) {
|
||||
if (!account || !account.registered) {
|
||||
@@ -44,22 +45,19 @@ function registerAccountModal() {
|
||||
label: 'Register',
|
||||
cls: 'btn-primary',
|
||||
action: 'r',
|
||||
handler: async () => {
|
||||
handler: formAction(async () => {
|
||||
const email = ($val('reg-email') || '').trim();
|
||||
if (!email) { toast('Email is required', 'error'); return; }
|
||||
if (!email) throw 'Email is required';
|
||||
const server = document.getElementById('reg-server')?.value || 'letsencrypt';
|
||||
const resp = await apiFetch('/api/certs/account/register', {
|
||||
method: 'POST',
|
||||
body: { email, server },
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('ACME account registered', 'success');
|
||||
closeModal();
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Registration failed', 'error');
|
||||
}
|
||||
},
|
||||
if (!resp.ok) throw resp.error || 'Registration failed';
|
||||
toast('ACME account registered', 'success');
|
||||
closeModal();
|
||||
modelFetch('acme');
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -95,30 +93,44 @@ function settingsModal(account) {
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
|
||||
const email = ($val('set-email') || '').trim();
|
||||
if (!email) { toast('Email is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/certs/email', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Email updated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const email = ($val('set-email') || '').trim();
|
||||
if (!email) { toast('Email is required', 'error'); return; }
|
||||
const resp = await apiFetch('/api/certs/email', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Email updated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => {
|
||||
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return;
|
||||
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Account deactivated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Account deactivated', 'success');
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -215,9 +227,11 @@ function _bindIssueButtons(inner, modalIdx) {
|
||||
|
||||
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
|
||||
if (_currentIssueState.validating) return;
|
||||
if (isModalProcessing()) return;
|
||||
const s = _currentIssueState;
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
setModalProcessing(true);
|
||||
s.validating = true;
|
||||
s.domain = domain;
|
||||
try {
|
||||
@@ -229,24 +243,33 @@ function _bindIssueButtons(inner, modalIdx) {
|
||||
refreshModals();
|
||||
} finally {
|
||||
s.validating = false;
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
|
||||
const body = { domain: _currentIssueState.domain };
|
||||
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
|
||||
if (issueResp.ok) {
|
||||
const status = issueResp.data?.status;
|
||||
if (status === 'existing') {
|
||||
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
|
||||
if (isModalProcessing()) return;
|
||||
setModalProcessing(true);
|
||||
try {
|
||||
const body = { domain: _currentIssueState.domain };
|
||||
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
|
||||
if (issueResp.ok) {
|
||||
const status = issueResp.data?.status;
|
||||
if (status === 'existing') {
|
||||
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
|
||||
} else {
|
||||
toast('Issuance started for ' + _currentIssueState.domain, 'success');
|
||||
}
|
||||
closeModal(modalIdx);
|
||||
const rid = issueResp.data?.request_id;
|
||||
if (rid) pollCertIssue(rid);
|
||||
} else {
|
||||
toast('Issuance started for ' + _currentIssueState.domain, 'success');
|
||||
toast(issueResp.error || 'Failed', 'error');
|
||||
}
|
||||
closeModal(modalIdx);
|
||||
const rid = issueResp.data?.request_id;
|
||||
if (rid) pollCertIssue(rid);
|
||||
} else {
|
||||
toast(issueResp.error || 'Failed', 'error');
|
||||
} finally {
|
||||
setModalProcessing(false);
|
||||
refreshModals();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -295,7 +318,8 @@ export default definePage({
|
||||
removeUrl=${'/api/certs/' + enc(c.domain)}
|
||||
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
||||
removeSuccess="Certificate removed"
|
||||
removeRefresh="acme" />
|
||||
removeRefresh="acme"
|
||||
deleteKey=${c.domain} />
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=9';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=8';
|
||||
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=9';
|
||||
|
||||
function makeAddRange(activeZones, interfaces) {
|
||||
const opts = [
|
||||
@@ -137,6 +137,7 @@ export default definePage({
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
url="/api/dhcp/ranges"
|
||||
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
|
||||
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
|
||||
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
||||
success="Range removed"
|
||||
@@ -151,6 +152,7 @@ export default definePage({
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
|
||||
deleteKey=${l.mac}
|
||||
message=${'Remove lease ' + l.mac + '?'}
|
||||
success="Lease removed"
|
||||
refresh="dnsmasq" />
|
||||
@@ -163,6 +165,7 @@ export default definePage({
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
|
||||
deleteKey=${rec.name || 'unnamed'}
|
||||
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
||||
success="Record removed"
|
||||
refresh="dnsmasq" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=9';
|
||||
|
||||
async function changeZone(name, zone, state) {
|
||||
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=9';
|
||||
|
||||
const logTabs = [
|
||||
{ key: 'journal', label: 'Journal' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=9';
|
||||
|
||||
const addFwd = QuickModal({
|
||||
title: 'Add Port Forward',
|
||||
@@ -60,7 +60,9 @@ export default definePage({
|
||||
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
|
||||
</tr>`);
|
||||
|
||||
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
|
||||
const masqRows = Object.entries(zoneData)
|
||||
.filter(([zone]) => zone !== "public")
|
||||
.map(([zone, zcfg]) => {
|
||||
const masq = !!zcfg.masquerade;
|
||||
return html`<tr key=${'m-' + zone}>
|
||||
<td><strong>${zone}</strong></td>
|
||||
@@ -92,6 +94,7 @@ export default definePage({
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
|
||||
deleteKey=${zone + '/' + port + '/' + proto}
|
||||
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
|
||||
success="Rule removed"
|
||||
refresh="firewall" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=9';
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
|
||||
+102
-120
@@ -1,9 +1,7 @@
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup } from '/static/hoover/index.js?v=8';
|
||||
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=8';
|
||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, formAction } from '/static/hoover/index.js?v=9';
|
||||
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9';
|
||||
import { openBackendModal } from '/static/pages/backends.js?v=11';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cert lookup map from ACME state keyed by domain name
|
||||
// ---------------------------------------------------------------------------
|
||||
function certLookup(acmeData) {
|
||||
const m = {};
|
||||
if (acmeData && acmeData.certs) {
|
||||
@@ -14,9 +12,6 @@ function certLookup(acmeData) {
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build cert select options from ACME certs array
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildCertOptions(certs) {
|
||||
const opts = [
|
||||
['', '(none)'],
|
||||
@@ -25,26 +20,18 @@ function buildCertOptions(certs) {
|
||||
['file', 'file — custom path'],
|
||||
];
|
||||
for (const c of (certs || [])) {
|
||||
const days = c.expired
|
||||
? 'Expired'
|
||||
: `${c.days_remaining}d`;
|
||||
const days = c.expired ? 'Expired' : `${c.days_remaining}d`;
|
||||
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map cert select value to payload cert string
|
||||
// ---------------------------------------------------------------------------
|
||||
function certValueFromSelect(raw) {
|
||||
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
|
||||
if (raw === 'selfsigned' || raw === 'file') return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build backend select options from backends model
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildBackendOptions(backends) {
|
||||
const opts = [['', '(select backend)']];
|
||||
const entries = Object.entries(backends || {});
|
||||
@@ -57,14 +44,44 @@ function buildBackendOptions(backends) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Domain modal — backend selector
|
||||
// ---------------------------------------------------------------------------
|
||||
function addDomain(state) {
|
||||
function _groupByBackend(domains, backends) {
|
||||
const backendMap = {};
|
||||
for (const d of domains) {
|
||||
const bn = d.backend_name;
|
||||
if (!backendMap[bn]) backendMap[bn] = {};
|
||||
if (!backendMap[bn][d.domain]) backendMap[bn][d.domain] = [];
|
||||
backendMap[bn][d.domain].push(d);
|
||||
}
|
||||
const backendNames = new Set(Object.keys(backends || {}));
|
||||
for (const bn of Object.keys(backendMap)) {
|
||||
backendNames.add(bn);
|
||||
}
|
||||
const sections = [];
|
||||
for (const bn of backendNames) {
|
||||
const b = (backends || {})[bn] || {};
|
||||
const domainGroups = backendMap[bn] || {};
|
||||
const sortedDomains = Object.entries(domainGroups)
|
||||
.map(([domainName, paths]) => ({ domain: domainName, paths }))
|
||||
.sort((a, b) => a.domain.localeCompare(b.domain));
|
||||
sections.push({
|
||||
backendName: bn,
|
||||
backend: { name: bn, label: b.label || bn, builtin: !!b.builtin, paths_count: Object.keys(b.paths || {}).length, ...b },
|
||||
domains: sortedDomains,
|
||||
hasBuiltin: !!b.builtin,
|
||||
});
|
||||
}
|
||||
sections.sort((a, b) => {
|
||||
if (a.hasBuiltin && !b.hasBuiltin) return -1;
|
||||
if (!a.hasBuiltin && b.hasBuiltin) return 1;
|
||||
return a.backend.label.localeCompare(b.backend.label);
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function addDomain(state, preselectedBackend) {
|
||||
const backends = state.backends ? (state.backends.data || {}) : {};
|
||||
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
|
||||
const backendOptions = buildBackendOptions(backends);
|
||||
|
||||
openModal((inner) => {
|
||||
formModal(inner, 'Add Proxy Domain', [
|
||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||
@@ -76,34 +93,26 @@ function addDomain(state) {
|
||||
label: 'Add',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: async () => {
|
||||
handler: formAction(async () => {
|
||||
const domain = ($val('p-domain') || '').trim();
|
||||
if (!domain) throw 'Domain is required';
|
||||
const backend = ($val('p-backend') || '').trim();
|
||||
const rawCert = $val('p-cert');
|
||||
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
if (!backend) { toast('Backend is required', 'error'); return; }
|
||||
|
||||
const body = {
|
||||
domain,
|
||||
backend,
|
||||
force_ssl: true,
|
||||
};
|
||||
const certVal = certValueFromSelect(rawCert);
|
||||
if (!backend) throw 'Backend is required';
|
||||
const body = { domain, backend, force_ssl: true };
|
||||
const certVal = certValueFromSelect($val('p-cert'));
|
||||
if (certVal) body.cert = certVal;
|
||||
|
||||
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
|
||||
if (res.ok) {
|
||||
toast('Domain added', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
if (!res.ok) throw res.error || 'Failed';
|
||||
toast('Domain added', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
if (preselectedBackend) {
|
||||
const backendSelect = inner.querySelector('#p-backend');
|
||||
if (backendSelect) backendSelect.value = preselectedBackend;
|
||||
}
|
||||
const certSelect = inner.querySelector('#p-cert');
|
||||
const domainInput = inner.querySelector('#p-domain');
|
||||
if (certSelect && domainInput) {
|
||||
@@ -117,14 +126,10 @@ function addDomain(state) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Domain modal — cert and force_ssl only (backend is read-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
function editDomain(d, state) {
|
||||
const backends = state.backends ? (state.backends.data || {}) : {};
|
||||
const backend = backends[d.backend_name] || {};
|
||||
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
|
||||
|
||||
const certMap = certLookup(state.acme ? state.acme.data : null);
|
||||
const domainCert = certMap[d.domain];
|
||||
let selectedCert = '';
|
||||
@@ -133,8 +138,6 @@ function editDomain(d, state) {
|
||||
} else if (d.cert) {
|
||||
selectedCert = d.cert;
|
||||
}
|
||||
|
||||
// Build path summary rows (read-only)
|
||||
const paths = backend.paths || {};
|
||||
const pathKeys = Object.keys(paths);
|
||||
const pathSummary = pathKeys.map(p => {
|
||||
@@ -142,7 +145,6 @@ function editDomain(d, state) {
|
||||
const be = pcfg.backend || {};
|
||||
return `${esc(p)} → ${esc(be.host || '-')}:${be.port || '-'}`;
|
||||
}).join('\n') || '—';
|
||||
|
||||
openModal((inner) => {
|
||||
formModal(inner, 'Edit: ' + esc(d.domain), [
|
||||
{ label: 'Domain', id: 'pe-domain', value: d.domain },
|
||||
@@ -156,59 +158,35 @@ function editDomain(d, state) {
|
||||
label: 'Save',
|
||||
cls: 'btn-primary',
|
||||
action: 's',
|
||||
handler: async () => {
|
||||
handler: formAction(async () => {
|
||||
const rawCert = $val('pe-cert');
|
||||
if (!rawCert) throw 'Cert is required';
|
||||
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
|
||||
|
||||
const body = {};
|
||||
body.cert = certValueFromSelect(rawCert);
|
||||
body.force_ssl = forceSsl;
|
||||
|
||||
const body = { cert: certValueFromSelect(rawCert), force_ssl: forceSsl };
|
||||
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
|
||||
if (res.ok) {
|
||||
toast('Domain updated', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
if (!res.ok) throw res.error || 'Failed';
|
||||
toast('Domain updated', 'success');
|
||||
closeModal();
|
||||
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
// Set cert select value
|
||||
const certSelect = inner.querySelector('#pe-cert');
|
||||
if (certSelect) certSelect.value = selectedCert;
|
||||
|
||||
// Make read-only fields actually read-only
|
||||
const domainInput = inner.querySelector('#pe-domain');
|
||||
if (domainInput) {
|
||||
domainInput.readOnly = true;
|
||||
domainInput.style.background = '#f5f5f5';
|
||||
}
|
||||
if (domainInput) { domainInput.readOnly = true; domainInput.style.background = '#f5f5f5'; }
|
||||
const backendInput = inner.querySelector('#pe-backend');
|
||||
if (backendInput) {
|
||||
backendInput.readOnly = true;
|
||||
backendInput.style.background = '#f5f5f5';
|
||||
}
|
||||
if (backendInput) { backendInput.readOnly = true; backendInput.style.background = '#f5f5f5'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row for a domain (grouped by domain name)
|
||||
// ---------------------------------------------------------------------------
|
||||
function domainRow(domainName, domainPaths, state) {
|
||||
const d = domainPaths[0];
|
||||
const certMap = certLookup(state.acme ? state.acme.data : null);
|
||||
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
|
||||
|
||||
const cert = certMap[d.domain];
|
||||
let certBadge, certTitle;
|
||||
if (cert) {
|
||||
certBadge = certStatusBadge({
|
||||
daysRemaining: cert.days_remaining,
|
||||
expired: cert.expired,
|
||||
});
|
||||
certBadge = certStatusBadge({ daysRemaining: cert.days_remaining, expired: cert.expired });
|
||||
certTitle = 'ACME: ' + cert.domain;
|
||||
} else if (d.cert === 'selfsigned') {
|
||||
certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
|
||||
@@ -220,8 +198,6 @@ function domainRow(domainName, domainPaths, state) {
|
||||
certBadge = Badge({ text: '—', variant: 'info' });
|
||||
certTitle = 'No certificate';
|
||||
}
|
||||
|
||||
// Build paths summary
|
||||
const pathSummaries = domainPaths.map(p => {
|
||||
const be = p.backend || {};
|
||||
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
|
||||
@@ -231,13 +207,8 @@ function domainRow(domainName, domainPaths, state) {
|
||||
if (flags.length) parts.push(flags.join(', '));
|
||||
return parts.join(' → ');
|
||||
});
|
||||
|
||||
return html`<tr key=${domainName} class="domain-row">
|
||||
<td><strong>${esc(domainName)}</strong></td>
|
||||
<td>
|
||||
<${Badge} text=${esc(d.backend_name || '-')} variant="primary" />
|
||||
<span class="text-muted" style="margin-left:4px">${esc(backend.label || '')}</span>
|
||||
</td>
|
||||
<td>${pathSummaries}</td>
|
||||
<td title=${certTitle}>${certBadge}</td>
|
||||
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
|
||||
@@ -254,9 +225,37 @@ function domainRow(domainName, domainPaths, state) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
function backendSection(section, state) {
|
||||
const { backendName, backend, domains } = section;
|
||||
const rows = domains.map(d => domainRow(d.domain, d.paths, state));
|
||||
const sectionActions = [];
|
||||
if (!backend.builtin) {
|
||||
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
|
||||
if (domains.length === 0) {
|
||||
sectionActions.push(html`<${ConfirmDelete}
|
||||
url=${'/api/proxy/backends/' + enc(backendName)}
|
||||
deleteKey=${backendName}
|
||||
message=${'Remove backend ' + enc(backendName) + '?'}
|
||||
success="Backend removed"
|
||||
refresh=["backends", "nginx"]
|
||||
label="Delete" />`);
|
||||
}
|
||||
}
|
||||
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
|
||||
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
|
||||
<h3 style="margin:0;display:flex;align-items:center;gap:8px;">
|
||||
<${Badge} text=${esc(backendName)} variant="primary" />
|
||||
${esc(backend.label || backendName)}
|
||||
</h3>
|
||||
<div style="display:flex;gap:8px;">${sectionActions}</div>
|
||||
</div>
|
||||
${domains.length
|
||||
? Table({ columns: ['Domain', 'Paths', 'Cert', 'Force SSL', 'Actions'], rows })
|
||||
: Empty({ text: 'No domains using this backend.' })}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
@@ -268,36 +267,19 @@ export default definePage({
|
||||
render(state) {
|
||||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
|
||||
if (guard) return guard;
|
||||
|
||||
const domains = state.nginx.data.domains || [];
|
||||
|
||||
// Group by domain name
|
||||
const groups = {};
|
||||
for (const d of domains) {
|
||||
if (!groups[d.domain]) groups[d.domain] = [];
|
||||
groups[d.domain].push(d);
|
||||
}
|
||||
|
||||
const rows = Object.values(groups).map(paths => domainRow(paths[0].domain, paths, state));
|
||||
|
||||
const backends = state.backends.data || {};
|
||||
const sections = _groupByBackend(domains, backends);
|
||||
const sectionVNodes = sections.map(s => backendSection(s, state));
|
||||
const actions = ActionGroup(
|
||||
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
||||
ActionButton({
|
||||
url: '/api/proxy/apply',
|
||||
successMsg: 'Nginx applied & reloaded',
|
||||
label: 'Apply',
|
||||
refresh: ['nginx', 'acme'],
|
||||
}),
|
||||
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply', refresh: ['nginx', 'acme'] }),
|
||||
);
|
||||
|
||||
return [
|
||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||
domains.length
|
||||
? Table({
|
||||
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
|
||||
rows,
|
||||
})
|
||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||
Object.keys(backends).length || domains.length
|
||||
? sectionVNodes
|
||||
: Empty({ text: 'No backends configured. Create a backend first, then add domains.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=9';
|
||||
|
||||
const addRule = QuickModal({
|
||||
title: 'Add Rich Rule',
|
||||
@@ -44,6 +44,7 @@ export default definePage({
|
||||
<td>
|
||||
<${ConfirmDelete}
|
||||
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
|
||||
deleteKey=${zone + '-' + (ruleId || i)}
|
||||
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
|
||||
success="Rule removed"
|
||||
refresh="firewall" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction } from '/static/hoover/index.js?v=9';
|
||||
|
||||
const addPeer = QuickModal({
|
||||
title: 'Add WireGuard Peer',
|
||||
@@ -29,21 +29,21 @@ function downloadConfigModal(peerName, config, state) {
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
||||
{
|
||||
label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => {
|
||||
label: 'Generate', cls: 'btn-primary', action: 's',
|
||||
handler: formAction(async () => {
|
||||
const endpoint = ($val('wg-srv-endpoint') || '').trim();
|
||||
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
|
||||
if (!endpoint) throw 'Server endpoint is required';
|
||||
const resp = await apiFetch('/api/wireguard/generate-client', {
|
||||
method: 'POST',
|
||||
body: { name: peerName, server_endpoint: endpoint },
|
||||
});
|
||||
if (resp.ok && resp.data?.config) {
|
||||
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
}
|
||||
},
|
||||
if (!resp.ok) throw resp.error || 'Failed';
|
||||
const configContent = resp.data?.config;
|
||||
if (!configContent) throw 'No config returned';
|
||||
downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf');
|
||||
toast('Config downloaded', 'success');
|
||||
closeModal(idx);
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -84,7 +84,8 @@ export default definePage({
|
||||
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
|
||||
removeMessage=${'Remove peer ' + p.name + '?'}
|
||||
removeSuccess="Peer removed"
|
||||
removeRefresh="wireguard" />
|
||||
removeRefresh="wireguard"
|
||||
deleteKey=${p.name} />
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=8';
|
||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=9';
|
||||
|
||||
const addZone = QuickModal({
|
||||
title: 'Add Zone',
|
||||
@@ -81,6 +81,7 @@ export default definePage({
|
||||
})()}>Services</button>
|
||||
<${ConfirmDelete}
|
||||
url=${'/api/firewall/zones/' + enc(name)}
|
||||
deleteKey=${name}
|
||||
message=${'Delete zone ' + name + '?'}
|
||||
success=${'Zone ' + name + ' deleted'}
|
||||
refresh="firewall"
|
||||
|
||||
@@ -699,6 +699,42 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Spinner animation for buttons during pending operations */
|
||||
@keyframes btn-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin: 0 0.5em;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: btn-spin 0.5s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Pending deletion row indicator */
|
||||
.pending-delete td {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pending-delete td:last-child {
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.pending-delete td:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
/* Disabled button styling */
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ApplyConfirm modal */
|
||||
.apply-subsystem-row {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user