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'; // --------------------------------------------------------------------------- // Add a path row element to the paths container // --------------------------------------------------------------------------- function _addPathRow(container, data) { const row = document.createElement('div'); row.className = 'path-row-row'; 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'); } }); 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 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; 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 // --------------------------------------------------------------------------- function openBackendModal(state, backend) { const isEdit = !!backend; const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend'; openModal((modalContent) => { const uniqueId = Date.now(); const pathsId = 'paths-' + uniqueId; // Build form HTML const authVal = isEdit && backend.data.has_auth ? 'htpasswd' : 'none'; modalContent.innerHTML = `
`; // 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 }); }); } else { _addPathRow(pathsContainer, null); } // "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)); 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 () => { 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('At least one valid path is required', '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'); } }); }); } // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export default definePage({ init() { return { backends: getModel('backends'), }; }, render(state) { const guard = renderGuard(state.backends, 'Backends', 'Reusable proxy backend templates', state.backends.data); 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)} message=${'Remove backend ' + enc(name) + '?'} success="Backend removed" refresh=["backends", "nginx"] label="Delete" />` } ` ); const actions = html` `; 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.' }), ]; }, });