From 3d38ddcc3b891e8830f5b82674e8eecb81b12a6f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 1 Sep 2026 14:04:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Dashboard=20zeigt=20echte=20Gesamtz?= =?UTF-8?q?ahl=20unbest=C3=A4tigter=20Fehler=20+=20AJAX-Reload=20(v6.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistoryEndpoint: neuer Parameter unacknowledged_only=1 (AND acknowledged_at IS NULL) - DashboardController: nutzt limit=10 + unacknowledged_only=1; agent-seitiges total liefert echte Gesamtzahl; Web-seitiger acknowledged_at-Filter und array_slice entfallen - Dashboard-Badge zeigt totalUnacknowledged (offen) statt fehlerhaftem failedLast24h-Proxy - Hinweis "Zeige N von M unbestätigten Fehlern – Alle in History →" erscheint wenn weitere Fehler existieren als angezeigt werden (eliminiert bisherige Verwirrung) - Nach AJAX-Acknowledge: Fehler-Sektion wird sofort vom Server neu geladen; nächster unbestätigter Fehler füllt die Liste auf (Custom-Event cm:ack-success) - 60-s-Poll aktualisiert Badge, Hinweis und Fehler-Liste - 2 neue Integrationstests für unacknowledged_only-Filter --- API.md | 4 +- CHANGELOG.md | 12 + TECHNICAL.md | 3 +- agent/VERSION | 2 +- agent/src/Endpoints/HistoryEndpoint.php | 46 ++-- .../Endpoints/HistoryEndpointTest.php | 47 ++++ web/VERSION | 2 +- web/lang/de.php | 7 +- web/lang/en.php | 7 +- web/src/Controller/DashboardController.php | 65 +++--- web/templates/dashboard.php | 221 ++++++++++++++---- 11 files changed, 320 insertions(+), 96 deletions(-) diff --git a/API.md b/API.md index 92006ca..ab9ee07 100644 --- a/API.md +++ b/API.md @@ -488,9 +488,10 @@ Execution history for a specific job. | Parameter | Description | |---|---| -| `limit` | Page size (default: 50) | +| `limit` | Page size (default: 50, max: 500) | | `offset` | Pagination offset | | `status` | Filter: `success`, `failed`, `running` | +| `unacknowledged_only` | `1` = only return executions where `acknowledged_at` is null | **Response 200:** @@ -1156,6 +1157,7 @@ to 60 seconds of delay before the daemon picks up the entry. | Version | Change | |---|---| +| 6.2.0 | Added `unacknowledged_only` query parameter to `GET /api/v1/jobs/{id}/history` (§8); filters to executions where `acknowledged_at IS NULL` | | 6.0.0 | Added `POST /api/v1/executions/{id}/acknowledge` and `DELETE /api/v1/executions/{id}/acknowledge` (§16); new scope `executions:acknowledge` (operator profile and above); `acknowledged_at` and `acknowledged_by_user_id` fields added to execution history objects; two new audit-log event types `execution.acknowledged` / `execution.unacknowledged` | | 4.8.0 | Added `GET /api/v1/targets` (§9) — distinct execution targets with job counts; optional `?active=` filter. Added `GET /api/v1/linux-users` (§9) — available Linux users for cron scheduling; includes `docker_mode` flag. Added three maintenance operation endpoints (§11): `POST /api/v1/maintenance/logs/purge`, `POST /api/v1/maintenance/history/cleanup` (optional `older_than_days`), `POST /api/v1/maintenance/once/cleanup`. All require `maintenance:write` scope. | | 4.6.1 | Every agent-specific endpoint now includes `"agent_id"` as the first field in its response (jobs, maintenance, export/json, audit, settings, timeline, tags). Resolves ambiguity in multi-agent setups where the same numeric job ID may refer to different jobs on different agents. UI links (notifications, breadcrumbs, filter resets, pagination) now carry `?agent_id=X` throughout. | diff --git a/CHANGELOG.md b/CHANGELOG.md index af1ff89..d714126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [6.2.0] – branch: `feature/dashboard-acknowledge-ux` + +### Changed + +- **Dashboard: Fehlerzähler zeigt echte Gesamtzahl unbestätigter Fehler:** Das Badge in der Kachel „Aktuelle Fehler" zeigte bisher `failedLast24h` – eine unvollständige Annäherung, die nur die bis zu 10 angezeigten Einträge der letzten 24 h berücksichtigte. Neu: Das Badge zeigt die tatsächliche Gesamtzahl aller unbestätigten Fehler (alle Zeiträume), mit der Beschriftung „offen" statt „(24h)". Dafür wurde im `HistoryEndpoint` des Agenten der neue optionale Parameter `unacknowledged_only=1` eingeführt (`AND el.acknowledged_at IS NULL` im WHERE). Der `DashboardController` nutzt diesen Parameter und fordert genau `limit=10` an; das server-seitige `total`-Feld liefert die Gesamtzahl. Der bisherige Web-seitige `array_slice`-Cap und der Web-seitige `acknowledged_at`-Filter entfallen. + +- **Dashboard: „Zeige N von M" Hinweis wenn weitere Fehler vorhanden:** Wenn die Gesamtzahl unbestätigter Fehler die angezeigte Listenlänge (10) übersteigt, erscheint unter der Fehler-Tabelle ein Hinweis „Zeige N von M unbestätigten Fehlern – Alle in History →". Der Hinweis wird im 60-s-AJAX-Poll aktualisiert und zeigt dem Nutzer sofort, dass noch weitere Einträge warten – die bisherige Verwirrung (leeres Dashboard nach Bestätigen von 10 Einträgen, dann nach Reload wieder 10 neue) entfällt. + +- **Dashboard: AJAX-Reload der Fehler-Sektion nach Bestätigen:** Nach jedem erfolgreichen AJAX-Acknowledge wird die Fehler-Sektion sofort vom Server neu geladen (`GET /dashboard?_json=1`). Der nächste unbestätigte Fehler füllt automatisch die Liste auf, Badge und Hinweis werden aktualisiert. Technisch: Der Acknowledge-Script feuert das Custom-Event `cm:ack-success`; der Haupt-Script hört darauf und führt `refresh()` aus – keine Code-Duplizierung, keine DOM-Manipulation außer dem sofortigen Entfernen der bestätigten Zeile. + +--- + ## [6.1.0] – branch: `fix/singleton-multi-target` ### Fixed diff --git a/TECHNICAL.md b/TECHNICAL.md index f7a3f6a..fbc438b 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -831,8 +831,9 @@ Paginated execution history. | `status` | string | `success`, `failed`, or `running` | | `from` | string | Start date (`YYYY-MM-DD`) | | `to` | string | End date (`YYYY-MM-DD`) | -| `limit` | int | Page size (default: 25) | +| `limit` | int | Page size (default: 50, max: 500) | | `offset` | int | Pagination offset (default: 0) | +| `unacknowledged_only` | `0\|1` | `1` = only return executions where `acknowledged_at IS NULL` | **Response:** ```json diff --git a/agent/VERSION b/agent/VERSION index dfda3e0..6abaeb2 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -6.1.0 +6.2.0 diff --git a/agent/src/Endpoints/HistoryEndpoint.php b/agent/src/Endpoints/HistoryEndpoint.php index 1cc82e7..018ef7b 100644 --- a/agent/src/Endpoints/HistoryEndpoint.php +++ b/agent/src/Endpoints/HistoryEndpoint.php @@ -12,16 +12,17 @@ * freely to narrow the result set. * * Supported query parameters: - * - job_id (int) Filter by a specific cron job ID. - * - tag (string) Filter by tag name – only jobs carrying this tag. - * - user (string) Filter by linux_user of the owning job. - * - status (string) One of: "failed", "success", "running". - * - search (string) Full-text LIKE filter on job description and command. - * - limit (int, 1–500) Max number of rows to return (default 50). - * - offset (int, ≥ 0) Pagination offset (default 0). - * - from (YYYY-MM-DD) Only executions started on or after this date. - * - to (YYYY-MM-DD) Only executions started on or before this date. - * - target (string) Filter by execution target (e.g. "local" or SSH host alias). + * - job_id (int) Filter by a specific cron job ID. + * - tag (string) Filter by tag name – only jobs carrying this tag. + * - user (string) Filter by linux_user of the owning job. + * - status (string) One of: "failed", "success", "running". + * - search (string) Full-text LIKE filter on job description and command. + * - limit (int, 1–500) Max number of rows to return (default 50). + * - offset (int, ≥ 0) Pagination offset (default 0). + * - from (YYYY-MM-DD) Only executions started on or after this date. + * - to (YYYY-MM-DD) Only executions started on or before this date. + * - target (string) Filter by execution target (e.g. "local" or SSH host alias). + * - unacknowledged_only (0|1) When "1", only return executions where acknowledged_at IS NULL. * * This class relies on the global `jsonResponse()` function being available * in the calling scope (defined in agent.php). @@ -115,16 +116,17 @@ public function handle(array $params): void // 1. Parse and validate query parameters // ------------------------------------------------------------------ - $jobId = $this->parsePositiveInt($_GET['job_id'] ?? null); - $tag = isset($_GET['tag']) && $_GET['tag'] !== '' ? (string) $_GET['tag'] : null; - $user = isset($_GET['user']) && $_GET['user'] !== '' ? (string) $_GET['user'] : null; - $target = isset($_GET['target']) && $_GET['target'] !== '' ? (string) $_GET['target'] : null; - $status = isset($_GET['status']) && $_GET['status'] !== '' ? (string) $_GET['status'] : null; - $search = isset($_GET['search']) && $_GET['search'] !== '' ? (string) $_GET['search'] : null; - $limit = $this->parseLimit($_GET['limit'] ?? null); - $offset = $this->parseOffset($_GET['offset'] ?? null); - $from = $this->parseDate($_GET['from'] ?? null); - $to = $this->parseDate($_GET['to'] ?? null); + $jobId = $this->parsePositiveInt($_GET['job_id'] ?? null); + $tag = isset($_GET['tag']) && $_GET['tag'] !== '' ? (string) $_GET['tag'] : null; + $user = isset($_GET['user']) && $_GET['user'] !== '' ? (string) $_GET['user'] : null; + $target = isset($_GET['target']) && $_GET['target'] !== '' ? (string) $_GET['target'] : null; + $status = isset($_GET['status']) && $_GET['status'] !== '' ? (string) $_GET['status'] : null; + $search = isset($_GET['search']) && $_GET['search'] !== '' ? (string) $_GET['search'] : null; + $limit = $this->parseLimit($_GET['limit'] ?? null); + $offset = $this->parseOffset($_GET['offset'] ?? null); + $from = $this->parseDate($_GET['from'] ?? null); + $to = $this->parseDate($_GET['to'] ?? null); + $unacknowledgedOnly = isset($_GET['unacknowledged_only']) && $_GET['unacknowledged_only'] === '1'; // Validate status value if ($status !== null && !in_array($status, self::VALID_STATUSES, true)) { @@ -220,6 +222,10 @@ public function handle(array $params): void $queryParams[':to'] = $to . ' 23:59:59'; } + if ($unacknowledgedOnly) { + $conditions[] = 'el.acknowledged_at IS NULL'; + } + $whereClause = $conditions !== [] ? 'WHERE ' . implode(' AND ', $conditions) : ''; diff --git a/tests/Integration/Endpoints/HistoryEndpointTest.php b/tests/Integration/Endpoints/HistoryEndpointTest.php index 7fde3e1..26db408 100644 --- a/tests/Integration/Endpoints/HistoryEndpointTest.php +++ b/tests/Integration/Endpoints/HistoryEndpointTest.php @@ -254,4 +254,51 @@ public function outputColumnSurvivesTheTwoPhaseQuery(): void $this->assertSame($output, (string) $body['data'][0]['output']); } + + #[Test] + public function unacknowledgedOnlyExcludesAcknowledgedExecutions(): void + { + $jobId = $this->seedJob(); + + // Unacknowledged failure – must appear + $this->seedFinishedExecution($jobId, [ + 'exit_code' => 1, + 'started_at' => '2026-01-15 08:00:00', + 'acknowledged_at' => null, + ]); + + // Acknowledged failure – must be excluded + $this->seedFinishedExecution($jobId, [ + 'exit_code' => 2, + 'started_at' => '2026-01-15 09:00:00', + 'acknowledged_at' => '2026-01-15 09:05:00', + ]); + + $body = $this->callHistory(['status' => 'failed', 'unacknowledged_only' => '1']); + + $this->assertSame(1, (int) ($body['total'] ?? -1)); + $this->assertCount(1, $body['data'] ?? []); + $this->assertSame(1, (int) $body['data'][0]['exit_code']); + $this->assertNull($body['data'][0]['acknowledged_at']); + } + + #[Test] + public function unacknowledgedOnlyZeroOrAbsentDoesNotFilterAcknowledged(): void + { + $jobId = $this->seedJob(); + + $this->seedFinishedExecution($jobId, [ + 'exit_code' => 1, + 'started_at' => '2026-01-15 08:00:00', + 'acknowledged_at' => '2026-01-15 08:05:00', + ]); + + // Without the flag: acknowledged entry is returned + $bodyAll = $this->callHistory(['status' => 'failed']); + $this->assertSame(1, (int) ($bodyAll['total'] ?? -1)); + + // With unacknowledged_only=0: same as without the flag + $bodyZero = $this->callHistory(['status' => 'failed', 'unacknowledged_only' => '0']); + $this->assertSame(1, (int) ($bodyZero['total'] ?? -1)); + } } diff --git a/web/VERSION b/web/VERSION index dfda3e0..6abaeb2 100644 --- a/web/VERSION +++ b/web/VERSION @@ -1 +1 @@ -6.1.0 +6.2.0 diff --git a/web/lang/de.php b/web/lang/de.php index 444ab5f..21e1b5c 100644 --- a/web/lang/de.php +++ b/web/lang/de.php @@ -157,7 +157,12 @@ 'dashboard_exec_last_24h' => 'Letzte 24 Stunden', 'dashboard_exec_executed' => 'Ausgeführt', 'dashboard_exec_failed' => 'Fehlerhaft', - 'dashboard_output_preview' => 'Ausgabe', + 'dashboard_output_preview' => 'Ausgabe', + 'dashboard_failures_open' => 'offen', + 'dashboard_failures_showing' => 'Zeige', + 'dashboard_failures_of' => 'von', + 'dashboard_failures_unack' => 'unbestätigten Fehlern', + 'dashboard_failures_history_link' => 'Alle in History →', // ------------------------------------------------------------------------- // Cron-Jobs diff --git a/web/lang/en.php b/web/lang/en.php index d02ff56..2f7ce2d 100644 --- a/web/lang/en.php +++ b/web/lang/en.php @@ -157,7 +157,12 @@ 'dashboard_exec_last_24h' => 'Last 24 Hours', 'dashboard_exec_executed' => 'Executed', 'dashboard_exec_failed' => 'Failed', - 'dashboard_output_preview' => 'Output', + 'dashboard_output_preview' => 'Output', + 'dashboard_failures_open' => 'open', + 'dashboard_failures_showing' => 'Showing', + 'dashboard_failures_of' => 'of', + 'dashboard_failures_unack' => 'unacknowledged failures', + 'dashboard_failures_history_link' => 'View all in History →', // ------------------------------------------------------------------------- // Cron jobs diff --git a/web/src/Controller/DashboardController.php b/web/src/Controller/DashboardController.php index 43c4d2b..03da2b4 100644 --- a/web/src/Controller/DashboardController.php +++ b/web/src/Controller/DashboardController.php @@ -43,9 +43,9 @@ class DashboardController extends BaseController * Display the main dashboard with aggregated statistics. * * Fetches: - * GET /crons – all configured jobs - * GET /history?limit=10&status=failed – recent failures - * GET /tags – all known tags + * GET /crons – all configured jobs + * GET /history?limit=10&status=failed&unacknowledged_only=1 – recent unacknowledged failures + * GET /tags – all known tags * * Computes locally: * - Total job count @@ -76,18 +76,23 @@ public function index(array $params): void // reducing wall-clock time from ~sum(latencies) to ~max(latency). $batch = [ 'crons' => ['path' => '/crons'], - // Fetch more than needed so that filtering maintenance skips - // still leaves enough entries after the -4 exit-code filter. - 'history' => ['path' => '/history', 'query' => ['limit' => 50, 'status' => 'failed']], + // The agent filters acknowledged and applies the limit server-side; + // the response total reflects all unacknowledged failures. + 'history' => ['path' => '/history', 'query' => [ + 'limit' => 10, + 'status' => 'failed', + 'unacknowledged_only' => 1, + ]], 'tags' => ['path' => '/tags'], ]; if (self::SHOW_EXECUTION_STATS) { $batch['execstats'] = ['path' => '/stats']; } $results = $agent->getMultiple($batch); - $jobs = $results['crons']['data'] ?? []; - $recentFailures = $results['history']['data'] ?? []; - $tags = $results['tags']['data'] ?? []; + $jobs = $results['crons']['data'] ?? []; + $recentFailures = $results['history']['data'] ?? []; + $totalUnacknowledgedFailures = (int) ($results['history']['total'] ?? 0); + $tags = $results['tags']['data'] ?? []; $executionStats = self::SHOW_EXECUTION_STATS ? ($results['execstats'] ?? []) : []; } catch (\RuntimeException $e) { $this->logger->error('DashboardController: agent request failed', [ @@ -117,17 +122,15 @@ public function index(array $params): void $inactiveJobs = $totalJobs - $activeJobs; - // Exclude maintenance-skipped executions (exit_code -4) and acknowledged failures. + // Exclude maintenance-skipped executions (exit_code -4); acknowledged + // failures are already filtered by the agent (unacknowledged_only=1). $recentFailures = array_values(array_filter( $recentFailures, - static fn(array $e): bool => - (int) ($e['exit_code'] ?? 0) !== -4 - && ($e['acknowledged_at'] ?? null) === null + static fn(array $e): bool => (int) ($e['exit_code'] ?? 0) !== -4 )); - // Cap list at 10 after filtering and count failures within last 24 hours - $recentFailures = array_slice($recentFailures, 0, 10); - $failedLast24h = 0; + // Count failures within last 24 hours (from the displayed entries) + $failedLast24h = 0; foreach ($recentFailures as $entry) { $startedAt = strtotime((string) ($entry['started_at'] ?? '')) ?: 0; if ($startedAt >= $oneDayAgo) { @@ -136,12 +139,13 @@ public function index(array $params): void } $stats = [ - 'total' => $totalJobs, - 'active' => $activeJobs, - 'inactive' => $inactiveJobs, - 'byUser' => $byUser, - 'failedLast24h'=> $failedLast24h, - 'tagsCount' => count($tags), + 'total' => $totalJobs, + 'active' => $activeJobs, + 'inactive' => $inactiveJobs, + 'byUser' => $byUser, + 'failedLast24h' => $failedLast24h, + 'tagsCount' => count($tags), + 'totalUnacknowledged' => $totalUnacknowledgedFailures, ]; // ------------------------------------------------------------------ @@ -159,14 +163,15 @@ public function index(array $params): void // Render // ------------------------------------------------------------------ $this->render('dashboard.php', $this->translator()->t('dashboard_title'), [ - 'jobs' => $jobs, - 'recentFailures' => $recentFailures, - 'tags' => $tags, - 'stats' => $stats, - 'multiUser' => count($byUser) > 1, - 'executionStats' => $executionStats, - 'showExecutionStats' => self::SHOW_EXECUTION_STATS, - 'isOperator' => SessionManager::hasRole('operator'), + 'jobs' => $jobs, + 'recentFailures' => $recentFailures, + 'totalUnacknowledgedFailures' => $totalUnacknowledgedFailures, + 'tags' => $tags, + 'stats' => $stats, + 'multiUser' => count($byUser) > 1, + 'executionStats' => $executionStats, + 'showExecutionStats' => self::SHOW_EXECUTION_STATS, + 'isOperator' => SessionManager::hasRole('operator'), ], '/dashboard'); } } diff --git a/web/templates/dashboard.php b/web/templates/dashboard.php index 84610e2..ec4cf0f 100644 --- a/web/templates/dashboard.php +++ b/web/templates/dashboard.php @@ -35,14 +35,16 @@ $executionStats = isset($executionStats) && is_array($executionStats) ? $executionStats : []; $showExecutionStats = isset($showExecutionStats) ? (bool) $showExecutionStats : false; -$total = (int) ($stats['total'] ?? 0); -$active = (int) ($stats['active'] ?? 0); -$inactive = (int) ($stats['inactive'] ?? 0); -$tagsCount = (int) ($stats['tagsCount'] ?? 0); -$failedLast24h = (int) ($stats['failedLast24h'] ?? 0); -$byUser = (array) ($stats['byUser'] ?? []); -$multiUser = isset($multiUser) ? (bool) $multiUser : true; -$isOperator = isset($isOperator) ? (bool) $isOperator : false; +$total = (int) ($stats['total'] ?? 0); +$active = (int) ($stats['active'] ?? 0); +$inactive = (int) ($stats['inactive'] ?? 0); +$tagsCount = (int) ($stats['tagsCount'] ?? 0); +$failedLast24h = (int) ($stats['failedLast24h'] ?? 0); +$totalUnacknowledged = (int) ($stats['totalUnacknowledged'] ?? isset($totalUnacknowledgedFailures) ? (int) $totalUnacknowledgedFailures : 0); +$byUser = (array) ($stats['byUser'] ?? []); +$multiUser = isset($multiUser) ? (bool) $multiUser : true; +$isOperator = isset($isOperator) ? (bool) $isOperator : false; +$shownFailures = count($recentFailures); ?> @@ -411,32 +430,167 @@ class="text-blue-600 hover:underline"> (function () { 'use strict'; + var CM_DASH = { + agentId: , + multiUser: , + isOperator: , + showOutput: , + noResults: , + ackLabel: , + hintShow: , + hintOf: , + hintUnack: , + hintLink: , + historyUrl: , + }; + function set(id, text) { var el = document.getElementById(id); if (el) { el.textContent = String(text); } } + function updateBadge(total) { + var badge = document.getElementById('cm-dash-fail-badge'); + var countEl = document.getElementById('cm-dash-fail-count'); + if (badge) { badge.classList.toggle('hidden', total === 0); } + if (countEl) { countEl.textContent = String(total); } + } + + function updateHint(shown, total) { + var hint = document.getElementById('cm-dash-fail-hint'); + if (!hint) { return; } + if (total > shown && shown > 0) { + set('cm-dash-hint-shown', shown); + set('cm-dash-hint-total', total); + hint.classList.remove('hidden'); + } else { + hint.classList.add('hidden'); + } + } + + function esc(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function buildRow(e) { + var agId = CM_DASH.agentId; + var q = 'job_id=' + encodeURIComponent(e.job_id || '') + + '&target=' + encodeURIComponent(e.target || '') + + '&status=failed&_direct=1'; + if (agId > 0) { q = 'agent_id=' + agId + '&' + q; } + var url = '/timeline?' + q; + var desc = e.description || ('Job #' + e.job_id); + + var html = '' + + (e.job_id + ? '' + esc(desc) + '' + : esc(desc)) + + ''; + + if (CM_DASH.multiUser) { + html += '' + esc(e.linux_user || '') + ''; + } + + var tgt = e.target || ''; + html += '' + + (tgt + ? '' + esc(tgt) + '' + : '') + + ''; + + var code = (e.exit_code !== null && e.exit_code !== undefined) ? e.exit_code : '?'; + html += '' + + '' + + esc(code) + ''; + + html += '' + esc(e.started_at || '') + ''; + + var dur = (e.duration_seconds !== null && e.duration_seconds !== undefined) + ? (Math.round(e.duration_seconds * 10) / 10) + 's' : '–'; + html += '' + esc(dur) + ''; + + if (CM_DASH.showOutput) { + var out = ((e.output || '').trim()); + if (out.length > 120) { out = '…' + out.slice(-120); } + html += '' + + (out + ? '' + esc(out) + '' + : '') + + ''; + } + + if (CM_DASH.isOperator && e.execution_id) { + html += '' + + ''; + } + + var tr = document.createElement('tr'); + tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-700'; + tr.innerHTML = html; + return tr; + } + + function updateFailureRows(entries) { + var tbody = document.getElementById('cm-dash-fail-tbody'); + + if (entries.length === 0) { + if (tbody) { + var wrap = tbody.closest('.overflow-x-auto'); + if (wrap) { + wrap.innerHTML = '
' + + CM_DASH.noResults + '
'; + } + } + return; + } + + if (!tbody) { + // Empty-state placeholder is showing but server has new entries. + location.reload(); + return; + } + + var currentIds = new Set(); + tbody.querySelectorAll('[data-ack-id]').forEach(function (b) { + currentIds.add(String(b.dataset.ackId)); + }); + entries.forEach(function (e) { + if (!currentIds.has(String(e.execution_id))) { + tbody.appendChild(buildRow(e)); + } + }); + } + function refresh() { cmFetch('/dashboard?_json=1') .then(function (r) { return r.json(); }) .then(function (data) { - var s = data.stats || {}; - set('cm-dash-total', s.total || 0); - set('cm-dash-active', s.active || 0); - set('cm-dash-inactive',s.inactive || 0); - set('cm-dash-tags', s.tagsCount || 0); - - var badge = document.getElementById('cm-dash-fail-badge'); - var failCount = document.getElementById('cm-dash-fail-count'); - var n = parseInt(s.failedLast24h || 0, 10); - if (badge) { badge.classList.toggle('hidden', n === 0); } - if (failCount) { failCount.textContent = String(n); } + var s = data.stats || {}; + var entries = data.recentFailures || []; + var total = parseInt(s.totalUnacknowledged || 0, 10); + + set('cm-dash-total', s.total || 0); + set('cm-dash-active', s.active || 0); + set('cm-dash-inactive', s.inactive || 0); + set('cm-dash-tags', s.tagsCount || 0); + + updateBadge(total); + updateHint(entries.length, total); + updateFailureRows(entries); }) .catch(function () { /* silent — stale data is acceptable on transient errors */ }); } + document.addEventListener('cm:ack-success', refresh); cmPoll(refresh, 60000); }()); @@ -444,7 +598,8 @@ function refresh() {