Files

325 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.' }),
];
},
});