Show how each domain has behaved over a day, a week, a month and a year, not just now

This commit is contained in:
Gmer4Lfe
2026-08-16 11:55:25 -04:00
parent 187dc188c2
commit f2ad41e8e2
5 changed files with 228 additions and 14 deletions
+21 -12
View File
@@ -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']++;
+1 -1
View File
@@ -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
#
# ==============================================================================================
+29
View File
@@ -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]);
+25
View File
@@ -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;
}
+152 -1
View File
@@ -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',
</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">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Uptime history</span>
<span class="vv-au-hist-note" id="vv-au-up-note"></span>
</div>
<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>
<?php endif; ?>
@@ -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 = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; 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 = '<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;
};
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 '<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"><span class="vv-au-dot" style="background:${dot}"
title="${dotT}"></span>${_esc(d)}</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.