Add state management, WebSocket polling, html.js templating, and refactor pages
- lib/state.py: per-subsystem collectors with versioned state store - daemon/server.py: state refresh on request, batch routing updates - webui/static/hoover/html.js: new html tag template helper via htm.js - webui/static/hoover/websocket.js: real-time state change notifications - webui/static/hoover/vdom.js: VDOM improvements for keyed diff - All frontend pages refactored to use html templates - Add tests for state management and polling - Update docs and AGENTS.md
This commit is contained in:
+152
-171
@@ -1,40 +1,29 @@
|
||||
import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
|
||||
const _issueState = { domain: '', modalIdx: -1, account: null, validating: false };
|
||||
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
|
||||
|
||||
function _accountCard(account) {
|
||||
if (!account || !account.registered) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' },
|
||||
[
|
||||
h('span', null, 'ACME Account'),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-primary',
|
||||
style: 'margin-left:auto;',
|
||||
'on:click': () => registerAccountModal(),
|
||||
}, 'Register Account'),
|
||||
]
|
||||
),
|
||||
h('div', { class: 'card-body' },
|
||||
h('div', { class: 'text-muted text-sm' }, 'Not registered'),
|
||||
),
|
||||
);
|
||||
return html`<div class="card">
|
||||
<div class="card-header">
|
||||
<span>ACME Account</span>
|
||||
<button class="btn btn-sm btn-primary" style="margin-left:auto"
|
||||
onClick=${() => registerAccountModal()}>Register Account</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-muted text-sm">Not registered</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' },
|
||||
[
|
||||
h('span', null, 'ACME Account'),
|
||||
h('button', {
|
||||
class: 'btn btn-sm btn-outline',
|
||||
style: 'margin-left:auto;',
|
||||
'on:click': () => settingsModal(account),
|
||||
}, '\u2699'),
|
||||
]
|
||||
),
|
||||
h('div', { class: 'card-body' },
|
||||
h('div', null, ['Registered as ', h('strong', null, esc(account.email))]),
|
||||
h('div', { class: 'text-sm text-muted' }, ['CA: ', esc(account.ca)]),
|
||||
),
|
||||
);
|
||||
return html`<div class="card">
|
||||
<div class="card-header">
|
||||
<span>ACME Account</span>
|
||||
<button class="btn btn-sm btn-outline" style="margin-left:auto"
|
||||
onClick=${() => settingsModal(account)}>\u2699</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>Registered as <strong>${esc(account.email)}</strong></div>
|
||||
<div class="text-sm text-muted">CA: ${esc(account.ca)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function registerAccountModal() {
|
||||
@@ -78,25 +67,31 @@ function registerAccountModal() {
|
||||
}
|
||||
|
||||
function settingsModal(account) {
|
||||
openModal((inner) => {
|
||||
inner.innerHTML = '<h2 class="modal-title">Account Settings</h2>'
|
||||
+ '<div class="modal-body">'
|
||||
+ '<div class="form-group"><label>Current Account</label><div class="text-sm">'
|
||||
+ esc(account.email) + (account.ca ? ' (' + esc(account.ca) + ')' : '')
|
||||
+ '</div></div>'
|
||||
+ '<hr>'
|
||||
+ '<div class="form-group"><label>Update Email</label>'
|
||||
+ '<input id="set-email" type="email" placeholder="new@example.com"></div>'
|
||||
+ '<hr>'
|
||||
+ '<div class="text-danger"><strong>Danger Zone</strong></div>'
|
||||
+ '<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>'
|
||||
+ '</div><div class="modal-actions">'
|
||||
+ '<button class="btn btn-outline" data-action="set-cancel">Cancel</button>'
|
||||
+ '<button class="btn btn-primary" data-action="set-save">Save Email</button>'
|
||||
+ '</div>';
|
||||
openModal((inner, idx) => {
|
||||
modalVNodes(inner, html`<div>
|
||||
<h2 class="modal-title">Account Settings</h2>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Current Account</label>
|
||||
<div class="text-sm">${esc(account.email)}${account.ca ? ' (' + esc(account.ca) + ')' : ''}</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="form-group">
|
||||
<label>Update Email</label>
|
||||
<input id="set-email" type="email" placeholder="new@example.com" />
|
||||
</div>
|
||||
<hr />
|
||||
<div class="text-danger"><strong>Danger Zone</strong></div>
|
||||
<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" data-action="set-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" data-action="set-save">Save Email</button>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => {
|
||||
closeModal();
|
||||
closeModal(idx);
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
|
||||
@@ -108,7 +103,7 @@ function settingsModal(account) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
toast('Email updated', 'success');
|
||||
closeModal();
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
@@ -120,7 +115,7 @@ function settingsModal(account) {
|
||||
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
toast('Account deactivated', 'success');
|
||||
closeModal();
|
||||
closeModal(idx);
|
||||
modelFetch('acme');
|
||||
} else {
|
||||
toast(resp.error || 'Failed', 'error');
|
||||
@@ -129,142 +124,129 @@ function settingsModal(account) {
|
||||
});
|
||||
}
|
||||
|
||||
function issueCertModal(state) {
|
||||
_issueState.domain = '';
|
||||
_issueState.modalIdx = -1;
|
||||
_issueState.account = null;
|
||||
_issueState.validating = false;
|
||||
function createIssueState() {
|
||||
return {
|
||||
step: 'init',
|
||||
domain: '',
|
||||
account: null,
|
||||
validating: false,
|
||||
checks: [],
|
||||
ready: false,
|
||||
};
|
||||
}
|
||||
|
||||
let _currentIssueState = null;
|
||||
|
||||
function issueCertModal(state) {
|
||||
_currentIssueState = createIssueState();
|
||||
(async () => {
|
||||
const accountResp = await apiFetch('/api/certs/account');
|
||||
_issueState.account = accountResp.ok ? accountResp.data : null;
|
||||
_renderIssueModal(state);
|
||||
_currentIssueState.account = accountResp.ok ? accountResp.data : null;
|
||||
openModal((inner, modalIdx) => {
|
||||
modalVNodes(inner, _renderIssueContent());
|
||||
_bindIssueButtons(inner, modalIdx);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function _renderIssueModal(state) {
|
||||
const account = _issueState.account;
|
||||
function _renderIssueContent() {
|
||||
const s = _currentIssueState;
|
||||
const account = s.account;
|
||||
const registered = account && account.registered;
|
||||
const accountBadge = registered
|
||||
? esc(account.email) + ' (' + esc(account.ca) + ')'
|
||||
: 'No account registered';
|
||||
|
||||
openModal((inner) => {
|
||||
inner.innerHTML = '<h2 class="modal-title">Issue Certificate</h2>'
|
||||
+ '<div class="modal-body">'
|
||||
+ '<div id="ic-account-info" class="text-sm mb-2">'
|
||||
+ '<strong>Using account:</strong> ' + esc(accountBadge) + '</div>'
|
||||
+ (registered
|
||||
? '<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com"></div>'
|
||||
: '<div class="text-warning">Register an ACME account first</div>'
|
||||
)
|
||||
+ '<div id="ic-vresults"></div>'
|
||||
+ '</div><div class="modal-actions">'
|
||||
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
|
||||
+ (registered
|
||||
? '<button class="btn btn-primary" data-action="ic-validate">Validate</button>'
|
||||
: '<button class="btn btn-primary" disabled>Validate</button>'
|
||||
+ '<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>'
|
||||
)
|
||||
+ '</div>';
|
||||
|
||||
_issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1;
|
||||
|
||||
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
|
||||
closeModal(_issueState.modalIdx);
|
||||
if (s.step === 'results') {
|
||||
const resultsVNodes = s.checks.map(c => {
|
||||
let cls = 'text-success', icon = '\u2713';
|
||||
if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
|
||||
else if (!c.passed) { cls = 'text-warning'; icon = '\u26A0'; }
|
||||
return html`<div>${icon} <strong>${esc(c.name)}</strong>: <span class=${cls}>${esc(c.message)}</span></div>`;
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
|
||||
closeModal(_issueState.modalIdx);
|
||||
registerAccountModal();
|
||||
});
|
||||
return html`<div>
|
||||
<h2 class="modal-title">Validate: ${esc(s.domain)}</h2>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Domain</label>
|
||||
<input id="ic-domain" value=${esc(s.domain)} />
|
||||
</div>
|
||||
<div id="ic-vresults">${...resultsVNodes}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
||||
<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>
|
||||
<button class="btn btn-primary" data-action="ic-issue"${s.ready ? '' : ' disabled'}>Issue</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!registered) return;
|
||||
|
||||
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
|
||||
if (_issueState.validating) return;
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
_issueState.validating = true;
|
||||
_issueState.domain = domain;
|
||||
try {
|
||||
const resp = await apiFetch('/api/certs/validate', {
|
||||
method: 'POST',
|
||||
body: { domain },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
toast(resp.error || 'Validation failed', 'error');
|
||||
return;
|
||||
}
|
||||
_showValidate(inner, domain, resp.data.checks, resp.data.ready, state);
|
||||
} finally {
|
||||
_issueState.validating = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
return html`<div>
|
||||
<h2 class="modal-title">Issue Certificate</h2>
|
||||
<div class="modal-body">
|
||||
<div id="ic-account-info" class="text-sm mb-2">
|
||||
<strong>Using account:</strong> ${esc(accountBadge)}
|
||||
</div>
|
||||
${registered
|
||||
? html`<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com" /></div>`
|
||||
: html`<div class="text-warning">Register an ACME account first</div>`}
|
||||
<div id="ic-vresults"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
||||
${registered
|
||||
? html`<button class="btn btn-primary" data-action="ic-validate">Validate</button>`
|
||||
: html`<button class="btn btn-primary" disabled>Validate</button>
|
||||
<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _showValidate(inner, domain, checks, ready, state) {
|
||||
const resultsHtml = checks.map(c => {
|
||||
let cls = 'text-success';
|
||||
let icon = '\u2713';
|
||||
if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
|
||||
else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; }
|
||||
return '<div>' + icon + ' <strong>' + esc(c.name) + '</strong>'
|
||||
+ ': ' + '<span class="' + cls + '">' + esc(c.message) + '</span></div>';
|
||||
}).join('');
|
||||
|
||||
inner.innerHTML = '<h2 class="modal-title">Validate: ' + esc(domain) + '</h2>'
|
||||
+ '<div class="modal-body">'
|
||||
+ '<div class="form-group"><label>Domain</label><input id="ic-domain" value="' + esc(domain) + '"></div>'
|
||||
+ '<div id="ic-vresults">' + resultsHtml + '</div>'
|
||||
+ '</div><div class="modal-actions">'
|
||||
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
|
||||
+ '<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>'
|
||||
+ '<button class="btn btn-primary" data-action="ic-issue"'
|
||||
+ (ready ? '' : ' disabled') + '>Issue</button>'
|
||||
+ '</div>';
|
||||
|
||||
function _bindIssueButtons(inner, modalIdx) {
|
||||
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
|
||||
closeModal(_issueState.modalIdx);
|
||||
closeModal(modalIdx);
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
|
||||
closeModal(modalIdx);
|
||||
registerAccountModal();
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
|
||||
if (_issueState.validating) return;
|
||||
const domain2 = ($val('ic-domain') || '').trim();
|
||||
if (!domain2) { toast('Domain is required', 'error'); return; }
|
||||
_issueState.validating = true;
|
||||
_issueState.domain = domain2;
|
||||
if (_currentIssueState.validating) return;
|
||||
const s = _currentIssueState;
|
||||
const domain = ($val('ic-domain') || '').trim();
|
||||
if (!domain) { toast('Domain is required', 'error'); return; }
|
||||
s.validating = true;
|
||||
s.domain = domain;
|
||||
try {
|
||||
const resp2 = await apiFetch('/api/certs/validate', {
|
||||
method: 'POST',
|
||||
body: { domain: domain2 },
|
||||
});
|
||||
if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; }
|
||||
_showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state);
|
||||
const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain } });
|
||||
if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; }
|
||||
s.step = 'results';
|
||||
s.checks = resp.data.checks;
|
||||
s.ready = resp.data.ready;
|
||||
refreshModals();
|
||||
} finally {
|
||||
_issueState.validating = false;
|
||||
s.validating = false;
|
||||
}
|
||||
});
|
||||
|
||||
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
|
||||
const body = { domain: _issueState.domain };
|
||||
const issueResp = await apiFetch('/api/certs/issue/start', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
const body = { domain: _currentIssueState.domain };
|
||||
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
|
||||
if (issueResp.ok) {
|
||||
toast('Issuance started for ' + _issueState.domain, 'success');
|
||||
closeModal(_issueState.modalIdx);
|
||||
toast('Issuance started for ' + _currentIssueState.domain, 'success');
|
||||
closeModal(modalIdx);
|
||||
const rid = issueResp.data?.request_id;
|
||||
if (rid) pollCertIssue(rid, state);
|
||||
if (rid) pollCertIssue(rid);
|
||||
} else {
|
||||
toast(issueResp.error || 'Failed', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pollCertIssue(rid, state) {
|
||||
async function pollCertIssue(rid) {
|
||||
poll({
|
||||
url: '/api/certs/issue/' + enc(rid),
|
||||
successKey: (d) => d.status === 'completed',
|
||||
@@ -293,32 +275,31 @@ export default definePage({
|
||||
const rows = (state.acme.data?.certs || []).map(c => {
|
||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||
|
||||
return h('tr', { key: c.domain },
|
||||
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
|
||||
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
|
||||
h('td', null, esc(c.expiry || 'N/A')),
|
||||
h('td', null, badge),
|
||||
ActionCell({
|
||||
editLabel: 'Renew',
|
||||
editClick: async () => {
|
||||
return html`<tr key=${c.domain}>
|
||||
<td><strong>${esc(c.domain || 'unknown')}</strong></td>
|
||||
<td class="text-sm">${esc(c.issuer || '-')}</td>
|
||||
<td>${esc(c.expiry || 'N/A')}</td>
|
||||
<td>${badge}</td>
|
||||
<${ActionCell}
|
||||
editLabel="Renew"
|
||||
editClick=${async () => {
|
||||
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
|
||||
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
|
||||
else toast(resp.error || 'Failed', 'error');
|
||||
},
|
||||
removeUrl: '/api/certs/' + enc(c.domain),
|
||||
removeMessage: 'Remove certificate for ' + c.domain + '?',
|
||||
removeSuccess: 'Certificate removed',
|
||||
removeRefresh: 'acme',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
removeUrl=${'/api/certs/' + enc(c.domain)}
|
||||
removeMessage=${'Remove certificate for ' + c.domain + '?'}
|
||||
removeSuccess="Certificate removed"
|
||||
removeRefresh="acme" />
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Certificates',
|
||||
subtitle: 'ACME certificate management',
|
||||
actions: h('button', { class: 'btn btn-primary',
|
||||
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
|
||||
actions: html`<button class="btn btn-primary"
|
||||
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
|
||||
}),
|
||||
_accountCard(account),
|
||||
rows.length
|
||||
|
||||
Reference in New Issue
Block a user