From 89aad3876f6e1755fa8eb218e5d598c3ff5a677f Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 26 Oct 2026 13:07:21 -0400 Subject: [PATCH 1/3] Fix alert rule previews after messages move into history --- README.md | 5 ++ includes/functions.php | 34 ++++++++- js/functions.js | 4 + .../rule_preview_history_database_test.php | 73 +++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/regression/rule_preview_history_database_test.php diff --git a/README.md b/README.md index a0bfbe1d..d36dc903 100644 --- a/README.md +++ b/README.md @@ -551,3 +551,8 @@ nesting levels. Invalid searches return no results and CSV export rejects them. ----------------------------------------------- Copyright (c) 2004-2026 - The Cacti Group, Inc. + +The Alert Rule condition-builder tester checks incoming messages first. If none +match, it previews the newest 10,000 stored messages and labels that scope. +It never replays historical messages as alarms. SQL and removal previews retain +their existing incoming-only scope. diff --git a/includes/functions.php b/includes/functions.php index bbbba4ce..ab616f38 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -3344,10 +3344,11 @@ function syslog_get_removal_rule_sql(&$remove, $table = 'syslog_incoming', $pref * response cannot stream unbounded data. */ define('SYSLOG_RULE_PREVIEW_MAX_ROWS', 10); +define('SYSLOG_RULE_PREVIEW_HISTORY_ROWS', 10000); /** * syslog_rule_preview - Run a read-only preview of a rule against the - * incoming table. + * incoming table, with recent stored history as a structured-alert fallback. * * The rule is compiled through the same QueryBuilder/compiler paths the * poller uses, so the preview reflects exactly what the rule would match @@ -3502,6 +3503,27 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows ]; } + $history = false; + if ((int) $count === 0 && $rule_type === 'alert' && $type === 'filter' && syslog_db_table_exists('syslog', false)) { + // Reuse normalized host/program matching; never reinterpret hand-written SQL. + $compiled = syslog_get_removal_rule_sql($rule, 'syslog', 's.', false); + if (!$compiled) { + return ['error' => __('The historical preview filter could not be compiled.', 'syslog'), 'count' => 0, 'rows' => []]; + } + $limit = SYSLOG_RULE_PREVIEW_HISTORY_ROWS; + $sql = "SELECT s.*, h.host, p.program + FROM (SELECT * FROM `$syslogdb_default`.`syslog` ORDER BY seq DESC LIMIT $limit) AS s + LEFT JOIN `$syslogdb_default`.`syslog_hosts` AS h ON h.host_id = s.host_id + LEFT JOIN `$syslogdb_default`.`syslog_programs` AS p ON p.program_id = s.program_id + {$compiled['sql']}"; + $params = $compiled['params']; + $count = syslog_db_fetch_cell_prepared("SELECT COUNT(*) FROM ($sql) AS preview", $params); + if (!is_numeric($count)) { + return ['error' => __('The historical preview query could not be evaluated.', 'syslog'), 'count' => 0, 'rows' => []]; + } + $history = true; + } + $sample = syslog_db_fetch_assoc_prepared("$sql ORDER BY seq DESC LIMIT $rows", $params); if (!is_array($sample)) { @@ -3512,8 +3534,8 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows ]; } - $time_field = isset($syslog_incoming_config['timeField']) ? $syslog_incoming_config['timeField'] : 'logtime'; - $text_field = isset($syslog_incoming_config['textField']) ? $syslog_incoming_config['textField'] : 'message'; + $time_field = !$history && isset($syslog_incoming_config['timeField']) ? $syslog_incoming_config['timeField'] : 'logtime'; + $text_field = !$history && isset($syslog_incoming_config['textField']) ? $syslog_incoming_config['textField'] : 'message'; $out = []; @@ -3527,7 +3549,11 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows ]; } - return ['error' => '', 'count' => (int) $count, 'rows' => $out]; + $result = ['error' => '', 'count' => (int) $count, 'rows' => $out]; + if ($history) { + $result['history_limit'] = SYSLOG_RULE_PREVIEW_HISTORY_ROWS; + } + return $result; } /** diff --git a/js/functions.js b/js/functions.js index 01becc08..45bd65c7 100644 --- a/js/functions.js +++ b/js/functions.js @@ -1785,6 +1785,10 @@ function testSyslogRule(formSelector, dialogSelector, title) { (result.count === 1 ? '1 matching message' : result.count + ' matching messages') ))); + if (result.history_limit) { + container.append($('

').text('No matches in incoming data. Results are from the most recent ' + result.history_limit + ' stored messages. Testing does not send alarms.')); + } + var table = $('' + '' + '
DateHostProgramMessage
'); diff --git a/tests/regression/rule_preview_history_database_test.php b/tests/regression/rule_preview_history_database_test.php new file mode 100644 index 00000000..b999420e --- /dev/null +++ b/tests/regression/rule_preview_history_database_test.php @@ -0,0 +1,73 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); +$syslogdb_default = 'syslog_preview_' . bin2hex(random_bytes(4)); +$syslog_incoming_config = ['textField'=>'message','timeField'=>'logtime','hostField'=>'host','programField'=>'program','facilityField'=>'facility_id','priorityField'=>'priority_id']; +$calls = []; +$checks = 0; +function cacti_sizeof($value) { return count($value); } +function __($text, ...$args) { return $text; } +function syslog_db_fetch_assoc_prepared($sql, $params = [], ...$args) { + if (!preg_match('/^SELECT\s/i', $sql)) { throw new RuntimeException('Preview attempted a write'); } + $GLOBALS['calls'][] = $sql; + if (!empty($GLOBALS['fail_history']) && str_contains($sql, 'ORDER BY seq DESC LIMIT 10000')) { return false; } + $q = $GLOBALS['pdo']->prepare($sql); $q->execute($params); return $q->fetchAll(); +} +function syslog_db_fetch_cell_prepared($sql, $params = [], ...$args) { $rows = syslog_db_fetch_assoc_prepared($sql, $params); return $rows === false ? false : array_values($rows[0] ?? [null])[0]; } +function syslog_db_table_exists($table, ...$args) { return true; } +require dirname(__DIR__, 2) . '/includes/functions.php'; +function check_preview($condition, $message) { $GLOBALS['checks']++; if (!$condition) { throw new RuntimeException($message); } } +function preview_rule(array $conditions): array { return ['type'=>'filter','message'=>json_encode(['version'=>1,'conditions'=>$conditions], JSON_THROW_ON_ERROR)]; } +$prefix = ['join'=>'AND','negative'=>false,'field'=>'message','operator'=>'begins','value'=>'Disconnect']; +try { + $pdo->exec("CREATE DATABASE `$syslogdb_default`"); + $pdo->exec("USE `$syslogdb_default`"); + $pdo->exec('CREATE TABLE syslog_incoming (seq BIGINT PRIMARY KEY, logtime DATETIME, message TEXT, host VARCHAR(255), program VARCHAR(255), facility_id INT, priority_id INT, raw_message TEXT, raw_time DATETIME)'); + $pdo->exec('CREATE TABLE syslog (seq BIGINT PRIMARY KEY, logtime DATETIME, message TEXT, host_id INT, program_id INT, facility_id INT, priority_id INT)'); + $pdo->exec('CREATE TABLE syslog_hosts (host_id INT PRIMARY KEY, host VARCHAR(255))'); + $pdo->exec('CREATE TABLE syslog_programs (program_id INT PRIMARY KEY, program VARCHAR(255))'); + $pdo->exec("INSERT INTO syslog_hosts VALUES (1,'router01')"); + $pdo->exec("INSERT INTO syslog_programs VALUES (1,'sshd')"); + $values = []; + for ($i=1; $i<=10001; $i++) { + $message = $i===1 ? 'Disconnect outside preview range' : ($i===10001 ? 'Disconnected from user sean 192.168.2.100 port 47934' : 'Other message'); + $values[] = "($i,'2026-10-26 12:53:48'," . $pdo->quote($message) . ',1,1,1,3)'; + } + $pdo->exec('INSERT INTO syslog VALUES ' . implode(',', $values)); + $rule = preview_rule([$prefix]); + $result = syslog_rule_preview($rule, 'alert', 100000); + check_preview($result['error']==='' && $result['count']===1, 'Empty incoming table falls back to stored prefix match'); + check_preview($result['rows'][0]['message']==='Disconnected from user sean 192.168.2.100 port 47934', 'Original example matches prefix'); + check_preview($result['history_limit']===10000 && $result['rows'][0]['seq']==='10001', 'History scan is bounded before filtering'); + check_preview($result['rows'][0]['host']==='router01' && $result['rows'][0]['program']==='sshd', 'Normalized history names are restored'); + $host = ['join'=>'AND','negative'=>false,'field'=>'host','operator'=>'begins','value'=>'router']; + $program = ['join'=>'AND','negative'=>false,'field'=>'program','operator'=>'=','value'=>'sshd']; + check_preview(syslog_rule_preview(preview_rule([$prefix,$host,$program]))['count']===1, 'Shared normalized compiler handles host and program conditions'); + $syslog_incoming_config['textField']='raw_message'; $syslog_incoming_config['timeField']='raw_time'; + check_preview(syslog_rule_preview($rule)['rows'][0]['message']===$result['rows'][0]['message'], 'History uses canonical fields with custom incoming mappings'); + $syslog_incoming_config['textField']='message'; $syslog_incoming_config['timeField']='logtime'; + $pdo->exec("INSERT INTO syslog_incoming(seq,logtime,message,host,program) VALUES (1,'2026-10-26 13:00:00','Disconnected incoming','router01','sshd')"); + $incoming = syslog_rule_preview($rule); + check_preview($incoming['count']===1 && !isset($incoming['history_limit']) && $incoming['rows'][0]['message']==='Disconnected incoming', 'Incoming matches keep existing priority and shape'); + $pdo->exec('DELETE FROM syslog_incoming'); + $calls=[]; + check_preview(syslog_rule_preview($rule,'removal')['count']===0 && count($calls)===2, 'Removal previews retain incoming-only scope'); + $calls=[]; + check_preview(syslog_rule_preview(['type'=>'sql','message'=>"message LIKE 'Disconnect%'"])['count']===0 && count($calls)===2, 'SQL previews are not rewritten for historical data'); + $invalid=preview_rule([array_replace($prefix,['field'=>'unknown'])]); + $calls=[]; + check_preview(syslog_rule_preview($invalid)['error']!=='' && !$calls, 'Invalid filter fails before any query'); + $fail_history=true; + check_preview(syslog_rule_preview($rule)['error']!=='', 'History read failures report an error instead of no matches'); + $fail_history=false; + $pdo->exec("INSERT INTO syslog VALUES (10002,'2026-10-26 13:00:00','Disconnect%_ literal',1,1,1,3)"); + check_preview(syslog_rule_preview(preview_rule([array_replace($prefix,['value'=>'Disconnect%_'])]))['count']===1, 'Historical LIKE wildcards remain literal'); + check_preview((int)$pdo->query('SELECT COUNT(*) FROM syslog')->fetchColumn()===10002, 'Preview preserves history'); + for ($i=10003; $i<=10017; $i++) { $pdo->exec("INSERT INTO syslog VALUES ($i,'2026-10-26 13:00:00','Disconnected sample',1,1,1,3)"); } + $bounded=syslog_rule_preview($rule,'alert',100000); + check_preview(count($bounded['rows'])===10 && $bounded['count']===17, 'Historical samples retain the ten-row response cap'); + echo "Rule preview history MariaDB checks passed: $checks\n"; +} finally { $pdo->exec("DROP DATABASE IF EXISTS `$syslogdb_default`"); } From af603640345b975ed0f187278eeb092733eed56e Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 26 Oct 2026 13:23:55 -0400 Subject: [PATCH 2/3] fix: update gettext template for historical previews Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- locales/po/cacti.pot | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 917946ea..bcee3fa1 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -208,6 +208,14 @@ msgstr "" msgid "The preview query could not be evaluated." msgstr "" +#: includes/functions.php +msgid "The historical preview filter could not be compiled." +msgstr "" + +#: includes/functions.php +msgid "The historical preview query could not be evaluated." +msgstr "" + #: includes/functions.php msgid "The preview sample could not be read." msgstr "" From 61a3c3965863f9627834a21ae4b3ae16b397337a Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 26 Oct 2026 13:40:21 -0400 Subject: [PATCH 3/3] test: cover historical preview fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Unit/RulePreviewHistoryFallbackTest.php | 166 ++++++++++++++++++ .../rule_preview_history_database_test.php | 2 +- 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/RulePreviewHistoryFallbackTest.php diff --git a/tests/Unit/RulePreviewHistoryFallbackTest.php b/tests/Unit/RulePreviewHistoryFallbackTest.php new file mode 100644 index 00000000..6ee8fae6 --- /dev/null +++ b/tests/Unit/RulePreviewHistoryFallbackTest.php @@ -0,0 +1,166 @@ + 'raw_message', + 'timeField' => 'raw_time', + 'hostField' => 'host', + 'programField' => 'program', + 'facilityField' => 'facility_id', + 'priorityField' => 'priority_id' + ]; + + $count_calls = 0; + $sample_sql = ''; + + test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog'); + test_override('syslog_db_fetch_cell_prepared', function () use (&$count_calls) { + return ++$count_calls === 1 ? 0 : '1'; + }); + test_override('syslog_db_fetch_assoc_prepared', function ($sql) use (&$sample_sql) { + $sample_sql = $sql; + + return [[ + 'seq' => 12, + 'logtime' => '2026-10-26 12:00:00', + 'host' => 'router01', + 'program' => 'sshd', + 'message' => 'Disconnected from a test session' + ]]; + }); + + $rule = [ + 'type' => 'filter', + 'message' => json_encode([ + 'version' => 1, + 'conditions' => [[ + 'join' => 'AND', + 'negative' => false, + 'field' => 'message', + 'operator' => 'begins', + 'value' => 'Disconnect' + ]] + ], JSON_THROW_ON_ERROR) + ]; + + $result = syslog_rule_preview($rule, 'alert'); + + expect($result['error'])->toBe('') + ->and($result['count'])->toBe(1) + ->and($result['history_limit'])->toBe(SYSLOG_RULE_PREVIEW_HISTORY_ROWS) + ->and($result['rows'][0])->toBe([ + 'seq' => '12', + 'logtime' => '2026-10-26 12:00:00', + 'host' => 'router01', + 'program' => 'sshd', + 'message' => 'Disconnected from a test session' + ]) + ->and($sample_sql)->toContain('ORDER BY seq DESC LIMIT 10000') + ->and($count_calls)->toBe(2); +}); + +it('returns a compile error if host normalization exceeds the condition limit', function () { + syslog_load_plugin_source('includes/functions.php'); + + $GLOBALS['syslogdb_default'] = 'syslog_preview_test'; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', + 'hostField' => 'host', + 'programField' => 'program', + 'facilityField' => 'facility_id', + 'priorityField' => 'priority_id' + ]; + + test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog'); + test_override('syslog_db_fetch_cell_prepared', static fn() => 0); + test_override('syslog_db_fetch_assoc_prepared', static function () { + return array_map(static fn($id) => ['id' => $id], range(1, 129)); + }); + + $rule = [ + 'type' => 'filter', + 'message' => json_encode([ + 'version' => 1, + 'conditions' => [[ + 'join' => 'AND', + 'negative' => false, + 'field' => 'host', + 'operator' => 'contains', + 'value' => 'router' + ]] + ], JSON_THROW_ON_ERROR) + ]; + + $result = syslog_rule_preview($rule, 'alert'); + + expect($result['error'])->toBe('The historical preview filter could not be compiled.') + ->and($result['count'])->toBe(0) + ->and($result['rows'])->toBe([]); +}); + +it('reports failure when the historical preview count cannot be evaluated', function () { + syslog_load_plugin_source('includes/functions.php'); + + $GLOBALS['syslogdb_default'] = 'syslog_preview_test'; + $GLOBALS['syslog_incoming_config'] = [ + 'textField' => 'message', + 'hostField' => 'host', + 'programField' => 'program', + 'facilityField' => 'facility_id', + 'priorityField' => 'priority_id' + ]; + + $count_calls = 0; + + test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog'); + test_override('syslog_db_fetch_cell_prepared', function () use (&$count_calls) { + return ++$count_calls === 1 ? 0 : false; + }); + + $rule = [ + 'type' => 'filter', + 'message' => json_encode([ + 'version' => 1, + 'conditions' => [[ + 'join' => 'AND', + 'negative' => false, + 'field' => 'message', + 'operator' => 'begins', + 'value' => 'Disconnect' + ]] + ], JSON_THROW_ON_ERROR) + ]; + + $result = syslog_rule_preview($rule, 'alert'); + + expect($result['error'])->toBe('The historical preview query could not be evaluated.') + ->and($result['count'])->toBe(0) + ->and($result['rows'])->toBe([]) + ->and($count_calls)->toBe(2); +}); diff --git a/tests/regression/rule_preview_history_database_test.php b/tests/regression/rule_preview_history_database_test.php index b999420e..ecb8c0bf 100644 --- a/tests/regression/rule_preview_history_database_test.php +++ b/tests/regression/rule_preview_history_database_test.php @@ -48,7 +48,7 @@ function preview_rule(array $conditions): array { return ['type'=>'filter','mess check_preview(syslog_rule_preview(preview_rule([$prefix,$host,$program]))['count']===1, 'Shared normalized compiler handles host and program conditions'); $syslog_incoming_config['textField']='raw_message'; $syslog_incoming_config['timeField']='raw_time'; check_preview(syslog_rule_preview($rule)['rows'][0]['message']===$result['rows'][0]['message'], 'History uses canonical fields with custom incoming mappings'); - $syslog_incoming_config['textField']='message'; $syslog_incoming_config['timeField']='logtime'; + unset($syslog_incoming_config['textField'], $syslog_incoming_config['timeField']); $pdo->exec("INSERT INTO syslog_incoming(seq,logtime,message,host,program) VALUES (1,'2026-10-26 13:00:00','Disconnected incoming','router01','sshd')"); $incoming = syslog_rule_preview($rule); check_preview($incoming['count']===1 && !isset($incoming['history_limit']) && $incoming['rows'][0]['message']==='Disconnected incoming', 'Incoming matches keep existing priority and shape');