aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 14:54:27 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 14:54:27 +0200
commit316a34d15d82b6c928bfb1e3670c052d55aa9875 (patch)
tree6bcf94567b91cdf6ef35f12297b6d95e042a62f3
parentfa6eca3eda89e88b01f58bc01ef60f3e7e5473fb (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.php175
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 &middot; <?= date('Y-m-d H:i:s') ?> &middot; <a href="stats.php">JSON Stats</a> &middot; <a href="clusters.php">Clusters</a> &middot; <a href="errors.php">Raw Errors</a> &middot; <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 &middot; 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 &lt;message&gt;</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' ?> &middot; <?= 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>