diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 14:54:27 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 14:54:27 +0200 |
| commit | 316a34d15d82b6c928bfb1e3670c052d55aa9875 (patch) | |
| tree | 6bcf94567b91cdf6ef35f12297b6d95e042a62f3 | |
| parent | fa6eca3eda89e88b01f58bc01ef60f3e7e5473fb (diff) | |
feat(newos4-debug): dashboard triage + thread panels, mobile layout, gate error.php
- Triage card reads admin/docs/newos4-triage-latest.json defensively: missing/unreadable
renders as a warning pill, never as zeros (a zero here reads as "nothing is wrong").
Freshness pill goes stale past 26h so a dead collector cannot look healthy.
- Maintenance thread merges in-app `feedback` reports (kind=maintenance) with agent
replies from data/maintenance/replies.json into one chronological view.
- Mobile: <=640px single column, smaller type, .table-scroll wrappers, Type column
hidden, max-width clamp. verify-mobile.py: 360/390px, light+dark -> ALL VIEWPORTS OK,
page width == viewport (no horizontal overflow).
- "Last ingest" now uses max(ingested_at) instead of end($errors): after the prune the
array is not chronological and the header contradicted the triage card by 3 days.
- Auto-refresh only fires while the tab is visible (was an unconditional 30s reload).
- Fixed triage checks parsing: digest ships {exit, findings[]}, not a list of strings —
the first render fataled on stripos(array). Verified 15,167 B render, 0 PHP errors.
nginx (not in git): error.php added to the admin-gated read regex alongside
stats|errors|clusters|dashboard|agent-replies — error.php?hash= was still serving stacks
to anonymous callers. All six now answer 302 without a session.
| -rw-r--r-- | api/debug/dashboard.php | 175 |
1 files changed, 163 insertions, 12 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php index 74ba3af..08e6194 100644 --- a/api/debug/dashboard.php +++ b/api/debug/dashboard.php @@ -41,6 +41,41 @@ $topErrors = array_slice($topErrors, 0, 10); ksort($byDay); $recentSessions = array_slice($sessions, -10, 10, true); + +// --- Maintenance layer ------------------------------------------------------- +// Both files are written by the Hermes maintenance job. Read them defensively: a partially +// written JSON must render as "unavailable", never as zero (a zero here would read as +// "nothing is wrong", which is the exact failure this dashboard exists to catch). +$DOCROOT = dirname(__DIR__, 4); +$triageFile = $DOCROOT . '/admin/docs/newos4-triage-latest.json'; +$repliesFile = __DIR__ . '/../../data/maintenance/replies.json'; + +$triage = null; $triageState = 'missing'; +if (is_file($triageFile)) { + $raw = @file_get_contents($triageFile); + $parsed = $raw === false ? null : json_decode($raw, true); + if (is_array($parsed)) { $triage = $parsed; $triageState = 'ok'; } + else { $triageState = 'unreadable'; } +} + +$replies = []; $repliesState = 'missing'; +if (is_file($repliesFile)) { + $raw = @file_get_contents($repliesFile); + $parsed = $raw === false ? null : json_decode($raw, true); + if (is_array($parsed)) { $replies = array_slice($parsed, -5); $repliesState = 'ok'; } + else { $repliesState = 'unreadable'; } +} + +// In-app feedback (kind=maintenance) — the other half of the thread. +$feedback = []; +foreach (glob(__DIR__ . '/../../data/reports/RPT-*.json') ?: [] as $rf) { + $r = json_decode((string)@file_get_contents($rf), true); + if (is_array($r) && ($r['kind'] ?? '') === 'maintenance') { + $feedback[] = $r; + } +} +usort($feedback, fn($a, $b) => strcmp($b['created_at'] ?? '', $a['created_at'] ?? '')); +$feedback = array_slice($feedback, 0, 5); ?> <!DOCTYPE html> <html lang="en"> @@ -75,20 +110,42 @@ $recentSessions = array_slice($sessions, -10, 10, true); a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } .max-w-300 { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -</style> + .thread { font-size: 0.85em; line-height: 1.45; } + .thread .msg { border-left: 3px solid var(--border); padding: 4px 0 4px 10px; margin-bottom: 10px; } + .thread .msg.hermes { border-left-color: var(--accent); } + .thread .msg.app { border-left-color: var(--info); } + .thread .who { color: var(--muted); font-size: 0.8em; } + .thread .body { white-space: pre-wrap; word-break: break-word; } + .status-pill { display:inline-block; padding:1px 8px; border-radius:10px; font-size:.75em; } + .status-pill.ok { background:#002a20; color:var(--accent); } + .status-pill.stale { background:#3a2a00; color:var(--warn); } + .status-pill.degraded { background:#3a1010; color:var(--error); } + .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } + + /* Phones: one column, no horizontal page overflow, smaller type. */ + @media (max-width: 640px) { + body { padding: 10px; font-size: 14px; } + h1 { font-size: 1.1em; } + .subtitle { font-size: 0.75em; line-height: 1.5; } + .grid { grid-template-columns: 1fr; gap: 8px; } + .card { padding: 10px; border-radius: 6px; } + .stat-value { font-size: 1.5em; } + table { font-size: 0.78em; } + th, td { padding: 4px 5px; } + .max-w-300 { max-width: 42vw; } + .hide-sm { display: none; } + }</style> </head> <body> <h1>🔍 newOS4 Debug Dashboard</h1> <div class="subtitle">Server-side error aggregation · <?= date('Y-m-d H:i:s') ?> · <a href="stats.php">JSON Stats</a> · <a href="clusters.php">Clusters</a> · <a href="errors.php">Raw Errors</a> · <a href="health.php">Health</a></div> <?php -// Find last sync time -$lastSync = null; -if (!empty($errors)) { - $lastSync = end($errors)['ingested_at'] ?? null; -} -if ($lastSync): ?> -<div style="color:var(--muted);font-size:0.8em;margin-bottom:16px;">Last sync: <?= htmlspecialchars(substr($lastSync, 0, 19)) ?> UTC</div> +// Use the newest ingest across all signatures, not the last array element: after the 2026-10-23 +// prune the array order is not chronological, and `end()` showed a 3-day-old timestamp while the +// triage card showed a fresh one — two numbers disagreeing on the same screen. +if ($lastIngest): ?> +<div style="color:var(--muted);font-size:0.8em;margin-bottom:16px;">Last ingest: <?= htmlspecialchars(substr($lastIngest, 0, 19)) ?> UTC · signatures sorted by occurrence count</div> <?php endif; ?> <div class="grid"> @@ -112,6 +169,92 @@ if ($lastSync): ?> </div> </div> +<?php +// Triage freshness: the digest is only useful if it is recent. Never present a stale digest as +// current — that is how a status page keeps claiming "all clear" days after the job died. +$triClass = 'degraded'; $triLabel = $triageState; +if ($triageState === 'ok') { + $triAgeH = (time() - (strtotime($triage['generated_at'] ?? '') ?: 0)) / 3600; + if ($triAgeH <= 26) { $triClass = 'ok'; $triLabel = 'fresh (' . round($triAgeH, 1) . 'h)'; } + else { $triClass = 'stale'; $triLabel = 'stale (' . round($triAgeH, 1) . 'h)'; } +} +$tri = $triage['counts'] ?? []; +// `checks` is {exit, findings[]}, not a list of strings (shape verified against the digest on +// 2026-10-23). Accept both so a future collector change cannot fatal the whole page. +$triChecks = []; +$rawChecks = $triage['checks'] ?? []; +if (is_array($rawChecks)) { + $list = $rawChecks['findings'] ?? $rawChecks; + foreach ((array)$list as $c) { + if (is_array($c)) { + $line = trim(($c['status'] ?? $c['level'] ?? '') . ' ' . ($c['name'] ?? $c['check'] ?? '') . ' ' . ($c['detail'] ?? $c['message'] ?? '')); + } else { + $line = (string)$c; + } + if (stripos($line, 'FAIL') !== false || stripos($line, 'WARN') !== false) $triChecks[] = $line; + } +} +?> +<div class="card" style="margin-bottom:20px;"> + <h3>Maintenance triage <span class="status-pill <?= $triClass ?>"><?= htmlspecialchars($triLabel) ?></span></h3> + <?php if ($triageState !== 'ok'): ?> + <div style="color:var(--warn);font-size:.85em;">Triage digest <?= htmlspecialchars($triageState) ?> — run <code>python3 scripts/newos4-triage.py</code> or check the newos4-maintenance cron.</div> + <?php else: ?> + <div class="table-scroll"> + <table> + <tr><th>Signatures</th><th>Occurrences</th><th>Real</th><th>Noise</th><th>Self-ref</th><th>New reports</th><th>Generated</th></tr> + <tr> + <td><?= (int)($tri['records'] ?? 0) ?></td> + <td><?= (int)($tri['occurrences'] ?? 0) ?></td> + <td><?= (int)($tri['real_records'] ?? 0) ?></td> + <td><?= (int)($tri['noise_records'] ?? 0) ?></td> + <td><?= (int)($tri['self_referential_records'] ?? 0) ?></td> + <td><?= (int)($tri['reports_new'] ?? 0) ?></td> + <td><?= htmlspecialchars(substr($triage['generated_at'] ?? '', 5, 11)) ?>Z</td> + </tr> + </table> + </div> + <?php if (!empty($triage['top_real'])): ?> + <div style="margin-top:10px;font-size:.82em;color:var(--muted);">Top real signatures</div> + <?php foreach (array_slice($triage['top_real'], 0, 3) as $t): ?> + <div class="thread"><div class="body" style="font-size:.82em;"><?= (int)($t['count'] ?? 0) ?>× <span class="tag"><?= htmlspecialchars($t['type'] ?? '?') ?></span> <?= htmlspecialchars(mb_substr($t['message'] ?? '', 0, 110)) ?></div></div> + <?php endforeach; ?> + <?php endif; ?> + <?php if ($triChecks): ?> + <div style="margin-top:10px;font-size:.82em;color:var(--warn);"> + <?php foreach (array_slice($triChecks, 0, 4) as $c): ?><div class="body"><?= htmlspecialchars($c) ?></div><?php endforeach; ?> + </div> + <?php endif; ?> + <?php endif; ?> +</div> + +<?php +// Merge both halves of the thread into one chronological view. +$thread = []; +foreach ($feedback as $f) { + $thread[] = ['at' => $f['created_at'] ?? '', 'who' => 'app', 'body' => ($f['title'] ?? '') . (($f['body'] ?? '') !== ($f['title'] ?? '') ? "\n" . ($f['body'] ?? '') : '')]; +} +foreach ($replies as $r) { + $thread[] = ['at' => $r['created_at'] ?? '', 'who' => ($r['author'] ?? 'hermes'), 'body' => $r['body'] ?? '']; +} +usort($thread, fn($a, $b) => strcmp($a['at'], $b['at'])); +$thread = array_slice($thread, -6); +?> +<div class="card" style="margin-bottom:20px;"> + <h3>Maintenance thread <?php if ($repliesState !== 'ok'): ?><span class="status-pill degraded"><?= htmlspecialchars($repliesState) ?></span><?php endif; ?></h3> + <?php if (!$thread): ?> + <div style="color:var(--muted);font-size:.85em;">No messages yet. In the app: <code>feedback <message></code>; agent replies appear here.</div> + <?php endif; ?> + <div class="thread"> + <?php foreach ($thread as $m): ?> + <div class="msg <?= $m['who'] === 'app' ? 'app' : 'hermes' ?>"> + <div class="who"><?= $m['who'] === 'app' ? 'app (feedback)' : 'maintenance agent' ?> · <?= htmlspecialchars(substr($m['at'], 0, 19)) ?></div> + <div class="body"><?= htmlspecialchars(mb_substr($m['body'], 0, 600)) ?></div> + </div> + <?php endforeach; ?> + </div> +</div> + <div class="grid"> <div class="card"> <h3>By Severity</h3> @@ -148,13 +291,14 @@ if ($lastSync): ?> <div class="card" style="margin-bottom:20px;"> <h3>Top Errors</h3> + <div class="table-scroll"> <table> - <tr><th>#</th><th>Message</th><th>Type</th><th>Count</th></tr> + <tr><th>#</th><th>Message</th><th class="hide-sm">Type</th><th>Count</th></tr> <?php $i = 1; foreach ($topErrors as $e): ?> <tr> <td><?= $i++ ?></td> <td class="max-w-300"><a href="error.php?hash=<?= urlencode($e['hash'] ?? '') ?>" style="color:var(--text);text-decoration:none;" title="View details"><?= htmlspecialchars(substr($e['message'] ?? '', 0, 80)) ?></a></td> - <td><span class="tag"><?= htmlspecialchars($e['type'] ?? '?') ?></span></td> + <td class="hide-sm"><span class="tag"><?= htmlspecialchars($e['type'] ?? '?') ?></span></td> <td><?= (int)($e['count'] ?? 0) ?></td> </tr> <?php endforeach; ?> @@ -162,6 +306,7 @@ if ($lastSync): ?> <tr><td colspan="4" style="color:var(--muted);text-align:center;">No errors recorded</td></tr> <?php endif; ?> </table> + </div> </div> <div class="card" style="margin-bottom:20px;"> @@ -198,7 +343,13 @@ if ($lastSync): ?> </table> </div> -<div class="refresh">Auto-refresh every 30s</div> -<script>setTimeout(() => location.reload(), 30000);</script> +<div class="refresh">Auto-refresh every 30s (paused while the tab is hidden)</div> +<script> + // Only reload when the tab is actually visible: the old unconditional timer could abort a + // half-read table on a phone that had been backgrounded for minutes. + setInterval(() => { + if (document.visibilityState === 'visible') location.reload(); + }, 30000); +</script> </body> </html> |