325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } 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 = `
|
||
<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'); // not state-store-backed — explicit fetch
|
||
// No nginx modelFetch — daemon broadcasts nginx via WS delta.
|
||
} else {
|
||
toast(res.error || 'Failed', 'error');
|
||
}
|
||
} finally {
|
||
setModalProcessing(false);
|
||
refreshModals();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Page
|
||
// ---------------------------------------------------------------------------
|
||
export default definePage({
|
||
title: 'Backends - Vacuum Wall',
|
||
init() {
|
||
return {
|
||
backends: getModel('backends'),
|
||
dnsmasq: getModel('dnsmasq'),
|
||
nginx: getModel('nginx'),
|
||
};
|
||
},
|
||
render(state) {
|
||
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
|
||
if (guard) return guard;
|
||
|
||
const set = dirtySet(state.nginx.data?.status);
|
||
const backends = state.backends.data || {};
|
||
const entries = Object.entries(backends);
|
||
|
||
const rows = entries.map(([name, b]) => {
|
||
const info = dirtyInfo(set, 'backends.' + name);
|
||
const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
|
||
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
|
||
<td>${info.dirty ? PendingDot({}) : ''}<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"
|
||
onComplete=${() => modelFetch('backends')}
|
||
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',
|
||
onSuccess: () => modelFetch('backends'),
|
||
}),
|
||
);
|
||
|
||
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.' }),
|
||
];
|
||
},
|
||
}); |