proxy: refactor modals and add cert selector options
This commit is contained in:
+170
-73
@@ -1,4 +1,5 @@
|
|||||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=7';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cert lookup map from ACME state keyed by domain name
|
// Cert lookup map from ACME state keyed by domain name
|
||||||
@@ -14,92 +15,188 @@ function certLookup(acmeData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Add Domain modal — paths-based body
|
// Build cert select options from ACME certs array
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const addDomain = QuickModal({
|
function buildCertOptions(certs) {
|
||||||
title: 'Add Proxy Domain',
|
const opts = [
|
||||||
fields: [
|
['', '(none)'],
|
||||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
['acme', 'acme — auto-issue'],
|
||||||
{ label: 'Path', id: 'p-path', placeholder: '/' },
|
['selfsigned', 'selfsigned'],
|
||||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
['file', 'file — custom path'],
|
||||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
];
|
||||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
for (const c of (certs || [])) {
|
||||||
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
|
const days = c.expired
|
||||||
],
|
? 'Expired'
|
||||||
submit: {
|
: `${c.days_remaining}d`;
|
||||||
url: '/api/proxy/domains',
|
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
|
||||||
body: () => {
|
}
|
||||||
const path = ($val('p-path') || '/').trim() || '/';
|
return opts;
|
||||||
return {
|
}
|
||||||
domain: ($val('p-domain') || '').trim(),
|
|
||||||
paths: {
|
|
||||||
[path]: {
|
|
||||||
backend: {
|
|
||||||
host: ($val('p-host') || '').trim(),
|
|
||||||
port: parseInt($val('p-port')),
|
|
||||||
proto: ($val('p-proto') || 'http').trim() || 'http',
|
|
||||||
},
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cert: ($val('p-cert') || '').trim() || undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
validate: (b) => {
|
|
||||||
if (!b.domain) return 'Domain is required';
|
|
||||||
const p = b.paths ? Object.values(b.paths)[0] : {};
|
|
||||||
const be = p && p.backend;
|
|
||||||
if (!be || !be.host || !be.port) return 'Host and port are required';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
successMsg: 'Domain added',
|
|
||||||
},
|
|
||||||
refresh: ['nginx', 'acme'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Edit Domain modal — updates backend for the root path
|
// Map cert select value to payload cert string
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const editDomain = QuickModal({
|
function certValueFromSelect(raw) {
|
||||||
title: (d) => 'Edit: ' + d.domain + ' ' + d.path,
|
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
|
||||||
fields: (d) => {
|
if (raw === 'selfsigned' || raw === 'file') return raw;
|
||||||
const be = d.backend || {};
|
return undefined;
|
||||||
return [
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add Domain modal — paths-based body with cert selector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function addDomain(state) {
|
||||||
|
const certs = state.acme ? (state.acme.data.certs || []) : [];
|
||||||
|
const certOptions = buildCertOptions(certs);
|
||||||
|
|
||||||
|
openModal((inner) => {
|
||||||
|
formModal(inner, 'Add Proxy Domain', [
|
||||||
|
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||||
|
{ label: 'Path', id: 'p-path', placeholder: '/' },
|
||||||
|
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||||
|
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||||
|
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||||
|
{ label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions },
|
||||||
|
], [
|
||||||
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||||
|
{
|
||||||
|
label: 'Add',
|
||||||
|
cls: 'btn-primary',
|
||||||
|
action: 's',
|
||||||
|
handler: async () => {
|
||||||
|
const path = ($val('p-path') || '/').trim() || '/';
|
||||||
|
const rawCert = $val('p-cert');
|
||||||
|
const body = {
|
||||||
|
domain: ($val('p-domain') || '').trim(),
|
||||||
|
paths: {
|
||||||
|
[path]: {
|
||||||
|
backend: {
|
||||||
|
host: ($val('p-host') || '').trim(),
|
||||||
|
port: parseInt($val('p-port')),
|
||||||
|
proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||||
|
},
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cert: certValueFromSelect(rawCert),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.domain) { toast('Domain is required', 'error'); return; }
|
||||||
|
const p = body.paths ? Object.values(body.paths)[0] : {};
|
||||||
|
const be = p && p.backend;
|
||||||
|
if (!be || !be.host || !be.port) { toast('Host and port are required', 'error'); return; }
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
|
||||||
|
if (res.ok) {
|
||||||
|
toast('Domain added', 'success');
|
||||||
|
closeModal();
|
||||||
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||||
|
} else {
|
||||||
|
toast(res.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const certSelect = document.getElementById('p-cert');
|
||||||
|
const domainInput = document.getElementById('p-domain');
|
||||||
|
if (certSelect && domainInput) {
|
||||||
|
certSelect.addEventListener('change', () => {
|
||||||
|
const val = certSelect.value;
|
||||||
|
if (val && val.startsWith('acme|')) {
|
||||||
|
domainInput.value = val.slice(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit Domain modal — updates backend for the root path, with cert selector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function editDomain(d, state) {
|
||||||
|
const certs = state.acme ? (state.acme.data.certs || []) : [];
|
||||||
|
const certOptions = buildCertOptions(certs);
|
||||||
|
|
||||||
|
let selectedCert = '';
|
||||||
|
if (d._cert) {
|
||||||
|
selectedCert = `acme|${d._cert.domain}`;
|
||||||
|
} else if (d.cert) {
|
||||||
|
selectedCert = d.cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
const be = d.backend || {};
|
||||||
|
|
||||||
|
openModal((inner) => {
|
||||||
|
formModal(inner, 'Edit: ' + d.domain + ' ' + d.path, [
|
||||||
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
|
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
|
||||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
|
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
|
||||||
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
|
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
|
||||||
{ label: 'Cert (optional)', id: 'pe-cert', value: d._cert || d.cert || '' },
|
{ label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
|
||||||
];
|
], [
|
||||||
},
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||||
submit: {
|
{
|
||||||
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
label: 'Save',
|
||||||
method: 'PUT',
|
cls: 'btn-primary',
|
||||||
body: (d) => ({
|
action: 's',
|
||||||
backend: {
|
handler: async () => {
|
||||||
host: ($val('pe-host') || '').trim(),
|
const rawCert = $val('pe-cert');
|
||||||
port: parseInt($val('pe-port')),
|
const body = {
|
||||||
proto: ($val('pe-proto') || 'http').trim(),
|
backend: {
|
||||||
|
host: ($val('pe-host') || '').trim(),
|
||||||
|
port: parseInt($val('pe-port')),
|
||||||
|
proto: ($val('pe-proto') || 'http').trim(),
|
||||||
|
},
|
||||||
|
cert: certValueFromSelect(rawCert),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.backend || !body.backend.host || !body.backend.port) {
|
||||||
|
toast('Host and port are required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
|
||||||
|
if (res.ok) {
|
||||||
|
toast('Domain updated', 'success');
|
||||||
|
closeModal();
|
||||||
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
||||||
|
} else {
|
||||||
|
toast(res.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
]);
|
||||||
}),
|
|
||||||
validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null,
|
const certSelect = inner.querySelector('#pe-cert');
|
||||||
successMsg: 'Domain updated',
|
if (certSelect) {
|
||||||
},
|
certSelect.value = selectedCert;
|
||||||
refresh: ['nginx', 'acme'],
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Path detail row
|
// Path detail row
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function pathRow(d, certs, domainPaths) {
|
function pathRow(d, certs, domainPaths, state) {
|
||||||
const be = d.backend || {};
|
const be = d.backend || {};
|
||||||
const cert = certs[d.domain];
|
const cert = certs[d.domain];
|
||||||
const certBadge = cert
|
let certBadge, certTitle;
|
||||||
? certStatusBadge({
|
if (cert) {
|
||||||
|
certBadge = certStatusBadge({
|
||||||
daysRemaining: cert.days_remaining,
|
daysRemaining: cert.days_remaining,
|
||||||
expired: cert.expired,
|
expired: cert.expired,
|
||||||
})
|
});
|
||||||
: Badge({ text: '—', variant: 'info' });
|
certTitle = 'ACME: ' + cert.domain;
|
||||||
|
} else if (d.cert === 'selfsigned') {
|
||||||
|
certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
|
||||||
|
certTitle = 'Self-signed';
|
||||||
|
} else if (d.cert === 'file') {
|
||||||
|
certBadge = Badge({ text: 'File', variant: 'secondary' });
|
||||||
|
certTitle = 'File';
|
||||||
|
} else {
|
||||||
|
certBadge = Badge({ text: '—', variant: 'info' });
|
||||||
|
certTitle = 'No certificate';
|
||||||
|
}
|
||||||
|
|
||||||
const isWs = d.is_websocket;
|
const isWs = d.is_websocket;
|
||||||
const isMgmt = d.is_management;
|
const isMgmt = d.is_management;
|
||||||
@@ -121,7 +218,7 @@ function pathRow(d, certs, domainPaths) {
|
|||||||
} else {
|
} else {
|
||||||
actions = ActionCell({
|
actions = ActionCell({
|
||||||
editLabel: 'Edit',
|
editLabel: 'Edit',
|
||||||
editClick: () => editDomain(d),
|
editClick: () => editDomain(d, state),
|
||||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||||
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
|
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
|
||||||
removeSuccess: 'Removed',
|
removeSuccess: 'Removed',
|
||||||
@@ -141,7 +238,7 @@ function pathRow(d, certs, domainPaths) {
|
|||||||
<td>${be.port || '-'}</td>
|
<td>${be.port || '-'}</td>
|
||||||
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
|
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
|
||||||
<td>${flagBadges}</td>
|
<td>${flagBadges}</td>
|
||||||
<td>${certBadge}</td>
|
<td title=${certTitle}>${certBadge}</td>
|
||||||
<td>${actions}</td>
|
<td>${actions}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
@@ -176,7 +273,7 @@ export default definePage({
|
|||||||
_cert: certs[d.domain] || null,
|
_cert: certs[d.domain] || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain]));
|
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain], state));
|
||||||
|
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
||||||
|
|||||||
Reference in New Issue
Block a user