Mesh is a mode rather than an AI profile — it has no model, tokens, reasoning or sources, so making it a profile would branch every profile-aware path on the one that has no model.
2865 lines
158 KiB
PHP
2865 lines
158 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Auth tab. Manages the stack sitting in front of every protected hostname — NPM proxy
|
|
// hosts and certificates, LLDAP users and groups, and Authelia access-control rules.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// The most consequential page in the UI. A wrong edit here does not render badly, it locks
|
|
// people out of every service or exposes one that should be protected.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Requires include/auth.php directly rather than going through an endpoint for everything.
|
|
// Server-rendered for the initial state, with api/auth.php handling mutations. The
|
|
// credentials involved never reach the browser either way.
|
|
//
|
|
// Only the owner host edits auth config.
|
|
// Changes are made here and reach the partner through Critical-Data sync, not by the
|
|
// browser writing to two hosts. One source of truth, one direction of travel.
|
|
//
|
|
// Authelia rules are edited as a block, preserving the rest of the YAML untouched.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Every destructive action is confirmed — deleting a user, removing a proxy host, or
|
|
// rewriting the rule set are all one click away from locking out a household.
|
|
//
|
|
// Rule writes are atomic and refuse a missing config. include/auth.php writes to a temp
|
|
// file and renames; a missing configuration.yml is an error, never a freshly created file
|
|
// with a permissive default policy.
|
|
//
|
|
// Certificate state is read-only here. Renewal is owned by the cert monitor.
|
|
//
|
|
// All values render escaped — usernames, domains and rule fields are attacker-adjacent
|
|
// strings by definition on this page.
|
|
//
|
|
// RENDERS
|
|
// NPM proxy host list and editor, certificate status
|
|
// LLDAP user and group management
|
|
// Authelia access-control rules and default policy
|
|
//
|
|
// STACK SWITCHING
|
|
// AUTH_STACK in master.conf decides which panels exist. Proxies and Certs belong to Nginx
|
|
// Proxy Manager and are drawn for every stack; Users and Access Control are the identity
|
|
// stack's and are drawn only for one that Varaverk can drive. api/auth.php applies the same
|
|
// rule to every action, so a tab left open across a switch cannot write to the old stack.
|
|
//
|
|
// DEPENDS ON
|
|
// include/auth.php required directly for initial render
|
|
// api/auth.php mutations
|
|
// api/cert.php certificate status
|
|
// api/confform.php inline conf edits → include/confui.php
|
|
require_once dirname(__DIR__) . '/include/confui.php';
|
|
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
|
?>
|
|
<style>
|
|
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
|
.vv-au-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap; }
|
|
.vv-au-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
|
|
.vv-au-tab { font-size:11px;padding:3px 12px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#666;cursor:pointer;white-space:nowrap; }
|
|
.vv-au-tab:hover{ background:#222;color:#aaa; }
|
|
.vv-au-tab.active { background:#0d1f2a;border-color:#1a3a5a;color:#5c9fd4; }
|
|
.vv-au-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
|
|
.vv-au-btn:hover{ background:#222;color:#bbb; }
|
|
.vv-au-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
|
|
.vv-au-btn.warn { border-color:#3a2000;background:#1f1200;color:#ff9800; }
|
|
.vv-au-btn.danger{border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
|
|
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
|
|
.vv-au-btn:disabled { opacity:.4;cursor:default; }
|
|
|
|
/* ── Stack indicator ─────────────────────────────────────────────────────── */
|
|
/* Which stack the tab is driving. Always visible, because every panel below it means something
|
|
different depending on this, and it is a conf value nothing else on the page reveals. */
|
|
.vv-au-stackchip{ font-size:10px;padding:2px 8px;border-radius:3px;background:#12181f;
|
|
border:1px solid #1e2a38;color:#5c9fd4;white-space:nowrap; }
|
|
.vv-au-stacknote{ font-size:11px;color:#997;background:#1a1400;border:1px solid #3a2d00;
|
|
border-radius:4px;padding:8px 12px;margin-bottom:10px;line-height:1.55; }
|
|
.vv-au-stacknote b { color:#ffb74d;display:block;margin-bottom:2px; }
|
|
.vv-au-stacknote.bad { background:#1a0d0d;border-color:#3a1a1a;color:#a77; }
|
|
.vv-au-stacknote.bad b { color:#ef5350; }
|
|
|
|
/* ── Panels ──────────────────────────────────────────────────────────────── */
|
|
.vv-au-panel { display:none; }
|
|
.vv-au-panel.active { display:block; }
|
|
|
|
/* ── Tables ──────────────────────────────────────────────────────────────── */
|
|
.vv-au-tbl { width:100%;border-collapse:collapse;font-size:11px; }
|
|
.vv-au-tbl th { text-align:left;padding:5px 10px;font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #1e1e1e;white-space:nowrap; }
|
|
.vv-au-tbl td { padding:7px 10px;border-bottom:1px solid #161616;vertical-align:middle; }
|
|
.vv-au-tbl tr:hover td { background:#141414; }
|
|
.vv-au-tbl tr:last-child td { border-bottom:none; }
|
|
|
|
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
|
.vv-au-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;display:inline-block; }
|
|
.vv-au-badge.ssl { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
|
.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
|
|
.vv-au-badge.on { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
|
.vv-au-badge.off { background:#1a0a0a;border:1px solid #2a1a1a;color:#555; }
|
|
.vv-au-badge.bypass { background:#0a1a2a;border:1px solid #1a3a5a;color:#5c9fd4; }
|
|
.vv-au-badge.one_factor { background:#1a1a0a;border:1px solid #3a3a1a;color:#cddc39; }
|
|
.vv-au-badge.two_factor { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
|
.vv-au-badge.deny { background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350; }
|
|
.vv-au-badge.grp { background:#1a0d2a;border:1px solid #2a1a4a;color:#9c6ff7;margin:1px 2px; }
|
|
.vv-au-badge.host { background:#111;border:1px solid #1e1e1e;color:#444; }
|
|
|
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
.vv-au-card { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
|
|
.vv-au-card-h { display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
|
|
.vv-au-card-title { font-size:11px;font-weight:bold;color:#666;text-transform:uppercase;letter-spacing:.06em;flex:1; }
|
|
|
|
/* ── Users / Groups split ────────────────────────────────────────────────── */
|
|
.vv-au-ug-grid { display:grid;grid-template-columns:3fr 2fr;gap:12px; }
|
|
@media (max-width:700px) { .vv-au-ug-grid { grid-template-columns:1fr; } }
|
|
|
|
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
|
|
.vv-au-user-row:last-child { border-bottom:none; }
|
|
.vv-au-user-row:hover { background:#141414; }
|
|
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px;display:flex;flex-direction:column;gap:1px; }
|
|
/* The real name under the display name, only when the two differ. */
|
|
.vv-au-user-real { font-size:9px;color:#4a4a4a;font-weight:normal; }
|
|
|
|
/* ── Avatars ─────────────────────────────────────────────────────────────── */
|
|
/* Fixed square with a flex-shrink guard: the rows are a flex layout and an image left to its own
|
|
intrinsic size drags every name on the page out of alignment while it loads. */
|
|
.vv-au-av { width:22px;height:22px;border-radius:3px;object-fit:cover;flex-shrink:0;
|
|
background:#111;border:1px solid #222;display:inline-block; }
|
|
.vv-au-av.none { font-size:9px;color:#4a4a4a;text-align:center;line-height:22px;font-weight:bold;
|
|
letter-spacing:.02em; }
|
|
.vv-au-av.big { width:88px;height:88px;border-radius:5px;line-height:88px;font-size:26px; }
|
|
|
|
/* ── Editor sections ─────────────────────────────────────────────────────── */
|
|
/* Below the field block and its Save, so the split between "staged until Save details" and
|
|
"applies when you press its own button" is a visible line rather than a convention. */
|
|
.vv-au-sect { margin-top:6px;padding-top:10px;border-top:1px solid #222; }
|
|
.vv-au-sect-h { font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;
|
|
margin:10px 0 5px; }
|
|
.vv-au-sect-h:first-child { margin-top:0; }
|
|
/* Wider only where it has to be — the editor now carries a photo beside a form. The other dialogs
|
|
on this page are single columns and would just get airier. */
|
|
.vv-au-modal.wide { max-width:620px; }
|
|
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
|
|
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
|
|
|
|
.vv-au-grp-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;cursor:pointer; }
|
|
.vv-au-grp-row:last-child { border-bottom:none; }
|
|
.vv-au-grp-row:hover { background:#141414; }
|
|
.vv-au-grp-name { font-size:12px;color:#bbb;font-weight:bold; }
|
|
.vv-au-grp-cnt { font-size:10px;color:#444;margin-top:2px; }
|
|
.vv-au-grp-acts { float:right;display:flex;gap:4px;margin-top:1px; }
|
|
|
|
.vv-au-grp-members { padding:6px 12px 10px 20px;background:#0f0f0f;border-bottom:1px solid #1a1a1a; display:none; }
|
|
.vv-au-grp-members.open { display:block; }
|
|
.vv-au-grp-member { font-size:11px;color:#666;padding:2px 0;display:flex;align-items:center;gap:6px; }
|
|
.vv-au-grp-member-rm { font-size:11px;color:#3a1a1a;cursor:pointer;padding:0 2px; }
|
|
.vv-au-grp-member-rm:hover { color:#ef5350; }
|
|
|
|
/* ── Access Control ──────────────────────────────────────────────────────── */
|
|
.vv-au-ac-defpol { display:flex;align-items:center;gap:10px;padding:8px 12px;background:#111;border:1px solid #222;border-radius:4px;margin-bottom:10px;font-size:11px;color:#555; }
|
|
.vv-au-ac-defpol select { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#888;font-size:11px;padding:2px 6px; }
|
|
.vv-au-rule-num { color:#333;font-size:10px;width:24px;text-align:right;flex-shrink:0; }
|
|
.vv-au-rule-acts { display:flex;gap:3px;white-space:nowrap; }
|
|
|
|
/* ── ACL rule cards ──────────────────────────────────────────────────────── */
|
|
/* Grid, not flex rows — every card the same width whatever its domain count, so the columns line
|
|
up down the page instead of each row negotiating its own. Same reason the Monitor uses one. */
|
|
.vv-au-acl-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:10px;align-items:start; }
|
|
.vv-au-rule { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
|
|
/* Order is semantics here — Authelia takes the first rule whose domain and subject both match —
|
|
so the number stays loud even though cards read less sequentially than rows did. */
|
|
.vv-au-rule-h { display:flex;align-items:center;gap:7px;padding:7px 10px;background:#111;border-bottom:1px solid #1e1e1e; }
|
|
.vv-au-rule-ord { font-size:10px;font-weight:700;color:#3a3a3a;min-width:14px; }
|
|
.vv-au-rule-subj { flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:3px; }
|
|
.vv-au-rule-any { font-size:10px;color:#b8860b;font-style:italic; }
|
|
.vv-au-rule-b { padding:8px 10px; }
|
|
.vv-au-rule-meta { font-size:9px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px;
|
|
display:flex;justify-content:space-between;gap:8px; }
|
|
.vv-au-rule-sfx { color:#4a4a4a;text-transform:none;letter-spacing:0;font-family:monospace; }
|
|
.vv-au-rule-lbl { color:#5a6a4a;text-transform:none;letter-spacing:0;font-style:italic;
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0; }
|
|
/* One row per domain, each an input. Chips read well and cannot be edited; this is a list you
|
|
work in, so the row is the field rather than a label that opens a dialog somewhere else. */
|
|
.vv-au-domlist { display:flex;flex-direction:column;gap:2px; }
|
|
.vv-au-domrow { display:flex;align-items:center;gap:4px; }
|
|
.vv-au-dominput { flex:1;min-width:0;background:#0f1419;border:1px solid #1e2a33;border-radius:2px;
|
|
color:#7c9fb8;font-family:monospace;font-size:10px;padding:2px 5px;outline:none; }
|
|
.vv-au-dominput:focus { border-color:#2d5a8a;background:#111820;color:#a8cde0; }
|
|
.vv-au-dominput.wild { color:#c9a227;border-color:#3a2800; }
|
|
/* An empty row would be written out as a blank domain, so it is marked while it is still on
|
|
screen rather than silently dropped at save time. */
|
|
.vv-au-dominput.blank { border-color:#3a1a1a;background:#1a0d0d; }
|
|
.vv-au-domadd { font-size:10px;color:#4a7a4a;background:none;border:1px dashed #23331f;border-radius:2px;
|
|
padding:2px 6px;cursor:pointer;margin-top:4px;width:100%;text-align:center; }
|
|
.vv-au-domadd:hover { color:#7ac77a;border-color:#2d5a2d;background:#0d1a0d; }
|
|
/* The unsaved marker. Inline editing makes it very easy to change three cards and walk away, and
|
|
nothing here reaches Authelia until Save & Restart is pressed. */
|
|
.vv-au-btn.dirty { border-color:#3a2800;background:#1f1200;color:#ffb74d; }
|
|
.vv-au-dirty-note{ font-size:10px;color:#ffb74d;margin-right:8px; }
|
|
.vv-au-rule-x { margin-top:7px;padding-top:6px;border-top:1px solid #1c1c1c;font-size:10px;color:#555;
|
|
display:flex;flex-direction:column;gap:2px; }
|
|
.vv-au-rule-x b { color:#3a3a3a;font-weight:normal;text-transform:uppercase;font-size:9px;letter-spacing:.06em; }
|
|
.vv-au-rule-x code { color:#8a7fb8;font-family:monospace;word-break:break-all; }
|
|
.vv-au-icon-btn { font-size:12px;color:#444;cursor:pointer;padding:1px 3px;border-radius:2px;background:none;border:none;line-height:1; }
|
|
.vv-au-icon-btn:hover { color:#bbb;background:#222; }
|
|
.vv-au-icon-btn.del:hover { color:#ef5350;background:#1a0808; }
|
|
|
|
/* ── Section toolbar (within panel) ─────────────────────────────────────── */
|
|
.vv-au-sec-bar { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
|
|
.vv-au-sec-title{ font-size:11px;color:#555;flex:1; }
|
|
|
|
/* ── Toggle switch ───────────────────────────────────────────────────────── */
|
|
.vv-au-tog { width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;position:relative;cursor:pointer;display:inline-block;flex-shrink:0; }
|
|
.vv-au-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
|
|
.vv-au-tog::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#444;transition:left .12s,background .12s; }
|
|
.vv-au-tog.on::after { left:14px;background:#4caf50; }
|
|
|
|
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
|
.vv-au-overlay { position:fixed;inset:0;background:#0009;z-index:9000;display:none;align-items:center;justify-content:center; }
|
|
.vv-au-overlay.open { display:flex; }
|
|
.vv-au-modal { background:#181818;border:1px solid #2a2a2a;border-radius:6px;padding:18px 20px;min-width:360px;max-width:520px;width:90vw;max-height:85vh;overflow-y:auto; }
|
|
.vv-au-modal h3 { font-size:12px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px; }
|
|
.vv-au-field { margin-bottom:10px; }
|
|
.vv-au-label { display:block;font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.04em; }
|
|
.vv-au-input { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
|
|
.vv-au-input:focus { border-color:#1a3a5a; }
|
|
.vv-au-select { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
|
|
.vv-au-hint { font-size:10px;color:#333;margin-top:2px; }
|
|
.vv-au-modal-acts { display:flex;justify-content:flex-end;gap:8px;margin-top:16px; }
|
|
.vv-au-tog-row { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
|
|
.vv-au-tog-lbl { font-size:11px;color:#666; }
|
|
.vv-au-adv-toggle { font-size:10px;color:#333;cursor:pointer;margin-bottom:8px; }
|
|
.vv-au-adv-toggle:hover { color:#666; }
|
|
.vv-au-adv-section { display:none; }
|
|
.vv-au-adv-section.open { display:block; }
|
|
/* nginx config is whitespace-significant and read in blocks — proportional text at 11px makes a
|
|
location block unreadable, and wrapping hides where a brace closes. */
|
|
.vv-au-nginx { font-family:monospace;font-size:11px;line-height:1.45;white-space:pre;
|
|
overflow-wrap:normal;overflow-x:auto;tab-size:2;resize:vertical;min-height:180px; }
|
|
.vv-au-err { font-size:11px;color:#ef5350;margin-top:8px;display:none; }
|
|
.vv-au-err.show { display:block; }
|
|
|
|
/* ── Misc ────────────────────────────────────────────────────────────────── */
|
|
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
|
|
/* 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
|
|
column at all — most importantly whether an auth_request block is in front of it. */
|
|
.vv-au-marks { display:flex;flex-wrap:wrap;gap:3px;align-items:center; }
|
|
.vv-au-m { font-size:9px;padding:1px 5px;border-radius:2px;background:#141414;
|
|
border:1px solid #232323;color:#666;white-space:nowrap; }
|
|
.vv-au-m.dim { color:#3a3a3a;border-color:#1c1c1c; }
|
|
/* The one mark worth finding at a glance: it is the difference between a service the household
|
|
can reach and one the whole internet can. */
|
|
.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; }
|
|
.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; }
|
|
.vv-au-stat-n.warn { color:#ff9800; }
|
|
.vv-au-stat-n.bad { color:#ef5350; }
|
|
/* The unit rides with the number rather than in the header alone: a column of bare figures next
|
|
to another column of bare figures is two things you have to look up to tell apart. */
|
|
.vv-au-stat-u { font-size:9px;font-weight:400;color:#4a4a4a;text-transform:uppercase;
|
|
letter-spacing:.05em;margin-left:4px; }
|
|
.vv-au-stat-s { font-size:9px;color:#3a3a3a;margin-top:1px; }
|
|
.vv-au-stat-s.warn { color:#8a6a2a; }
|
|
.vv-au-stat-s.bad { color:#a34; }
|
|
/* Healthy uptime is deliberately quiet — a page where thirty green numbers shout is a page where
|
|
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.
|
|
|
|
Percentages, not pixels, and no minimum width. A pixel minimum makes the table wider than the
|
|
page on a narrow window, and a table that cannot fit is a table that pushes its container around
|
|
— which is a layout problem everywhere else on the page in exchange for alignment in one card.
|
|
Proportions give the same thing the fixed widths gave: three figure columns identical to each
|
|
other and steady from row to row, at any width.
|
|
|
|
The modifier is namespaced rather than called `fixed`. Unraid Connect mounts Vue components on
|
|
every WebGUI page and Vite injects their Tailwind layer into the document head, which carries
|
|
`.fixed{position:fixed}` — a bare `fixed` on this table took the whole thing out of flow, the
|
|
panel collapsed to its section bar and the Assistant card slid up underneath it. Nothing in the
|
|
plugin's own CSS can show that: the rule arrives from another plugin at runtime. Never give an
|
|
element in here a class name that is also a Tailwind utility. */
|
|
.vv-au-tbl.vv-au-tbl-fixed { table-layout:fixed; }
|
|
/* With fixed layout a cell keeps its width and its content simply overhangs, so anything that
|
|
cannot shrink has to be clipped here rather than allowed to run into the next column. The strip
|
|
below is the case that matters: sixty bars at a fixed 3px each do not care how narrow the window
|
|
is. Losing the oldest few minutes off the left of it is a fair price for a table that never
|
|
deforms — the percentage beside it is the precise figure anyway. */
|
|
.vv-au-tbl.vv-au-tbl-fixed td { overflow:hidden; }
|
|
.vv-au-c-flags { width:14%; }
|
|
.vv-au-c-stat { width:17%; }
|
|
.vv-au-c-tog { width:5%; }
|
|
.vv-au-c-act { width:6%; }
|
|
|
|
/* ── Uptime history card ─────────────────────────────────────────────────── */
|
|
/* One grid for the header row and every data row, declared once, so the four period columns line
|
|
up down the card without each row measuring its own. The domain column takes what is left and
|
|
the four periods are equal — they hold the same kind of figure and an unequal split would read
|
|
as one of them mattering more. */
|
|
/* The domain column is capped rather than 1fr: at 1fr on a 3440 monitor it took half the card and
|
|
squeezed the four graphs — the names are the label here, the graphs are the content. */
|
|
/* 22px between the periods, not 10. Four strips of small bars sitting a hair apart read as one
|
|
long graph with seams in it — the gap has to be wider than the space between the bars inside a
|
|
strip by enough that the eye groups the bars first and the columns second. */
|
|
.vv-au-hb-head, .vv-au-hb-row {
|
|
display:grid; grid-template-columns:minmax(160px,300px) repeat(4, minmax(96px,1fr));
|
|
gap:22px; align-items:center; padding:5px 12px;
|
|
}
|
|
.vv-au-hb-head { font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:#3a3a3a;
|
|
border-bottom:1px solid #1e1e1e; padding-top:7px; padding-bottom:7px; }
|
|
.vv-au-hb-head span:not(:first-child) { text-align:center; }
|
|
.vv-au-hb-row { border-bottom:1px solid #141414; }
|
|
.vv-au-hb-row:last-child { border-bottom:none; }
|
|
.vv-au-hb-row:hover { background:#141414; }
|
|
.vv-au-hb-dom { font-size:11px; color:#bbb; overflow-wrap:anywhere; }
|
|
.vv-au-hb-dom .vv-au-dot { margin-right:5px; }
|
|
/* Figure over strip, both centred on the column. The number is what gets read; the strip is there
|
|
to say whether that number is one long outage or a hundred small ones. */
|
|
.vv-au-hb-cell { display:flex; flex-direction:column; align-items:center; gap:3px; }
|
|
/* The state modifiers are namespaced, unlike the bare `ok2`/`warn`/`bad` the cells above use.
|
|
Unraid's default-base.css carries `span.warn { background:var(--yellow-200); display:block;
|
|
width:100% }` — element-qualified, so it only bites spans, and it beats nothing here on colour
|
|
while still painting a pale yellow band the full width of the cell. Same lesson as the Tailwind
|
|
`.fixed` collision: a bare state word on an element is a name somebody else already owns. */
|
|
.vv-au-hb-pct { font-size:11px; font-family:monospace; line-height:1; }
|
|
.vv-au-hb-pct.vv-au-hb-ok2 { color:#4caf50; }
|
|
.vv-au-hb-pct.vv-au-hb-warn { color:#cddc39; }
|
|
.vv-au-hb-pct.vv-au-hb-bad { color:#ef5350; }
|
|
.vv-au-hb-pct.vv-au-hb-dim { color:#3a3a3a; }
|
|
/* Bars are flex-1 with no fixed width, so twenty-four hourly bars and twelve monthly ones both
|
|
fill their column exactly. min-width:1px keeps a bar visible rather than collapsing to nothing
|
|
if the card is ever squeezed. */
|
|
.vv-au-hb-bars { display:flex; align-items:flex-end; gap:1px; height:16px; width:100%;
|
|
background:#111; border-radius:2px; padding:1px; }
|
|
.vv-au-hb-bars i { flex:1; min-width:1px; border-radius:1px; align-self:flex-end; }
|
|
.vv-au-hb-bars i.vv-au-hb-ok2 { background:#2d5a2d; }
|
|
.vv-au-hb-bars i.vv-au-hb-warn { background:#5a5a1e; }
|
|
.vv-au-hb-bars i.vv-au-hb-bad { background:#5a1e1e; }
|
|
/* Not measured is not the same as measured at zero, and must never look like it. A gap reads as
|
|
absence — flat, unsaturated, no height to compare against the bars beside it. */
|
|
.vv-au-hb-bars i.vv-au-hb-gap { background:#191919; height:2px !important; }
|
|
.vv-au-hb-cover { font-size:9px; color:#3a3a3a; font-family:monospace; line-height:1; }
|
|
/* Dimmed rather than hidden: the history is real and worth keeping, the domain just is not being
|
|
served any more. Sorted below the live ones, so it never leads the card at 0%. */
|
|
.vv-au-hb-dom.vv-au-hb-stale { color:#4a4a4a; }
|
|
|
|
/* Collapse. The open class is namespaced like the rest of this card rather than the bare `open`
|
|
the AI tab uses — same reasoning as the state modifiers above. */
|
|
.vv-au-hb-t { cursor:pointer; user-select:none; }
|
|
.vv-au-hb-t:hover .vv-au-card-title { color:#8a8a8a; }
|
|
.vv-au-hb-c { color:#333; font-size:9px; transition:transform .12s; flex-shrink:0; }
|
|
.vv-au-hb-t.vv-au-hb-open .vv-au-hb-c { transform:rotate(90deg); }
|
|
/* Collapsed, the header is the whole card, and a divider under it would be a line to nowhere. */
|
|
.vv-au-hb-t:not(.vv-au-hb-open) { border-bottom:none; }
|
|
.vv-au-hb-body { display:none; }
|
|
.vv-au-hb-body.vv-au-hb-open { display:block; }
|
|
|
|
/* ── 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; }
|
|
.vv-au-spark i { width:2px;height:100%;background:#2d5a2d;border-radius:1px;flex-shrink:0; }
|
|
.vv-au-spark i.d{ background:#ef5350; }
|
|
/* A disabled host is still listed — it is a thing you might re-enable — but it should not read
|
|
as part of what is currently serving. */
|
|
.vv-au-off td { opacity:.42; }
|
|
.vv-au-loading { color:#333;font-size:11px;padding:16px;text-align:center; }
|
|
|
|
/* ── Certs panel ─────────────────────────────────────────────────────────── */
|
|
.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
|
|
.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
|
|
require_once __DIR__ . '/../include/auth.php';
|
|
$isOwner = vv_is_owner();
|
|
// AUTH_STACK decides which panels exist at all. Read once here and used for both the tabs and the
|
|
// panels, so a tab can never be drawn for a panel the stack does not carry.
|
|
$stackDef= vv_auth_stack_def();
|
|
$panels = $stackDef['panels'];
|
|
$first = $panels[0] ?? 'proxies';
|
|
$tabs = ['proxies' => 'Proxies', 'users' => 'Users & Groups',
|
|
'acl' => 'Access Control', 'certs' => 'Certs'];
|
|
?>
|
|
|
|
<div class="vv-au-toolbar">
|
|
<span class="vv-au-title">Auth Stack</span>
|
|
<?php foreach ($tabs as $id => $label): if (!in_array($id, $panels, true)) continue; ?>
|
|
<button class="vv-au-tab<?= $id === $first ? ' active' : '' ?>" data-tab="<?= $id ?>"><?= $label ?></button>
|
|
<?php endforeach; ?>
|
|
<span class="vv-au-stackchip" title="AUTH_STACK in master.conf — Settings below"><?= htmlspecialchars($stackDef['label']) ?></span>
|
|
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">↻ Refresh</button>
|
|
</div>
|
|
|
|
<?php if (!vv_auth_stack_valid()): ?>
|
|
<!-- A value that is not one of the known stacks. Named rather than silently corrected, because
|
|
the tab is now showing a different stack than the conf asks for and that is worth knowing. -->
|
|
<div class="vv-au-stacknote bad">
|
|
<b>AUTH_STACK is set to "<?= htmlspecialchars(trim(vv_conf_vars()['AUTH_STACK'] ?? '')) ?>", which is not a stack this page knows.</b>
|
|
Showing <?= htmlspecialchars($stackDef['label']) ?> instead. Fix it in Auth settings, below.
|
|
</div>
|
|
<?php elseif (!$stackDef['ready']): ?>
|
|
<!-- Selected but not implemented. The panels that do work are still drawn — proxy hosts and
|
|
certificates belong to Nginx Proxy Manager, not to the identity stack — and the two that
|
|
cannot are absent with the reason stated, rather than present and broken. -->
|
|
<div class="vv-au-stacknote">
|
|
<b><?= htmlspecialchars($stackDef['label']) ?> is selected, and Varaverk cannot manage it yet.</b>
|
|
<?= htmlspecialchars($stackDef['summary']) ?>
|
|
<span style="color:#4a4a4a">Needed: <?= htmlspecialchars($stackDef['needs'] ?? '') ?></span>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
|
|
<?php if (in_array("proxies",$panels,true)): ?>
|
|
<div class="vv-au-panel<?= $first==="proxies" ? " active" : "" ?>" id="vv-au-panel-proxies">
|
|
<div class="vv-au-sec-bar">
|
|
<span class="vv-au-sec-title" id="vv-au-proxy-count"></span>
|
|
<button class="vv-au-btn prim" id="vv-au-proxy-add">+ Add Proxy</button>
|
|
</div>
|
|
<div class="vv-au-card">
|
|
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
|
|
<table class="vv-au-tbl vv-au-tbl-fixed" id="vv-au-proxy-tbl" style="display:none">
|
|
<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 class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
|
|
</div>
|
|
|
|
<!-- ── Uptime history ──────────────────────────────────────────────────────
|
|
The table above answers "is this host up now, and how was the last hour". This answers
|
|
"has it always been like this", which is the question the 24h figure cannot: a domain at
|
|
99.9% today and 62% last month is a domain that got fixed, and one at 100% today and 100%
|
|
for the year is a different thing entirely from one with no history at all.
|
|
|
|
Four windows side by side rather than a period toggle, because the comparison between them
|
|
is the finding. A toggle would make "fine this week, bad this month" something you have to
|
|
remember across two clicks. -->
|
|
<div class="vv-au-card vv-au-hist-card" style="margin-top:12px">
|
|
<!-- Expanded in the markup, not by JS. The card is open because the class is written here, so
|
|
a slow load or a script that throws leaves it showing its contents rather than collapsed
|
|
with no way to open it. JS only ever closes it, and only when storage says to. -->
|
|
<div class="vv-au-card-h vv-au-hb-t vv-au-hb-open" id="vv-au-up-t"
|
|
title="Click to collapse — remembered on this browser">
|
|
<span class="vv-au-hb-c">▶</span>
|
|
<span class="vv-au-card-title">Uptime history</span>
|
|
<!-- Stays visible while collapsed: a header that says nothing when shut is a header you have
|
|
to open to know whether opening it was worth it. -->
|
|
<span class="vv-au-hist-note" id="vv-au-up-note"></span>
|
|
</div>
|
|
<div class="vv-au-hb-body vv-au-hb-open" id="vv-au-up-body">
|
|
<div class="vv-au-hb-head">
|
|
<span></span>
|
|
<span>24 hours</span><span>7 days</span><span>30 days</span><span>12 months</span>
|
|
</div>
|
|
<div id="vv-au-up-hist"><div class="vv-au-loading">Loading…</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
|
|
<?php if (in_array("users",$panels,true)): ?>
|
|
<div class="vv-au-panel<?= $first==="users" ? " active" : "" ?>" id="vv-au-panel-users">
|
|
<div class="vv-au-ug-grid">
|
|
|
|
<div class="vv-au-card">
|
|
<div class="vv-au-card-h">
|
|
<span class="vv-au-card-title">Users</span>
|
|
<button class="vv-au-btn prim" id="vv-au-user-add">+ Add User</button>
|
|
</div>
|
|
<div class="vv-au-loading" id="vv-au-users-loading">Loading…</div>
|
|
<div id="vv-au-users-list"></div>
|
|
<div class="vv-au-empty" id="vv-au-users-empty" style="display:none">No users found.</div>
|
|
</div>
|
|
|
|
<div class="vv-au-card">
|
|
<div class="vv-au-card-h">
|
|
<span class="vv-au-card-title">Groups</span>
|
|
<button class="vv-au-btn prim" id="vv-au-group-add">+ Add Group</button>
|
|
</div>
|
|
<div class="vv-au-loading" id="vv-au-groups-loading">Loading…</div>
|
|
<div id="vv-au-groups-list"></div>
|
|
<div class="vv-au-empty" id="vv-au-groups-empty" style="display:none">No groups found.</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
|
|
<?php if (in_array("acl",$panels,true)): ?>
|
|
<div class="vv-au-panel<?= $first==="acl" ? " active" : "" ?>" id="vv-au-panel-acl">
|
|
<div class="vv-au-ac-defpol" id="vv-au-ac-defpol-bar">
|
|
<span>Default policy:</span>
|
|
<select id="vv-au-ac-defpol" <?= $isOwner ? '' : 'disabled' ?>>
|
|
<option value="deny">deny</option>
|
|
<option value="two_factor">two_factor</option>
|
|
<option value="one_factor">one_factor</option>
|
|
<option value="bypass">bypass</option>
|
|
</select>
|
|
<?php if (!$isOwner): ?>
|
|
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
|
|
<?php endif; ?>
|
|
<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 & 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>
|
|
</div>
|
|
<div class="vv-au-loading" id="vv-au-acl-loading">Loading…</div>
|
|
<!-- A card per rule rather than a table row. The domain list is the whole point of a rule here
|
|
and it is 13 or 14 entries on most of them, which in one table cell is a comma-joined run of
|
|
four hundred characters that says nothing at a glance. Cards give it room to wrap, and put
|
|
the group — the thing you are actually looking for — in the heading. -->
|
|
<div class="vv-au-acl-grid" id="vv-au-acl-body" style="display:none"></div>
|
|
<div class="vv-au-card"><div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div></div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- ── Certs ───────────────────────────────────────────────────────────────── -->
|
|
<?php if (in_array("certs",$panels,true)): ?>
|
|
<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>
|
|
<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; ?>
|
|
|
|
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
|
|
<div class="vv-au-overlay" id="vv-au-overlay">
|
|
<div class="vv-au-modal" id="vv-au-modal"></div>
|
|
</div>
|
|
|
|
<?php if (vv_ai_ui_on()): ?>
|
|
<div class="vv-card" id="vv-au-ai-card" style="margin-top:12px;">
|
|
<?php
|
|
// The factory, the profile registry and the store are three separate emits and none implies
|
|
// the others — omitting any renders a chat that looks complete and dies on the first click.
|
|
vv_ai_profiles_script();
|
|
vv_ai_chat_store_script();
|
|
vv_ai_chat_assets();
|
|
// Scoped to the tab. This page's vocabulary is the part of the stack least likely to be in
|
|
// anyone's head — a proxy host, a forward target, an Authelia policy and an LDAP group are four
|
|
// different objects that all end up deciding whether one person can open one URL, and the
|
|
// question is nearly always "which of these is stopping me".
|
|
vv_ai_chat_markup('vv-au-ai', [
|
|
'mesh' => true,
|
|
'profile' => 'varaverk',
|
|
'compact' => true,
|
|
'title' => 'Assistant',
|
|
'scopeLabel' => 'Auth',
|
|
'empty' => 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
|
|
'placeholder' => 'Ask about what is on this page…',
|
|
]); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// The three services this page drives, plus the cert thresholds behind the Certs panel. They had
|
|
// no card at all until now, so the credentials that make the whole page work were reachable only
|
|
// from the Settings catch-all or over SSH — and an empty NPM_USER renders here as a failed login,
|
|
// which sends you looking for a password rather than a blank field.
|
|
//
|
|
// The match mirrors VV_UI_SECTION_SURFACES in confform.php and has to keep mirroring it: that
|
|
// constant is what tells the assistant where to send someone, and this is what the page actually
|
|
// draws. The two disagreeing means being given directions to a card that is not there.
|
|
//
|
|
// Both passwords render masked and are logged by name only — vv_conf_key_is_secret() matches
|
|
// PASS, so the same key cannot be redacted in the audit log and legible in the form.
|
|
vv_conf_ui_card('vv-cf-auth', 'Auth Stack|NginxProxyManager|lldap|Authelia|Certificate Monitor', 'Auth settings');
|
|
?>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API = '/plugins/varaverk/api/auth.php';
|
|
const CERT_API = '/plugins/varaverk/api/cert.php';
|
|
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
let _proxies = [], _certs = [];
|
|
// Keyed by proxy-host id, from Tools/npm_access_stats.sh. Empty until it has run once.
|
|
let _proxyStats = {};
|
|
// Keyed by hostname, from Tools/uptime_probe.sh. Empty until the probe has run once.
|
|
let _uptime = {};
|
|
let _uptimePass = null;
|
|
let _users = [], _groups = [];
|
|
let _rules = [], _defaultPolicy = 'deny';
|
|
// The trailing comment on the default_policy line, carried so a save puts it back. The block is
|
|
// rebuilt from this model, so anything not held here is deleted by the next save.
|
|
let _defaultNote = '';
|
|
// Seeded from PHP rather than hardcoded to 'proxies'. Which panels exist is AUTH_STACK's decision,
|
|
// and a stack whose first panel is not Proxies would boot with _activeTab naming a tab that is not
|
|
// on the page — so Refresh would reload nothing and the first render would be empty.
|
|
let _activeTab = <?= json_encode($first) ?>;
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
function _esc(s) {
|
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// 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) }));
|
|
}
|
|
|
|
function _post(params, cb) {
|
|
const fd = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) fd.append(k, v);
|
|
fetch(API, { method: 'POST', body: fd })
|
|
.then(r => r.json()).then(cb)
|
|
.catch(e => cb({ ok: false, error: String(e) }));
|
|
}
|
|
|
|
// wide is for the one dialog that carries a photo beside a form. Reset on every call rather than
|
|
// only set, or a narrow dialog opened after the editor would inherit its width.
|
|
// Attach only if the element is there. AUTH_STACK decides which panels the page renders, so on a
|
|
// stack that does not carry Users or Access Control those containers genuinely do not exist —
|
|
// and a bare getElementById(...).addEventListener would throw on load and take every listener
|
|
// after it down with it, including the ones for the panels that do exist.
|
|
function _on(id, ev, fn) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener(ev, fn);
|
|
return !!el;
|
|
}
|
|
|
|
function _modal(html, wide) {
|
|
const m = document.getElementById('vv-au-modal');
|
|
m.innerHTML = html;
|
|
m.classList.toggle('wide', !!wide);
|
|
document.getElementById('vv-au-overlay').classList.add('open');
|
|
}
|
|
function _closeModal() {
|
|
document.getElementById('vv-au-overlay').classList.remove('open');
|
|
}
|
|
|
|
function _togHtml(id, on, title) {
|
|
return `<span class="vv-au-tog${on?' on':''}" data-tog="${_esc(id)}" title="${_esc(title)}"></span>`;
|
|
}
|
|
|
|
function _policyBadge(p) {
|
|
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
|
|
}
|
|
|
|
// Every list-shaped field in an Authelia rule is "a string or a list of them", and the config
|
|
// on this host uses both forms in the same file — one rule's domain is a bare string, another's
|
|
// is a list of fourteen. Two identical helpers existed for domain and subject; networks and
|
|
// resources had none and were read with a bare .join(), so `resources: "^\/web.*"` written as a
|
|
// scalar threw a TypeError and the Edit dialog for that rule never opened at all.
|
|
function _normList(val) {
|
|
if (val === null || val === undefined || val === '') return [];
|
|
return Array.isArray(val) ? val : [val];
|
|
}
|
|
const _normSubject = _normList;
|
|
const _normDomain = _normList;
|
|
|
|
// A subject entry is itself allowed to be a list, which Authelia reads as "all of these", so it
|
|
// renders joined by + rather than flattened into siblings that would read as alternatives.
|
|
function _subjLabel(s) { return Array.isArray(s) ? s.join(' + ') : String(s); }
|
|
|
|
// The shared tail of a rule's domains, so fourteen chips can read npm, npm2, main instead of
|
|
// spending two thirds of every chip restating gmer4lfe.com. Returned only when it is at least two
|
|
// labels deep and every domain keeps something in front of it — stripping a bare .com would make
|
|
// the chips longer to read, not shorter, and stripping everything would leave one blank.
|
|
function _commonSuffix(list) {
|
|
if (list.length < 2) return '';
|
|
const parts = list.map(d => String(d).split('.'));
|
|
let n = 0;
|
|
for (;;) {
|
|
const idx = parts.map(p => p.length - 1 - n);
|
|
if (idx.some(i => i < 1)) break;
|
|
const seg = parts[0][idx[0]];
|
|
if (!parts.every((p, k) => p[idx[k]] === seg)) break;
|
|
n++;
|
|
}
|
|
return n >= 2 ? '.' + parts[0].slice(parts[0].length - n).join('.') : '';
|
|
}
|
|
|
|
// ── Tab switching ─────────────────────────────────────────────────────────────
|
|
document.querySelectorAll('.vv-au-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.dataset.tab;
|
|
document.querySelectorAll('.vv-au-tab').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.vv-au-panel').forEach(p => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('vv-au-panel-' + tab).classList.add('active');
|
|
_activeTab = tab;
|
|
_loadTab(tab);
|
|
});
|
|
});
|
|
|
|
_on('vv-au-refresh', 'click', () => _loadTab(_activeTab));
|
|
|
|
// ── Uptime history collapse ───────────────────────────────────────────────────
|
|
// Open on a browser that has never been told otherwise, and whatever it was last set to after
|
|
// that — `!== '0'` rather than `=== '1'`, so an absent key reads as open rather than as closed.
|
|
//
|
|
// The markup ships open, so this only ever has to close it. That ordering matters: localStorage
|
|
// throws outright in a few configurations, and if the default lived here instead of in the HTML a
|
|
// failed read would leave the card collapsed with its own toggle the only way back.
|
|
const UP_OPEN_KEY = 'vv-au-uptime-open';
|
|
|
|
function _upSetOpen(open, remember) {
|
|
['vv-au-up-t', 'vv-au-up-body'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.toggle('vv-au-hb-open', open);
|
|
});
|
|
const t = document.getElementById('vv-au-up-t');
|
|
if (t) t.title = (open ? 'Click to collapse' : 'Click to expand') + ' — remembered on this browser';
|
|
// Not remembered when restoring, only when the operator actually chooses. Writing on restore
|
|
// would turn a browser that cannot read storage into one that rewrites it on every load.
|
|
if (remember) { try { localStorage.setItem(UP_OPEN_KEY, open ? '1' : '0'); } catch (_) {} }
|
|
}
|
|
|
|
if (_on('vv-au-up-t', 'click', () => {
|
|
const body = document.getElementById('vv-au-up-body');
|
|
_upSetOpen(!body.classList.contains('vv-au-hb-open'), true);
|
|
})) {
|
|
try { if (localStorage.getItem(UP_OPEN_KEY) === '0') _upSetOpen(false, false); } catch (_) {}
|
|
}
|
|
|
|
function _loadTab(tab) {
|
|
if (tab === 'proxies') _loadProxies();
|
|
if (tab === 'users') { _loadUsers(); _loadGroups(); }
|
|
if (tab === 'acl') _loadAcl();
|
|
if (tab === 'certs') _loadCerts();
|
|
}
|
|
|
|
// ── Proxies ───────────────────────────────────────────────────────────────────
|
|
function _loadProxies() {
|
|
const loading = document.getElementById('vv-au-proxy-loading');
|
|
const tbl = document.getElementById('vv-au-proxy-tbl');
|
|
const empty = document.getElementById('vv-au-proxy-empty');
|
|
loading.style.display = 'block';
|
|
tbl.style.display = 'none';
|
|
empty.style.display = 'none';
|
|
|
|
// Load certs and proxies in parallel
|
|
let certsLoaded = false, proxiesLoaded = false;
|
|
function _check() {
|
|
if (!certsLoaded || !proxiesLoaded) return;
|
|
loading.style.display = 'none';
|
|
if (!_proxies.length) { empty.style.display = 'block'; return; }
|
|
tbl.style.display = 'table';
|
|
_renderProxies();
|
|
}
|
|
|
|
// Stats are optional decoration — the list must render whether or not the aggregator has run,
|
|
// so this neither blocks _check() nor fails the load.
|
|
_get('npm_stats', r => { _proxyStats = (r && r.ok && r.hosts) ? r.hosts : {}; if (proxiesLoaded) _renderProxies(); });
|
|
_get('npm_uptime', r => {
|
|
_uptime = (r && r.ok && r.domains) ? r.domains : {};
|
|
_uptimePass = (r && r.last_pass) || null;
|
|
if (proxiesLoaded) _renderProxies();
|
|
// Independent of the proxy list: this card is keyed by what was probed, not by proxy id, and
|
|
// it is the one thing on the tab that still says something when NPM itself is unreachable.
|
|
_renderUptimeHistory();
|
|
});
|
|
_get('npm_certs', r => { _certs = r.certs || []; certsLoaded = true; _check(); });
|
|
_get('npm_proxies', r => {
|
|
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; return; }
|
|
_proxies = r.proxies || [];
|
|
proxiesLoaded = true;
|
|
_check();
|
|
});
|
|
}
|
|
|
|
function _renderProxies() {
|
|
const count = document.getElementById('vv-au-proxy-count');
|
|
count.textContent = _proxies.length + ' host' + (_proxies.length !== 1 ? 's' : '');
|
|
|
|
const body = document.getElementById('vv-au-proxy-body');
|
|
body.innerHTML = _proxies.map(p => {
|
|
const domains = (p.domain_names || []).join(', ');
|
|
const fwd = p.forward_scheme + '://' + p.forward_host + ':' + p.forward_port;
|
|
const hasSsl = p.certificate_id && p.certificate_id !== '0';
|
|
const enabled = p.enabled;
|
|
const st = _proxyStats[String(p.id)] || null;
|
|
|
|
// Everything the host is doing that used to be invisible, as one row of small marks rather
|
|
// than four more columns: whether it is protected by an auth_request block, whether SSL is
|
|
// forced, and whether it carries custom nginx at all. 25 of these have config the page could
|
|
// not previously show, and the auth ones are the important case.
|
|
const adv = (p.advanced_config || '').trim();
|
|
const guarded = /auth_request/.test(adv);
|
|
const marks = [
|
|
hasSsl ? `<span class="vv-au-badge ssl" title="${_esc(_certName(p.certificate_id))}">SSL</span>`
|
|
: `<span class="vv-au-badge nossl">none</span>`,
|
|
p.ssl_forced ? '<span class="vv-au-m" title="HTTP redirected to HTTPS">force</span>' : '',
|
|
guarded ? '<span class="vv-au-m auth" title="auth_request — behind Authelia">auth</span>' : '',
|
|
adv && !guarded ? '<span class="vv-au-m" title="has Custom Nginx Configuration">nginx</span>' : '',
|
|
p.http2_support ? '<span class="vv-au-m dim" title="HTTP/2">h2</span>' : '',
|
|
p.hsts_enabled ? '<span class="vv-au-m dim" title="HSTS">hsts</span>' : '',
|
|
p.block_exploits ? '<span class="vv-au-m dim" title="Block common exploits">blk</span>' : '',
|
|
].filter(Boolean).join('');
|
|
|
|
// Two columns, not one run of numbers. Requests and errors answer different questions — "is
|
|
// anything using this" and "is it working" — and a host serving thirty-five thousand requests
|
|
// that are all failures read as a busy host when the two sat side by side.
|
|
//
|
|
// Only shown once the aggregator has run. A dash is honest; a zero would read as "nobody has
|
|
// ever visited this" when it means "nothing has counted yet".
|
|
const bad = st ? (st.s4xx || 0) + (st.s5xx || 0) : 0;
|
|
const pct = (st && st.requests) ? (bad / st.requests * 100) : 0;
|
|
// Banded rather than a gradient: the question is which of "fine", "worth a look" and "this is
|
|
// not working" applies, and three answers do not need a hundred shades.
|
|
const pctCls = pct >= 50 ? ' bad' : (pct >= 5 ? ' warn' : '');
|
|
|
|
// Every figure carries its own word. A bare "15m" next to a byte count is a number you have to
|
|
// hover to identify, and the sub-line is the one place there is room to just say it.
|
|
const reqCell = st
|
|
? `<div class="vv-au-stat-n">${_fmtNum(st.requests)}<span class="vv-au-stat-u">requests</span></div>
|
|
<div class="vv-au-stat-s">${_fmtBytes(st.bytes)} sent${st.last_seen ? ' · last request ' + _ago(st.last_seen) + ' ago' : ' · never hit'}</div>`
|
|
: '<div class="vv-au-stat-n dim">—</div>';
|
|
|
|
const errCell = !st ? '<div class="vv-au-stat-n dim">—</div>'
|
|
: (bad === 0
|
|
? `<div class="vv-au-stat-n ok">0<span class="vv-au-stat-u">errors</span></div>`
|
|
: `<div class="vv-au-stat-n${pctCls}" title="${_fmtNum(st.s4xx)} client (4xx), ${_fmtNum(st.s5xx)} server (5xx)">${_fmtNum(bad)}<span class="vv-au-stat-u">errors</span></div>
|
|
<div class="vv-au-stat-s${pctCls}">${pct >= 10 ? Math.round(pct) : pct.toFixed(1)}% of requests</div>`);
|
|
|
|
return `<tr class="${enabled ? '' : 'vv-au-off'}">
|
|
<td>
|
|
<div class="vv-au-domain">${_esc(domains)}</div>
|
|
<div class="vv-au-fwd">→ ${_esc(fwd)}</div>
|
|
</td>
|
|
<td><div class="vv-au-marks">${marks}</div></td>
|
|
<td class="vv-au-num">${_upCell(p)}</td>
|
|
<td class="vv-au-num">${reqCell}</td>
|
|
<td class="vv-au-num">${errCell}</td>
|
|
<td>${_togHtml('proxy-' + p.id, enabled, enabled ? 'Enabled — click to disable' : 'Disabled — click to enable')}</td>
|
|
<td style="text-align:right">
|
|
<div class="vv-au-rule-acts">
|
|
<button class="vv-au-icon-btn" data-proxy-edit="${p.id}" title="Edit">✎</button>
|
|
<button class="vv-au-icon-btn del" data-proxy-del="${p.id}" title="Delete">✕</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Uptime history card ───────────────────────────────────────────────────────
|
|
// Keyed by domain, not by proxy host, because that is the unit that was probed. The percentages
|
|
// and the series both arrive from the server already rolled up — see vv_uptime_period() — so this
|
|
// draws what it is given and never re-derives a window, which is what kept the figure and the
|
|
// strip beside it from disagreeing on the row above.
|
|
const _UP_PERIODS = [['h24', '24 hours'], ['d7', '7 days'], ['d30', '30 days'], ['m12', '12 months']];
|
|
|
|
// The same three-state banding the table uses. One rule, so a domain that is amber up there is
|
|
// never green down here. Namespaced class names — see the CSS note on span.warn.
|
|
function _upCls(pct) {
|
|
if (pct === null || pct === undefined) return 'vv-au-hb-dim';
|
|
return pct >= 99.5 ? 'vv-au-hb-ok2' : (pct >= 95 ? 'vv-au-hb-warn' : 'vv-au-hb-bad');
|
|
}
|
|
|
|
function _renderUptimeHistory() {
|
|
const box = document.getElementById('vv-au-up-hist');
|
|
if (!box) return;
|
|
const doms = Object.keys(_uptime);
|
|
if (!doms.length) {
|
|
box.innerHTML = '<div class="vv-au-empty">Nothing probed yet — the first pass runs within a minute.</div>';
|
|
return;
|
|
}
|
|
|
|
// Worst first, and "worst" is the lowest figure the domain has in any window: a host that is
|
|
// fine today and was terrible last month is exactly what this card exists to surface, and
|
|
// sorting on the 24h figure alone would bury it among the healthy ones. Unmeasured windows do
|
|
// not count as bad — a domain with no history sinks rather than floats.
|
|
const score = d => {
|
|
const h = _uptime[d].hist || {};
|
|
const vals = _UP_PERIODS.map(([k]) => h[k] && h[k].pct).filter(v => v !== null && v !== undefined);
|
|
return vals.length ? Math.min(...vals) : 101;
|
|
};
|
|
|
|
// The probe follows NPM's host list, so a domain removed there stops being visited while its
|
|
// record stays in the store — frozen at whatever it was doing when it left. Its stored state is
|
|
// then a fact about that moment and not about now, and the worst thing this card could do is
|
|
// report a decommissioned host as down: it reads as an outage nobody is fixing.
|
|
//
|
|
// Ten minutes at a one-minute cadence, so a single missed pass or a slow one never counts. The
|
|
// history stays visible — it was true — but the row says so and sorts below live domains rather
|
|
// than leading the list at 0%.
|
|
const _now = Math.round(Date.now() / 1000);
|
|
const stale = d => !_uptime[d].last_at || (_now - _uptime[d].last_at) > 600;
|
|
|
|
doms.sort((a, b) => (stale(a) - stale(b)) || score(a) - score(b) || a.localeCompare(b));
|
|
|
|
box.innerHTML = doms.map(d => {
|
|
const rec = _uptime[d], h = rec.hist || {};
|
|
// Four states, not two: up, down, never probed, and no longer probed. Painting a domain green
|
|
// because it is "not down", or red when nobody has asked it anything for eleven hours, would
|
|
// both be the card asserting something it does not know.
|
|
const st = stale(d);
|
|
const dot = st ? '#333'
|
|
: (rec.state === 'down' ? '#ef5350' : (rec.state === 'up' ? '#2d5a2d' : '#333'));
|
|
// "Not probed" rather than "not in NPM": the probe also skips hosts that are merely switched
|
|
// off in the Proxies tab, and calling a disabled host deleted would send someone looking for
|
|
// something that is still sitting there with its toggle turned off.
|
|
const dotT = st ? ('last probed ' + (rec.last_at ? _ago(rec.last_at) + ' ago' : 'never')
|
|
+ ' — not being probed: disabled or removed in NPM')
|
|
: (rec.state === 'down' ? 'down now' : (rec.state === 'up' ? 'up now' : 'not yet probed'));
|
|
const cells = _UP_PERIODS.map(([k]) => {
|
|
const p = h[k];
|
|
if (!p) return '<div class="vv-au-hb-cell"><span class="vv-au-hb-pct vv-au-hb-dim">—</span></div>';
|
|
|
|
// A bar per calendar slot, null included. Height is the percentage, floored at 2px so a
|
|
// month that was 100% down still draws something to point at — a zero-height bar is
|
|
// indistinguishable from a slot that was never measured, and those two mean opposite things.
|
|
const bars = (p.series || []).map(v => v === null
|
|
? '<i class="vv-au-hb-gap" title="not measured"></i>'
|
|
: `<i class="${_upCls(v)}" style="height:${Math.max(2, Math.round(v * 0.14))}px" title="${v}%"></i>`
|
|
).join('');
|
|
|
|
// Partial windows say so instead of printing a percentage that is technically true of the
|
|
// sample and misleading about the period. The store began on 2026-08-15; a month takes a
|
|
// month, and pretending otherwise is how a dashboard earns distrust.
|
|
const partial = p.have < p.want;
|
|
const pct = (p.pct === null || p.pct === undefined)
|
|
? '<span class="vv-au-hb-pct vv-au-hb-dim">—</span>'
|
|
: `<span class="vv-au-hb-pct ${_upCls(p.pct)}">${p.pct >= 99.95 ? '100' : p.pct.toFixed(p.pct >= 99 ? 2 : 1)}<span style="color:#3a3a3a">%</span></span>`;
|
|
return `<div class="vv-au-hb-cell">${pct}<div class="vv-au-hb-bars">${bars}</div>`
|
|
+ (partial ? `<span class="vv-au-hb-cover">${p.have} of ${p.want}</span>` : '')
|
|
+ `</div>`;
|
|
}).join('');
|
|
|
|
return `<div class="vv-au-hb-row">
|
|
<span class="vv-au-hb-dom ${st ? 'vv-au-hb-stale' : ''}"><span class="vv-au-dot" style="background:${dot}"
|
|
title="${dotT}"></span>${_esc(d)}${st ? '<span class="vv-au-hb-cover"> not served</span>' : ''}</span>
|
|
${cells}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const note = document.getElementById('vv-au-up-note');
|
|
if (note) note.textContent = doms.length + ' domains · probed every minute'
|
|
+ (_uptimePass ? ' · last pass ' + _ago(_uptimePass) + ' ago' : '');
|
|
}
|
|
|
|
// Uptime for the host, from Tools/uptime_probe.sh. A proxy host can carry several domains, so the
|
|
// worst of them is what the row reports — a host is only as reachable as its least reachable name,
|
|
// and averaging would hide one dead domain behind three healthy ones.
|
|
function _upCell(p) {
|
|
const doms = (p.domain_names || []).map(d => String(d).toLowerCase()).filter(d => !d.includes('*'));
|
|
const recs = doms.map(d => _uptime[d]).filter(Boolean);
|
|
if (!recs.length) return '<div class="vv-au-stat-n dim">—</div>';
|
|
|
|
const worst = recs.reduce((a, b) => ((a.h24 ?? 101) <= (b.h24 ?? 101) ? a : b));
|
|
const down = recs.some(r => r.state === 'down');
|
|
const pct = worst.h24;
|
|
// Banded on the same three-state rule as the error column: fine, worth a look, not working.
|
|
const cls = down ? ' bad' : (pct === null ? ' dim' : (pct >= 99.5 ? ' ok2' : (pct >= 95 ? ' warn' : ' bad')));
|
|
|
|
// Sixty samples is the last hour at a one-minute cadence. Drawn as bars rather than a number
|
|
// because the shape — one long outage or sixty scattered blips — is the part a percentage loses.
|
|
const bars = (worst.samples || []).map(s =>
|
|
`<i class="${s ? '' : 'd'}"></i>`).join('');
|
|
|
|
const title = down
|
|
? 'DOWN — ' + (worst.last_detail || 'no response')
|
|
: 'up' + (worst.last_ms ? ' · ' + worst.last_ms + 'ms' : '');
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Compact because these sit in a column beside a domain name: 664,673 is wider than the name it
|
|
// belongs to and the exact figure is in the title attribute either way.
|
|
function _fmtNum(n) {
|
|
n = Number(n) || 0;
|
|
if (n >= 1e6) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0) + 'M';
|
|
if (n >= 1e3) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + 'k';
|
|
return String(n);
|
|
}
|
|
|
|
// Decimal, matching every network tool this will be compared against. Memory is the binary one.
|
|
function _fmtBytes(b) {
|
|
b = Number(b) || 0;
|
|
const u = ['B','kB','MB','GB','TB'];
|
|
let i = 0;
|
|
while (b >= 1000 && i < u.length - 1) { b /= 1000; i++; }
|
|
return (b < 10 && i ? b.toFixed(1) : Math.round(b)) + ' ' + u[i];
|
|
}
|
|
|
|
function _ago(ts) {
|
|
const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
|
if (s < 90) return s + 's';
|
|
if (s < 5400) return Math.round(s / 60) + 'm';
|
|
if (s < 172800)return Math.round(s / 3600) + 'h';
|
|
return Math.round(s / 86400) + 'd';
|
|
}
|
|
|
|
function _proxyModal(id) {
|
|
const p = id ? _proxies.find(x => x.id === id) : null;
|
|
const certOptions = _certs.map(c =>
|
|
`<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
|
|
).join('');
|
|
|
|
_modal(`<h3>${p ? 'Edit Proxy' : 'Add Proxy'}</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Domain Names</label>
|
|
<input class="vv-au-input" id="pm-domains" value="${_esc((p?.domain_names||[]).join(', '))}" placeholder="example.com, *.example.com">
|
|
<div class="vv-au-hint">Comma-separated</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:1fr 2fr 80px;gap:8px">
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Scheme</label>
|
|
<select class="vv-au-select" id="pm-scheme">
|
|
<option${(!p||p.forward_scheme==='http')?' selected':''}>http</option>
|
|
<option${(p?.forward_scheme==='https')?' selected':''}>https</option>
|
|
</select>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Forward Host</label>
|
|
<input class="vv-au-input" id="pm-host" value="${_esc(p?.forward_host||'')}" placeholder="192.168.1.100">
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Port</label>
|
|
<input class="vv-au-input" id="pm-port" type="number" value="${_esc(p?.forward_port||80)}">
|
|
</div>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">SSL Certificate</label>
|
|
<select class="vv-au-select" id="pm-cert">
|
|
<option value="0">None</option>
|
|
${certOptions}
|
|
</select>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${p?.ssl_forced?' on':''}" id="pm-ssl-forced"></span>
|
|
<span class="vv-au-tog-lbl">Force SSL</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${p?.block_exploits?' on':''}" id="pm-block-exploits"></span>
|
|
<span class="vv-au-tog-lbl">Block Common Exploits</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${(!p||p.allow_websocket_upgrade!==false)?' on':''}" id="pm-websocket"></span>
|
|
<span class="vv-au-tog-lbl">WebSocket Support</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${(!p||p.http2_support!==false)?' on':''}" id="pm-http2"></span>
|
|
<span class="vv-au-tog-lbl">HTTP/2 Support</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${p?.hsts_enabled?' on':''}" id="pm-hsts"></span>
|
|
<span class="vv-au-tog-lbl">HSTS Enabled</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${p?.hsts_subdomains?' on':''}" id="pm-hsts-sub"></span>
|
|
<span class="vv-au-tog-lbl">HSTS Subdomains</span>
|
|
</div>
|
|
<div class="vv-au-tog-row">
|
|
<span class="vv-au-tog${(!p||p.enabled!==false)?' on':''}" id="pm-enabled"></span>
|
|
<span class="vv-au-tog-lbl">Enabled</span>
|
|
</div>
|
|
|
|
<!-- The field this dialog used to send as an empty string on every save. Twenty-five of the
|
|
thirty-five hosts here carry one, and on the protected ones it is the auth_request block
|
|
that puts Authelia in front of the site — so an edit to a port silently removed the
|
|
authentication from it. -->
|
|
<div class="vv-au-adv-toggle" id="pm-adv-toggle">${(p?.advanced_config||'').trim() ? '▼' : '▶'} Custom Nginx Configuration${(p?.advanced_config||'').trim() ? ' (in use)' : ''}</div>
|
|
<div class="vv-au-adv-section${(p?.advanced_config||'').trim() ? ' open' : ''}" id="pm-adv">
|
|
<textarea class="vv-au-input vv-au-nginx" id="pm-advanced" rows="12" spellcheck="false"
|
|
placeholder="location / { ... }">${_esc(p?.advanced_config || '')}</textarea>
|
|
<div class="vv-au-hint">Pasted into the server block verbatim. NPM rejects the save if nginx
|
|
will not load it, and the error comes back here.</div>
|
|
</div>
|
|
|
|
<div class="vv-au-err" id="pm-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="pm-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="pm-save">${p ? 'Save' : 'Create'}</button>
|
|
</div>`, true);
|
|
|
|
// Wire inline toggles
|
|
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
|
|
t.addEventListener('click', () => t.classList.toggle('on'));
|
|
});
|
|
|
|
document.getElementById('pm-adv-toggle').addEventListener('click', () => {
|
|
const sec = document.getElementById('pm-adv');
|
|
const open = sec.classList.toggle('open');
|
|
document.getElementById('pm-adv-toggle').textContent =
|
|
(open ? '▼' : '▶') + ' Custom Nginx Configuration';
|
|
});
|
|
|
|
document.getElementById('pm-cancel').onclick = _closeModal;
|
|
document.getElementById('pm-save').onclick = () => {
|
|
const domains = document.getElementById('pm-domains').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
if (!domains.length) { _showModalErr('pm-err', 'Domain required'); return; }
|
|
const host = document.getElementById('pm-host').value.trim();
|
|
if (!host) { _showModalErr('pm-err', 'Forward host required'); return; }
|
|
|
|
// NPM's update replaces the whole host, so anything not sent here is not "left alone" — it is
|
|
// reset. This object used to hardcode advanced_config to '', http2 and both HSTS flags to
|
|
// false, meta to {} and locations to [], and enabled to true. Editing a port therefore removed
|
|
// the site's auth_request block, turned off HTTP/2 and HSTS, dropped its custom locations, and
|
|
// switched a deliberately disabled host back on.
|
|
//
|
|
// Everything the dialog does not offer is carried from the host it is editing; only a genuinely
|
|
// new host gets defaults.
|
|
const data = {
|
|
domain_names: domains,
|
|
forward_scheme: document.getElementById('pm-scheme').value,
|
|
forward_host: host,
|
|
forward_port: parseInt(document.getElementById('pm-port').value) || 80,
|
|
certificate_id: parseInt(document.getElementById('pm-cert').value) || 0,
|
|
ssl_forced: document.getElementById('pm-ssl-forced').classList.contains('on'),
|
|
block_exploits: document.getElementById('pm-block-exploits').classList.contains('on'),
|
|
allow_websocket_upgrade: document.getElementById('pm-websocket').classList.contains('on'),
|
|
http2_support: document.getElementById('pm-http2').classList.contains('on'),
|
|
hsts_enabled: document.getElementById('pm-hsts').classList.contains('on'),
|
|
hsts_subdomains: document.getElementById('pm-hsts-sub').classList.contains('on'),
|
|
enabled: document.getElementById('pm-enabled').classList.contains('on'),
|
|
advanced_config: document.getElementById('pm-advanced').value,
|
|
// Not represented in this dialog at all. Preserved rather than blanked — an access list is a
|
|
// deliberate restriction and a location block is routing, and losing either quietly is worse
|
|
// than not being able to edit it here.
|
|
access_list_id: p ? (p.access_list_id ?? 0) : 0,
|
|
meta: p ? (p.meta ?? {}) : {},
|
|
locations: p ? (p.locations ?? []) : [],
|
|
};
|
|
|
|
const btn = document.getElementById('pm-save');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
|
|
const done = r => {
|
|
if (!r.ok) { _showModalErr('pm-err', r.error||'Save failed'); btn.disabled=false; btn.textContent = p?'Save':'Create'; return; }
|
|
_closeModal(); _loadProxies();
|
|
};
|
|
if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
|
|
else _post({ action:'npm_create', data: JSON.stringify(data) }, done);
|
|
};
|
|
}
|
|
|
|
function _showModalErr(id, msg) {
|
|
const el = document.getElementById(id);
|
|
if (el) { el.textContent = msg; el.classList.add('show'); }
|
|
}
|
|
|
|
// Proxy event delegation
|
|
_on('vv-au-panel-proxies', 'click', async e => {
|
|
// Add button
|
|
if (e.target.id === 'vv-au-proxy-add') { _proxyModal(null); return; }
|
|
// Toggle
|
|
const tog = e.target.closest('[data-tog^="proxy-"]');
|
|
if (tog) {
|
|
const id = parseInt(tog.dataset.tog.split('-')[1]);
|
|
const was = tog.classList.contains('on');
|
|
tog.classList.toggle('on');
|
|
_post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
|
|
if (!r.ok) { tog.classList.toggle('on'); }
|
|
});
|
|
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; }
|
|
// Delete
|
|
const delBtn = e.target.closest('[data-proxy-del]');
|
|
if (delBtn) {
|
|
const id = parseInt(delBtn.dataset.proxyDel);
|
|
const p = _proxies.find(x => x.id === id);
|
|
if (!await vvConfirm('Delete proxy for ' + (p?.domain_names||['this host']).join(', ') + '?')) return;
|
|
_post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
|
|
}
|
|
});
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
|
function _loadUsers(done) {
|
|
const loading = document.getElementById('vv-au-users-loading');
|
|
const list = document.getElementById('vv-au-users-list');
|
|
const empty = document.getElementById('vv-au-users-empty');
|
|
loading.style.display = 'block';
|
|
list.innerHTML = '';
|
|
empty.style.display = 'none';
|
|
|
|
_get('lldap_users', r => {
|
|
loading.style.display = 'none';
|
|
// done() fires on every path including failure and the empty list. An open editor waits on it
|
|
// to redraw its sections, and a refresh that silently never called back would leave the dialog
|
|
// showing the state from before the change with no indication anything had happened.
|
|
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; if (done) done(); return; }
|
|
_users = r.users || [];
|
|
if (!_users.length) { empty.style.display = 'block'; if (done) done(); return; }
|
|
list.innerHTML = _users.map(u => {
|
|
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
|
|
// Only fetched for the users who have one — has_avatar comes from the attribute names, so
|
|
// the list costs nothing for the 27 people here without a photo. _avatarBust changes after
|
|
// an upload so the browser reloads rather than showing the cached previous face.
|
|
const av = u.has_avatar
|
|
? `<img class="vv-au-av" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(u.id)}&v=${_avatarBust}" alt="">`
|
|
: `<span class="vv-au-av none">${_esc(_initials(u))}</span>`;
|
|
// The real name, when it differs from the display name. 31 of 33 users here have first and
|
|
// last set and none of it was on the page.
|
|
const real = [u.firstName, u.lastName].filter(Boolean).join(' ');
|
|
const sub = (real && real !== (u.displayName||'')) ? real : '';
|
|
return `<div class="vv-au-user-row">
|
|
${av}
|
|
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}${sub ? `<span class="vv-au-user-real">${_esc(sub)}</span>` : ''}</span>
|
|
<span class="vv-au-user-email">${_esc(u.email||'')}</span>
|
|
<div style="display:flex;gap:3px;align-items:center;flex-wrap:wrap">${grpBadges}</div>
|
|
<div class="vv-au-user-acts">
|
|
<button class="vv-au-icon-btn" data-user-grp="${_esc(u.id)}" title="Add to group">+grp</button>
|
|
<button class="vv-au-icon-btn" data-user-photo="${_esc(u.id)}" title="Set or remove photo">🖼</button>
|
|
<button class="vv-au-icon-btn" data-user-pass="${_esc(u.id)}" title="Change password">🔑</button>
|
|
<button class="vv-au-icon-btn" data-user-edit="${_esc(u.id)}" title="Edit">✎</button>
|
|
<button class="vv-au-icon-btn del" data-user-del="${_esc(u.id)}" title="Delete">✕</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
if (done) done();
|
|
});
|
|
}
|
|
|
|
function _userModal(uid) {
|
|
const u = uid ? _users.find(x => x.id === uid) : null;
|
|
_modal(`<h3>${u ? 'Edit User' : 'Add User'}</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Username (ID)</label>
|
|
<input class="vv-au-input" id="um-uid" value="${_esc(u?.id||'')}" ${u?'readonly':''} placeholder="johndoe">
|
|
${u ? '' : '<div class="vv-au-hint">Lowercase letters, digits, hyphens — cannot be changed later</div>'}
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Display Name</label>
|
|
<input class="vv-au-input" id="um-name" value="${_esc(u?.displayName||'')}" placeholder="John Doe">
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Email</label>
|
|
<input class="vv-au-input" id="um-email" type="email" value="${_esc(u?.email||'')}" placeholder="john@example.com">
|
|
</div>
|
|
<!-- lldap's full editable set for a user is display_name, mail, first_name, last_name and
|
|
avatar. These two were the ones with nowhere to go, so the only way to correct a name was
|
|
to open lldap's own WebUI. -->
|
|
<div style="display:flex;gap:8px">
|
|
<div class="vv-au-field" style="flex:1">
|
|
<label class="vv-au-label">First Name</label>
|
|
<input class="vv-au-input" id="um-first" value="${_esc(u?.firstName||'')}" placeholder="John">
|
|
</div>
|
|
<div class="vv-au-field" style="flex:1">
|
|
<label class="vv-au-label">Last Name</label>
|
|
<input class="vv-au-input" id="um-last" value="${_esc(u?.lastName||'')}" placeholder="Doe">
|
|
</div>
|
|
</div>
|
|
${!u ? `<div class="vv-au-field">
|
|
<label class="vv-au-label">Password</label>
|
|
<input class="vv-au-input" id="um-pass" type="password" placeholder="Initial password">
|
|
</div>` : ''}
|
|
${u ? `<div class="vv-au-hint" style="margin-bottom:10px">
|
|
created ${_esc(_fmtDate(u.creationDate))} · uuid <span style="font-family:monospace">${_esc(u.uuid||'—')}</span>
|
|
</div>` : ''}
|
|
<div class="vv-au-err" id="um-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="um-cancel">${u ? 'Close' : 'Cancel'}</button>
|
|
<button class="vv-au-btn prim" id="um-save">${u ? 'Save details' : 'Create'}</button>
|
|
</div>
|
|
<!-- Everything else lldap can do to this user, in the place you already have open. The row
|
|
buttons stay for one-click access from the list; this is for when you are already in here
|
|
and the alternative was closing the dialog to reach a different one.
|
|
|
|
These sections apply on their own buttons rather than on Save, because each is a separate
|
|
lldap mutation with its own failure — batching them behind one button would mean reporting
|
|
"saved" for a password that took and a group that did not. Save details covers exactly the
|
|
fields above it, which is what its label says. -->
|
|
${u ? `
|
|
<div class="vv-au-sect" id="um-more">
|
|
<div class="vv-au-sect-h">Groups</div>
|
|
<div id="um-groups"></div>
|
|
|
|
<div class="vv-au-sect-h">Password</div>
|
|
<div style="display:flex;gap:6px;align-items:flex-start">
|
|
<input class="vv-au-input" id="um-pw" type="password" autocomplete="new-password"
|
|
placeholder="New password" style="flex:1">
|
|
<button class="vv-au-btn" id="um-pw-set">Set</button>
|
|
</div>
|
|
<div class="vv-au-hint" id="um-pw-msg"></div>
|
|
|
|
<div class="vv-au-sect-h">Photo</div>
|
|
<div style="display:flex;gap:10px;align-items:center">
|
|
<div id="um-ph-preview"></div>
|
|
<div style="flex:1;min-width:0">
|
|
<input class="vv-au-input" type="file" id="um-ph-file" accept="image/*">
|
|
<div class="vv-au-hint" id="um-ph-msg">Any image; cropped square, ${VV_AVATAR_PX}px, saved as JPEG.</div>
|
|
<div style="display:flex;gap:6px;margin-top:5px">
|
|
<button class="vv-au-btn prim" id="um-ph-set" disabled>Set photo</button>
|
|
<button class="vv-au-btn danger" id="um-ph-rm" ${u.has_avatar ? '' : 'disabled'}>Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>` : ''}`, !!u);
|
|
|
|
if (u) _userModalExtras(u);
|
|
|
|
document.getElementById('um-cancel').onclick = _closeModal;
|
|
document.getElementById('um-save').onclick = () => {
|
|
const id = (document.getElementById('um-uid').value||'').trim();
|
|
const name = (document.getElementById('um-name').value||'').trim();
|
|
const email= (document.getElementById('um-email').value||'').trim();
|
|
const first= (document.getElementById('um-first').value||'').trim();
|
|
const last = (document.getElementById('um-last').value||'').trim();
|
|
const pass = document.getElementById('um-pass')?.value || '';
|
|
if (!id) { _showModalErr('um-err','Username required'); return; }
|
|
if (!email){ _showModalErr('um-err','Email required'); return; }
|
|
|
|
const btn = document.getElementById('um-save');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
|
|
const done = r => {
|
|
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save details':'Create'; return; }
|
|
// A create is finished when it succeeds, so the dialog closes. An edit is not — the sections
|
|
// below are the reason it is worth staying in, and closing on the first Save would put the
|
|
// operator straight back to reopening it.
|
|
if (!u) { _closeModal(); _loadUsers(); return; }
|
|
_showModalErr('um-err', '');
|
|
btn.disabled = false; btn.textContent = 'Saved ✓';
|
|
setTimeout(() => { btn.textContent = 'Save details'; }, 2500);
|
|
_loadUsers();
|
|
};
|
|
// first_name/last_name are always sent from this form, including empty, because the form does
|
|
// offer them — an empty box here means "clear it", which the endpoint turns into a proper
|
|
// attribute removal rather than an empty string.
|
|
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name, first_name:first, last_name:last }, done);
|
|
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass, first_name:first, last_name:last }, done);
|
|
};
|
|
}
|
|
|
|
// ── The editor's live sections ────────────────────────────────────────────────
|
|
// Groups, password and photo, wired inside the open Edit User dialog. Each acts immediately and
|
|
// reports in place; none of them closes the dialog, because the reason they are here at all is
|
|
// that having to leave the editor to reach them was the complaint.
|
|
//
|
|
// The background lists are refreshed too, so the row behind the dialog is not left showing the
|
|
// groups or the photo the user had a moment ago.
|
|
function _userModalExtras(u) {
|
|
const uid = u.id;
|
|
|
|
// Re-read from _users after a refresh rather than trusting the copy captured when the dialog
|
|
// opened — a group added here changes the object the next redraw has to render from.
|
|
const cur = () => _users.find(x => x.id === uid) || u;
|
|
|
|
const drawGroups = () => {
|
|
const me = cur();
|
|
const mine = me.groups || [];
|
|
const gids = new Set(mine.map(g => g.id));
|
|
const free = _groups.filter(g => !gids.has(g.id));
|
|
document.getElementById('um-groups').innerHTML =
|
|
`<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
|
|
${mine.length ? mine.map(g => `<span class="vv-au-badge grp" style="cursor:pointer"
|
|
data-um-rmgrp="${g.id}" title="Remove from ${_esc(g.displayName)}">${_esc(g.displayName)} ✕</span>`).join('')
|
|
: '<span class="vv-au-hint">No groups</span>'}
|
|
</div>
|
|
${free.length ? `<div style="display:flex;gap:6px">
|
|
<select class="vv-au-select" id="um-grp-pick" style="flex:1">
|
|
${free.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('')}
|
|
</select>
|
|
<button class="vv-au-btn" id="um-grp-add">Add</button>
|
|
</div>` : '<div class="vv-au-hint">In every group</div>'}`;
|
|
|
|
const add = document.getElementById('um-grp-add');
|
|
if (add) add.onclick = () => {
|
|
const gid = parseInt(document.getElementById('um-grp-pick').value);
|
|
add.disabled = true;
|
|
_post({ action:'lldap_add_to_group', uid, gid }, r => {
|
|
if (!r.ok) { _showModalErr('um-err', r.error||'Could not add to group'); add.disabled = false; return; }
|
|
refresh(drawGroups);
|
|
});
|
|
};
|
|
document.querySelectorAll('[data-um-rmgrp]').forEach(el => el.onclick = async () => {
|
|
const gid = parseInt(el.dataset.umRmgrp);
|
|
const g = _groups.find(x => x.id === gid);
|
|
if (!await vvConfirm('Remove from group "' + (g?.displayName||gid) + '"?')) return;
|
|
_post({ action:'lldap_remove_from_group', uid, gid }, r => {
|
|
if (!r.ok) { _showModalErr('um-err', r.error||'Could not remove from group'); return; }
|
|
refresh(drawGroups);
|
|
});
|
|
});
|
|
};
|
|
|
|
const drawPhoto = () => {
|
|
const me = cur();
|
|
document.getElementById('um-ph-preview').innerHTML = me.has_avatar
|
|
? `<img class="vv-au-av big" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(uid)}&v=${_avatarBust}" alt="">`
|
|
: `<span class="vv-au-av big none">${_esc(_initials(me))}</span>`;
|
|
const rm = document.getElementById('um-ph-rm');
|
|
if (rm) rm.disabled = !me.has_avatar;
|
|
};
|
|
|
|
// Both lists, because a group change moves membership on either side of it. The dialog stays
|
|
// open throughout — only the section that changed is redrawn.
|
|
const refresh = after => {
|
|
let left = 2;
|
|
const done = () => { if (--left === 0) after && after(); };
|
|
_loadUsers(done); _loadGroups(done);
|
|
};
|
|
|
|
drawGroups();
|
|
drawPhoto();
|
|
|
|
// ── Password ──
|
|
const pwBtn = document.getElementById('um-pw-set');
|
|
pwBtn.onclick = () => {
|
|
const inp = document.getElementById('um-pw'), msg = document.getElementById('um-pw-msg');
|
|
const pass = inp.value;
|
|
if (!pass) { msg.textContent = 'Enter a password first.'; msg.style.color = '#ef5350'; return; }
|
|
pwBtn.disabled = true; pwBtn.textContent = 'Setting…';
|
|
_post({ action:'lldap_set_password', uid, password: pass }, r => {
|
|
pwBtn.disabled = false; pwBtn.textContent = 'Set';
|
|
if (!r.ok) { msg.textContent = r.error || 'Failed'; msg.style.color = '#ef5350'; return; }
|
|
// Cleared on success so the new password is not left sitting in a field behind an open
|
|
// dialog, and so a second click cannot silently set it again.
|
|
inp.value = '';
|
|
msg.textContent = 'Password changed ✓'; msg.style.color = '#4caf50';
|
|
setTimeout(() => { msg.textContent = ''; msg.style.color = ''; }, 4000);
|
|
});
|
|
};
|
|
|
|
// ── Photo ──
|
|
let pending = null;
|
|
document.getElementById('um-ph-file').addEventListener('change', async e => {
|
|
const f = e.target.files && e.target.files[0];
|
|
const msg = document.getElementById('um-ph-msg');
|
|
if (!f) return;
|
|
try {
|
|
pending = await _toJpegBase64(f);
|
|
msg.textContent = `${f.name} → ${Math.round(pending.length * 3 / 4 / 1024)} KB JPEG`;
|
|
msg.style.color = '';
|
|
document.getElementById('um-ph-preview').innerHTML =
|
|
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
|
|
document.getElementById('um-ph-set').disabled = false;
|
|
} catch (err) {
|
|
pending = null;
|
|
document.getElementById('um-ph-set').disabled = true;
|
|
msg.textContent = err.message || 'Could not read that image'; msg.style.color = '#ef5350';
|
|
}
|
|
});
|
|
|
|
document.getElementById('um-ph-set').onclick = () => {
|
|
if (!pending) return;
|
|
const btn = document.getElementById('um-ph-set'), msg = document.getElementById('um-ph-msg');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
|
|
btn.textContent = 'Set photo';
|
|
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Upload failed'; msg.style.color = '#ef5350'; return; }
|
|
pending = null;
|
|
document.getElementById('um-ph-file').value = '';
|
|
_avatarBust = Date.now();
|
|
msg.textContent = 'Photo saved ✓'; msg.style.color = '#4caf50';
|
|
refresh(drawPhoto);
|
|
});
|
|
};
|
|
|
|
document.getElementById('um-ph-rm').onclick = async () => {
|
|
if (!await vvConfirm('Remove this photo?')) return;
|
|
const btn = document.getElementById('um-ph-rm'), msg = document.getElementById('um-ph-msg');
|
|
btn.disabled = true;
|
|
_post({ action:'lldap_remove_avatar', uid }, r => {
|
|
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Removal failed'; msg.style.color = '#ef5350'; return; }
|
|
_avatarBust = Date.now();
|
|
msg.textContent = 'Photo removed ✓'; msg.style.color = '#4caf50';
|
|
refresh(drawPhoto);
|
|
});
|
|
};
|
|
}
|
|
|
|
// ── Photo ─────────────────────────────────────────────────────────────────────
|
|
// Bumped after every upload or removal. The avatar endpoint is a plain GET the browser caches, so
|
|
// without a changing parameter the row would keep drawing the previous face after a change.
|
|
let _avatarBust = Date.now();
|
|
|
|
function _initials(u) {
|
|
const s = ((u.firstName||'') + ' ' + (u.lastName||'')).trim() || u.displayName || u.id || '';
|
|
return s.split(/\s+/).filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('') || '?';
|
|
}
|
|
|
|
function _fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return isNaN(d) ? String(iso).slice(0,10) : d.toISOString().slice(0,10);
|
|
}
|
|
|
|
// lldap types the avatar attribute JPEG_PHOTO and refuses anything else, so a PNG or a HEIC out of
|
|
// a phone would be rejected on arrival. Everything is drawn to a canvas and re-encoded as JPEG
|
|
// here instead, which means any format the browser can open is a valid import — and the same pass
|
|
// bounds the size. The originals on this directory run to 273 KB for a picture shown at 22px;
|
|
// 256px at q0.85 lands around 15 KB, and this value is read back on every user query forever.
|
|
const VV_AVATAR_PX = 256, VV_AVATAR_Q = 0.85;
|
|
|
|
function _toJpegBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const fr = new FileReader();
|
|
fr.onerror = () => reject(new Error('Could not read that file'));
|
|
fr.onload = () => {
|
|
const img = new Image();
|
|
img.onerror = () => reject(new Error('That file is not an image the browser can open'));
|
|
img.onload = () => {
|
|
// Square, centre-cropped. An avatar is drawn in a square slot everywhere it appears, and
|
|
// letterboxing it here would bake the padding into the stored image.
|
|
const side = Math.min(img.width, img.height);
|
|
const sx = (img.width - side) / 2, sy = (img.height - side) / 2;
|
|
const px = Math.min(VV_AVATAR_PX, side);
|
|
const cv = document.createElement('canvas');
|
|
cv.width = cv.height = px;
|
|
const cx = cv.getContext('2d');
|
|
// White rather than transparent: JPEG has no alpha, and a transparent PNG flattened onto
|
|
// the default black reads as a photo of nothing.
|
|
cx.fillStyle = '#fff'; cx.fillRect(0, 0, px, px);
|
|
cx.drawImage(img, sx, sy, side, side, 0, 0, px, px);
|
|
resolve(cv.toDataURL('image/jpeg', VV_AVATAR_Q).replace(/^data:image\/jpeg;base64,/, ''));
|
|
};
|
|
img.src = fr.result;
|
|
};
|
|
fr.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function _photoModal(uid) {
|
|
const u = _users.find(x => x.id === uid);
|
|
if (!u) return;
|
|
const cur = u.has_avatar
|
|
? `<img class="vv-au-av big" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(uid)}&v=${_avatarBust}" alt="">`
|
|
: `<span class="vv-au-av big none">${_esc(_initials(u))}</span>`;
|
|
|
|
_modal(`<h3>Photo — ${_esc(u.displayName||uid)}</h3>
|
|
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
|
|
<div id="ph-preview">${cur}</div>
|
|
<div style="flex:1;min-width:0">
|
|
<input class="vv-au-input" type="file" id="ph-file" accept="image/*">
|
|
<div class="vv-au-hint">Any image the browser can open. Centre-cropped square, resized to
|
|
${VV_AVATAR_PX}px and converted to JPEG — lldap stores nothing else.</div>
|
|
<div class="vv-au-hint" id="ph-size"></div>
|
|
</div>
|
|
</div>
|
|
<div class="vv-au-err" id="ph-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
${u.has_avatar ? '<button class="vv-au-btn danger" id="ph-remove" style="margin-right:auto">Remove photo</button>' : ''}
|
|
<button class="vv-au-btn" id="ph-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="ph-save" disabled>Save</button>
|
|
</div>`);
|
|
|
|
let pending = null;
|
|
document.getElementById('ph-file').addEventListener('change', async e => {
|
|
const f = e.target.files && e.target.files[0];
|
|
if (!f) return;
|
|
try {
|
|
pending = await _toJpegBase64(f);
|
|
// Rounded from the base64 length, which is the payload that actually travels and gets stored.
|
|
const kb = Math.round(pending.length * 3 / 4 / 1024);
|
|
document.getElementById('ph-size').textContent = `${f.name} → ${kb} KB JPEG`;
|
|
document.getElementById('ph-preview').innerHTML =
|
|
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
|
|
document.getElementById('ph-save').disabled = false;
|
|
_showModalErr('ph-err', '');
|
|
} catch (err) {
|
|
pending = null;
|
|
document.getElementById('ph-save').disabled = true;
|
|
_showModalErr('ph-err', err.message || 'Could not read that image');
|
|
}
|
|
});
|
|
|
|
document.getElementById('ph-cancel').onclick = _closeModal;
|
|
const rm = document.getElementById('ph-remove');
|
|
if (rm) rm.onclick = async () => {
|
|
if (!await vvConfirm('Remove this photo?')) return;
|
|
rm.disabled = true;
|
|
_post({ action:'lldap_remove_avatar', uid }, r => {
|
|
if (!r.ok) { _showModalErr('ph-err', r.error||'Removal failed'); rm.disabled = false; return; }
|
|
_avatarBust = Date.now(); _closeModal(); _loadUsers();
|
|
});
|
|
};
|
|
document.getElementById('ph-save').onclick = () => {
|
|
if (!pending) return;
|
|
const btn = document.getElementById('ph-save');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
// Through _post, so URLSearchParams and never FormData — a multipart POST to this plugin's
|
|
// endpoints hangs with no status ever returned, which is exactly the shape an upload invites.
|
|
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
|
|
if (!r.ok) { _showModalErr('ph-err', r.error||'Upload failed'); btn.disabled=false; btn.textContent='Save'; return; }
|
|
_avatarBust = Date.now(); _closeModal(); _loadUsers();
|
|
});
|
|
};
|
|
}
|
|
|
|
function _passModal(uid) {
|
|
_modal(`<h3>Change Password</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">User</label>
|
|
<input class="vv-au-input" value="${_esc(uid)}" readonly>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">New Password</label>
|
|
<input class="vv-au-input" id="pw-pass" type="password" placeholder="New password" autofocus>
|
|
</div>
|
|
<div class="vv-au-err" id="pw-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="pw-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="pw-save">Set Password</button>
|
|
</div>`);
|
|
document.getElementById('pw-cancel').onclick = _closeModal;
|
|
document.getElementById('pw-save').onclick = () => {
|
|
const pass = document.getElementById('pw-pass').value;
|
|
if (!pass) { _showModalErr('pw-err','Password required'); return; }
|
|
const btn = document.getElementById('pw-save');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
_post({ action:'lldap_set_password', uid, password:pass }, r => {
|
|
if (!r.ok) { _showModalErr('pw-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Set Password'; return; }
|
|
_closeModal();
|
|
});
|
|
};
|
|
}
|
|
|
|
function _addToGroupModal(uid) {
|
|
const user = _users.find(u => u.id === uid);
|
|
const userGids= new Set((user?.groups||[]).map(g => g.id));
|
|
const available = _groups.filter(g => !userGids.has(g.id));
|
|
if (!available.length) { vvAlert('User is already in all groups.'); return; }
|
|
const opts = available.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('');
|
|
_modal(`<h3>Add to Group</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">User</label>
|
|
<input class="vv-au-input" value="${_esc(user?.displayName||uid)}" readonly>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Group</label>
|
|
<select class="vv-au-select" id="ag-grp">${opts}</select>
|
|
</div>
|
|
<div class="vv-au-err" id="ag-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="ag-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="ag-save">Add</button>
|
|
</div>`);
|
|
document.getElementById('ag-cancel').onclick = _closeModal;
|
|
document.getElementById('ag-save').onclick = () => {
|
|
const gid = parseInt(document.getElementById('ag-grp').value);
|
|
const btn = document.getElementById('ag-save');
|
|
btn.disabled = true; btn.textContent = 'Adding…';
|
|
_post({ action:'lldap_add_to_group', uid, gid }, r => {
|
|
if (!r.ok) { _showModalErr('ag-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Add'; return; }
|
|
_closeModal(); _loadUsers(); _loadGroups();
|
|
});
|
|
};
|
|
}
|
|
|
|
// User event delegation
|
|
_on('vv-au-panel-users', 'click', async e => {
|
|
if (e.target.id === 'vv-au-user-add') { _userModal(null); return; }
|
|
|
|
const editBtn = e.target.closest('[data-user-edit]');
|
|
if (editBtn) { _userModal(editBtn.dataset.userEdit); return; }
|
|
|
|
const passBtn = e.target.closest('[data-user-pass]');
|
|
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
|
|
|
|
const photoBtn = e.target.closest('[data-user-photo]');
|
|
if (photoBtn) { _photoModal(photoBtn.dataset.userPhoto); return; }
|
|
|
|
const grpBtn = e.target.closest('[data-user-grp]');
|
|
if (grpBtn) { _addToGroupModal(grpBtn.dataset.userGrp); return; }
|
|
|
|
const delBtn = e.target.closest('[data-user-del]');
|
|
if (delBtn) {
|
|
const uid = delBtn.dataset.userDel;
|
|
const user = _users.find(u => u.id === uid);
|
|
if (!await vvConfirm('Delete user "' + (user?.displayName||uid) + '"?')) return;
|
|
_post({ action:'lldap_delete_user', uid }, r => { if (r.ok) _loadUsers(); });
|
|
return;
|
|
}
|
|
|
|
const rmBadge = e.target.closest('[data-rm-from-group]');
|
|
if (rmBadge) {
|
|
const uid = rmBadge.dataset.rmFromGroup;
|
|
const gid = parseInt(rmBadge.dataset.gid);
|
|
const grp = _groups.find(g => g.id === gid);
|
|
if (!await vvConfirm('Remove from group "' + (grp?.displayName||gid) + '"?')) return;
|
|
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
|
|
}
|
|
});
|
|
|
|
// ── Groups ────────────────────────────────────────────────────────────────────
|
|
function _loadGroups(done) {
|
|
const loading = document.getElementById('vv-au-groups-loading');
|
|
const list = document.getElementById('vv-au-groups-list');
|
|
const empty = document.getElementById('vv-au-groups-empty');
|
|
loading.style.display = 'block';
|
|
list.innerHTML = '';
|
|
empty.style.display = 'none';
|
|
|
|
_get('lldap_groups', r => {
|
|
loading.style.display = 'none';
|
|
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; if (done) done(); return; }
|
|
_groups = r.groups || [];
|
|
if (!_groups.length) { empty.style.display = 'block'; if (done) done(); return; }
|
|
list.innerHTML = _groups.map(g => {
|
|
const members = (g.users||[]).map(u =>
|
|
`<div class="vv-au-grp-member">
|
|
<span>${_esc(u.displayName||u.id)}</span>
|
|
<span class="vv-au-grp-member-rm" data-rm-user="${_esc(u.id)}" data-gid="${g.id}" title="Remove from group">✕</span>
|
|
</div>`
|
|
).join('');
|
|
return `<div class="vv-au-grp-row" data-grp="${g.id}">
|
|
<div class="vv-au-grp-acts">
|
|
<button class="vv-au-icon-btn" data-group-ren="${g.id}" title="Rename group">✎</button>
|
|
<button class="vv-au-icon-btn del" data-group-del="${g.id}" title="Delete group">✕</button>
|
|
</div>
|
|
<div class="vv-au-grp-name">${_esc(g.displayName)}</div>
|
|
<div class="vv-au-grp-cnt">${(g.users||[]).length} member${(g.users||[]).length!==1?'s':''}</div>
|
|
</div>
|
|
<div class="vv-au-grp-members" id="gm-${g.id}">
|
|
${members || '<div style="font-size:11px;color:#333;padding:2px 0">No members</div>'}
|
|
</div>`;
|
|
}).join('');
|
|
if (done) done();
|
|
});
|
|
}
|
|
|
|
// Group event delegation
|
|
_on('vv-au-panel-users', 'click', async e => {
|
|
if (e.target.id === 'vv-au-group-add') {
|
|
_modal(`<h3>Add Group</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Group Name</label>
|
|
<input class="vv-au-input" id="gm-name" placeholder="admins" autofocus>
|
|
</div>
|
|
<div class="vv-au-err" id="gm-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="gm-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="gm-save">Create</button>
|
|
</div>`);
|
|
document.getElementById('gm-cancel').onclick = _closeModal;
|
|
document.getElementById('gm-save').onclick = () => {
|
|
const name = (document.getElementById('gm-name').value||'').trim();
|
|
if (!name) { _showModalErr('gm-err','Name required'); return; }
|
|
const btn = document.getElementById('gm-save');
|
|
btn.disabled = true; btn.textContent = 'Creating…';
|
|
_post({ action:'lldap_create_group', name }, r => {
|
|
if (!r.ok) { _showModalErr('gm-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Create'; return; }
|
|
_closeModal(); _loadGroups();
|
|
});
|
|
};
|
|
return;
|
|
}
|
|
|
|
const grpRow = e.target.closest('[data-grp]');
|
|
if (grpRow && !e.target.closest('button') && !e.target.closest('.vv-au-icon-btn')) {
|
|
const panel = document.getElementById('gm-' + grpRow.dataset.grp);
|
|
if (panel) panel.classList.toggle('open');
|
|
return;
|
|
}
|
|
|
|
const renGrp = e.target.closest('[data-group-ren]');
|
|
if (renGrp) {
|
|
e.stopPropagation();
|
|
const id = parseInt(renGrp.dataset.groupRen);
|
|
const grp = _groups.find(g => g.id === id);
|
|
const name = await vvPrompt('Rename group', grp?.displayName || '');
|
|
if (name === null) return;
|
|
const trimmed = String(name).trim();
|
|
if (!trimmed || trimmed === grp?.displayName) return;
|
|
// Worth stopping on: the Access Control rules match groups by name, so a rename that the rules
|
|
// do not follow leaves every rule naming a group nobody is in — which fails closed, silently,
|
|
// for whoever was in it.
|
|
//
|
|
// Fetched rather than read from _rules, because _rules is only populated once the Access
|
|
// Control tab has been opened. Renaming a group from a fresh page load would otherwise find an
|
|
// empty list and report no rules affected, which is the reassuring answer and the wrong one.
|
|
const rules = _rules.length ? _rules : await new Promise(res =>
|
|
_get('authelia_rules', r => res((r && r.ok && r.rules) ? r.rules : [])));
|
|
const used = rules.filter(r => _normList(r.subject).some(s =>
|
|
_normList(s).some(v => String(v) === 'group:' + grp?.displayName)));
|
|
if (used.length && !await vvConfirm(
|
|
`"${grp.displayName}" is named by ${used.length} access-control rule${used.length>1?'s':''}. ` +
|
|
`Renaming it here does not update ${used.length>1?'them':'it'} — you will need to edit ` +
|
|
`${used.length>1?'those rules':'that rule'} on the Access Control tab too. Continue?`)) return;
|
|
_post({ action:'lldap_rename_group', id, name: trimmed }, r => {
|
|
if (!r.ok) { vvAlert('Rename failed: ' + (r.error||'unknown error')); return; }
|
|
_loadGroups(); _loadUsers();
|
|
});
|
|
return;
|
|
}
|
|
|
|
const delGrp = e.target.closest('[data-group-del]');
|
|
if (delGrp) {
|
|
e.stopPropagation();
|
|
const id = parseInt(delGrp.dataset.groupDel);
|
|
const grp = _groups.find(g => g.id === id);
|
|
if (!await vvConfirm('Delete group "' + (grp?.displayName||id) + '"?')) return;
|
|
_post({ action:'lldap_delete_group', id }, r => { if (r.ok) _loadGroups(); });
|
|
return;
|
|
}
|
|
|
|
const rmUser = e.target.closest('[data-rm-user]');
|
|
if (rmUser) {
|
|
e.stopPropagation();
|
|
const uid = rmUser.dataset.rmUser;
|
|
const gid = parseInt(rmUser.dataset.gid);
|
|
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
|
|
}
|
|
});
|
|
|
|
// ── 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');
|
|
loading.style.display = 'block';
|
|
grid.style.display = 'none';
|
|
empty.style.display = 'none';
|
|
|
|
_get('authelia_rules', r => {
|
|
loading.style.display = 'none';
|
|
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
|
|
_rules = r.rules || [];
|
|
_defaultPolicy = r.default_policy || 'deny';
|
|
_defaultNote = r.default_note || '';
|
|
// Freshly loaded is by definition not modified — this also resets the marker after a reload
|
|
// that followed an abandoned edit.
|
|
_aclMarkDirty(false);
|
|
|
|
const sel = document.getElementById('vv-au-ac-defpol');
|
|
if (sel) sel.value = _defaultPolicy;
|
|
|
|
if (!_rules.length) { empty.style.display = 'block'; return; }
|
|
grid.style.display = 'grid';
|
|
_renderAcl();
|
|
});
|
|
}
|
|
|
|
function _renderAcl() {
|
|
const count = document.getElementById('vv-au-acl-count');
|
|
count.textContent = _rules.length + ' rule' + (_rules.length !== 1 ? 's' : '');
|
|
|
|
const body = document.getElementById('vv-au-acl-body');
|
|
// Owned here, not only by the loader. Adding the first rule to an empty config, or deleting the
|
|
// last one, re-renders without going back to the endpoint — so whichever of the two is showing
|
|
// has to be decided by the render that knows the new count.
|
|
body.style.display = _rules.length ? 'grid' : 'none';
|
|
const emptyEl = document.getElementById('vv-au-acl-empty');
|
|
if (emptyEl) emptyEl.style.display = _rules.length ? 'none' : 'block';
|
|
|
|
body.innerHTML = _rules.map((rule, i) => {
|
|
// Normalised in place so a domain has a stable index to edit against. Authelia allows the
|
|
// scalar form and this config uses it, but "rule 1's third domain" has to mean something for
|
|
// an inline editor to address it at all.
|
|
if (!Array.isArray(rule.domain)) rule.domain = _normDomain(rule.domain);
|
|
const doms = rule.domain;
|
|
const subjs = _normSubject(rule.subject);
|
|
// The comment the operator wrote above this rule in configuration.yml. It is the only thing in
|
|
// the file that says what a rule is for — the rule itself is a group id and thirteen hostnames
|
|
// — so it belongs on the card rather than only in the file it came from.
|
|
const label = _normList(rule._label).map(s => String(s).replace(/^#+\s*/, '')).join(' · ');
|
|
|
|
// No subject means the rule applies to everyone who reaches that domain, which is the single
|
|
// most consequential thing a rule can say and read as an empty cell in the table it replaced.
|
|
const subjHtml = subjs.length
|
|
? subjs.map(s => `<span class="vv-au-badge grp">${_esc(_subjLabel(s))}</span>`).join('')
|
|
: '<span class="vv-au-rule-any">anyone</span>';
|
|
|
|
// Full domain in every field, not the shortened form the chips used. A shortened value in an
|
|
// editable box is a value that has to be reassembled before it means anything, and this is the
|
|
// page where a wrong domain hands a service to the wrong group.
|
|
const domHtml = doms.map((d, j) => {
|
|
const full = String(d);
|
|
const cls = (full.includes('*') ? ' wild' : '') + (full.trim() === '' ? ' blank' : '');
|
|
return `<div class="vv-au-domrow">
|
|
<input class="vv-au-dominput${cls}" data-rule="${i}" data-dom="${j}"
|
|
value="${_esc(full)}" spellcheck="false" autocomplete="off"
|
|
placeholder="host.example.com">
|
|
<button class="vv-au-icon-btn del" data-dom-del="${i}:${j}" title="Remove this domain">✕</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Both were invisible in the table — there was no column for them — so a rule narrowed to one
|
|
// path or one subnet looked identical to one that was not.
|
|
const nets = _normList(rule.networks), res = _normList(rule.resources);
|
|
const extra = (nets.length || res.length) ? `<div class="vv-au-rule-x">
|
|
${nets.length ? `<div><b>networks</b> <code>${_esc(nets.join(', '))}</code></div>` : ''}
|
|
${res.length ? `<div><b>resources</b> <code>${_esc(res.join(', '))}</code></div>` : ''}
|
|
</div>` : '';
|
|
|
|
const upBtn = i === 0 ? '<span style="width:16px;display:inline-block"></span>' :
|
|
`<button class="vv-au-icon-btn" data-rule-up="${i}" title="Move up">↑</button>`;
|
|
const dnBtn = i === _rules.length-1 ? '<span style="width:16px;display:inline-block"></span>' :
|
|
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
|
|
|
|
return `<div class="vv-au-rule">
|
|
<div class="vv-au-rule-h">
|
|
<span class="vv-au-rule-ord">${i+1}</span>
|
|
<span class="vv-au-rule-subj">${subjHtml}</span>
|
|
${_policyBadge(rule.policy)}
|
|
<div class="vv-au-rule-acts">
|
|
${upBtn}${dnBtn}
|
|
<button class="vv-au-icon-btn" data-rule-edit="${i}" title="Edit">✎</button>
|
|
<button class="vv-au-icon-btn del" data-rule-del="${i}" title="Delete">✕</button>
|
|
</div>
|
|
</div>
|
|
<div class="vv-au-rule-b">
|
|
<div class="vv-au-rule-meta">
|
|
<span>${doms.length} domain${doms.length !== 1 ? 's' : ''}</span>
|
|
${label ? `<span class="vv-au-rule-lbl" title="comment in configuration.yml">${_esc(label)}</span>` : ''}
|
|
</div>
|
|
<div class="vv-au-domlist">${domHtml}</div>
|
|
<button class="vv-au-domadd" data-dom-add="${i}" type="button">+ add domain</button>
|
|
${extra}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function _ruleModal(idx) {
|
|
const rule = idx !== null ? _rules[idx] : null;
|
|
// All four through the same normaliser. networks and resources used to be read with a bare
|
|
// .join(), which threw on the scalar form Authelia allows and left the dialog unopenable.
|
|
const domains = _normList(rule?.domain).join(', ');
|
|
const subjects = _normList(rule?.subject).map(_subjLabel).join(', ');
|
|
// Captured before the field is drawn, so "did the operator change this?" is answerable at save.
|
|
const subjects_initial = subjects;
|
|
const nets = _normList(rule?.networks).join(', ');
|
|
const resources= _normList(rule?.resources).join(', ');
|
|
|
|
const policyOpts = ['bypass','one_factor','two_factor','deny'].map(p =>
|
|
`<option value="${p}"${(rule?.policy||'two_factor')===p?' selected':''}>${p}</option>`
|
|
).join('');
|
|
|
|
_modal(`<h3>${rule ? 'Edit Rule' : 'Add Rule'}</h3>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Domain</label>
|
|
<input class="vv-au-input" id="rm-domain" value="${_esc(domains)}" placeholder="*.example.com, example.com">
|
|
<div class="vv-au-hint">Comma-separated; wildcards supported</div>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Policy</label>
|
|
<select class="vv-au-select" id="rm-policy">${policyOpts}</select>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Subject <span style="color:#2a2a2a">(optional)</span></label>
|
|
<input class="vv-au-input" id="rm-subject" value="${_esc(subjects)}" placeholder="group:admins, user:john">
|
|
<div class="vv-au-hint">Comma-separated; prefix with group: or user:</div>
|
|
</div>
|
|
<div class="vv-au-adv-toggle" id="rm-adv-toggle">▶ Advanced (networks, resources)</div>
|
|
<div class="vv-au-adv-section" id="rm-adv">
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Networks <span style="color:#2a2a2a">(optional)</span></label>
|
|
<input class="vv-au-input" id="rm-networks" value="${_esc(nets)}" placeholder="192.168.1.0/24, 10.0.0.0/8">
|
|
<div class="vv-au-hint">Comma-separated CIDR ranges</div>
|
|
</div>
|
|
<div class="vv-au-field">
|
|
<label class="vv-au-label">Resources <span style="color:#2a2a2a">(optional)</span></label>
|
|
<input class="vv-au-input" id="rm-resources" value="${_esc(resources)}" placeholder="^/api, ^/admin">
|
|
<div class="vv-au-hint">Comma-separated regex patterns</div>
|
|
</div>
|
|
</div>
|
|
<div class="vv-au-err" id="rm-err"></div>
|
|
<div class="vv-au-modal-acts">
|
|
<button class="vv-au-btn" id="rm-cancel">Cancel</button>
|
|
<button class="vv-au-btn prim" id="rm-save">${rule ? 'Save' : 'Add Rule'}</button>
|
|
</div>`);
|
|
|
|
document.getElementById('rm-adv-toggle').addEventListener('click', () => {
|
|
const sec = document.getElementById('rm-adv');
|
|
const tog = document.getElementById('rm-adv-toggle');
|
|
const open = sec.classList.toggle('open');
|
|
tog.textContent = (open ? '▼' : '▶') + ' Advanced (networks, resources)';
|
|
});
|
|
|
|
document.getElementById('rm-cancel').onclick = _closeModal;
|
|
document.getElementById('rm-save').onclick = () => {
|
|
const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
if (!domains.length) { _showModalErr('rm-err','Domain required'); return; }
|
|
|
|
const subjRaw = document.getElementById('rm-subject').value;
|
|
const subjects = subjRaw.split(',').map(s=>s.trim()).filter(Boolean);
|
|
const networks = document.getElementById('rm-networks').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
const resources = document.getElementById('rm-resources').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
|
|
const newRule = {
|
|
domain: domains.length === 1 ? domains[0] : domains,
|
|
policy: document.getElementById('rm-policy').value,
|
|
};
|
|
// Authelia lets a subject entry be a list, meaning "in all of these groups". A text box split
|
|
// on commas cannot express that — reading it back would turn one AND into two ORs and widen
|
|
// who the rule admits. So an untouched field writes the original structure back verbatim, and
|
|
// only a field the operator actually edited is re-parsed. Nothing on this host uses the nested
|
|
// form today; this is here so that changing a policy on a rule that does cannot quietly
|
|
// rewrite who it applies to.
|
|
if (rule && subjRaw === subjects_initial) {
|
|
if (rule.subject !== undefined) newRule.subject = rule.subject;
|
|
}
|
|
else if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects;
|
|
if (networks.length) newRule.networks = networks;
|
|
if (resources.length) newRule.resources = resources;
|
|
// The comment lines above this rule in configuration.yml — ## Admin Only and the rest. The
|
|
// dialog does not show them and rebuilds the rule from scratch, so without this, editing a
|
|
// rule's policy would delete the only line in the file that says what the rule is for.
|
|
if (rule && rule._label) newRule._label = rule._label;
|
|
|
|
if (idx !== null) _rules[idx] = newRule;
|
|
else _rules.push(newRule);
|
|
|
|
_closeModal();
|
|
_aclMarkDirty(true);
|
|
_renderAcl();
|
|
};
|
|
}
|
|
|
|
// ── Unsaved state ────────────────────────────────────────────────────────────
|
|
// Nothing on this tab reaches Authelia until Save & Restart is pressed — every edit mutates the
|
|
// in-memory rules and re-renders. That was tolerable when the only ways to change anything were a
|
|
// modal and a delete confirmation; with the domain rows editable in place it is far too easy to
|
|
// change three cards, switch tabs and lose the lot with no indication anything was pending.
|
|
let _aclDirty = false;
|
|
function _aclMarkDirty(on) {
|
|
_aclDirty = on;
|
|
const btn = document.getElementById('vv-au-ac-save');
|
|
if (btn) btn.classList.toggle('dirty', on);
|
|
const note = document.getElementById('vv-au-ac-dirty');
|
|
if (note) note.textContent = on ? 'unsaved changes' : '';
|
|
}
|
|
|
|
// Writes straight into the model and deliberately does not re-render: the element being typed in
|
|
// is inside the markup a render would replace, which would drop focus on the first keystroke.
|
|
_on('vv-au-panel-acl', 'input', e => {
|
|
const inp = e.target.closest('.vv-au-dominput');
|
|
if (!inp) return;
|
|
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
|
|
if (!_rules[r] || !Array.isArray(_rules[r].domain)) return;
|
|
_rules[r].domain[d] = inp.value;
|
|
inp.classList.toggle('blank', inp.value.trim() === '');
|
|
inp.classList.toggle('wild', inp.value.includes('*'));
|
|
_aclMarkDirty(true);
|
|
});
|
|
|
|
// 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();
|
|
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
|
|
if (!_rules[r]) return;
|
|
_rules[r].domain.splice(d + 1, 0, '');
|
|
_aclMarkDirty(true);
|
|
_renderAcl();
|
|
_focusDomain(r, d + 1);
|
|
});
|
|
|
|
function _focusDomain(r, d) {
|
|
const el = document.querySelector(`.vv-au-dominput[data-rule="${r}"][data-dom="${d}"]`);
|
|
if (el) { el.focus(); el.select(); }
|
|
}
|
|
|
|
// 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]');
|
|
if (domAdd) {
|
|
const r = parseInt(domAdd.dataset.domAdd);
|
|
if (!_rules[r]) return;
|
|
_rules[r].domain.push('');
|
|
_aclMarkDirty(true);
|
|
_renderAcl();
|
|
_focusDomain(r, _rules[r].domain.length - 1);
|
|
return;
|
|
}
|
|
|
|
const domDel = e.target.closest('[data-dom-del]');
|
|
if (domDel) {
|
|
const [r, d] = domDel.dataset.domDel.split(':').map(Number);
|
|
if (!_rules[r]) return;
|
|
// A rule with no domain matches nothing and Authelia will not load it. Refused here rather
|
|
// than allowed and caught at save, so the answer arrives while the rule is still on screen.
|
|
if (_rules[r].domain.length <= 1) {
|
|
vvAlert('A rule needs at least one domain. Delete the whole rule instead, with the ✕ in its header.');
|
|
return;
|
|
}
|
|
_rules[r].domain.splice(d, 1);
|
|
_aclMarkDirty(true);
|
|
_renderAcl();
|
|
return;
|
|
}
|
|
|
|
if (e.target.id === 'vv-au-ac-save') {
|
|
const dp = document.getElementById('vv-au-ac-defpol').value;
|
|
const btn = document.getElementById('vv-au-ac-save');
|
|
|
|
// Trimmed and emptied out here rather than on every keystroke, so a row can legitimately be
|
|
// blank while it is being typed into. A rule left with no domain at all is refused instead of
|
|
// written, because Authelia will not load the file and the failure would land at container
|
|
// restart — with everything behind it already down.
|
|
const payload = _rules.map(rule => {
|
|
const out = Object.assign({}, rule);
|
|
const doms = _normList(rule.domain).map(d => String(d).trim()).filter(Boolean);
|
|
out.domain = doms.length === 1 ? doms[0] : doms;
|
|
return out;
|
|
});
|
|
const empty = payload.findIndex(r => !_normList(r.domain).length);
|
|
if (empty !== -1) {
|
|
vvAlert('Rule ' + (empty + 1) + ' has no domains left. Give it one, or delete the rule.');
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
_post({ action:'authelia_save', rules: JSON.stringify(payload), default_policy: dp,
|
|
default_note: _defaultNote }, r => {
|
|
btn.disabled = false; btn.textContent = 'Save & Restart Authelia';
|
|
if (!r.ok) { vvAlert('Save failed: ' + (r.error||'unknown error')); return; }
|
|
// The model on screen is the model on disk now — including the trimming just applied, which
|
|
// is why the rules are replaced rather than left as typed.
|
|
_rules = payload;
|
|
_aclMarkDirty(false);
|
|
_renderAcl();
|
|
btn.textContent = 'Saved ✓';
|
|
setTimeout(() => { btn.textContent = 'Save & Restart Authelia'; }, 2000);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const editBtn = e.target.closest('[data-rule-edit]');
|
|
if (editBtn) { _ruleModal(parseInt(editBtn.dataset.ruleEdit)); return; }
|
|
|
|
const delBtn = e.target.closest('[data-rule-del]');
|
|
if (delBtn) {
|
|
const i = parseInt(delBtn.dataset.ruleDel);
|
|
if (!await vvConfirm('Delete this rule?')) return;
|
|
_rules.splice(i, 1);
|
|
_aclMarkDirty(true);
|
|
_renderAcl();
|
|
return;
|
|
}
|
|
|
|
const upBtn = e.target.closest('[data-rule-up]');
|
|
if (upBtn) {
|
|
const i = parseInt(upBtn.dataset.ruleUp);
|
|
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _aclMarkDirty(true); _renderAcl(); }
|
|
return;
|
|
}
|
|
|
|
const dnBtn = e.target.closest('[data-rule-dn]');
|
|
if (dnBtn) {
|
|
const i = parseInt(dnBtn.dataset.ruleDn);
|
|
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _aclMarkDirty(true); _renderAcl(); }
|
|
}
|
|
});
|
|
|
|
// The default policy is the one control here that is not a rule, and it is the most consequential
|
|
// one on the tab — it decides what happens to every hostname no rule names.
|
|
_on('vv-au-ac-defpol', 'change', () => _aclMarkDirty(true));
|
|
|
|
// ── Close modal on overlay click ──────────────────────────────────────────────
|
|
_on('vv-au-overlay', 'click', e => {
|
|
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
|
|
});
|
|
|
|
// ── Certs ─────────────────────────────────────────────────────────────────────
|
|
function _certBadgeCls(s) {
|
|
return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
|
|
}
|
|
function _certBadgeTxt(s) {
|
|
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
|
|
}
|
|
function _certDayColor(days, warn, crit) {
|
|
if (days == null) return '#3a3a3a';
|
|
return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
|
|
}
|
|
function _certRel(ts) {
|
|
if (!ts) return '—';
|
|
const d = Math.floor(Date.now()/1000) - ts;
|
|
if (d < 60) return 'just now';
|
|
if (d < 3600) return Math.floor(d/60) + 'm ago';
|
|
if (d < 86400) return Math.floor(d/3600) + 'h ago';
|
|
return Math.floor(d/86400) + 'd ago';
|
|
}
|
|
|
|
function _renderNpmCerts(data) {
|
|
const grid = document.getElementById('vv-au-cert-grid');
|
|
const ts = document.getElementById('vv-au-cert-ts');
|
|
const cfg = document.getElementById('vv-au-cert-cfg');
|
|
const warn = data.warn_days || 30, crit = data.crit_days || 7;
|
|
const certs = data.certs || [];
|
|
|
|
ts.textContent = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
|
|
cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
|
|
|
|
if (!certs.length) {
|
|
grid.innerHTML = '<div class="vv-au-empty">No certificates found in NPM</div>';
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = certs.map(c => {
|
|
const s = c.status || 'UNKN';
|
|
const days = c.days;
|
|
const col = _certDayColor(days, warn, crit);
|
|
const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;
|
|
const expStr = c.expires ? 'Expires ' + c.expires : '';
|
|
const extra = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');
|
|
return `<div class="vv-au-card" style="padding:12px 14px;">
|
|
<div class="vv-au-domain">${_esc(c.nice_name)}</div>
|
|
${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}
|
|
<div class="vv-au-cert-days" style="color:${col}">${days != null ? days : '—'}</div>
|
|
<div style="font-size:9px;color:#444;margin-bottom:6px;">${days != null ? 'days remaining' : ''}</div>
|
|
<span class="vv-au-badge ${_certBadgeCls(s)}">${_certBadgeTxt(s)}</span>
|
|
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${_esc(expStr)}</div>
|
|
${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
|
|
</div>`;
|
|
}).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 => {
|
|
if (d.ok) _renderNpmCerts(d);
|
|
else throw new Error(d.error || 'NPM error');
|
|
if (onDone) onDone();
|
|
})
|
|
.catch(e => {
|
|
grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;
|
|
if (onDone) onDone();
|
|
});
|
|
}
|
|
|
|
_on('vv-au-cert-run', 'click', function() {
|
|
const btn = this;
|
|
btn.disabled = true; btn.textContent = 'Loading…';
|
|
_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:
|
|
// the profile chip has no label because applyProfile() never ran, and clicking it does nothing
|
|
// because the picker was never wired.
|
|
//
|
|
// Guarded on the element because the card is behind vv_ai_ui_on(), so on a node with no reachable
|
|
// model this block finds nothing and does nothing rather than throwing on a missing prefix.
|
|
if (document.getElementById('vv-au-ai-chat')) {
|
|
VvAiChat({
|
|
prefix: 'vv-au-ai',
|
|
profile: 'varaverk',
|
|
scopeLabel: 'Auth',
|
|
scope: () => 'Auth',
|
|
// Pinned, like the other cards: without it this resumes whatever thread was last open
|
|
// anywhere, which could land the card mid-conversation under a profile this tab never offers.
|
|
resumeProfile: 'varaverk',
|
|
empty: 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
|
|
});
|
|
}
|
|
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
// Through _loadTab so the opening panel follows AUTH_STACK, rather than calling _loadProxies()
|
|
// directly and reaching into elements a different stack does not render.
|
|
_loadTab(_activeTab);
|
|
|
|
})();
|
|
</script>
|