Make the Auth tab explain a number instead of only showing it

A low uptime figure, a refused login and a certificate that stopped
renewing all looked the same from the row: a number, with the reason
split across NPM, an Authelia config and the directory.

The why-check goes and looks — TCP to the forward target, HTTP through
the proxy, a second handshake with verification off to tell a broken
certificate from a broken service. Forward hosts are docker names that
only resolve on NPM's network, so an unresolvable one is redirected to
the container address and the substitution is reported; a check that
could not be made must never read as a check that failed.

The access simulator walks the rules the way Authelia does and shows the
ones it stepped over, reading whichever instance the chosen host points
at rather than the one conf names — there are two here.

Cert triage counts runs rather than log lines and orders by rotation
suffix rather than mtime, both of which change the answer.
This commit is contained in:
Gmer4Lfe
2026-08-16 01:59:01 -04:00
parent 1001c25487
commit 99b58c0c4f
7 changed files with 1540 additions and 31 deletions
+491 -9
View File
@@ -238,8 +238,10 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
/* ── Misc ────────────────────────────────────────────────────────────────── */
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-fwd { font-size:10px;color:#444;font-family:monospace;margin-top:1px; }
/* The host column is the flexible one in the proxy table, so a long list of domains has to wrap
inside it rather than push the figure columns out of line. */
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold;overflow-wrap:anywhere; }
.vv-au-fwd { font-size:10px;color:#444;font-family:monospace;margin-top:1px;overflow-wrap:anywhere; }
/* ── Proxy row marks and traffic ─────────────────────────────────────────── */
/* Small marks rather than four more columns. Each one is a fact about the host that had no
@@ -253,7 +255,7 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-au-m.auth { background:#1a0d2a;border-color:#2a1a4a;color:#9c6ff7;font-weight:600; }
/* Requests and errors are their own columns now. Right-aligned so the magnitudes line up down the
page — the whole point of the errors column is spotting the one host that is all failures. */
.vv-au-num { text-align:right;white-space:nowrap;width:1%; }
.vv-au-num { text-align:right;white-space:nowrap; }
.vv-au-stat-n { font-size:13px;font-weight:700;color:#bbb;line-height:1.15; }
.vv-au-stat-n.dim { color:#2e2e2e;font-weight:normal;font-size:12px; }
.vv-au-stat-n.ok { color:#2d4a2d; }
@@ -270,6 +272,79 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
the two red ones do not. */
.vv-au-stat-n.ok2 { color:#4a7a4a; }
/* Fixed columns for the proxy table. With auto layout each figure column sized itself to its own
widest cell, so the three of them were three different widths and every column shifted whenever
the numbers changed. Declared once here and the host column takes whatever is left. */
.vv-au-tbl.fixed { table-layout:fixed;min-width:960px; }
.vv-au-tblwrap { overflow-x:auto; }
.vv-au-c-flags { width:154px; }
/* 60 spark bars at 2px plus 1px gaps is 179px, and the cell has 10px padding either side. Every
figure column gets that same width whether or not it draws bars. */
.vv-au-c-stat { width:200px; }
.vv-au-c-tog { width:56px; }
.vv-au-c-act { width:64px; }
/* ── Why ─────────────────────────────────────────────────────────────────── */
/* On the figure's own line, not under the strip: the strip is 179px of fixed-width bars and is what
sets this column's width, so anything below it would widen every row for the four that need it. */
.vv-au-upl { display:flex;align-items:center;justify-content:flex-end;gap:6px; }
.vv-au-why { font-size:9px;padding:1px 5px;border-radius:2px;background:#1f1200;
border:1px solid #3a2000;color:#ff9800;cursor:pointer;line-height:1.4; }
.vv-au-why:hover{ background:#2a1900;color:#ffb74d; }
.vv-au-why:disabled { opacity:.4;cursor:default; }
.vv-au-why-wait { font-size:11px;color:#666;padding:14px 2px;line-height:1.5; }
/* The findings are the answer, so they get the top of the dialog and enough weight to be read as
sentences rather than as another row of status. */
.vv-au-find { font-size:11px;line-height:1.5;padding:8px 10px;border-radius:3px;margin-bottom:6px;
border-left:2px solid #333;background:#141414;color:#888; }
.vv-au-find.bad { border-left-color:#ef5350;color:#d88; }
.vv-au-find.warn{ border-left-color:#ff9800;color:#b98a4a; }
.vv-au-find.info{ border-left-color:#2a4a6a;color:#777; }
.vv-au-why-grid { display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:12px 0 4px;font-size:11px; }
.vv-au-why-k { color:#444;white-space:nowrap; }
.vv-au-why-v { color:#999;overflow-wrap:anywhere; }
.vv-au-why-sec { font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;
margin:14px 0 6px;border-top:1px solid #222;padding-top:10px; }
.vv-au-why-h { margin-bottom:10px; }
.vv-au-why-hd { font-size:11px;color:#999;display:flex;justify-content:space-between;gap:10px; }
.vv-au-why-bad { font-size:10px;color:#8a6a2a;margin:2px 0 3px; }
.vv-au-why-ev { font-size:10px;color:#666;margin-top:2px; }
.vv-au-why-note { font-size:10px;color:#4a4a4a;margin-top:2px;line-height:1.45; }
/* ── Access simulator ────────────────────────────────────────────────────── */
.vv-au-sim { padding:12px 14px;margin-bottom:10px; }
.vv-au-sim-row { display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
.vv-au-sim-row .vv-au-label { margin-bottom:0; }
.vv-au-sim-row .vv-au-select { width:auto;min-width:150px;flex:0 1 auto; }
.vv-au-sim-path { width:110px;flex:0 0 auto; }
/* One word, because that is the question. Sized to be readable from the second screen. */
.vv-au-sim-verdict { font-size:20px;font-weight:700;letter-spacing:.02em;margin:14px 0 10px; }
.vv-au-sim-verdict.ok { color:#4a7a4a; }
.vv-au-sim-verdict.warn { color:#ff9800; }
.vv-au-sim-verdict.bad { color:#ef5350; }
.vv-au-sim-t { display:flex;gap:8px;font-size:10px;color:#555;padding:3px 0;align-items:baseline; }
.vv-au-sim-t.hit{ color:#999; }
.vv-au-sim-n { color:#333;min-width:14px; }
.vv-au-sim-t.hit .vv-au-sim-n { color:#4a7a4a; }
.vv-au-sim-l { min-width:130px;color:#666; }
.vv-au-sim-t.hit .vv-au-sim-l { color:#bbb;font-weight:600; }
.vv-au-sim-w { color:#444;overflow-wrap:anywhere; }
.vv-au-sim-t.hit .vv-au-sim-w { color:#777; }
/* ── Renewal triage ──────────────────────────────────────────────────────── */
/* Root causes carry the marker; consequences are dimmed. Sorting these by count would put rate
limiting first every time, and rate limiting is the one thing here never worth fixing directly. */
.vv-au-tri-row { display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #1a1a1a;align-items:baseline; }
.vv-au-tri-row:last-of-type { border-bottom:none; }
.vv-au-tri-row.root .vv-au-tri-n { color:#ff9800; }
.vv-au-tri-n { font-size:15px;font-weight:700;color:#555;min-width:34px;text-align:right; }
.vv-au-tri-w { font-size:11px;color:#888;line-height:1.5; }
.vv-au-tri-d { font-size:10px;color:#4a4a4a;font-family:monospace;margin-top:2px;overflow-wrap:anywhere; }
.vv-au-ok { color:#4a7a4a; }
.vv-au-bad { color:#ef5350; }
.vv-au-warn { color:#ff9800; }
.vv-au-dim { color:#444; }
/* One bar per probe, last hour. The shape is the part a percentage throws away: one long outage
and sixty scattered blips are the same 50% and completely different problems. */
.vv-au-spark { display:flex;gap:1px;justify-content:flex-end;align-items:flex-end;height:11px;margin-top:3px; }
@@ -361,12 +436,18 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users & Groups',
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-proxy-tbl" style="display:none">
<div class="vv-au-tblwrap" id="vv-au-proxy-wrap" style="display:none">
<table class="vv-au-tbl fixed" id="vv-au-proxy-tbl">
<colgroup>
<col><col class="vv-au-c-flags"><col class="vv-au-c-stat"><col class="vv-au-c-stat">
<col class="vv-au-c-stat"><col class="vv-au-c-tog"><col class="vv-au-c-act">
</colgroup>
<thead><tr>
<th>Host</th><th>Flags</th><th style="text-align:right">Uptime</th><th style="text-align:right">Requests</th><th style="text-align:right">Errors</th><th>On</th><th></th>
</tr></thead>
<tbody id="vv-au-proxy-body"></tbody>
</table>
</div>
<div class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
</div>
</div>
@@ -418,6 +499,22 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users &amp; Groups',
<span class="vv-au-dirty-note" id="vv-au-ac-dirty" style="margin-left:auto"></span>
<button class="vv-au-btn green" id="vv-au-ac-save">Save &amp; Restart Authelia</button>
</div>
<!-- Whether one person can open one URL is decided by three objects on three different tabs: the
NPM host (is it handed to Authelia at all), the rule list below (which rule wins, in file
order), and the LDAP group the rule names. This asks the question directly instead of making
someone hold all three in their head — and it reads the Authelia instance the chosen host
actually talks to, which is not always the one this tab edits. -->
<div class="vv-au-card vv-au-sim">
<div class="vv-au-sim-row">
<span class="vv-au-label">Can</span>
<select class="vv-au-select" id="vv-au-sim-user"></select>
<span class="vv-au-label">open</span>
<select class="vv-au-select" id="vv-au-sim-dom"></select>
<input class="vv-au-input vv-au-sim-path" id="vv-au-sim-path" value="/" title="Path — rules carrying a resources pattern only apply to the paths they name">
<button class="vv-au-btn prim" id="vv-au-sim-run">Test</button>
</div>
<div id="vv-au-sim-out"></div>
</div>
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
<button class="vv-au-btn prim" id="vv-au-rule-add">+ Add Rule</button>
@@ -437,8 +534,13 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users &amp; Groups',
<div class="vv-au-panel<?= $first==="certs" ? " active" : "" ?>" id="vv-au-panel-certs">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
<!-- The counts on this tab say a domain stopped renewing. certbot's logs say why, and they are
639 MB of Python tracebacks — which is why the last answer to that question was somebody
reading them by hand. This reads the newest runs and names the categories. -->
<button class="vv-au-btn" id="vv-au-cert-triage">Why renewals fail</button>
<button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>
</div>
<div id="vv-au-cert-triage-out"></div>
<div class="vv-au-cert-grid" id="vv-au-cert-grid">
<div class="vv-au-loading">Loading…</div>
</div>
@@ -536,8 +638,11 @@ function _esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _get(action, cb) {
fetch(API + '?action=' + action)
// params is optional and encoded rather than concatenated — the only caller passing one passes an
// id, but a query string built by hand is how a value with an & in it silently becomes two.
function _get(action, cb, params) {
const qs = params ? '&' + new URLSearchParams(params).toString() : '';
fetch(API + '?action=' + action + qs)
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
@@ -639,7 +744,7 @@ function _loadTab(tab) {
// ── Proxies ───────────────────────────────────────────────────────────────────
function _loadProxies() {
const loading = document.getElementById('vv-au-proxy-loading');
const tbl = document.getElementById('vv-au-proxy-tbl');
const tbl = document.getElementById('vv-au-proxy-wrap');
const empty = document.getElementById('vv-au-proxy-empty');
loading.style.display = 'block';
tbl.style.display = 'none';
@@ -651,7 +756,7 @@ function _loadProxies() {
if (!certsLoaded || !proxiesLoaded) return;
loading.style.display = 'none';
if (!_proxies.length) { empty.style.display = 'block'; return; }
tbl.style.display = 'table';
tbl.style.display = 'block';
_renderProxies();
}
@@ -765,10 +870,189 @@ function _upCell(p) {
? 'DOWN — ' + (worst.last_detail || 'no response')
: 'up' + (worst.last_ms ? ' · ' + worst.last_ms + 'ms' : '');
return `<div class="vv-au-stat-n${cls}" title="${_esc(title)}">${pct === null ? '—' : (pct >= 99.95 ? '100' : pct.toFixed(pct >= 99 ? 2 : 1))}<span class="vv-au-stat-u">% 24h</span></div>
// Offered only where there is something to explain. A why? on all thirty-five rows is a button
// nobody reads; on the four that are not fine it is the next thing you were going to do anyway.
//
// 96 rather than 100 because a probe every minute makes 99.9% an ordinary week — one missed
// sample in a day is 99.93 and means nothing. Below 96 is roughly an hour lost in a day, which is
// always something. Anything currently down qualifies whatever its percentage says.
const needWhy = down || (pct !== null && pct < 96);
const why = needWhy
? `<button class="vv-au-why" data-why="${p.id}" title="Go and look at what is behind this figure">why?</button>`
: '';
return `<div class="vv-au-upl">${why}<div class="vv-au-stat-n${cls}" title="${_esc(title)}">${pct === null ? '—' : (pct >= 99.95 ? '100' : pct.toFixed(pct >= 99 ? 2 : 1))}<span class="vv-au-stat-u">% 24h</span></div></div>
<div class="vv-au-spark" title="last hour, one bar per minute">${bars}</div>`;
}
// ── Why ───────────────────────────────────────────────────────────────────────
// Held so the assistant hand-off has something to send without asking the server twice. Cleared
// with the dialog, because a brief describing a probe from ten minutes ago is worse than no brief.
let _why = null;
function _whyModal(id, btn) {
const p = _proxies.find(x => x.id === id);
const name = (p?.domain_names || []).join(', ') || ('host ' + id);
_why = null;
// Disabled for the duration. The check takes seconds and opens sockets, and a second press while
// the first is in flight probes the host twice to answer the same question.
if (btn) btn.disabled = true;
// Named while it works, because it genuinely takes a few seconds and a silent dialog reads as a
// hang. What it is about to do is listed rather than hidden behind a spinner — this opens
// connections, and an operator watching a limping host should know the page is about to touch it.
_modal(`<h3>Why — ${_esc(name)}</h3>
<div class="vv-au-why-wait">Looking… connecting to the service behind this host, asking the
domain itself, and reading the probe history.</div>`, true);
_get('npm_why', r => {
if (btn) btn.disabled = false;
if (!r || !r.ok) {
_modal(`<h3>Why — ${_esc(name)}</h3>
<div class="vv-au-err show">${_esc(r?.error || 'The check failed')}</div>
<div class="vv-au-modal-acts"><button class="vv-au-btn" id="wm-close">Close</button></div>`, true);
document.getElementById('wm-close').onclick = _closeModal;
return;
}
_why = r;
_modal(_whyHtml(r, name), true);
document.getElementById('wm-close').onclick = _closeModal;
const ask = document.getElementById('wm-ask');
if (ask) ask.onclick = () => _whyAsk(name);
}, { id });
}
// Hand the measurements to the assistant. The dialog closes first: the chat card is at the foot of
// the page and an answer streaming in behind an overlay is an answer nobody sees.
function _whyAsk(name) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat || !_why) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const brief = _whyBrief(_why, name);
_closeModal();
// retarget, so the thread is about this host and not appended to whatever was asked before it.
// Same reasoning as the Watchdog tab: a troubleshooting thread about one host must not bleed into
// a question about another.
chat.retarget('troubleshoot', name, 'now looking at ' + name);
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = brief;
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function _whyHtml(r, name) {
const lvl = { bad: 'bad', warn: 'warn', info: 'info' };
// The findings first and everything else under them. The evidence is what makes the answer
// checkable, but it is not the answer, and a dialog that opens on a table of status codes makes
// the reader do the work the check just did.
const findings = (r.findings || []).map(f =>
`<div class="vv-au-find ${lvl[f.level] || 'info'}">${_esc(f.text)}</div>`).join('');
const up = r.upstream || {};
const tcp = up.tcp || {};
const rows = [];
rows.push(['Forwards to', _esc(r.forward)
+ (up.container ? ` <span class="vv-au-m${up.container.running ? '' : ' auth'}">${_esc(up.container.name)} · ${_esc(up.container.status)}</span>` : '')]);
// The address actually knocked on, not the one written in NPM. They differ whenever the forward
// host is a docker name, and a result attributed to the wrong address is worse than no result.
rows.push([tcp.ok ? 'Port open' : 'Port', (tcp.ok
? `<span class="vv-au-ok">${_esc(up.probed || '')}:${up.port}</span> <span class="vv-au-dim">${tcp.ms}ms</span>`
: `<span class="vv-au-bad">${_esc(up.probed || '')}:${up.port} — ${_esc(tcp.err || 'no answer')}</span>`)
+ (up.note ? `<div class="vv-au-why-note">${_esc(up.note)}</div>` : '')]);
if (up.http)
rows.push(['Service answers', up.http.code
? `<span class="${up.http.code >= 500 ? 'vv-au-bad' : 'vv-au-ok'}">HTTP ${up.http.code}</span> <span class="vv-au-dim">${up.http.ms}ms</span>`
: `<span class="vv-au-bad">${_esc(up.http.err || 'no response')}</span>`]);
// Both halves of the certificate: what NPM records and what the handshake did. They disagree more
// often than they should — a cert with a month left still fails if the chain being served is wrong.
if (r.cert) {
const d = r.cert.days_left;
rows.push(['Certificate', `${_esc(r.cert.name)} <span class="${d !== null && d < 0 ? 'vv-au-bad' : (d !== null && d <= 14 ? 'vv-au-warn' : 'vv-au-dim')}">`
+ (d === null ? 'no expiry recorded' : (d < 0 ? `expired ${Math.abs(d)}d ago` : `${d}d left`)) + '</span>']);
}
if (r.guarded) rows.push(['Protection', '<span class="vv-au-dim">behind Authelia (auth_request)</span>']);
if (!r.enabled) rows.push(['State', '<span class="vv-au-bad">disabled in NPM</span>']);
for (const [d, l] of Object.entries(r.live || {})) {
const okNow = l.code > 0 && l.code < 500;
rows.push([_esc(d), (okNow ? `<span class="vv-au-ok">HTTP ${l.code}</span> <span class="vv-au-dim">${l.ms}ms</span>`
: `<span class="vv-au-bad">${l.code ? 'HTTP ' + l.code : _esc(l.err || 'no answer')}</span>`)
+ (l.insecure ? ` <span class="vv-au-warn">— answers ${l.insecure.code} without certificate verification</span>` : '')]);
}
const t = r.traffic;
if (t) rows.push(['Logged traffic', `${_fmtNum(t.requests || 0)} requests · ${_fmtNum(t.s4xx || 0)} 4xx · ${_fmtNum(t.s5xx || 0)} 5xx`
+ (t.last_seen ? ` <span class="vv-au-dim">· last ${_ago(t.last_seen)} ago</span>` : ' <span class="vv-au-dim">· never hit</span>')]);
const grid = rows.map(([k, v]) =>
`<div class="vv-au-why-k">${k}</div><div class="vv-au-why-v">${v}</div>`).join('');
// The recorded history, per domain. Kept last and kept short: it is the part that says whether
// this is happening now or already over, which only matters once you know what "this" is.
const hist = Object.entries(r.history || {}).map(([d, h]) => {
const ev = (h.events || []).map(e =>
`<div class="vv-au-why-ev"><span class="${e.to === 'up' ? 'vv-au-ok' : 'vv-au-bad'}">${_esc(String(e.to).toUpperCase())}</span>
<span class="vv-au-dim">${_ago(e.ts)} ago</span> ${_esc(e.detail || '')}</div>`).join('');
const bad = (h.bad_hours || []).map(b =>
`${String(b.hour).slice(8, 10)}:00 <span class="vv-au-dim">${b.up}/${b.total}</span>`).join(' · ');
return `<div class="vv-au-why-h">
<div class="vv-au-why-hd">${_esc(d)}
<span class="vv-au-dim">${h.h1 === null ? '—' : h.h1 + '% 1h'} · ${h.h24 === null ? '—' : h.h24 + '% 24h'} · ${h.d30 === null ? '—' : h.d30 + '% 30d'}</span></div>
${bad ? `<div class="vv-au-why-bad">Hours that lost samples: ${bad}</div>` : ''}
${ev || '<div class="vv-au-why-ev vv-au-dim">no state changes recorded</div>'}
</div>`;
}).join('');
// Offered only where there is a model to ask. The findings above are the page's own reading and
// stand without it — the assistant is for the step after, which is what to do about it.
const ask = document.getElementById('vv-au-ai-chat')
? '<button class="vv-au-btn prim" id="wm-ask">Ask the assistant</button>' : '';
return `<h3>Why — ${_esc(name)}</h3>
${findings}
<div class="vv-au-why-grid">${grid}</div>
${hist ? `<div class="vv-au-why-sec">Recorded history</div>${hist}` : ''}
<div class="vv-au-modal-acts">${ask}<button class="vv-au-btn" id="wm-close">Close</button></div>`;
}
// The brief the assistant is given. Deliberately the same facts the dialog just showed, written out
// rather than summarised: a model asked "why is this host down" with no evidence answers from the
// general shape of the question, and the whole point of the check above is that it went and looked.
function _whyBrief(r, name) {
const L = [];
L.push(`Proxy host ${name} on this machine is not at full uptime. Here is what the Auth tab just measured.`);
L.push(`Forwards to ${r.forward}${r.guarded ? ', behind Authelia via auth_request' : ''}${r.enabled ? '' : '. The host is DISABLED in NPM'}.`);
const up = r.upstream || {}, tcp = up.tcp || {};
if (up.note) L.push(`Note on the check itself: ${up.note}`);
L.push(`TCP to ${up.probed}:${up.port} — ${tcp.ok ? 'open in ' + tcp.ms + 'ms' : 'failed: ' + (tcp.err || 'no reason')}.`);
if (up.container) L.push(`That target is container ${up.container.name}, currently ${up.container.status}.`);
if (up.http) L.push(`Asked the service directly: ${up.http.code ? 'HTTP ' + up.http.code : 'no response (' + (up.http.err || '') + ')'}.`);
if (r.cert) L.push(`Certificate ${r.cert.name}: ${r.cert.days_left === null ? 'no expiry recorded' : r.cert.days_left + ' days left'}.`);
for (const [d, l] of Object.entries(r.live || {}))
L.push(`Through the proxy, https://${d}/ — ${l.code ? 'HTTP ' + l.code + ' in ' + l.ms + 'ms' : 'no answer: ' + (l.err || '')}`
+ (l.insecure ? `; with certificate verification off it answers ${l.insecure.code}` : '') + '.');
const t = r.traffic;
if (t) L.push(`Access log totals: ${t.requests || 0} requests, ${t.s4xx || 0} 4xx, ${t.s5xx || 0} 5xx.`);
for (const [d, h] of Object.entries(r.history || {})) {
L.push(`${d}: ${h.h1 === null ? '?' : h.h1}% in the last hour, ${h.h24 === null ? '?' : h.h24}% over 24h, ${h.d30 === null ? '?' : h.d30}% over 30 days; currently ${h.state || 'unknown'}, last probe said "${h.last_detail || '?'}".`);
for (const e of (h.events || []).slice(0, 5))
L.push(` went ${e.to} ${_ago(e.ts)} ago — ${e.detail || ''}`);
}
L.push('');
L.push('What is the most likely cause, and what should I check or change first? '
+ 'If the evidence above already settles it, say so rather than listing possibilities.');
return L.join('\n');
}
function _certName(id) {
const c = _certs.find(x => String(x.id) === String(id));
return c ? (c.nice_name || (c.domain_names || []).join(', ')) : 'certificate ' + id;
@@ -964,6 +1248,9 @@ _on('vv-au-panel-proxies', 'click', async e => {
});
return;
}
// Why
const whyBtn = e.target.closest('[data-why]');
if (whyBtn) { _whyModal(parseInt(whyBtn.dataset.why), whyBtn); return; }
// Edit
const editBtn = e.target.closest('[data-proxy-edit]');
if (editBtn) { _proxyModal(parseInt(editBtn.dataset.proxyEdit)); return; }
@@ -1607,8 +1894,129 @@ _on('vv-au-panel-users', 'click', async e => {
}
});
// ── Access simulator ──────────────────────────────────────────────────────────
// The dropdowns come from the other two panels' data, which this panel does not otherwise load —
// AUTH_STACK decides which panels exist and _loadTab only fetches what the open one needs, so both
// lists are fetched here rather than assumed to be sitting in the page already.
function _simFill() {
const dom = document.getElementById('vv-au-sim-dom');
const user = document.getElementById('vv-au-sim-user');
if (!dom || !user) return;
const fillDom = () => {
// Wildcards are dropped: they are not a hostname anyone opens, so simulating one answers a
// question nobody can ask. Disabled hosts stay in — "why does this not work" is often "it is
// switched off", and hiding them hides the answer.
const seen = new Set();
_proxies.forEach(p => (p.domain_names || []).forEach(d => { if (!String(d).includes('*')) seen.add(String(d).toLowerCase()); }));
dom.innerHTML = [...seen].sort().map(d => `<option value="${_esc(d)}">${_esc(d)}</option>`).join('')
|| '<option value="">no proxy hosts</option>';
};
const fillUser = () => {
user.innerHTML = _users.slice().sort((a, b) => String(a.id).localeCompare(String(b.id)))
.map(u => `<option value="${_esc(u.id)}">${_esc(u.id)}</option>`).join('')
|| '<option value="">no users</option>';
};
if (_proxies.length) fillDom();
else _get('npm_proxies', r => { if (r.ok) { _proxies = r.proxies || []; fillDom(); } });
if (_users.length) fillUser();
else _get('lldap_users', r => { if (r.ok) { _users = r.users || []; fillUser(); } });
}
// Held for the assistant hand-off, same as the proxy check.
let _sim = null;
function _simRun() {
const out = document.getElementById('vv-au-sim-out');
const btn = document.getElementById('vv-au-sim-run');
const dom = document.getElementById('vv-au-sim-dom').value;
const uid = document.getElementById('vv-au-sim-user').value;
const path = document.getElementById('vv-au-sim-path').value || '/';
if (!dom) return;
_sim = null;
btn.disabled = true;
out.innerHTML = '<div class="vv-au-why-wait">Reading the proxy host, the rules of the Authelia it talks to, and the directory…</div>';
_get('access_check', r => {
btn.disabled = false;
if (!r || !r.ok) { out.innerHTML = `<div class="vv-au-err show">${_esc(r?.error || 'The check failed')}</div>`; return; }
_sim = r;
out.innerHTML = _simHtml(r);
const ask = document.getElementById('vv-au-sim-ask');
if (ask) ask.onclick = () => _simAsk(r);
}, { domain: dom, uid, path });
}
function _simHtml(r) {
// The verdict as one word, because that is the question. Bypass is coloured as a warning rather
// than a success — reaching a page without being asked to log in is only good news if it was
// meant to be public, and the findings below say which case this is.
const pol = r.policy || 'unknown';
const cls = pol === 'deny' ? 'bad' : (pol === 'bypass' ? 'warn' : 'ok');
const verdict = `<div class="vv-au-sim-verdict ${cls}">${_esc(pol)}</div>`;
const findings = (r.findings || []).map(f =>
`<div class="vv-au-find ${_esc(f.level || 'info')}">${_esc(f.text)}</div>`).join('');
// Every rule, including the ones that did nothing. Which rule you expected to win and why it was
// stepped over is the part that is impossible to see from the cards below.
const trace = (r.trace || []).map(t =>
`<div class="vv-au-sim-t${t.applied ? ' hit' : ''}">
<span class="vv-au-sim-n">${t.n}</span>
<span class="vv-au-sim-l">${_esc(t.label)}</span>
<span class="vv-au-sim-w">${_esc(t.why)}${t.applied ? ' → ' + _esc(t.policy || '') : ''}</span>
</div>`).join('');
const groups = (r.groups || []).length
? (r.groups || []).map(g => `<span class="vv-au-m">${_esc(g)}</span>`).join(' ')
: '<span class="vv-au-dim">no groups</span>';
const ask = document.getElementById('vv-au-ai-chat')
? '<button class="vv-au-btn prim" id="vv-au-sim-ask">Ask the assistant</button>' : '';
return `${verdict}${findings}
<div class="vv-au-why-grid">
<div class="vv-au-why-k">Groups</div><div class="vv-au-why-v">${groups}</div>
${r.forward ? `<div class="vv-au-why-k">Forwards to</div><div class="vv-au-why-v">${_esc(r.forward)}</div>` : ''}
${r.authelia && r.authelia.container ? `<div class="vv-au-why-k">Decided by</div><div class="vv-au-why-v">${_esc(r.authelia.container)}<div class="vv-au-why-note">${_esc(r.authelia.config || '')}</div></div>` : ''}
${r.default_policy ? `<div class="vv-au-why-k">Default policy</div><div class="vv-au-why-v">${_esc(r.default_policy)}</div>` : ''}
</div>
${trace ? `<div class="vv-au-why-sec">Every rule, in file order</div>${trace}` : ''}
${ask ? `<div class="vv-au-modal-acts">${ask}</div>` : ''}`;
}
function _simAsk(r) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const L = [];
L.push(`On this machine, can ${r.uid || 'anyone'} open https://${r.domain}${r.path}? Here is what the Auth tab worked out.`);
L.push(`The proxy host ${r.served ? 'exists' : 'does not exist'}${r.served && !r.enabled ? ' but is disabled' : ''}${r.forward ? ', forwarding to ' + r.forward : ''}.`);
L.push(`${r.uid || 'The user'} is in these LDAP groups: ${(r.groups || []).join(', ') || 'none'}.`);
if (r.authelia && r.authelia.container)
L.push(`Authentication is decided by ${r.authelia.container}, config ${r.authelia.config || 'not found'}${r.authelia.is_configured ? '' : ' — which is not the instance this tab edits'}.`);
L.push(`Rules were walked in file order:`);
for (const t of (r.trace || []))
L.push(` ${t.n}. ${t.label} — ${t.applied ? 'APPLIED, policy ' + (t.policy || '') : 'skipped: ' + t.why}`);
L.push(`Default policy is ${r.default_policy || 'unknown'}. The resulting policy is ${r.policy || 'unknown'}.`);
L.push('');
L.push('Is that the intended outcome? If not, say which rule to change and how — be specific about '
+ 'rule order, because Authelia stops at the first rule that matches on every axis.');
chat.retarget('troubleshoot', r.domain, 'now looking at access to ' + r.domain);
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = L.join('\n');
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Access Control ────────────────────────────────────────────────────────────
function _loadAcl() {
_simFill();
const loading = document.getElementById('vv-au-acl-loading');
const grid = document.getElementById('vv-au-acl-body');
const empty = document.getElementById('vv-au-acl-empty');
@@ -1842,6 +2250,10 @@ _on('vv-au-panel-acl', 'input', e => {
// Enter adds a row underneath and moves to it, so a list of fourteen can be typed straight
// through instead of returning to the add button between each one.
_on('vv-au-panel-acl', 'keydown', e => {
// Enter in the path box runs the simulation. Checked before the domain-input handler below,
// which would otherwise never see it anyway but reads as if it might.
if (e.target.id === 'vv-au-sim-path' && e.key === 'Enter') { e.preventDefault(); _simRun(); return; }
const inp = e.target.closest('.vv-au-dominput');
if (!inp || e.key !== 'Enter') return;
e.preventDefault();
@@ -1860,6 +2272,7 @@ function _focusDomain(r, d) {
// ACL event delegation
_on('vv-au-panel-acl', 'click', async e => {
if (e.target.id === 'vv-au-sim-run') { _simRun(); return; }
if (e.target.id === 'vv-au-rule-add') { _ruleModal(null); return; }
const domAdd = e.target.closest('[data-dom-add]');
@@ -2112,6 +2525,75 @@ _on('vv-au-cert-run', 'click', function() {
_loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});
// ── Renewal triage ────────────────────────────────────────────────────────────
let _triage = null;
_on('vv-au-cert-triage', 'click', function() {
const btn = this, out = document.getElementById('vv-au-cert-triage-out');
btn.disabled = true;
out.innerHTML = '<div class="vv-au-why-wait">Reading certbot\'s logs…</div>';
fetch(CERT_API + '?action=triage').then(r => r.json()).then(r => {
btn.disabled = false;
if (!r || !r.ok) { out.innerHTML = `<div class="vv-au-err show">${_esc(r?.error || 'Triage failed')}</div>`; return; }
_triage = r;
out.innerHTML = _triageHtml(r);
const ask = document.getElementById('vv-au-triage-ask');
if (ask) ask.onclick = () => _triageAsk(r);
}).catch(e => { btn.disabled = false; out.innerHTML = `<div class="vv-au-err show">${_esc(String(e))}</div>`; });
});
function _triageHtml(r) {
if (!r.total)
return `<div class="vv-au-card vv-au-sim"><div class="vv-au-find info">No renewal failures in the
last ${r.files_read} certbot runs. Nothing is failing to renew right now.</div></div>`;
// Causes first and consequences after, marked as such. Sorted by count would put rate limiting
// at the top of every one of these, and rate limiting is almost never the thing to fix.
const cats = (r.categories || []).map(c => `
<div class="vv-au-tri-row${c.root ? ' root' : ''}">
<span class="vv-au-tri-n">${c.count}</span>
<span class="vv-au-tri-w">${_esc(c.what)}${c.root ? '' : ' <span class="vv-au-dim">— a consequence, not a cause</span>'}
${(c.domains || []).length ? `<div class="vv-au-tri-d">${(c.domains || []).map(d => _esc(d)).join(' · ')}</div>` : ''}
</span>
</div>`).join('');
const reading = (r.reading || []).map(l => `<div class="vv-au-find info">${_esc(l)}</div>`).join('');
const ask = document.getElementById('vv-au-ai-chat')
? '<div class="vv-au-modal-acts"><button class="vv-au-btn prim" id="vv-au-triage-ask">Ask the assistant</button></div>' : '';
return `<div class="vv-au-card vv-au-sim">
<div class="vv-au-why-sec" style="margin-top:0;border-top:none;padding-top:0">
${r.total} of the last ${r.files_read} certbot runs failed${r.unclassified ? ` · ${r.unclassified} matched nothing known` : ''}
</div>
${cats}${reading}${ask}
</div>`;
}
function _triageAsk(r) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const L = [];
L.push(`Certificate renewals on this machine. ${r.total} of the last ${r.files_read} certbot runs failed. `
+ `Categories, counted per run rather than per log line:`);
for (const c of (r.categories || []))
L.push(` ${c.count} — ${c.what}${c.root ? ' [root cause]' : ' [consequence]'}`
+ ((c.domains || []).length ? ` — ${c.domains.join(', ')}` : ''));
for (const l of (r.reading || [])) L.push(l);
L.push('');
L.push('What do I fix first, and in what order? Be concrete about which hostnames to add DNS for, '
+ 'delete, or remove from NPM — and say whether the rate limit clears on its own once that is done.');
chat.retarget('troubleshoot', 'certificates', 'now looking at certificate renewals');
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = L.join('\n');
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Assistant ────────────────────────────────────────────────────────────────
// vv_ai_chat_markup() emits markup and nothing else — the instance has to be constructed here,
// as every other placement does. Without this the card renders complete and is entirely inert: