feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages
This commit is contained in:
+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 }),
|
||||
|
||||
Reference in New Issue
Block a user