Track what happens to every certificate, and show it on the Certs tab

NPM knows what a certificate is today and nothing about what it was, so ten of them could fail
renewal for months — 1001 certbot runs, zero successes — without anything on any page saying so.
Counts start at zero and are only ever observed; only first_seen is seeded, from NPM's own date.
This commit is contained in:
Gmer4Lfe
2026-08-15 18:51:38 -04:00
parent 13de1dab82
commit 71ba0a239f
6 changed files with 554 additions and 0 deletions
+119
View File
@@ -243,6 +243,32 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
/* ── Cert history ────────────────────────────────────────────────────────── */
.vv-au-tot { display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px; }
.vv-au-tot-b { background:#161616;border:1px solid #222;border-radius:5px;padding:8px 10px;text-align:center; }
.vv-au-tot-n { font-size:19px;font-weight:700;color:#bbb;line-height:1.1; }
.vv-au-tot-n.bad { color:#ef5350; }
.vv-au-tot-l { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.05em;margin-top:2px; }
/* Fixed columns rather than flex: forty rows of counters only read as a table if the numbers line
up down the page, and a domain name is the one part whose width varies. */
.vv-au-hist-row { display:grid;grid-template-columns:1fr 34px 34px 74px 84px auto;gap:6px;
align-items:center;padding:4px 12px;border-bottom:1px solid #1a1a1a;font-size:11px; }
.vv-au-hist-row:last-child { border-bottom:none; }
.vv-au-hist-row:hover { background:#141414; }
.vv-au-hist-row.retired { background:#150c0c; }
.vv-au-hist-row.removed { opacity:.45; }
.vv-au-hist-dom { color:#bbb;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-au-hist-n { text-align:right;font-weight:600;font-size:11px; }
.vv-au-hist-n.ok { color:#4caf50; }
.vv-au-hist-n.bad{ color:#ef5350; }
.vv-au-hist-n.dim{ color:#333; }
.vv-au-hist-t { color:#666;font-size:10px;text-align:right; }
.vv-au-hist-e { color:#444;font-size:10px;font-family:monospace;text-align:right; }
.vv-au-hist-note { font-size:9px;color:#3a3a3a;font-weight:normal;text-transform:none;letter-spacing:0; }
.vv-au-strike { font-size:9px;color:#ff9800;background:#1f1200;border:1px solid #3a2800;
border-radius:2px;padding:0 5px;white-space:nowrap; }
.vv-au-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block; }
</style>
<?php
@@ -375,6 +401,24 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users &amp; Groups',
<div class="vv-au-loading">Loading…</div>
</div>
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
<!-- Everything below is history rather than current state, and comes from
DB_DIR/cert_history.json which Tools/cert_history.sh writes. The grid above answers "is
this cert about to expire"; this answers "has this domain ever been trouble". -->
<div id="vv-au-hist-totals" style="margin-top:14px"></div>
<div class="vv-au-ug-grid" style="margin-top:10px;grid-template-columns:2fr 1fr">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Domain history</span>
<span class="vv-au-hist-note" id="vv-au-hist-when"></span>
</div>
<div id="vv-au-hist-list"><div class="vv-au-loading">Loading…</div></div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h"><span class="vv-au-card-title">DDNS</span></div>
<div id="vv-au-ddns"><div class="vv-au-loading">Loading…</div></div>
</div>
</div>
</div>
<?php endif; ?>
@@ -1769,9 +1813,84 @@ function _renderNpmCerts(data) {
}).join('');
}
// ── History, totals and DDNS ─────────────────────────────────────────────────
// Read-only: the counters advance when Tools/cert_history.sh runs, not when this page is opened.
// A page that wrote the history it displays would count a refresh as an observation.
function _loadCertHistory() {
const list = document.getElementById('vv-au-hist-list');
const tot = document.getElementById('vv-au-hist-totals');
const ddns = document.getElementById('vv-au-ddns');
if (!list) return;
fetch(CERT_API + '?action=history')
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'history unavailable');
const t = d.totals || {};
if (!t.tracked) {
tot.innerHTML = '';
list.innerHTML = '<div class="vv-au-empty">No history yet — run '
+ '<code style="color:#5c9fd4">Tools/cert_history.sh</code> once to start tracking.</div>';
ddns.innerHTML = '';
return;
}
// Failures are only red when there are any: a zero in an alarm colour trains you to ignore
// the colour rather than the number.
tot.innerHTML = `<div class="vv-au-tot">
${_totBox(t.tracked, 'domains tracked')}
${_totBox(t.active, 'active')}
${_totBox(t.renewals, 'renewals seen')}
${_totBox(t.failures, 'failures', t.failures ? 'bad' : '')}
${_totBox(t.retired, 'retired', t.retired ? 'bad' : '')}
${_totBox(t.checks, 'checks')}
${_totBox(t.oldest_span || '—', 'longest tracked')}
</div>`;
const when = document.getElementById('vv-au-hist-when');
if (when) when.textContent = d.last_pass
? 'last pass ' + new Date(d.last_pass * 1000).toLocaleString()
: 'never run';
list.innerHTML = (d.rows || []).map(r => {
const cls = r.state === 'retired' ? ' retired' : (r.state === 'removed' ? ' removed' : '');
// Strikes are only worth showing while they are accruing; a retired domain already says so.
const strike = (r.strikes && r.state === 'active')
? `<span class="vv-au-strike">${r.strikes}/${d.strike_limit} strikes</span>` : '';
const tag = r.state === 'retired' ? '<span class="vv-au-badge deny">retired</span>'
: r.state === 'removed' ? '<span class="vv-au-badge nossl">removed</span>' : '';
return `<div class="vv-au-hist-row${cls}">
<span class="vv-au-hist-dom" title="${_esc(r.domain)}">${_esc(r.domain)}</span>
<span class="vv-au-hist-n ok" title="renewals observed">${r.renewals}</span>
<span class="vv-au-hist-n ${r.failures ? 'bad' : 'dim'}" title="times found expired">${r.failures}</span>
<span class="vv-au-hist-t" title="first seen ${_esc(new Date(r.first_seen*1000).toISOString().slice(0,10))}">${_esc(r.tracked)}</span>
<span class="vv-au-hist-e" title="current expiry">${_esc(r.expires || '—')}</span>
${strike}${tag}
</div>`;
}).join('') || '<div class="vv-au-empty">Nothing tracked yet.</div>';
ddns.innerHTML = (d.ddns || []).length
? d.ddns.map(c => `<div class="vv-au-user-row">
<span class="vv-au-dot" style="background:${c.running ? '#4caf50' : '#ef5350'}"></span>
<span class="vv-au-user-name" style="flex:1">${_esc(c.name)}</span>
<span class="vv-au-user-email">${_esc(c.status)}</span>
</div>`).join('')
: '<div class="vv-au-empty">No DDNS containers configured.</div>';
})
.catch(e => {
list.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed')}</div>`;
});
}
function _totBox(n, label, cls) {
return `<div class="vv-au-tot-b"><div class="vv-au-tot-n ${cls || ''}">${_esc(String(n))}</div>`
+ `<div class="vv-au-tot-l">${_esc(label)}</div></div>`;
}
function _loadCerts(onDone) {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
_loadCertHistory();
fetch(CERT_API + '?action=npm')
.then(r => r.json())
.then(d => {