ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
320 lines
15 KiB
JavaScript
320 lines
15 KiB
JavaScript
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=10';
|
||
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, 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"${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>
|
||
<option value="https"${data && (data.backend || {}).proto === 'https' ? ' selected' : ''}>https</option>
|
||
</select>
|
||
<label><input type="checkbox" class="path-ws"${data && data.is_websocket ? ' checked' : ''} /> ws</label>
|
||
<label><input type="checkbox" class="path-mgmt"${data && data.is_management ? ' checked' : ''} /> mgmt</label>
|
||
<button type="button" class="btn btn-sm btn-outline path-remove"><i>×</i></button>
|
||
`;
|
||
row.querySelector('.path-remove').addEventListener('click', () => {
|
||
if (container.querySelectorAll('.path-row-row').length > 1) {
|
||
row.remove();
|
||
} else {
|
||
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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Collect paths from the paths container
|
||
// ---------------------------------------------------------------------------
|
||
function _collectPaths(container) {
|
||
const result = {};
|
||
const seen = new Set();
|
||
container.querySelectorAll('.path-row-row').forEach(row => {
|
||
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);
|
||
const entry = { backend: { host, port, proto } };
|
||
if (row.querySelector('.path-ws').checked) entry.is_websocket = true;
|
||
if (row.querySelector('.path-mgmt').checked) entry.is_management = true;
|
||
result[path] = entry;
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Open backend form modal
|
||
// ---------------------------------------------------------------------------
|
||
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
|
||
const authVal = isEdit && backend.data.has_auth ? 'htpasswd' : 'none';
|
||
modalContent.innerHTML = `
|
||
<h3 class="modal-title">${esc(title)}</h3>
|
||
<div class="form-group">
|
||
<label class="form-label">Name</label>
|
||
<input class="form-input" id="name-${uniqueId}" type="text" placeholder="my-app"
|
||
value="${isEdit ? esc(backend.name) : ''}"
|
||
${isEdit ? 'readonly' : ''} />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Label</label>
|
||
<input class="form-input" id="label-${uniqueId}" type="text" placeholder="My App"
|
||
value="${isEdit ? esc(backend.data.label || '') : ''}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Auth</label>
|
||
<select class="form-select" id="auth-${uniqueId}">
|
||
<option value="none"${authVal === 'none' ? ' selected' : ''}>No auth</option>
|
||
<option value="htpasswd"${authVal === 'htpasswd' ? ' selected' : ''}>HTTP Basic Auth</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Paths</label>
|
||
<div id="${pathsId}" class="paths-container"></div>
|
||
</div>
|
||
<div style="padding-top:12px;text-align:right;border-top:1px solid #dee2e6;" class="modal-actions-bar">
|
||
<button type="button" class="btn btn-outline modal-cancel">Cancel</button>
|
||
<button type="button" class="btn btn-primary" id="submit-${uniqueId}">${isEdit ? 'Save' : 'Add'}</button>
|
||
</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) {
|
||
const entries = Object.entries(backend.data.paths);
|
||
entries.forEach(([path, cfg], i) => {
|
||
_addPathRow(pathsContainer, { path, ...cfg }, datalistId, i === 0);
|
||
});
|
||
} else {
|
||
_addPathRow(pathsContainer, null, datalistId, true);
|
||
}
|
||
|
||
// "Add path" button
|
||
const addPathBtn = document.createElement('button');
|
||
addPathBtn.type = 'button';
|
||
addPathBtn.className = 'btn btn-sm btn-outline';
|
||
addPathBtn.style.marginTop = '8px';
|
||
addPathBtn.textContent = '+ Add Path';
|
||
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null, datalistId, false));
|
||
pathsContainer.parentNode.querySelector('.form-label')
|
||
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
|
||
|
||
// Cancel button
|
||
modalContent.querySelector('.modal-cancel').addEventListener('click', () => closeModal());
|
||
|
||
// Submit button
|
||
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
|
||
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);
|
||
|
||
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 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();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Page
|
||
// ---------------------------------------------------------------------------
|
||
export default definePage({
|
||
init() {
|
||
return {
|
||
backends: getModel('backends'),
|
||
dnsmasq: getModel('dnsmasq'),
|
||
};
|
||
},
|
||
render(state) {
|
||
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} 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>
|
||
<td>
|
||
${b.builtin ? html`<${Badge} text="builtin" variant="warning" />` : ''}
|
||
${b.has_auth ? html`<${Badge} text="auth" variant="info" />` : ''}
|
||
</td>
|
||
<td>
|
||
${b.builtin
|
||
? ''
|
||
: html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name, data: b })}>Edit</button>`
|
||
}
|
||
${b.builtin
|
||
? ''
|
||
: html`<${ConfirmDelete}
|
||
url=${'/api/proxy/backends/' + enc(name)}
|
||
deleteKey=${name}
|
||
message=${'Remove backend ' + enc(name) + '?'}
|
||
success="Backend removed"
|
||
refresh=["backends", "nginx"]
|
||
label="Delete" />`
|
||
}
|
||
</td>
|
||
</tr>`
|
||
);
|
||
|
||
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 }),
|
||
entries.length
|
||
? Table({
|
||
columns: ['Name', 'Label', 'Paths', 'Flags', 'Actions'],
|
||
rows,
|
||
})
|
||
: Empty({ text: 'No backends configured. The built-in "webui" backend is created automatically after migration.' }),
|
||
];
|
||
},
|
||
}); |