diff --git a/includes/functions.php b/includes/functions.php
index 3c442f70..f54625d8 100644
--- a/includes/functions.php
+++ b/includes/functions.php
@@ -3346,10 +3346,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
@@ -3504,6 +3505,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)) {
@@ -3514,8 +3536,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 = [];
@@ -3529,7 +3551,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 = $('' +
'| Date | Host | Program | Message | ' +
'
');
diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot
index 8304bd27..9134fa21 100644
--- a/locales/po/cacti.pot
+++ b/locales/po/cacti.pot
@@ -217,6 +217,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 ""
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
new file mode 100644
index 00000000..ecb8c0bf
--- /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');
+ 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');
+ $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`"); }