From 661e3a6223fb33228ff92a772ddaa58dfc347f8a Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 12 Oct 2026 16:43:37 -0400 Subject: [PATCH 1/7] Apply the same styling to dashboard cards as we have in syslog status --- css/dashboard.css | 60 +++++--------------------- js/dashboard.js | 70 ++++++++++++++++++++++++------- lib/syslog_dashboard.php | 1 + locales/po/cacti.pot | 10 ++--- tests/browser/README.md | 10 +++++ tests/browser/dashboard_cards.cjs | 68 ++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 71 deletions(-) create mode 100644 tests/browser/dashboard_cards.cjs diff --git a/css/dashboard.css b/css/dashboard.css index 3eca9f7d..68bd8350 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -86,55 +86,14 @@ align-items: start; } +/* Card surfaces, headers, titles and tools reuse the Status tab classes. */ .syslogDashboardCard { - box-sizing: border-box; position: relative; - border: 1px solid var(--search-border, #cbd8d1); - border-radius: 8px; - background: var(--search-card, #fff); - color: var(--search-text, #23362f); - box-shadow: 0 1px 2px rgba(35, 54, 47, 0.08); - overflow: hidden; -} - -.syslogDashboardCardHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid var(--search-tint, #e2ede6); - background: var(--search-tint, #e2ede6); -} - -.syslogDashboardCardTitle { - margin: 0; - font-size: 13px; - font-weight: 600; - line-height: 1.4; - overflow-wrap: anywhere; -} - -.syslogDashboardCardHeader .syslogDashboardCardButton { - border: 0; - background: transparent; - color: var(--search-muted, #596d64); - cursor: pointer; - padding: 3px 4px; - border-radius: 4px; - font-size: 12px; -} - -.syslogDashboardCardHeader .syslogDashboardCardButton:hover, -.syslogDashboardCardHeader .syslogDashboardCardButton:focus { - background: var(--search-accent, #276448); - color: #fff; - outline: none; } .syslogChart { min-height: 260px; - padding: 6px 6px 0 0; + padding: 6px 0 0; } /* Bottom-right drag handle; expands the pointer target to the corner. */ @@ -182,7 +141,7 @@ } .syslogDashboardCardStatus { - margin: 40px 12px; + margin: 8px 0; font-size: 12px; color: var(--search-muted, #596d64); } @@ -204,12 +163,8 @@ } /* Drag-and-drop reorder affordances. */ -.syslogDashboardCardHeader { - cursor: grab; -} - .syslogDashboardCardDragging { - opacity: 0.45; + opacity: 0.5; } .syslogDashboardCardDropBefore { @@ -266,7 +221,12 @@ grid-template-columns: 1fr; } + .syslogDashboardCard { + /* Fit saved multi-column panels into the mobile grid. */ + grid-column: 1 / -1 !important; + } + .syslogDashboardPanelActions { margin-left: 0; } -} \ No newline at end of file +} diff --git a/js/dashboard.js b/js/dashboard.js index bc178925..bf929ebf 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -50,35 +50,47 @@ function syslogDashboardLoad(data) { function syslogDashboardPanelCard(panel) { var card = document.createElement('article'); - card.className = 'syslogDashboardCard'; + card.className = 'syslogDashboardCard syslogStatusCard'; card.id = 'syslog_panel_' + panel.id; var header = document.createElement('header'); - header.className = 'syslogDashboardCardHeader'; - if (syslogDashboard.canManage) header.setAttribute('draggable', 'true'); + header.className = 'syslogDashboardCardHeader syslogStatusCardHeader syslogStatusCardHeader--none'; + if (syslogDashboard.canManage) { + var handle = document.createElement('button'); + handle.type = 'button'; + handle.className = 'syslogStatusCardDrag'; + handle.setAttribute('aria-label', syslogDashboard.text.dragPanel); + handle.innerHTML = ''; + header.append(handle); + } var title = document.createElement('h3'); - title.className = 'syslogDashboardCardTitle'; + title.className = 'syslogDashboardCardTitle syslogStatusCardTitle'; title.textContent = panel.title; header.append(title); var actions = document.createElement('div'); + actions.className = 'syslogStatusCardTools'; actionButtons().forEach(function(button) { actions.append(button); }); header.append(actions); card.append(header); + var body = document.createElement('div'); + body.className = 'syslogStatusCardBody'; + card.append(body); + var chart = document.createElement('div'); chart.className = 'syslogChart'; chart.id = 'syslog_chart_' + panel.id; - card.append(chart); + body.append(chart); var status = document.createElement('p'); status.className = 'syslogDashboardCardStatus'; status.setAttribute('role', 'status'); status.textContent = '...'; - card.append(status); + body.append(status); // Shared dashboards are view-only: no resize or drag affordances. if (syslogDashboard.canManage) { @@ -223,7 +235,7 @@ function actionButtons() { actions.forEach(function(entry) { var button = document.createElement('button'); button.type = 'button'; - button.className = 'syslogDashboardCardButton'; + button.className = 'syslogDashboardCardButton syslogStatusCardTool'; button.dataset.action = entry[0]; button.innerHTML = ''; button.title = entry[2]; @@ -337,7 +349,7 @@ function syslogDashboardRenderGrid() { /* ===================== Drag-and-drop reorder ===================== */ /** - * Make a card reorderable: dragging its header shows a placeholder, and + * Make a card reorderable: dragging its handle shows a placeholder, and * dropping posts the new 1-based position. Drop targets are the card * midpoints, so moving a panel underneath another works in both * directions without up/down buttons. @@ -345,19 +357,45 @@ function syslogDashboardRenderGrid() { function syslogDashboardInitDrag(card, panel) { if (!syslogDashboard.canManage) return; - var header = card.querySelector('.syslogDashboardCardHeader'); - if (!header) return; + var handle = card.querySelector('.syslogStatusCardDrag'); + if (!handle) return; + + var disarm = function() { card.draggable = false; }; + handle.addEventListener('mousedown', function() { + card.draggable = true; + document.addEventListener('mouseup', disarm, {once: true}); + }); + handle.addEventListener('touchstart', function() { + card.draggable = true; + document.addEventListener('touchend', disarm, {once: true}); + document.addEventListener('touchcancel', disarm, {once: true}); + }, {passive: true}); + handle.addEventListener('keydown', function(event) { + var direction = ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 + : (['ArrowRight', 'ArrowDown'].includes(event.key) ? 1 : 0); + if (!direction) return; + event.preventDefault(); + var order = syslogDashboard.panels.map(function(item) { return item.id; }); + var from = order.indexOf(panel.id); + var to = from + direction; + if (from < 0 || to < 0 || to >= order.length) return; + order.splice(from, 1); + order.splice(to, 0, panel.id); + syslogDashboardApplyOrder(order, panel.id, to + 1); + handle.focus(); + }); - header.addEventListener('dragstart', function(event) { + card.addEventListener('dragstart', function(event) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(panel.id)); card.classList.add('syslogDashboardCardDragging'); }); - header.addEventListener('dragend', function() { + card.addEventListener('dragend', function() { + card.draggable = false; card.classList.remove('syslogDashboardCardDragging'); - document.querySelectorAll('.syslogDashboardCardDropBefore') - .forEach(function(el) { el.classList.remove('syslogDashboardCardDropBefore'); }); + document.querySelectorAll('.syslogDashboardCardDropBefore, .syslogDashboardCardDropAfter') + .forEach(function(el) { el.classList.remove('syslogDashboardCardDropBefore', 'syslogDashboardCardDropAfter'); }); }); card.addEventListener('dragover', function(event) { @@ -366,7 +404,7 @@ function syslogDashboardInitDrag(card, panel) { event.dataTransfer.dropEffect = 'move'; var rect = card.getBoundingClientRect(); - var before = event.clientY < rect.top + rect.height / 2; + var before = event.clientY < rect.top || (event.clientY <= rect.bottom && event.clientX < rect.left + rect.width / 2); card.classList.toggle('syslogDashboardCardDropBefore', before); card.classList.toggle('syslogDashboardCardDropAfter', !before); }); @@ -390,7 +428,7 @@ function syslogDashboardInitDrag(card, panel) { if (draggedId === panel.id) return; var rect = card.getBoundingClientRect(); - var before = event.clientY < rect.top + rect.height / 2; + var before = event.clientY < rect.top || (event.clientY <= rect.bottom && event.clientX < rect.left + rect.width / 2); var panels = syslogDashboard.panels || []; var order = panels.map(function(p) { return p.id; }); var from = order.indexOf(draggedId); diff --git a/lib/syslog_dashboard.php b/lib/syslog_dashboard.php index 41d65329..35fdf9f5 100644 --- a/lib/syslog_dashboard.php +++ b/lib/syslog_dashboard.php @@ -1363,6 +1363,7 @@ function syslog_dashboard(): void { canShare: , shared: , text: { + dragPanel: , refreshPanel: , totalMessages: , libraryUnavailable: , diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 58a080f0..a28fca82 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.2.32\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-10 09:25-0400\n" +"POT-Creation-Date: 2026-10-12 16:41-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -1079,6 +1079,10 @@ msgstr "" msgid "A title of up to 128 characters is required." msgstr "" +#: lib/syslog_dashboard.php syslog.php +msgid "Drag to reorder card" +msgstr "" + #: lib/syslog_dashboard.php msgid "Refresh Panel" msgstr "" @@ -1808,10 +1812,6 @@ msgstr "" msgid "Remove from page" msgstr "" -#: syslog.php -msgid "Drag to reorder card" -msgstr "" - #: syslog.php msgid "Unknown card." msgstr "" diff --git a/tests/browser/README.md b/tests/browser/README.md index 5aec3eaa..2b962b52 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -9,3 +9,13 @@ CACTI_ROOT=/path/to/cacti node tests/browser/compact_workspace.cjs Optional environment variables: `PLAYWRIGHT_MODULE` (installed module path), `CHROMIUM_PATH` (browser executable), and `TEST_ARTIFACT_DIR` (existing screenshot directory). Covers builder date and negation submission, severity color preservation, opening the message details from any cell of a row, selected-message rule links and unavailable actions, escaped raw messages, inspector keyboard close, filter collapse, the results limit beside the search buttons, view options, refresh without detaching a saved search, and narrow-screen overflow. + +The Dashboard card fixture checks shared Status styling in light/dark themes, +existing tools, drag/keyboard ordering requests, and narrow-screen panel sizing: + +```sh +node tests/browser/dashboard_cards.cjs +``` + +It uses the actual Dashboard renderer and styles with fixture panels and records +ordering requests; it does not verify a live database or chart data. diff --git a/tests/browser/dashboard_cards.cjs b/tests/browser/dashboard_cards.cjs new file mode 100644 index 00000000..cfa2058e --- /dev/null +++ b/tests/browser/dashboard_cards.cjs @@ -0,0 +1,68 @@ +const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const root = path.resolve(__dirname, '../..'); +const cacti = process.env.CACTI_ROOT || path.resolve(root, '../cacti'); +(async () => { + const browser = await chromium.launch({headless: true, executablePath: process.env.CHROMIUM_PATH, args: ['--no-sandbox']}); + try { + const page = await browser.newPage({viewport: {width: 1200, height: 800}}); + await page.route('http://fixture/webfonts/**', route => route.fulfill({path: path.join(cacti, 'include/fa/webfonts', path.basename(new URL(route.request().url()).pathname))})); + await page.setContent('

Status reference

Status
'); + await page.addStyleTag({path: path.join(cacti, 'include/fa/css/all.min.css')}); + await page.addStyleTag({path: path.join(root, 'css/search.css')}); + await page.addStyleTag({path: path.join(root, 'css/dashboard.css')}); + await page.addScriptTag({path: path.join(root, 'js/dashboard.js')}); + await page.evaluate(() => { + window.posts = []; + window.syslogDashboard = {canManage: true, dashboardId: 5, text: {dragPanel: 'Drag to reorder card', refreshPanel: 'Refresh Panel'}, panels: [1,2,3].map(id => ({id, title: 'Panel ' + id, width: 1, height: 0}))}; + syslogDashboardPost = data => posts.push(data); + syslogDashboard.panels.forEach(panel => { + const card = syslogDashboardPanelCard(panel); + document.getElementById('syslog_dashboard_grid').append(card); + syslogDashboardInitDrag(card, panel); + }); + }); + const compare = () => page.evaluate(() => { + const pairs = [['.syslogStatusCard', '.syslogDashboardCard', ['backgroundColor','border','borderRadius','boxShadow']], ['.syslogStatusCardHeader', '.syslogDashboardCardHeader', ['padding','backgroundColor','backgroundImage','color','height']], ['.syslogStatusCardTitle', '.syslogDashboardCardTitle', ['fontSize','fontWeight','lineHeight']], ['.syslogStatusCardTool', '.syslogDashboardCardButton', ['padding','backgroundColor','color','border']]]; + return pairs.map(([a,b,keys]) => keys.map(key => [key, getComputedStyle(document.querySelector('#status ' + a))[key], getComputedStyle(document.querySelector('#syslog_dashboard_grid ' + b))[key]])); + }); + for (const mode of ['light', 'dark']) { + await page.evaluate(mode => { + for (const scope of document.querySelectorAll('#status, #syslog_dashboard_grid')) { + const tokens = mode === 'dark' ? {text:'#eee',card:'#222',border:'#555','header-bg':'#333','header-fg':'#eee'} : {text:'#23362f',card:'#fff',border:'#cbd8d1','header-bg':'#e5ece8','header-fg':'#23362f'}; + Object.entries(tokens).forEach(([key,value]) => scope.style.setProperty('--search-' + key, value)); + } + }, mode); + for (const pair of await compare()) for (const [key,status,dashboard] of pair) assert.equal(dashboard,status, mode + ' ' + key); + } + assert.equal(await page.locator('#syslog_panel_1 [data-action="refresh"]').count(), 1); + assert.equal(await page.locator('#syslog_panel_1 [data-action="edit"]').count(), 1); + assert.equal(await page.locator('#syslog_panel_1 [data-action="delete"]').count(), 1); + // Keyboard reordering persists the absolute position through the existing endpoint. + await page.locator('#syslog_panel_1 .syslogStatusCardDrag').focus(); + await page.keyboard.press('ArrowRight'); + assert.deepEqual(await page.evaluate(() => syslogDashboard.panels.map(p => p.id)), [2,1,3]); + assert.equal(await page.evaluate(() => posts.at(-1).position), 2); + // Pointer drag handles work across columns and clear both placeholder states. + const target = await page.locator('#syslog_panel_3').boundingBox(); + const handle = await page.locator('#syslog_panel_2 .syslogStatusCardDrag').boundingBox(); + await page.mouse.move(handle.x + handle.width/2, handle.y + handle.height/2); + await page.mouse.down(); + await page.mouse.move(handle.x + handle.width/2 + 12, handle.y + handle.height/2, {steps: 3}); + await page.mouse.move(target.x + target.width*0.75, target.y + 45, {steps: 10}); + await page.mouse.up(); + assert.deepEqual(await page.evaluate(() => syslogDashboard.panels.map(p => p.id)), [1,3,2]); + assert.equal(await page.evaluate(() => posts.at(-1).position), 3); + assert.equal(await page.locator('.syslogDashboardCardDropBefore, .syslogDashboardCardDropAfter').count(), 0); + if (process.env.TEST_ARTIFACT_DIR) await page.screenshot({path:path.join(process.env.TEST_ARTIFACT_DIR,'dashboard-status-style-desktop.png'),fullPage:true}); + await page.evaluate(() => syslogDashboardApplySize({width:3,height:300}, document.getElementById('syslog_panel_1'))); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await page.evaluate(() => {syslogDashboard.canManage = false; const card = syslogDashboardPanelCard({id:4,title:'Shared',width:1,height:0}); document.getElementById('syslog_dashboard_grid').append(card);syslogDashboardInitDrag(card,{id:4});}); + assert.equal(await page.locator('#syslog_panel_4 .syslogStatusCardDrag').count(), 0); + assert.equal(await page.locator('#syslog_panel_4 [data-action="refresh"]').count(), 1); + if (process.env.TEST_ARTIFACT_DIR) await page.screenshot({path:path.join(process.env.TEST_ARTIFACT_DIR,'dashboard-status-style.png'),fullPage:true}); + console.log('Dashboard card styles and drag ordering passed (browser fixture)'); + } finally { await browser.close(); } +})().catch(error => {console.error(error);process.exit(1);}); From a890a433ef6369234d0e75ba6d31cd2cff6a1c92 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 12 Oct 2026 17:20:47 -0400 Subject: [PATCH 2/7] Fix dashboard seed unit test mocks and setup boundary allowlist --- tests/Unit/DashboardDefaultTest.php | 6 ++---- tests/Unit/SetupModuleBoundaryTest.php | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Unit/DashboardDefaultTest.php b/tests/Unit/DashboardDefaultTest.php index 18e531c7..0ce24b55 100644 --- a/tests/Unit/DashboardDefaultTest.php +++ b/tests/Unit/DashboardDefaultTest.php @@ -2,12 +2,10 @@ it('seeds valid starter panels atomically and does not reseed an existing default', function () { syslog_load_plugin_source('setup.php'); + syslog_load_plugin_source('includes/schema.php'); syslog_load_plugin_source('lib/syslog_dashboard.php'); - test_override('syslog_ensure_dashboard_tables', fn () => null); - test_override('syslog_db_table_exists', fn () => true); - test_override('syslog_db_column_exists', fn () => true); + test_override('db_update_table', fn () => true); $GLOBALS['syslogdb_default'] = 'syslog'; - test_override('syslog_dashboard_username', fn () => 'admin'); test_override('syslog_db_fetch_insert_id', fn () => 42); $existing = false; $calls = []; diff --git a/tests/Unit/SetupModuleBoundaryTest.php b/tests/Unit/SetupModuleBoundaryTest.php index abfb284f..b4ef4d74 100644 --- a/tests/Unit/SetupModuleBoundaryTest.php +++ b/tests/Unit/SetupModuleBoundaryTest.php @@ -29,6 +29,7 @@ 'syslog_upgrade_explicit_realm_grants', 'syslog_upgrade_create_permission_realms', 'syslog_check_upgrade', + 'syslog_dashboard_seed_default', 'plugin_syslog_version', 'syslog_refresh_plugin_version', 'syslog_upgrade_hook_module_paths', From dfca95dffd4ea53df476bdba3a56a0cd29db39f1 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 12 Oct 2026 17:26:25 -0400 Subject: [PATCH 3/7] Cover dashboard bootstrap drag and refresh labels --- tests/Unit/DashboardRenderTest.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/Unit/DashboardRenderTest.php diff --git a/tests/Unit/DashboardRenderTest.php b/tests/Unit/DashboardRenderTest.php new file mode 100644 index 00000000..81aa1e58 --- /dev/null +++ b/tests/Unit/DashboardRenderTest.php @@ -0,0 +1,26 @@ + 'Never']; + $GLOBALS['request']['refresh'] = 0; + $GLOBALS['request']['dashboard_id'] = 0; + test_override('syslog_db_fetch_assoc_prepared', fn () => []); + test_override('syslog_db_fetch_assoc', fn () => []); + test_override('syslog_db_fetch_row_prepared', fn () => []); + + ob_start(); + try { + syslog_dashboard(); + $html = ob_get_contents(); + } finally { + ob_end_clean(); + } + + expect($html)->toContain('dragPanel: "Drag to reorder card"') + ->toContain('refreshPanel: "Refresh Panel"') + ->toContain("id='syslog_dashboard_grid'"); +}); From 331b74eec87a67272cd953241fcf391360b03f78 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 12 Oct 2026 18:21:24 -0400 Subject: [PATCH 4/7] Creat admin alert to alert on syslog poller failures or no incoming syslogs --- CHANGELOG.md | 3 + README.md | 29 ++++ includes/functions.php | 153 +++++++++++++++++ includes/processing.php | 1 + includes/settings.php | 26 ++- locales/po/cacti.pot | 195 ++++++++++++++++++++-- syslog.php | 49 ++++-- tests/Unit/CollectorHealthTest.php | 20 +++ tests/Unit/PipelineHealthTest.php | 253 +++++++++++++++++++++++++++++ tests/bootstrap-unit.php | 6 + 10 files changed, 703 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/PipelineHealthTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fab5170..cd2ee876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ --- develop --- +* feature: Add poller-driven pipeline health alerts for stalled processing, incoming backlogs, remote synchronization failures or delays, and blocked partition maintenance; expand the existing Status health card with check results and email delivery status, with optional problem, reminder, and recovery notifications +* bug: Treat an empty incoming queue as drained rather than missing data, and evaluate stale queued messages even when new messages continue arriving + * feature: Add an auto-refresh interval dropdown and a manual refresh glyph to the Syslog Status toolbar (mirroring the intropage refresh concept); the interval persists through the page's auto-reload and the refresh glyphs spin while their cards reload * bug: Show the Syslog Status card refresh glyph again by using Font Awesome's fa-sync-alt (the bundled Font Awesome 6 no longer renders the Font Awesome 4 fa-refresh alias) * change: Repaint the Syslog Status card headers with the active theme's native html_start_box title bar (sampled from the live cactiTableTitle at runtime) instead of a flat surface tint so they match Cacti on every theme, widen the card grid from 6 to 8 columns, drop the card corner radius, and give each header one of four translucent severity overlays - a neutral grey "no severity" default plus intropage's good / warning / error palette (#94b36b / #ff8f1e / #cc0000) diff --git a/README.md b/README.md index 265e7f3f..e6eb5247 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,35 @@ be quieted using syslog's 'Re-Alert' setting. * Ability to run alert specific commands at Alert and Re-alert times +## Pipeline Health Alerts + +The Status tab includes a **Pipeline health** card in the existing Collector +health card position. It reports stalled processing, excessive or old incoming +backlogs, failed or delayed remote synchronization, and blocked partition +maintenance. An empty incoming queue is healthy when processing is completing; +quiet devices do not trigger source-silence alerts. Unavailable queue metrics +are reported as problems rather than healthy values. + +Checks run from Cacti's poller before launching the Syslog processor, so a +stalled processor can still be detected. The delay is the greater of the +Collector Staleness Threshold, three poller intervals, and 300 seconds. New or +re-enabled monitoring gets a startup grace period. Incoming age uses the +configured incoming timestamp field; delayed historical events or incorrect +source clocks can therefore trigger an old-backlog warning. + +In **Settings > Syslog**, set **Pipeline Health Email Recipients** to enable +problem, changed-problem, reminder, and recovery emails using Cacti's mail +settings. Email is disabled by default. Persistent problems repeat at the +configured reminder interval, and failed delivery attempts retry no more than +once every five minutes. The card shows the last health check, email attempt +status, and last successful send. Health state changes also appear in Cacti's +log. Intentionally disabled processing does not generate health notifications. + +Monitoring requires a running Cacti poller and an accessible Syslog database. +Use external monitoring for a complete Cacti or database outage. Remote health +checks run on each enabled remote collector; this does not infer a collector +outage merely from a quiet stream of device messages. + ## Table Architecture and Storage Engine Support New installs always create partitioned `syslog` tables. The traditional diff --git a/includes/functions.php b/includes/functions.php index 28c67fbb..40c57afd 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1577,6 +1577,7 @@ function syslog_status_get(): array { syslog_status_ensure_table(); $status = [ + 'pipeline_health_monitor' => '', 'last_polling_time' => '', 'last_start_time' => '', 'last_end_time' => '', @@ -1596,6 +1597,8 @@ function syslog_status_get(): array { 'partition_maintenance_last_attempt' => '', 'partition_maintenance_last_success' => '', 'partition_maintenance_outcome' => '', + 'partition_maintenance_blocked' => '', + 'partition_maintenance_reason' => '', 'partition_recovery_progress' => '', 'partition_maintenance_history' => '', 'replication_state' => '', @@ -1607,6 +1610,7 @@ function syslog_status_get(): array { $rows = syslog_db_fetch_assoc("SELECT `name`, `value`, `updated` FROM `$syslogdb_default`.`syslog_status` WHERE `name` IN ( + 'pipeline_health_monitor', 'last_polling_time', 'last_start_time', 'last_end_time', @@ -1626,6 +1630,8 @@ function syslog_status_get(): array { 'partition_maintenance_last_attempt', 'partition_maintenance_last_success', 'partition_maintenance_outcome', + 'partition_maintenance_blocked', + 'partition_maintenance_reason', 'partition_recovery_progress', 'partition_maintenance_history', 'replication_state', @@ -1641,6 +1647,153 @@ function syslog_status_get(): array { return $status; } +/** Read pipeline health without changing state or sending notifications. */ +function syslog_pipeline_health(): array { + global $config, $syslogdb_default, $syslog_incoming_config; + + $status = syslog_status_get(); + $monitor = json_decode($status['pipeline_health_monitor'], true); + $monitor = is_array($monitor) ? $monitor : []; + $health = ['state' => 'up', 'issues' => [], 'checks' => [], 'monitor' => $monitor]; + + if (read_config_option('syslog_enabled') !== 'on' + || ((int) ($config['poller_id'] ?? 1) > 1 && read_config_option('syslog_remote_enabled') !== 'on')) { + $health['state'] = 'disabled'; + return $health; + } + + $now = time(); + $interval = max(60, (int) read_config_option('poller_interval')); + $delay = max($interval * 3, (int) read_config_option('syslog_stale_threshold'), 300); + $limit = (int) read_config_option('syslog_backlog_threshold'); + $limit = $limit > 0 ? $limit : 10000; + $completed = (int) $status['last_end_time']; + $baseline = max($completed, (int) ($monitor['first_checked'] ?? 0)); + if (($monitor['signature'] ?? '') === 'disabled:') { + $baseline = 0; + } + $health['checks']['processing'] = $completed === 0 || $baseline === 0 + ? __('Waiting for a completed processing run.', 'syslog') + : __('Processing is completing within the allowed delay.', 'syslog'); + if ($baseline > 0 && $now - $baseline > $delay) { + $health['issues']['processing'] = __('No processing run has completed for more than %d seconds.', $delay, 'syslog'); + } + + $column = $syslog_incoming_config['timeField'] ?? 'logtime'; + $incoming = []; + if (is_string($column) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + $incoming = syslog_db_fetch_row("SELECT COUNT(*) AS pending, UNIX_TIMESTAMP(MIN(`$column`)) AS oldest + FROM `$syslogdb_default`.`syslog_incoming`", false); + } + $health['checks']['backlog'] = __('Incoming messages are within the configured limits.', 'syslog'); + if (!isset($incoming['pending']) || !is_numeric($incoming['pending'])) { + $health['issues']['backlog'] = __('Incoming queue metrics are unavailable.', 'syslog'); + } elseif ((int) $incoming['pending'] > $limit) { + $health['issues']['backlog'] = __('Incoming backlog of %s messages exceeds the limit of %s.', number_format((int) $incoming['pending']), number_format($limit), 'syslog'); + } elseif ((int) $incoming['pending'] > 0) { + if (!isset($incoming['oldest']) || !is_numeric($incoming['oldest'])) { + $health['issues']['backlog'] = __('The oldest incoming message timestamp is unavailable.', 'syslog'); + } elseif ($now - (int) $incoming['oldest'] > $delay) { + $health['issues']['backlog'] = __('Unprocessed messages are older than %d seconds.', $delay, 'syslog'); + } + } + + $replication = syslog_replication_operational_status(); + $health['checks']['replication'] = __('Synchronization is not enabled on this collector.', 'syslog'); + if (!empty($replication['enabled'])) { + $health['checks']['replication'] = __('Synchronization is within the allowed delay.', 'syslog'); + $oldest = strtotime($replication['oldest_pending']); + if ($replication['pending'] === null) { + $health['issues']['replication'] = __('Synchronization queue metrics are unavailable.', 'syslog'); + } elseif ($replication['pending'] > 0 && $oldest === false) { + $health['issues']['replication'] = __('The oldest synchronization record timestamp is unavailable.', 'syslog'); + } elseif ($replication['pending'] > 0 && ($replication['last_error'] !== '' + || ($oldest !== false && $now - $oldest > $delay))) { + $health['issues']['replication'] = __('Synchronization has failed or queued records exceed the allowed delay (%s pending).', number_format($replication['pending']), 'syslog'); + } + } + + $health['checks']['partition'] = __('Partition maintenance is not blocked.', 'syslog'); + if ((int) ($status['partition_maintenance_blocked'] ?? 0) === 1) { + $health['issues']['partition'] = __('Partition maintenance is blocked: %s', $status['partition_maintenance_reason'] ?? '', 'syslog'); + } + + if ($health['issues']) { + $health['state'] = 'down'; + } elseif ($completed === 0 || $baseline === 0) { + $health['state'] = 'recovering'; + } + return $health; +} + +/** Check independently of the Syslog processor; serialize notifications per collector. */ +function syslog_pipeline_health_check(): void { + global $config, $syslogdb_default; + + $lock = 'syslog_health_' . sha1($syslogdb_default . ':' . (int) ($config['poller_id'] ?? 1)); + if ((int) syslog_db_fetch_cell_prepared('SELECT GET_LOCK(?, 0)', [$lock], false) !== 1) { + return; + } + + try { + $health = syslog_pipeline_health(); + $monitor = $health['monitor']; + $now = time(); + $keys = array_keys($health['issues']); + sort($keys); + $signature = $health['state'] . ':' . implode(',', $keys); + $prior = $monitor['signature'] ?? ''; + $monitor['first_checked'] = $prior === 'disabled:' ? $now : ($monitor['first_checked'] ?? $now); + $monitor['checked'] = $now; + $monitor['signature'] = $signature; + + if ($signature !== $prior && $health['state'] !== 'disabled' && ($health['issues'] || str_starts_with($prior, 'down:'))) { + cacti_log('SYSLOG PIPELINE: ' . ($health['issues'] ? implode(' ', $health['issues']) : 'Pipeline recovered'), false, 'SYSLOG'); + } + + $recipients = trim((string) read_config_option('syslog_pipeline_health_email')); + if ($recipients === '' || $health['state'] === 'disabled') { + $monitor['notification'] = 'disabled'; + unset($monitor['notified_signature']); + } else { + $notified = $monitor['notified_signature'] ?? ''; + $repeat = max(300, (int) (read_config_option('syslog_pipeline_health_repeat') ?: 3600)); + $due = ($health['issues'] && ($signature !== $notified || $now - (int) ($monitor['last_notified'] ?? 0) >= $repeat)) + || (!$health['issues'] && str_starts_with($notified, 'down:')); + // Bound failed mail retries even if poller hooks overlap or run more often. + if ($due && $now - (int) ($monitor['last_attempt'] ?? 0) >= 300) { + $monitor['last_attempt'] = $now; + $monitor['notification'] = 'pending'; + if (!syslog_status_set('pipeline_health_monitor', (string) json_encode($monitor))) { + return; + } + $subject = __('Syslog pipeline health - collector %d', (int) ($config['poller_id'] ?? 1), 'syslog'); + $message = $health['issues'] ? implode("\n", $health['issues']) : __('The Syslog pipeline has recovered.', 'syslog'); + $from = [(string) (read_config_option('settings_from_email') ?: 'Cacti@cacti.net'), (string) (read_config_option('settings_from_name') ?: 'Cacti')]; + try { + $error = mailer($from, $recipients, '', '', '', $subject, '', $message, '', '', false); + $monitor['notification'] = $error === '' ? 'sent' : 'failed'; + } catch (Throwable $error) { + $monitor['notification'] = 'failed'; + } + if ($monitor['notification'] === 'sent') { + $monitor['notified_signature'] = $signature; + $monitor['last_notified'] = $now; + } else { + cacti_log('SYSLOG PIPELINE: Health notification failed; retrying on a later poller run', false, 'SYSLOG'); + } + } elseif ($due && ($monitor['notification'] ?? '') !== 'failed') { + $monitor['notification'] = 'pending'; + } elseif (!isset($monitor['notification']) || $monitor['notification'] === 'disabled') { + $monitor['notification'] = 'ready'; + } + } + syslog_status_set('pipeline_health_monitor', (string) json_encode($monitor)); + } finally { + syslog_db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock], false); + } +} + /** * syslog_worker_stats_get - Collect the parallel worker statistics shown * on the Syslog Status tab. diff --git a/includes/processing.php b/includes/processing.php index d645c3d9..51b7789e 100644 --- a/includes/processing.php +++ b/includes/processing.php @@ -78,6 +78,7 @@ function syslog_poller_bottom(): void { if (syslog_config_safe()) { syslog_connect(); + syslog_pipeline_health_check(); syslog_status_set('last_polling_time', time()); $command_string = (string) read_config_option('path_php_binary'); diff --git a/includes/settings.php b/includes/settings.php index bf1a4233..2e68e551 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -191,7 +191,7 @@ function syslog_config_settings(): void { ], 'syslog_stale_threshold' => [ 'friendly_name' => __('Collector Staleness Threshold', 'syslog'), - 'description' => __('The Syslog Status page shows a warning when no new message has arrived within this many seconds. Set to 0 to use the default of 300 seconds.', 'syslog'), + 'description' => __('Pipeline health alerts warn when unprocessed messages, processing completion, or queued synchronization exceed this delay. The effective delay is at least three poller intervals and 300 seconds. Set to 0 to use 300 seconds.', 'syslog'), 'method' => 'drop_array', 'default' => '300', 'array' => [ @@ -216,6 +216,30 @@ function syslog_config_settings(): void { '50000' => __('%d Messages', 50000, 'syslog') ] ], + 'syslog_pipeline_health_header' => [ + 'friendly_name' => __('Pipeline Health Alerts', 'syslog'), + 'method' => 'spacer', + ], + 'syslog_pipeline_health_email' => [ + 'friendly_name' => __('Pipeline Health Email Recipients', 'syslog'), + 'description' => __('Comma-separated email recipients for pipeline failures and recovery. Leave blank to disable email; health remains visible on the Status tab and state changes are logged. Checks require the Cacti poller to run.', 'syslog'), + 'method' => 'textbox', + 'size' => 80, + 'max_length' => 255, + 'default' => '' + ], + 'syslog_pipeline_health_repeat' => [ + 'friendly_name' => __('Pipeline Health Reminder Interval', 'syslog'), + 'description' => __('Repeat notifications for ongoing pipeline problems after this interval. Failed email attempts are retried no more than once every five minutes.', 'syslog'), + 'method' => 'drop_array', + 'default' => '3600', + 'array' => [ + '900' => __('%d minutes', 15, 'syslog'), + '3600' => __('%d hours', 1, 'syslog'), + '21600' => __('%d hours', 6, 'syslog'), + '86400' => __('%d days', 1, 'syslog') + ] + ], 'syslog_alert_retention' => [ 'friendly_name' => __('Syslog Alert Retention', 'syslog'), 'description' => __('This is the number of days to keep alert logs.', 'syslog'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a28fca82..6a675eed 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -81,6 +81,80 @@ msgstr "" msgid "The Syslog tables are not partitioned. Traditional (non-partitioned) tables are deprecated, and partitioned tables are required for new installs. Your data will continue to be collected, but you should migrate the syslog table to a partitioned architecture." msgstr "" +#: includes/functions.php +msgid "Waiting for a completed processing run." +msgstr "" + +#: includes/functions.php +msgid "Processing is completing within the allowed delay." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "No processing run has completed for more than %d seconds." +msgstr "" + +#: includes/functions.php +msgid "Incoming messages are within the configured limits." +msgstr "" + +#: includes/functions.php +msgid "Incoming queue metrics are unavailable." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Incoming backlog of %s messages exceeds the limit of %s." +msgstr "" + +#: includes/functions.php +msgid "The oldest incoming message timestamp is unavailable." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Unprocessed messages are older than %d seconds." +msgstr "" + +#: includes/functions.php +msgid "Synchronization is not enabled on this collector." +msgstr "" + +#: includes/functions.php +msgid "Synchronization is within the allowed delay." +msgstr "" + +#: includes/functions.php +msgid "Synchronization queue metrics are unavailable." +msgstr "" + +#: includes/functions.php +msgid "The oldest synchronization record timestamp is unavailable." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Synchronization has failed or queued records exceed the allowed delay (%s pending)." +msgstr "" + +#: includes/functions.php +msgid "Partition maintenance is not blocked." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Partition maintenance is blocked: %s" +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Syslog pipeline health - collector %d" +msgstr "" + +#: includes/functions.php +msgid "The Syslog pipeline has recovered." +msgstr "" + #: includes/functions.php #, php-format msgid "Partition layout for %s is invalid or incomplete; partition maintenance stopped. Verify the partition metadata with SHOW CREATE TABLE and, if required, rebuild the partitions; writes continue into the dMaxValue safety partition." @@ -537,7 +611,7 @@ msgstr "" msgid "Every day" msgstr "" -#: includes/settings.php syslog_alerts.php syslog_device_rules.php +#: includes/settings.php syslog.php syslog_alerts.php syslog_device_rules.php #: syslog_removal.php syslog_reports.php msgid "Disabled" msgstr "" @@ -702,7 +776,7 @@ msgid "Collector Staleness Threshold" msgstr "" #: includes/settings.php -msgid "The Syslog Status page shows a warning when no new message has arrived within this many seconds. Set to 0 to use the default of 300 seconds." +msgid "Pipeline health alerts warn when unprocessed messages, processing completion, or queued synchronization exceed this delay. The effective delay is at least three poller intervals and 300 seconds. Set to 0 to use 300 seconds." msgstr "" #: includes/settings.php @@ -731,6 +805,41 @@ msgstr "" msgid "%d Messages" msgstr "" +#: includes/settings.php +msgid "Pipeline Health Alerts" +msgstr "" + +#: includes/settings.php +msgid "Pipeline Health Email Recipients" +msgstr "" + +#: includes/settings.php +msgid "Comma-separated email recipients for pipeline failures and recovery. Leave blank to disable email; health remains visible on the Status tab and state changes are logged. Checks require the Cacti poller to run." +msgstr "" + +#: includes/settings.php +msgid "Pipeline Health Reminder Interval" +msgstr "" + +#: includes/settings.php +msgid "Repeat notifications for ongoing pipeline problems after this interval. Failed email attempts are retried no more than once every five minutes." +msgstr "" + +#: includes/settings.php +#, php-format +msgid "%d minutes" +msgstr "" + +#: includes/settings.php +#, php-format +msgid "%d hours" +msgstr "" + +#: includes/settings.php +#, php-format +msgid "%d days" +msgstr "" + #: includes/settings.php msgid "Syslog Alert Retention" msgstr "" @@ -1407,7 +1516,7 @@ msgstr "" #: syslog.php #, php-format -msgid "no message received for %s" +msgid "unprocessed messages are %s old" msgstr "" #: syslog.php @@ -1415,10 +1524,6 @@ msgstr "" msgid "incoming backlog of %s messages" msgstr "" -#: syslog.php -msgid "no incoming data is available to evaluate" -msgstr "" - #: syslog.php msgid "day" msgstr "" @@ -1504,7 +1609,7 @@ msgid "Recent partition maintenance activity" msgstr "" #: syslog.php -msgid "Collector health" +msgid "Pipeline health" msgstr "" #: syslog.php @@ -1681,23 +1786,83 @@ msgid "No action required." msgstr "" #: syslog.php -msgid "Last received log" +msgid "Incoming backlog" msgstr "" #: syslog.php -msgid "Oldest unprocessed message" +msgid "Processing" msgstr "" #: syslog.php -msgid "Incoming backlog" +msgid "Partition maintenance" msgstr "" #: syslog.php -msgid "Records processed in latest run" +msgid "Synchronization" +msgstr "" + +#: syslog.php +msgid "Attention required" +msgstr "" + +#: syslog.php +msgid "Health" +msgstr "" + +#: syslog.php +msgid "Healthy" +msgstr "" + +#: syslog.php +msgid "Processing disabled" +msgstr "" + +#: syslog.php +msgid "Waiting for processing" +msgstr "" + +#: syslog.php +msgid "Failed; retry pending" +msgstr "" + +#: syslog.php +msgid "Pending; next poller check" +msgstr "" + +#: syslog.php +msgid "Ready" +msgstr "" + +#: syslog.php +msgid "Sent" +msgstr "" + +#: syslog.php +msgid "Last health check" +msgstr "" + +#: syslog.php +msgid "Email notifications" +msgstr "" + +#: syslog.php +msgid "Not checked yet" +msgstr "" + +#: syslog.php +msgid "Last notification sent" +msgstr "" + +#: syslog.php +msgid "Last received log" msgstr "" #: syslog.php -msgid "Collector warning" +msgid "Oldest unprocessed message" +msgstr "" + +#: syslog.php +msgid "Records processed in latest run" msgstr "" #: syslog.php @@ -1740,10 +1905,6 @@ msgstr "" msgid "Records" msgstr "" -#: syslog.php -msgid "Partition maintenance" -msgstr "" - #: syslog.php msgid "Reference updates" msgstr "" diff --git a/syslog.php b/syslog.php index c2308a6f..368b69fe 100644 --- a/syslog.php +++ b/syslog.php @@ -422,14 +422,13 @@ function syslog_status_collector_health(): array { FROM `$syslogdb_default`.`syslog_incoming`"); $last_received = __('Unavailable', 'syslog'); - $seconds_since = null; + $oldest_seconds = null; if (is_string($last_received_raw) && $last_received_raw !== '' && $last_received_raw !== 'NULL') { $epoch = strtotime($last_received_raw); if ($epoch !== false) { $last_received = date('Y-m-d H:i:s', $epoch); - $seconds_since = time() - $epoch; } } @@ -446,6 +445,7 @@ function syslog_status_collector_health(): array { $age = time() - $oldest_epoch; if ($age >= 0) { + $oldest_seconds = $age; $oldest_age = syslog_status_format_age($age); } } @@ -476,19 +476,14 @@ function syslog_status_collector_health(): array { $backlog_threshold = SYSLOG_COLLECTOR_BACKLOG_THRESHOLD; } - if ($seconds_since !== null && $seconds_since > $stale_threshold) { + if (is_numeric($backlog_raw) && (int) $backlog_raw > 0 && $oldest_seconds !== null && $oldest_seconds > $stale_threshold) { $warning = true; - $warn_parts[] = sprintf(__('no message received for %s', 'syslog'), syslog_status_format_age($seconds_since)); + $warn_parts[] = __('unprocessed messages are %s old', syslog_status_format_age($oldest_seconds), 'syslog'); } if (is_numeric($backlog_raw) && (int) $backlog_raw > $backlog_threshold) { $warning = true; - $warn_parts[] = sprintf(__('incoming backlog of %s messages', 'syslog'), number_format((int) $backlog_raw)); - } - - if (is_numeric($backlog_raw) && (int) $backlog_raw === 0 && $seconds_since === null) { - $warning = true; - $warn_parts[] = __('no incoming data is available to evaluate', 'syslog'); + $warn_parts[] = __('incoming backlog of %s messages', number_format((int) $backlog_raw), 'syslog'); } return [ @@ -659,7 +654,7 @@ function syslog_status_card_meta(): array { 'partition_health' => ['title' => __('Partition health', 'syslog'), 'span' => 4, 'expandable' => false], 'maintenance' => ['title' => __('Partition maintenance activity', 'syslog'), 'span' => 2, 'expandable' => false], 'maintenance_recent' => ['title' => __('Recent partition maintenance activity', 'syslog'), 'span' => 4, 'expandable' => true], - 'collector_health' => ['title' => __('Collector health', 'syslog'), 'span' => 2, 'expandable' => false], + 'collector_health' => ['title' => __('Pipeline health', 'syslog'), 'span' => 4, 'expandable' => false, 'state' => syslog_status_pipeline()['state']], 'workers' => ['title' => __('Parallel workers', 'syslog'), 'span' => 2, 'expandable' => false], 'worker_processes' => ['title' => __('Worker processes', 'syslog'), 'span' => 2, 'expandable' => true], 'phases' => ['title' => __('Processing phases', 'syslog'), 'span' => 4, 'expandable' => true], @@ -741,6 +736,12 @@ function syslog_status_replication(): array { return $replication; } +/** Share the same live health snapshot between the card header and body. */ +function syslog_status_pipeline(): array { + static $health = null; + return $health ??= syslog_pipeline_health(); +} + /** * Render the inner body HTML of a single Status card. * @@ -842,6 +843,29 @@ function syslog_status_card_body(string $key): string { break; case 'collector_health': + $pipeline = syslog_status_pipeline(); + $labels = [ + 'processing' => __('Processing', 'syslog'), 'backlog' => __('Incoming backlog', 'syslog'), + 'replication' => __('Synchronization', 'syslog'), 'partition' => __('Partition maintenance', 'syslog') + ]; + print ''; + $states = [ + 'up' => __('Healthy', 'syslog'), 'down' => __('Attention required', 'syslog'), + 'disabled' => __('Processing disabled', 'syslog'), 'recovering' => __('Waiting for processing', 'syslog') + ]; + $kv(__('Health', 'syslog'), $states[$pipeline['state']]); + foreach ($pipeline['checks'] as $check => $detail) { + $kv($labels[$check], $pipeline['issues'][$check] ?? $detail); + } + $monitor = $pipeline['monitor']; + $notifications = [ + 'disabled' => __('Disabled', 'syslog'), 'ready' => __('Ready', 'syslog'), 'sent' => __('Sent', 'syslog'), + 'failed' => __('Failed; retry pending', 'syslog'), 'pending' => __('Pending; next poller check', 'syslog') + ]; + $kv(__('Last health check', 'syslog'), syslog_status_format_time((string) ($monitor['checked'] ?? ''))); + $kv(__('Email notifications', 'syslog'), $notifications[$monitor['notification'] ?? ''] ?? __('Not checked yet', 'syslog')); + $kv(__('Last notification sent', 'syslog'), syslog_status_format_time((string) ($monitor['last_notified'] ?? ''))); + print '
'; $health = syslog_status_collector_health(); print ''; $kv(__('Last received log', 'syslog'), (string) $health['last_received']); @@ -849,9 +873,6 @@ function syslog_status_card_body(string $key): string { $kv(__('Incoming backlog', 'syslog'), (string) $health['backlog']); $kv(__('Records processed in latest run', 'syslog'), (string) $health['processed']); print '
'; - if ($health['warning']) { - print '

' . __esc('Collector warning', 'syslog') . ': ' . html_escape($health['warning_text']) . '

'; - } break; case 'workers': diff --git a/tests/Unit/CollectorHealthTest.php b/tests/Unit/CollectorHealthTest.php index 05f0b830..3cde9eca 100644 --- a/tests/Unit/CollectorHealthTest.php +++ b/tests/Unit/CollectorHealthTest.php @@ -69,6 +69,7 @@ function collector_health_load_functions(): void { } function collector_health_setup_config(): void { + syslog_test_reset_globals(); $GLOBALS['syslogdb_default'] = 'syslogdb'; $GLOBALS['syslog_incoming_config'] = [ 'timeField' => 'logtime', @@ -268,3 +269,22 @@ function collector_health_capture_db(array &$calls, array $overrides = []): void expect(syslog_status_format_age(5400))->toContain('hour'); expect(syslog_status_format_age(90000))->toContain('day'); }); + + +it('does not warn for an empty queue after processing drains incoming messages', function () { + syslog_load_plugin_source('includes/functions.php'); + collector_health_load_functions(); + collector_health_setup_config(); + $calls = []; + collector_health_capture_db($calls, ['count' => '0']); + expect(syslog_status_collector_health()['warning'])->toBeFalse(); +}); + +it('warns about old queued messages even while new messages arrive', function () { + syslog_load_plugin_source('includes/functions.php'); + collector_health_load_functions(); + collector_health_setup_config(); + $calls = []; + collector_health_capture_db($calls, ['max' => date('Y-m-d H:i:s'), 'min' => date('Y-m-d H:i:s', time() - 600), 'count' => '2']); + expect(syslog_status_collector_health()['warning'])->toBeTrue(); +}); diff --git a/tests/Unit/PipelineHealthTest.php b/tests/Unit/PipelineHealthTest.php new file mode 100644 index 00000000..f072b6e5 --- /dev/null +++ b/tests/Unit/PipelineHealthTest.php @@ -0,0 +1,253 @@ + 'received_at']; + $GLOBALS['pipeline_fixture'] = [ + 'settings' => ['syslog_enabled' => 'on', 'poller_interval' => '300'], + 'status' => ['last_end_time' => (string) time()], + 'incoming' => ['pending' => '0', 'oldest' => null], + 'outbox' => ['pending' => '0', 'oldest_pending' => ''], + 'mails' => [], 'locks' => [], 'queries' => [], 'logs' => [], 'mail_error' => '', 'lock' => 1, + ]; + test_override('read_config_option', fn ($key) => $GLOBALS['pipeline_fixture']['settings'][$key] ?? ''); + test_override('syslog_db_table_exists', fn () => true); + test_override('syslog_db_fetch_assoc', function ($sql) { + $GLOBALS['pipeline_fixture']['queries'][] = $sql; + $rows = []; + foreach ($GLOBALS['pipeline_fixture']['status'] as $key => $value) { + $rows[] = ['name' => $key, 'value' => $value, 'updated' => time()]; + } + return $rows; + }); + test_override('syslog_db_fetch_row', function ($sql) { + $GLOBALS['pipeline_fixture']['queries'][] = $sql; + if (str_contains($sql, 'syslog_incoming')) return $GLOBALS['pipeline_fixture']['incoming']; + if (str_contains($sql, 'syslog_replication_output')) return $GLOBALS['pipeline_fixture']['outbox']; + return []; + }); + test_override('syslog_db_fetch_cell_prepared', function ($sql, $params) { + $GLOBALS['pipeline_fixture']['locks'][] = [$sql, $params]; + return $GLOBALS['pipeline_fixture']['lock']; + }); + test_override('syslog_db_execute_prepared', function ($sql, $params) { + if ($params[0] === 'pipeline_health_monitor') { + if (!empty($GLOBALS['pipeline_fixture']['write_failed'])) return false; + $GLOBALS['pipeline_fixture']['status'][$params[0]] = $params[1]; + } + return true; + }); + test_override('mailer', function (...$args) { + $GLOBALS['pipeline_fixture']['mails'][] = $args; + return $GLOBALS['pipeline_fixture']['mail_error']; + }); + test_override('cacti_log', function ($text) { $GLOBALS['pipeline_fixture']['logs'][] = $text; }); +} + +it('treats an empty drained queue and recent processing as healthy without writes', function () { + pipeline_health_fixture(); + $health = syslog_pipeline_health(); + expect($health['state'])->toBe('up')->and($health['issues'])->toBeEmpty() + ->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty() + ->and($GLOBALS['__test_db_calls'])->toBeEmpty(); + expect(implode(' ', $GLOBALS['pipeline_fixture']['queries']))->toContain('MIN(`received_at`)') + ->toContain('`health_test`.`syslog_incoming`')->toContain("'partition_maintenance_blocked'"); +}); + +it('detects stalled processing even with an empty queue', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + expect(syslog_pipeline_health()['issues'])->toHaveKey('processing'); +}); + +it('allows three poller intervals before declaring delayed processing', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['poller_interval'] = '600'; + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); +}); + +it('gives a new processor a grace period but detects failure to ever complete', function () { + pipeline_health_fixture(); + unset($GLOBALS['pipeline_fixture']['status']['last_end_time']); + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode(['first_checked' => time() - 1000]); + expect(syslog_pipeline_health()['issues'])->toHaveKey('processing'); +}); + +it('detects old queued events even when fresh messages are arriving', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 10, 'oldest' => time() - 1000]; + expect(syslog_pipeline_health()['issues'])->toHaveKey('backlog'); +}); + +it('uses the configured backlog threshold', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_backlog_threshold'] = '1000'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 1001, 'oldest' => time()]; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('1,001')->toContain('1,000'); +}); + +it('never reports unavailable metrics or unsafe column mappings as healthy', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = false; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('unavailable'); + $GLOBALS['syslog_incoming_config']['timeField'] = 'bad`; DROP TABLE syslog'; + $GLOBALS['pipeline_fixture']['queries'] = []; + expect(syslog_pipeline_health()['issues']['backlog'])->toContain('unavailable'); + expect(implode(' ', $GLOBALS['pipeline_fixture']['queries']))->not->toContain('DROP TABLE'); +}); + +it('detects failed and delayed remote delivery without treating ordinary recovery as failure', function () { + pipeline_health_fixture(); + $GLOBALS['config']['poller_id'] = 2; + $GLOBALS['config']['connection'] = 'offline'; + $GLOBALS['pipeline_fixture']['settings']['syslog_remote_enabled'] = 'on'; + $GLOBALS['pipeline_fixture']['outbox'] = ['pending' => 10, 'oldest_pending' => date('Y-m-d H:i:s')]; + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['outbox']['oldest_pending'] = date('Y-m-d H:i:s', time() - 1000); + expect(syslog_pipeline_health()['issues'])->toHaveKey('replication'); + $GLOBALS['pipeline_fixture']['outbox']['oldest_pending'] = date('Y-m-d H:i:s'); + $GLOBALS['pipeline_fixture']['status']['replication_last_error'] = 'Central commit failed'; + expect(syslog_pipeline_health()['issues'])->toHaveKey('replication'); + $GLOBALS['pipeline_fixture']['outbox']['pending'] = null; + expect(syslog_pipeline_health()['issues']['replication'])->toContain('unavailable'); +}); + +it('reports blocked partition maintenance with its reason', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_blocked'] = '1'; + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_reason'] = 'DDL failed'; + expect(syslog_pipeline_health()['issues']['partition'])->toContain('DDL failed'); +}); + +it('sends one problem notification and one recovery notification without duplicate polling emails', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(1); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + $monitor['last_attempt'] -= 301; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 0; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2) + ->and($GLOBALS['pipeline_fixture']['mails'][1][7])->toContain('recovered'); + expect($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('retries failed mail after five minutes without claiming success', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + $GLOBALS['pipeline_fixture']['mail_error'] = 'SMTP unavailable'; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + expect($monitor['notification'])->toBe('failed')->and($monitor)->not->toHaveKey('last_notified') + ->and($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(1); + $monitor['last_attempt'] -= 301; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + $GLOBALS['pipeline_fixture']['mail_error'] = ''; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2); + expect(json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true)['notification'])->toBe('sent'); +}); + +it('reminds for ongoing problems only after the configured interval', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings'] += ['syslog_pipeline_health_email' => 'ops@example.test', 'syslog_pipeline_health_repeat' => '900']; + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + $monitor = json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true); + $monitor['last_attempt'] -= 901; + $monitor['last_notified'] -= 901; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode($monitor); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toHaveCount(2); +}); + +it('checks and logs health with email disabled', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['incoming'] = ['pending' => 20000, 'oldest' => time()]; + syslog_pipeline_health_check(); + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['logs'])->toHaveCount(1) + ->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); +}); + +it('does not alert for intentionally disabled processing and gives re-enablement a grace period', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings'] += ['syslog_pipeline_health_email' => 'ops@example.test']; + $GLOBALS['pipeline_fixture']['settings']['syslog_enabled'] = ''; + $GLOBALS['pipeline_fixture']['status']['last_end_time'] = (string) (time() - 1000); + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['state'])->toBe('disabled')->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); + $GLOBALS['pipeline_fixture']['settings']['syslog_enabled'] = 'on'; + syslog_pipeline_health_check(); + expect(syslog_pipeline_health()['issues'])->toBeEmpty(); +}); + +it('skips notifications when another monitor owns the lock', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['lock'] = 0; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['queries'])->toBeEmpty()->and($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty(); +}); + +it('runs the monitor from the independent poller before launching the processor', function () { + $source = plugin_test_read_source('includes/processing.php'); + expect(strpos($source, 'syslog_pipeline_health_check();'))->toBeLessThan(strpos($source, 'exec_background($command_string, $extra_args);')); +}); + + +it('does not send email when the retry state cannot be persisted', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 20000; + $GLOBALS['pipeline_fixture']['write_failed'] = true; + syslog_pipeline_health_check(); + expect($GLOBALS['pipeline_fixture']['mails'])->toBeEmpty() + ->and($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('keeps mail exceptions from blocking processing and releases the monitor lock', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['settings']['syslog_pipeline_health_email'] = 'ops@example.test'; + $GLOBALS['pipeline_fixture']['incoming']['pending'] = 20000; + test_override('mailer', function () { throw new RuntimeException('Mail transport failed'); }); + syslog_pipeline_health_check(); + expect(json_decode($GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'], true)['notification'])->toBe('failed') + ->and($GLOBALS['pipeline_fixture']['locks'][1][0])->toContain('RELEASE_LOCK'); +}); + +it('renders pipeline problems and notification status in the existing Status card with escaped details', function () { + pipeline_health_fixture(); + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_blocked'] = '1'; + $GLOBALS['pipeline_fixture']['status']['partition_maintenance_reason'] = ''; + $GLOBALS['pipeline_fixture']['status']['pipeline_health_monitor'] = json_encode(['checked' => time(), 'notification' => 'failed']); + $source = plugin_test_read_source('syslog.php'); + foreach (['syslog_status_data', 'syslog_status_workers', 'syslog_status_pipeline', 'syslog_status_card_meta', 'syslog_status_card_body', 'syslog_status_collector_health', 'syslog_status_collector_unavailable', 'syslog_status_format_age', 'syslog_status_format_count', 'syslog_status_format_time'] as $name) { + if (!function_exists($name)) { + $start = strpos($source, "function $name("); + $end = strpos($source, "\n}", $start); + eval(substr($source, $start, $end - $start + 2)); + } + } + if (!defined('SYSLOG_COLLECTOR_STALE_SECONDS')) define('SYSLOG_COLLECTOR_STALE_SECONDS', 300); + if (!defined('SYSLOG_COLLECTOR_BACKLOG_THRESHOLD')) define('SYSLOG_COLLECTOR_BACKLOG_THRESHOLD', 10000); + $html = syslog_status_card_body('collector_health'); + expect(syslog_status_card_meta()['collector_health']['title'])->toBe('Pipeline health') + ->and(syslog_status_card_meta()['collector_health']['state'])->toBe('down') + ->and($html)->toContain('Attention required')->toContain('Partition maintenance') + ->toContain('Failed; retry pending')->toContain('Last health check') + ->toContain('<script>')->not->toContain('