feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages

This commit is contained in:
2026-07-13 14:30:35 +00:00
parent 05524f3756
commit 2e49dec633
34 changed files with 790 additions and 411 deletions
+147 -44
View File
@@ -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 }),