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'; import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js'; import { _deleting } from '/static/hoover/components/data.js'; // --------------------------------------------------------------------------- // 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 = ` `; 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 = `
`; // 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` ${esc(name)} ${esc(b.label || name)} ${Object.keys(b.paths || {}).length} ${b.builtin ? html`<${Badge} text="builtin" variant="warning" />` : ''} ${b.has_auth ? html`<${Badge} text="auth" variant="info" />` : ''} ${b.builtin ? '' : html`` } ${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" />` } ` ); 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.' }), ]; }, });