From f2ad41e8e26f8cbf10ca92ccb743dc40ebb5ed79 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 16 Aug 2026 11:55:25 -0400 Subject: [PATCH] Show how each domain has behaved over a day, a week, a month and a year, not just now --- Plugin/unraid/Tools/uptime_probe.php | 33 +++--- Plugin/unraid/Tools/uptime_probe.sh | 2 +- Plugin/unraid/api/auth.php | 29 +++++ Plugin/unraid/include/auth.php | 25 +++++ Plugin/unraid/pages/auth.php | 153 ++++++++++++++++++++++++++- 5 files changed, 228 insertions(+), 14 deletions(-) diff --git a/Plugin/unraid/Tools/uptime_probe.php b/Plugin/unraid/Tools/uptime_probe.php index 8e462d7..f5f3082 100644 --- a/Plugin/unraid/Tools/uptime_probe.php +++ b/Plugin/unraid/Tools/uptime_probe.php @@ -30,9 +30,10 @@ // majority of the traffic it reports and bury whatever real use these hosts get. // // Bounded storage, decided up front. -// Per domain: lifetime counters, the last 60 samples for the strip, 48 hourly buckets and -// 30 daily ones. That is a fixed size — a per-minute probe kept as raw samples would be -// fifty thousand rows a day and the file would be the problem instead of the answer. +// Per domain: lifetime counters, the last 60 samples for the strip, 48 hourly buckets, +// 30 daily ones and 12 monthly ones. That is a fixed size — a per-minute probe kept as raw +// samples would be fifty thousand rows a day and the file would be the problem instead of +// the answer. Each tier is what one view on the Proxies tab draws: 24h, 7d, 30d, 12 months. // // The domain list follows NPM, not a hand-kept list in conf. // A host added in the Proxies tab starts being probed without anyone remembering to add it @@ -65,6 +66,10 @@ const VV_UPTIME_UA = 'Varaverk-Uptime/1.0'; const VV_SAMPLES_KEEP = 60; // one hour at a one-minute cadence const VV_HOURS_KEEP = 48; const VV_DAYS_KEEP = 30; +// A year as twelve monthly buckets rather than 365 daily ones. The yearly view is a shape — which +// months were bad — not a date lookup, and rolling the day buckets out to 365 would grow the file +// twelvefold to answer the same question at a resolution nothing displays. +const VV_MONTHS_KEEP = 12; const VV_EVENTS_KEEP = 20; $dryRun = in_array('--dry-run', $argv, true); @@ -138,14 +143,15 @@ if ($status || $events) { strtoupper($e['to']), $e['detail'] ?? ''); exit(0); } - printf("%-34s %-6s %8s %8s %8s %s\n", 'domain', 'state', '1h', '24h', '30d', 'since'); + printf("%-34s %-6s %8s %8s %8s %8s %s\n", 'domain', 'state', '24h', '7d', '30d', '1y', 'since'); foreach ($doms as $d => $r) { - $h = vv_uptime_window($r['hours'] ?? [], 1); - $h24 = vv_uptime_window($r['hours'] ?? [], 24); - $d30 = vv_uptime_window($r['days'] ?? [], 30); - printf("%-34s %-6s %8s %8s %8s %s\n", substr($d, 0, 34), $r['state'] ?? '-', - $h === null ? '-' : $h . '%', $h24 === null ? '-' : $h24 . '%', - $d30 === null ? '-' : $d30 . '%', + $h24 = vv_uptime_window($r['hours'] ?? [], 24); + $d7 = vv_uptime_window($r['days'] ?? [], 7); + $d30 = vv_uptime_window($r['days'] ?? [], 30); + $y1 = vv_uptime_window($r['months'] ?? [], 12); + printf("%-34s %-6s %8s %8s %8s %8s %s\n", substr($d, 0, 34), $r['state'] ?? '-', + $h24 === null ? '-' : $h24 . '%', $d7 === null ? '-' : $d7 . '%', + $d30 === null ? '-' : $d30 . '%', $y1 === null ? '-' : $y1 . '%', !empty($r['last_change']) ? date('m-d H:i', $r['last_change']) : '-'); } printf("\n%d domains, last pass %s\n", count($doms), @@ -233,6 +239,7 @@ try { $doms = $store['domains'] ?? []; $hourKey = date('YmdH', $now); $dayKey = date('Ymd', $now); + $monKey = date('Ym', $now); foreach ($handles as $d => $ch) { $errno = curl_errno($ch); @@ -249,7 +256,7 @@ try { $r = $doms[$d] ?? ['checks' => 0, 'up' => 0, 'down' => 0, 'state' => null, 'last_change' => null, 'samples' => [], 'hours' => [], 'days' => [], - 'events' => [], 'since' => $now]; + 'months' => [], 'events' => [], 'since' => $now]; $r['checks']++; $up ? $r['up']++ : $r['down']++; $r['last_code'] = $code; @@ -274,7 +281,9 @@ try { if (count($r['samples']) > VV_SAMPLES_KEEP) $r['samples'] = array_slice($r['samples'], -VV_SAMPLES_KEEP); - foreach ([['hours', $hourKey, VV_HOURS_KEEP], ['days', $dayKey, VV_DAYS_KEEP]] as [$k, $key, $keep]) { + foreach ([['hours', $hourKey, VV_HOURS_KEEP], + ['days', $dayKey, VV_DAYS_KEEP], + ['months', $monKey, VV_MONTHS_KEEP]] as [$k, $key, $keep]) { $b = $r[$k][$key] ?? ['u' => 0, 't' => 0]; $b['t']++; if ($up) $b['u']++; diff --git a/Plugin/unraid/Tools/uptime_probe.sh b/Plugin/unraid/Tools/uptime_probe.sh index ea17b53..1bfc9c3 100755 --- a/Plugin/unraid/Tools/uptime_probe.sh +++ b/Plugin/unraid/Tools/uptime_probe.sh @@ -30,7 +30,7 @@ # # uptime_probe.sh one pass # uptime_probe.sh --dry-run probe and report, write nothing -# uptime_probe.sh --status per-domain uptime table (1h / 24h / 30d) +# uptime_probe.sh --status per-domain uptime table (24h / 7d / 30d / 1y) # uptime_probe.sh --events recent state changes, newest first # # ============================================================================================== diff --git a/Plugin/unraid/api/auth.php b/Plugin/unraid/api/auth.php index 171f1c1..1cdb208 100644 --- a/Plugin/unraid/api/auth.php +++ b/Plugin/unraid/api/auth.php @@ -126,6 +126,19 @@ function vv_uptime_window_api(array $buckets, int $n): ?float { return vv_auth_uptime_window($buckets, $n); } +// One period of the history card: the rolled-up percentage, the drawable series, and how much of +// the window has actually been observed. All three come off the same buckets, so the number and +// the graph beside it can never disagree. +function vv_uptime_period(array $buckets, int $n, string $unit): array { + $series = vv_auth_uptime_series($buckets, $n, $unit); + return [ + 'pct' => vv_auth_uptime_window($buckets, $n), + 'series' => $series, + 'have' => count(array_filter($series, fn($v) => $v !== null)), + 'want' => $n, + ]; +} + function vv_auth_action_allowed(string $action): bool { $panel = VV_AUTH_ACTION_PANEL[$action] ?? null; // Unmapped actions are left to the existing "Unknown action" answer rather than being refused @@ -190,6 +203,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { 'last_change' => $r['last_change'] ?? null, 'last_detail' => $r['last_detail'] ?? null, 'last_ms' => $r['last_ms'] ?? null, + // The history card below the table. One aligned series per period rather than the + // raw buckets: the client would otherwise have to re-derive calendar keys to know + // which of thirty slots a given day belongs in, and there would then be two + // implementations of that rule in two languages. + // + // `have` is what actually exists, so the card can say "collecting — 2 of 30 days" + // instead of printing a percentage computed from two days as though it were a + // month. The store began 2026-08-15; every window longer than a day is partial + // for a while, and a confident figure over a short sample is the one thing this + // card must not do. + 'hist' => [ + 'h24' => vv_uptime_period($r['hours'] ?? [], 24, 'hour'), + 'd7' => vv_uptime_period($r['days'] ?? [], 7, 'day'), + 'd30' => vv_uptime_period($r['days'] ?? [], 30, 'day'), + 'm12' => vv_uptime_period($r['months'] ?? [], 12, 'month'), + ], ]; } echo json_encode(['ok' => true, 'domains' => $out, 'last_pass' => $u['last_pass'] ?? null]); diff --git a/Plugin/unraid/include/auth.php b/Plugin/unraid/include/auth.php index 9c6cdf4..138ad85 100644 --- a/Plugin/unraid/include/auth.php +++ b/Plugin/unraid/include/auth.php @@ -332,6 +332,31 @@ function vv_auth_uptime_window(array $buckets, int $n): ?float { return $t > 0 ? round($u / $t * 100, 2) : null; } +// The same buckets as a drawable series: one entry per period on the calendar, oldest first, null +// where nothing was recorded. Aligned to the clock rather than packed, because the gap is the +// information — a domain probed for two of the last thirty days must read as twenty-eight unknowns +// and not as a solid bar chart that happens to be short. +// +// Keys are built with mktime rather than strtotime("-N month"), which resolves relative to today's +// day-of-month and skips a month entirely when run on the 31st. +function vv_auth_uptime_series(array $buckets, int $n, string $unit, ?int $now = null): array { + $now = $now ?? time(); + [$Y, $M, $D, $H] = [(int) date('Y', $now), (int) date('n', $now), + (int) date('j', $now), (int) date('G', $now)]; + $out = []; + for ($i = $n - 1; $i >= 0; $i--) { + // mktime normalises underflow, so hour -3 is 21:00 the previous day and month 0 is December. + [$fmt, $ts] = match ($unit) { + 'hour' => ['YmdH', mktime($H - $i, 0, 0, $M, $D, $Y)], + 'month' => ['Ym', mktime(0, 0, 0, $M - $i, 1, $Y)], + default => ['Ymd', mktime(0, 0, 0, $M, $D - $i, $Y)], + }; + $b = $buckets[date($fmt, $ts)] ?? null; + $out[] = ($b && ($b['t'] ?? 0) > 0) ? round($b['u'] / $b['t'] * 100, 2) : null; + } + return $out; +} + function vv_auth_db_file(string $name): string { return rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/' . $name; } diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php index e59b8f9..0247bfb 100644 --- a/Plugin/unraid/pages/auth.php +++ b/Plugin/unraid/pages/auth.php @@ -300,6 +300,52 @@ require_once dirname(__DIR__) . '/include/ai_chat.php'; .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. */ +.vv-au-hb-head, .vv-au-hb-row { + display:grid; grid-template-columns:minmax(160px,300px) repeat(4, minmax(96px,1fr)); + gap:10px; 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; } + /* ── 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. */ @@ -464,6 +510,27 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users & Groups', + + +
+
+ Uptime history + +
+
+ + 24 hours7 days30 days12 months +
+
Loading…
+
@@ -637,6 +704,7 @@ let _proxies = [], _certs = []; 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 @@ -777,7 +845,14 @@ function _loadProxies() { // 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 : {}; 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 = ''+_esc(r.error)+''; return; } @@ -861,6 +936,82 @@ function _renderProxies() { }).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 = '
Nothing probed yet — the first pass runs within a minute.
'; + 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; + }; + doms.sort((a, b) => score(a) - score(b) || a.localeCompare(b)); + + box.innerHTML = doms.map(d => { + const rec = _uptime[d], h = rec.hist || {}; + // Three states, not two: a domain the probe has never reached a verdict on is grey. Painting + // it green because it is "not down" would be the card asserting something it does not know. + const dot = rec.state === 'down' ? '#ef5350' : (rec.state === 'up' ? '#2d5a2d' : '#333'); + const dotT = 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 '
'; + + // 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 + ? '' + : `` + ).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) + ? '' + : `${p.pct >= 99.95 ? '100' : p.pct.toFixed(p.pct >= 99 ? 2 : 1)}%`; + return `
${pct}
${bars}
` + + (partial ? `${p.have} of ${p.want}` : '') + + `
`; + }).join(''); + + return `
+ ${_esc(d)} + ${cells} +
`; + }).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.