Show how each domain has behaved over a day, a week, a month and a year, not just now
This commit is contained in:
@@ -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);
|
||||
$d7 = vv_uptime_window($r['days'] ?? [], 7);
|
||||
$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 . '%',
|
||||
$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']++;
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user