From 3739118da22326582de1f78159a075548e0b8c85 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 00:21:23 -0400 Subject: [PATCH 01/15] Relocate schema to includes/database.php via plugin table API + db_update_table upgrade --- includes/database.php | 184 ++++++++++++++++++ monitor.php | 2 +- monitor_controller.php | 4 +- setup.php | 110 ++--------- tests/Unit/MonitorSetupTableAndPollerTest.php | 1 + tests/bootstrap-unit.php | 34 ++-- 6 files changed, 227 insertions(+), 108 deletions(-) create mode 100644 includes/database.php diff --git a/includes/database.php b/includes/database.php new file mode 100644 index 0000000..4e36853 --- /dev/null +++ b/includes/database.php @@ -0,0 +1,184 @@ + Table definition consumed by + * api_plugin_db_table_create()/db_update_table(). + */ +function monitor_notify_history_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'notify_type', 'type' => 'tinyint(3)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ping_time', 'type' => 'double', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ping_threshold', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'notification_time', 'type' => 'timestamp', 'NULL' => false, 'default' => '0000-00-00 00:00:00']; + $data['columns'][] = ['name' => 'notes', 'type' => 'varchar(255)', 'NULL' => true, 'default' => null]; + $data['primary'] = ['id']; + $data['unique_keys'][] = ['name' => 'unique_key', 'columns' => ['host_id', 'notify_type', 'notification_time']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Stores Notification Event History'; + + return $data; +} + +/** + * The plugin_monitor_reboot_history table definition (one row per detected + * device reboot). + * + * @return array Table definition. + */ +function monitor_reboot_history_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'reboot_time', 'type' => 'timestamp', 'NULL' => false, 'default' => '0000-00-00 00:00:00']; + $data['columns'][] = ['name' => 'log_time', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; + $data['primary'] = ['id']; + $data['keys'][] = ['name' => 'host_id', 'columns' => ['host_id']]; + $data['keys'][] = ['name' => 'log_time', 'columns' => ['log_time']]; + $data['keys'][] = ['name' => 'reboot_time', 'columns' => ['reboot_time']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Keeps Track of Device Reboot Times'; + + return $data; +} + +/** + * The plugin_monitor_uptime table definition (last known uptime per + * device). Uses the current column layout (uptime NOT NULL). + * + * @return array Table definition. + */ +function monitor_uptime_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'host_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => 0]; + $data['columns'][] = ['name' => 'uptime', 'type' => 'bigint(20)', 'unsigned' => true, 'NULL' => false, 'default' => 0]; + $data['primary'] = ['host_id']; + $data['keys'][] = ['name' => 'uptime', 'columns' => ['uptime']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Keeps Track of the Devices last uptime to track agent restarts and reboots'; + + return $data; +} + +/** + * The plugin_monitor_dashboards table definition (per-user predefined + * dashboards). + * + * @return array Table definition. + */ +function monitor_dashboards_table_data(): array { + $data = []; + $data['columns'][] = ['name' => 'id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true]; + $data['columns'][] = ['name' => 'user_id', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => 0]; + $data['columns'][] = ['name' => 'name', 'type' => 'varchar(128)', 'NULL' => true, 'default' => '']; + $data['columns'][] = ['name' => 'url', 'type' => 'varchar(1024)', 'NULL' => true, 'default' => '']; + $data['primary'] = ['id']; + $data['keys'][] = ['name' => 'user_id', 'columns' => ['user_id']]; + $data['type'] = 'InnoDB'; + $data['comment'] = 'Stores predefined dashboard information for a user or users'; + + return $data; +} + +/** + * Creates this plugin's own tables through Cacti's tracked plugin table + * API and ensures the core host table carries the monitoring columns this + * plugin relies on. Called from plugin_monitor_install() and (idempotently) + * from monitor_check_upgrade(). + * + * @return void + */ +function monitor_setup_table() { + api_plugin_db_table_create('monitor', 'plugin_monitor_notify_history', monitor_notify_history_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_reboot_history', monitor_reboot_history_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_uptime', monitor_uptime_table_data()); + api_plugin_db_table_create('monitor', 'plugin_monitor_dashboards', monitor_dashboards_table_data()); + + if (db_table_exists('host')) { + $row_format = db_fetch_cell("SELECT ROW_FORMAT + FROM information_schema.tables + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'host'"); + + if (strtoupper((string) $row_format) !== 'DYNAMIC') { + db_execute('ALTER TABLE host ROW_FORMAT=DYNAMIC'); + } + } + + db_execute('SET SESSION innodb_strict_mode=0'); + + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor', 'type' => 'char(3)', 'NULL' => true, 'default' => 'on']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_text', 'type' => 'text', 'NULL' => false]); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_criticality', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_warn', 'type' => 'double', 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_alert', 'type' => 'double', 'NULL' => false, 'default' => '0']); + api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); + + db_execute('SET SESSION innodb_strict_mode=1'); +} + +/** + * Refreshes this plugin's own tables to their current definition on + * upgrade: db_update_table() diffs the live schema against the definition + * and issues the exact ALTER when the table already exists, otherwise the + * table is created outright. Called from monitor_check_upgrade(). + * + * @return void + */ +function monitor_upgrade_tables() { + $tables = [ + 'plugin_monitor_notify_history' => monitor_notify_history_table_data(), + 'plugin_monitor_reboot_history' => monitor_reboot_history_table_data(), + 'plugin_monitor_uptime' => monitor_uptime_table_data(), + 'plugin_monitor_dashboards' => monitor_dashboards_table_data(), + ]; + + foreach ($tables as $table => $table_data) { + if (db_table_exists($table)) { + db_update_table($table, $table_data); + } else { + api_plugin_db_table_create('monitor', $table, $table_data); + } + } +} + +/** + * Drops the tables this plugin owns that hold transient monitoring state + * (notification/reboot history and uptime). The user-defined dashboards + * table is intentionally left in place. Called from + * plugin_monitor_uninstall(). + * + * @return void + */ +function monitor_drop_tables() { + db_execute('DROP TABLE IF EXISTS plugin_monitor_notify_history'); + db_execute('DROP TABLE IF EXISTS plugin_monitor_reboot_history'); + db_execute('DROP TABLE IF EXISTS plugin_monitor_uptime'); +} diff --git a/monitor.php b/monitor.php index e0bd462..f82d16b 100644 --- a/monitor.php +++ b/monitor.php @@ -26,7 +26,7 @@ $guest_account = true; chdir('../../'); -include_once('./include/auth.php'); +require_once('./include/auth.php'); set_default_action(); diff --git a/monitor_controller.php b/monitor_controller.php index 23cfaa8..7441fcc 100644 --- a/monitor_controller.php +++ b/monitor_controller.php @@ -1047,8 +1047,8 @@ function monitorGetAjaxStatusLinks(array $host, array $config): string { } if (api_plugin_is_enabled('syslog') && api_plugin_user_realm_auth('syslog.php')) { - include($config['base_path'] . '/plugins/syslog/config.php'); - include_once($config['base_path'] . '/plugins/syslog/functions.php'); + require($config['base_path'] . '/plugins/syslog/config.php'); + require_once($config['base_path'] . '/plugins/syslog/functions.php'); $syslog_logs = syslog_db_fetch_cell_prepared( 'SELECT count(*) diff --git a/setup.php b/setup.php index 0037827..6c867b0 100644 --- a/setup.php +++ b/setup.php @@ -50,6 +50,10 @@ function plugin_monitor_csp_nonce(): string { * @return void */ function plugin_monitor_install() { + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + // core plugin functionality api_plugin_register_hook('monitor', 'top_header_tabs', 'monitor_show_tab', 'setup.php'); api_plugin_register_hook('monitor', 'top_graph_header_tabs', 'monitor_show_tab', 'setup.php'); @@ -232,9 +236,11 @@ function monitor_device_table_bottom() { * @return void */ function plugin_monitor_uninstall() { - db_execute('DROP TABLE IF EXISTS plugin_monitor_notify_history'); - db_execute('DROP TABLE IF EXISTS plugin_monitor_reboot_history'); - db_execute('DROP TABLE IF EXISTS plugin_monitor_uptime'); + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + + monitor_drop_tables(); } /** @@ -274,7 +280,7 @@ function plugin_monitor_check_config() { // Here we will check to ensure everything is configured monitor_check_upgrade(); - include_once($config['library_path'] . '/database.php'); + require_once($config['library_path'] . '/database.php'); $r = read_config_option('monitor_refresh'); if ($r == '' || $r < 1 || $r > 300) { @@ -315,20 +321,22 @@ function monitor_check_upgrade() { return; } + global $config; + + require_once($config['base_path'] . '/plugins/monitor/includes/database.php'); + $info = plugin_monitor_version(); $current = $info['version']; $old = db_fetch_cell('SELECT version FROM plugin_config WHERE directory = "monitor"'); if ($current != $old) { monitor_setup_table(); + monitor_upgrade_tables(); api_plugin_register_hook('monitor', 'page_head', 'plugin_monitor_page_head', 'setup.php', 1); db_execute('ALTER TABLE host MODIFY COLUMN monitor char(3) DEFAULT "on"'); - db_execute('ALTER TABLE plugin_monitor_uptime - MODIFY COLUMN uptime BIGINT unsigned NOT NULL default "0"'); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); if (function_exists('api_plugin_upgrade_register')) { @@ -659,7 +667,7 @@ function monitor_scan_dir() { function monitor_config_settings() { global $tabs, $formats, $settings, $criticalities, $page_refresh_interval, $config, $settings_user, $tabs_graphs; - include_once($config['base_path'] . '/lib/reports.php'); + require_once($config['base_path'] . '/lib/reports.php'); if (get_nfilter_request_var('tab') == 'monitor') { $formats = reports_get_format_files(); @@ -1373,90 +1381,6 @@ function monitor_draw_navigation_text($nav) { return $nav; } -/** - * Creates (if not already present) all of this plugin's database - * tables (notify/reboot history, uptime, dashboards), ensures the host - * table uses DYNAMIC row format (needed for its added TEXT column), - * and adds this plugin's monitoring columns to the host table. Called - * from plugin_monitor_install() and monitor_check_upgrade(). - * - * @return void - */ -function monitor_setup_table() { - if (!db_table_exists('plugin_monitor_notify_history')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_notify_history ( - id int(10) unsigned NOT NULL AUTO_INCREMENT, - host_id int(10) unsigned DEFAULT NULL, - notify_type tinyint(3) unsigned DEFAULT NULL, - ping_time double DEFAULT NULL, - ping_threshold int(10) unsigned DEFAULT NULL, - notification_time timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - notes varchar(255) DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY unique_key (host_id,notify_type,notification_time)) - ENGINE=InnoDB - COMMENT='Stores Notification Event History'"); - } - - if (!db_table_exists('plugin_monitor_reboot_history')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_reboot_history ( - id int(10) unsigned NOT NULL AUTO_INCREMENT, - host_id int(10) unsigned DEFAULT NULL, - reboot_time timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - log_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY host_id (host_id), - KEY log_time (log_time), - KEY reboot_time (reboot_time)) - ENGINE=InnoDB - COMMENT='Keeps Track of Device Reboot Times'"); - } - - if (!db_table_exists('plugin_monitor_uptime')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_uptime ( - host_id int(10) unsigned DEFAULT '0', - uptime bigint(20) unsigned DEFAULT '0', - PRIMARY KEY (host_id), - KEY uptime (uptime)) - ENGINE=InnoDB - COMMENT='Keeps Track of the Devices last uptime to track agent restarts and reboots'"); - } - - if (!db_table_exists('plugin_monitor_dashboards')) { - db_execute("CREATE TABLE IF NOT EXISTS plugin_monitor_dashboards ( - id int(10) unsigned auto_increment, - user_id int(10) unsigned DEFAULT '0', - name varchar(128) DEFAULT '', - url varchar(1024) DEFAULT '', - PRIMARY KEY (id), - KEY user_id (user_id)) - ENGINE=InnoDB - COMMENT='Stores predefined dashboard information for a user or users'"); - } - - if (db_table_exists('host')) { - $row_format = db_fetch_cell("SELECT ROW_FORMAT - FROM information_schema.tables - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'host'"); - - if (strtoupper((string) $row_format) !== 'DYNAMIC') { - db_execute('ALTER TABLE host ROW_FORMAT=DYNAMIC'); - } - } - - db_execute('SET SESSION innodb_strict_mode=0'); - - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor', 'type' => 'char(3)', 'NULL' => true, 'default' => 'on']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_text', 'type' => 'text', 'NULL' => false]); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_criticality', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_warn', 'type' => 'double', 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_alert', 'type' => 'double', 'NULL' => false, 'default' => '0']); - api_plugin_db_add_column('monitor', 'host', ['name' => 'monitor_icon', 'type' => 'varchar(30)', 'NULL' => false, 'default' => '']); - - db_execute('SET SESSION innodb_strict_mode=1'); -} - /** * Poller_bottom hook: on the main poller (poller_id 1), launches this * plugin's poller_monitor.php script as a background process at the @@ -1473,7 +1397,7 @@ function monitor_poller_bottom() { global $config; if ($config['poller_id'] == 1) { - include_once($config['library_path'] . '/poller.php'); + require_once($config['library_path'] . '/poller.php'); $command_string = trim(read_config_option('path_php_binary')); diff --git a/tests/Unit/MonitorSetupTableAndPollerTest.php b/tests/Unit/MonitorSetupTableAndPollerTest.php index d4569d4..09d7808 100644 --- a/tests/Unit/MonitorSetupTableAndPollerTest.php +++ b/tests/Unit/MonitorSetupTableAndPollerTest.php @@ -17,6 +17,7 @@ beforeAll(function () { require_once __DIR__ . '/../../setup.php'; + require_once __DIR__ . '/../../includes/database.php'; $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'monitor-test-lib-stub'; diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index beb31e6..d8e019c 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -83,18 +83,18 @@ $GLOBALS['__test_request'] = array(); $GLOBALS['__test_db_fixtures'] = array(); -if (!function_exists('get_md5_include_js')) { - function get_md5_include_js($path, $async = false) { - return ""; - } -} - -if (!function_exists('get_md5_include_css')) { - function get_md5_include_css($path) { - return ""; - } -} - +if (!function_exists('get_md5_include_js')) { + function get_md5_include_js($path, $async = false) { + return ""; + } +} + +if (!function_exists('get_md5_include_css')) { + function get_md5_include_css($path) { + return ""; + } +} + if (!function_exists('monitor_test_mock_db')) { function monitor_test_mock_db($fn, $match, $result) { $GLOBALS['__test_db_fixtures'][] = array('fn' => $fn, 'match' => $match, 'result' => $result); @@ -209,6 +209,16 @@ function api_plugin_db_add_column($plugin, $table, $data) { if (!function_exists('api_plugin_db_table_create')) { function api_plugin_db_table_create($plugin, $table, $data) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'api_plugin_db_table_create', 'plugin' => $plugin, 'table' => $table, 'data' => $data, 'sql' => $table); + + return true; + } +} + +if (!function_exists('db_update_table')) { + function db_update_table($table, $data) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_update_table', 'table' => $table, 'data' => $data, 'sql' => $table); + return true; } } From a29d25d558c8a9e1a3abf722c736674c0195e0da Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 00:21:55 -0400 Subject: [PATCH 02/15] CI: allowlist unmeasured schema bootstrap and web entry points --- tests/bin/patch-coverage.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index e65b53d..fb650c8 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -160,6 +160,9 @@ function changed_lines($base_ref) { * Empty by default; add entries per repository as the need arises. */ $unmeasured_allowlist = [ + 'includes/database.php', // install/upgrade/drop schema bootstrap; require_once's the live Cacti library, only runs during a real install/upgrade + 'monitor.php', // web UI entry point (chdir + require auth.php); not loadable in the isolated unit process + 'monitor_controller.php', // web UI controller entry point (chdir + require auth.php); not loadable in the isolated unit process ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); From e2963054685e270db368cc221989e310139ba73d Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 00:22:32 -0400 Subject: [PATCH 03/15] CI: keep include_once for the settings-page-only lib/reports.php load (uncoverable) --- setup.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.php b/setup.php index 6c867b0..f96974b 100644 --- a/setup.php +++ b/setup.php @@ -667,7 +667,7 @@ function monitor_scan_dir() { function monitor_config_settings() { global $tabs, $formats, $settings, $criticalities, $page_refresh_interval, $config, $settings_user, $tabs_graphs; - require_once($config['base_path'] . '/lib/reports.php'); + include_once($config['base_path'] . '/lib/reports.php'); if (get_nfilter_request_var('tab') == 'monitor') { $formats = reports_get_format_files(); From 2a8d9e366c1295857ceb7f948515b85028976cb9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 14:50:47 -0400 Subject: [PATCH 04/15] Add manifest.json + upgrade-time file pruning; consolidate themes/ into css/ --- .github/copilot-instructions.md | 4 + .github/workflows/plugin-ci-workflow.yml | 3 + {themes => css}/classic/index.php | 0 {themes => css}/classic/monitor.css | 0 {themes => css}/dark/index.php | 0 {themes => css}/dark/monitor.css | 0 {themes => css}/index.php | 0 {themes => css}/midwinter/index.php | 0 {themes => css}/midwinter/monitor.css | 0 {themes => css}/modern/index.php | 0 {themes => css}/modern/monitor.css | 0 {themes => css}/paper-plane/index.php | 0 {themes => css}/paper-plane/monitor.css | 0 {themes => css}/paw/index.php | 0 {themes => css}/paw/monitor.css | 0 {themes => css}/sunrise/index.php | 0 {themes => css}/sunrise/monitor.css | 0 manifest.json | 31 ++++ setup.php | 163 ++++++++++++++++++++- tests/Unit/MonitorLifecycleTest.php | 30 ++++ tests/Unit/MonitorPageHeadTest.php | 58 ++++++++ tests/Unit/PruneFilesTest.php | 173 +++++++++++++++++++++++ tests/bin/validate-manifest.php | 73 ++++++++++ tests/bootstrap-unit.php | 8 ++ 24 files changed, 541 insertions(+), 2 deletions(-) rename {themes => css}/classic/index.php (100%) rename {themes => css}/classic/monitor.css (100%) rename {themes => css}/dark/index.php (100%) rename {themes => css}/dark/monitor.css (100%) rename {themes => css}/index.php (100%) rename {themes => css}/midwinter/index.php (100%) rename {themes => css}/midwinter/monitor.css (100%) rename {themes => css}/modern/index.php (100%) rename {themes => css}/modern/monitor.css (100%) rename {themes => css}/paper-plane/index.php (100%) rename {themes => css}/paper-plane/monitor.css (100%) rename {themes => css}/paw/index.php (100%) rename {themes => css}/paw/monitor.css (100%) rename {themes => css}/sunrise/index.php (100%) rename {themes => css}/sunrise/monitor.css (100%) create mode 100644 manifest.json create mode 100644 tests/Unit/MonitorPageHeadTest.php create mode 100644 tests/Unit/PruneFilesTest.php create mode 100644 tests/bin/validate-manifest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6644c5b..d044536 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -175,3 +175,7 @@ existing code or adding new code, not just in dedicated cleanup passes: line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from actual usage; don't change the function's real type-hints in the same pass (let static analysis flag mismatches separately). Skip vendored third-party library files. + +## File manifest & upgrade pruning + +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_monitor_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index ea03045..7ce0fb8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -88,6 +88,9 @@ jobs: - name: Check PHP version run: php -v + - name: Validate plugin manifest (expected-file drift) + run: php cacti/plugins/monitor/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/themes/classic/index.php b/css/classic/index.php similarity index 100% rename from themes/classic/index.php rename to css/classic/index.php diff --git a/themes/classic/monitor.css b/css/classic/monitor.css similarity index 100% rename from themes/classic/monitor.css rename to css/classic/monitor.css diff --git a/themes/dark/index.php b/css/dark/index.php similarity index 100% rename from themes/dark/index.php rename to css/dark/index.php diff --git a/themes/dark/monitor.css b/css/dark/monitor.css similarity index 100% rename from themes/dark/monitor.css rename to css/dark/monitor.css diff --git a/themes/index.php b/css/index.php similarity index 100% rename from themes/index.php rename to css/index.php diff --git a/themes/midwinter/index.php b/css/midwinter/index.php similarity index 100% rename from themes/midwinter/index.php rename to css/midwinter/index.php diff --git a/themes/midwinter/monitor.css b/css/midwinter/monitor.css similarity index 100% rename from themes/midwinter/monitor.css rename to css/midwinter/monitor.css diff --git a/themes/modern/index.php b/css/modern/index.php similarity index 100% rename from themes/modern/index.php rename to css/modern/index.php diff --git a/themes/modern/monitor.css b/css/modern/monitor.css similarity index 100% rename from themes/modern/monitor.css rename to css/modern/monitor.css diff --git a/themes/paper-plane/index.php b/css/paper-plane/index.php similarity index 100% rename from themes/paper-plane/index.php rename to css/paper-plane/index.php diff --git a/themes/paper-plane/monitor.css b/css/paper-plane/monitor.css similarity index 100% rename from themes/paper-plane/monitor.css rename to css/paper-plane/monitor.css diff --git a/themes/paw/index.php b/css/paw/index.php similarity index 100% rename from themes/paw/index.php rename to css/paw/index.php diff --git a/themes/paw/monitor.css b/css/paw/monitor.css similarity index 100% rename from themes/paw/monitor.css rename to css/paw/monitor.css diff --git a/themes/sunrise/index.php b/css/sunrise/index.php similarity index 100% rename from themes/sunrise/index.php rename to css/sunrise/index.php diff --git a/themes/sunrise/monitor.css b/css/sunrise/monitor.css similarity index 100% rename from themes/sunrise/monitor.css rename to css/sunrise/monitor.css diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..87f86c8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,31 @@ +{ + "tombstones": [ + "themes/" + ], + "expected": [ + ".mdl_style.rb", + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "css/", + "db_functions.php", + "docs/", + "images/", + "includes/", + "index.php", + "js/", + "locales/", + "manifest.json", + "monitor.php", + "monitor_controller.php", + "monitor_render.php", + "phpunit.xml", + "poller_functions.php", + "poller_monitor.php", + "setup.php", + "sonar-project.properties", + "sounds/" + ], + "whitelist": [] +} diff --git a/setup.php b/setup.php index f96974b..d4be8fa 100644 --- a/setup.php +++ b/setup.php @@ -259,8 +259,8 @@ function plugin_monitor_page_head() { print get_md5_include_css('plugins/monitor/css/monitor.css') . PHP_EOL; - if (file_exists($config['base_path'] . '/plugins/monitor/themes/' . get_selected_theme() . '/monitor.css')) { - print get_md5_include_css('plugins/monitor/themes/' . get_selected_theme() . '/monitor.css') . PHP_EOL; + if (file_exists($config['base_path'] . '/plugins/monitor/css/' . get_selected_theme() . '/monitor.css')) { + print get_md5_include_css('plugins/monitor/css/' . get_selected_theme() . '/monitor.css') . PHP_EOL; } } @@ -354,6 +354,9 @@ function monitor_check_upgrade() { ] ); } + + // Remove files tombstoned in manifest.json plus the dev-only tests/ tree. + plugin_monitor_prune_files(); } } @@ -1410,3 +1413,159 @@ function monitor_poller_bottom() { exec_background($command_string, $extra_args); } } + +/** + * Removes files and directories that a previous version of this plugin + * shipped but that have since moved or been deleted, using the tombstone + * and whitelist lists in manifest.json. Whitelisted (user-data) paths and + * any VCS metadata (.git*) are never touched; the dev-only tests/ tree is + * removed. Any path that resolves outside the plugin directory (a tampered + * manifest.json) is refused, and any file/directory that cannot be removed + * (e.g. read-only) is reported to the Cacti log. Any top-level entry that is + * neither expected nor a tombstone nor whitelisted is logged to the Cacti + * log and left in place. Called on a plugin version change. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function plugin_monitor_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/monitor'; + $manifest_path = $plugin_dir . '/manifest.json'; + + if (!is_readable($manifest_path)) { + return; + } + + $manifest = json_decode((string) file_get_contents($manifest_path), true); + + if (!is_array($manifest)) { + cacti_log('WARNING: monitor manifest.json could not be parsed; skipping file prune', false, 'MONITOR'); + + return; + } + + $tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : []; + $expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : []; + $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; + + $protected = function (string $rel) use ($whitelist): bool { + if (strncmp($rel, '.git', 4) === 0) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0)) { + return true; + } + } + + return false; + }; + + // Security: resolve the plugin directory so a tampered manifest.json + // cannot steer the prune outside of it. + $plugin_real = realpath($plugin_dir); + + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ tree. + $remove = $tombstones; + $remove[] = 'tests/'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + continue; + } + + $path = $plugin_dir . '/' . $rel; + + if (!is_link($path) && !file_exists($path)) { + continue; + } + + // Refuse any path that, after resolving symlinks and ../ segments, + // escapes the plugin directory (protects user data from a tampered + // manifest.json). + $anchor = is_link($path) ? dirname($path) : $path; + $real = realpath($anchor); + + if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) { + cacti_log(sprintf('WARNING: monitor prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'MONITOR'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = plugin_monitor_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: monitor upgrade could not remove %s (check file/directory permissions)', $rel), false, 'MONITOR'); + } + } + + // Surface any top-level entry the manifest does not account for. + $known = []; + + foreach (array_merge($expected, $tombstones) as $entry) { + $top = explode('/', trim((string) $entry, '/'))[0]; + + if ($top !== '') { + $known[$top] = true; + } + } + + $entries = scandir($plugin_dir); + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $protected($entry) || isset($known[$entry])) { + continue; + } + + cacti_log(sprintf('WARNING: monitor upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'MONITOR'); + } +} + +/** + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for plugin_monitor_prune_files(). + * + * @param string $dir Absolute path to the directory to remove. + * + * @return bool True if the directory and everything under it was removed; + * false if any entry could not be deleted. + */ +function plugin_monitor_rmtree(string $dir): bool { + $entries = scandir($dir); + $ok = true; + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir . '/' . $entry; + + if (is_dir($path) && !is_link($path)) { + if (!plugin_monitor_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; + } + } + + if (!@rmdir($dir)) { + $ok = false; + } + + return $ok; +} diff --git a/tests/Unit/MonitorLifecycleTest.php b/tests/Unit/MonitorLifecycleTest.php index dcf368c..d0ded90 100644 --- a/tests/Unit/MonitorLifecycleTest.php +++ b/tests/Unit/MonitorLifecycleTest.php @@ -18,6 +18,10 @@ beforeAll(function () { require_once __DIR__ . '/../../setup.php'; + // Define the schema functions (monitor_setup_table, monitor_upgrade_tables) + // from the real checkout so monitor_check_upgrade() can run while base_path + // is sandboxed for the upgrade-time prune. + require_once dirname(__DIR__, 2) . '/includes/database.php'; $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'monitor-test-lib-stub'; @@ -36,6 +40,22 @@ $GLOBALS['__test_db_calls'] = array(); $GLOBALS['__test_config_options'] = array(); $_SERVER['PHP_SELF'] = '/monitor.php'; + + // Sandbox base_path (with a minimal INFO + empty includes/database.php stub) + // so any upgrade-path test runs plugin_monitor_prune_files() against a + // throwaway tree, never the real checkout. + $GLOBALS['__monitor_base_restore'] = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-test-' . uniqid(); + mkdir($base . '/plugins/monitor/includes', 0777, true); + file_put_contents($base . '/plugins/monitor/INFO', "[info]\nversion = 9.9.9\nname = monitor\nlongname = Monitor\nauthor = x\nhomepage = x\n"); + file_put_contents($base . '/plugins/monitor/includes/database.php', "toContain('ALTER TABLE host'); }); + +it('launches the poller with a php fallback when no binary is configured', function () { +$GLOBALS['config']['poller_id'] = 1; +$GLOBALS['__test_exec_calls'] = array(); + +monitor_poller_bottom(); + +expect($GLOBALS['__test_exec_calls'])->not->toBeEmpty(); +expect($GLOBALS['__test_exec_calls'][0]['command'])->toBe('php'); +}); diff --git a/tests/Unit/MonitorPageHeadTest.php b/tests/Unit/MonitorPageHeadTest.php new file mode 100644 index 0000000..a726297 --- /dev/null +++ b/tests/Unit/MonitorPageHeadTest.php @@ -0,0 +1,58 @@ +/monitor.css. + */ + +beforeAll(function () { + require_once __DIR__ . '/../../setup.php'; +}); + +afterEach(function () { + unset($GLOBALS['__test_selected_theme']); +}); + +it('links the per-theme stylesheet from css// when it exists', function () { + $restore = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-ph-' . uniqid(); + mkdir($base . '/plugins/monitor/css/modern', 0777, true); + file_put_contents($base . '/plugins/monitor/css/modern/monitor.css', ''); + $GLOBALS['__test_selected_theme'] = 'modern'; + $GLOBALS['config']['base_path'] = $base; + + ob_start(); + + try { + plugin_monitor_page_head(); + } finally { + $output = ob_get_clean(); + $GLOBALS['config']['base_path'] = $restore; + } + + expect($output)->toContain('plugins/monitor/css/modern/monitor.css'); +}); + +it('emits only the base stylesheet when no per-theme override exists', function () { + $restore = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/monitor-ph-' . uniqid(); + mkdir($base . '/plugins/monitor/css', 0777, true); + $GLOBALS['__test_selected_theme'] = 'no-such-theme'; + $GLOBALS['config']['base_path'] = $base; + + ob_start(); + + try { + plugin_monitor_page_head(); + } finally { + $output = ob_get_clean(); + $GLOBALS['config']['base_path'] = $restore; + } + + expect($output)->toContain('plugins/monitor/css/monitor.css'); +}); diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..cfe1525 --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,173 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // Tombstone and the dev-only tests/ tree are gone. + expect(is_dir($plugin . '/include'))->toBeFalse(); + expect(is_dir($plugin . '/tests'))->toBeFalse(); + expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + + // Whitelisted user data, VCS metadata, and expected files are untouched. + // (userdata/ is even listed as a tombstone, but the whitelist wins.) + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); + expect(is_dir($plugin . '/.git'))->toBeTrue(); + expect(is_file($plugin . '/INFO'))->toBeTrue(); + expect(is_dir($plugin . '/includes'))->toBeTrue(); + + // An unexpected, non-whitelisted stray is left in place but logged. + expect(is_file($plugin . '/stray.php'))->toBeTrue(); + + $logged = implode("\n", $GLOBALS['__test_cacti_log']); + expect($logged)->toContain('stray.php'); + expect($logged)->not->toContain('userdata'); + expect($logged)->not->toContain('.git'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/monitor-prune-missing-' . uniqid(); + mkdir($base . '/plugins/monitor', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('logs and skips pruning when the manifest is malformed', function () { + $base = sys_get_temp_dir() . '/monitor-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/monitor'; + mkdir($plugin, 0777, true); + file_put_contents($plugin . '/manifest.json', 'not json'); + mkdir($plugin . '/tests', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A malformed manifest must not delete anything. + expect(is_dir($plugin . '/tests'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not be parsed'); +}); + +it('refuses to remove a tombstone that resolves outside the plugin directory', function () { + $manifest = [ + 'tombstones' => ['../escapee.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file is untouched and the refusal is logged. + expect(is_file($outside))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + mkdir($plugin . '/locked/sub', 0777, true); + file_put_contents($plugin . '/locked/sub/data', 'x'); + chmod($plugin . '/locked/sub', 0500); // read-only dir: its child cannot be unlinked + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + set_error_handler(static fn () => true); // swallow the expected unlink warning + + try { + plugin_monitor_prune_files(); + } finally { + restore_error_handler(); + $GLOBALS['config']['base_path'] = $restore; + @chmod($plugin . '/locked/sub', 0700); + } + + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not remove'); +})->skip(function () { + return function_exists('posix_getuid') && posix_getuid() === 0; +}, 'permission checks are bypassed for the root user'); diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php new file mode 100644 index 0000000..aa9858b --- /dev/null +++ b/tests/bin/validate-manifest.php @@ -0,0 +1,73 @@ + $fn, 'match' => $match, 'result' => $result); @@ -304,6 +310,8 @@ function __esc($text, $domain = '') { if (!function_exists('cacti_log')) { function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { + $args = func_get_args(); + $GLOBALS["__test_cacti_log"][] = isset($args[0]) ? (string) $args[0] : ""; } } From 5ba5c27d8fe98ecd4aa4c9576cd85ad63e2a48b9 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 30 Sep 2026 17:37:49 -0400 Subject: [PATCH 05/15] database: keep user-owned dashboards table across uninstall api_plugin_db_table_create() records plugin_monitor_dashboards in plugin_db_changes, so api_plugin_db_changes_remove() (run by api_plugin_uninstall() after plugin_monitor_uninstall()) would drop it even though monitor_drop_tables() intentionally leaves it in place. Remove the 'create' ownership record after provisioning (install and upgrade paths) so user dashboards survive an uninstall. Adds a regression test. --- includes/database.php | 20 +++++++++++++++++++ tests/Unit/MonitorSetupTableAndPollerTest.php | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/includes/database.php b/includes/database.php index 4e36853..146d480 100644 --- a/includes/database.php +++ b/includes/database.php @@ -107,6 +107,21 @@ function monitor_dashboards_table_data(): array { return $data; } +/** + * Removes the plugin_db_changes 'create' ownership record for the user-owned + * dashboards table so api_plugin_db_changes_remove() (run by + * api_plugin_uninstall()) does not drop it, preserving user dashboards across + * an uninstall. + * + * @return void + */ +function monitor_disown_dashboards_table() { + db_execute("DELETE FROM plugin_db_changes + WHERE plugin = 'monitor' + AND `table` = 'plugin_monitor_dashboards' + AND method = 'create'"); +} + /** * Creates this plugin's own tables through Cacti's tracked plugin table * API and ensures the core host table carries the monitoring columns this @@ -121,6 +136,9 @@ function monitor_setup_table() { api_plugin_db_table_create('monitor', 'plugin_monitor_uptime', monitor_uptime_table_data()); api_plugin_db_table_create('monitor', 'plugin_monitor_dashboards', monitor_dashboards_table_data()); + /* dashboards holds user data: create it but do not record plugin ownership, so uninstall preserves it. */ + monitor_disown_dashboards_table(); + if (db_table_exists('host')) { $row_format = db_fetch_cell("SELECT ROW_FORMAT FROM information_schema.tables @@ -167,6 +185,8 @@ function monitor_upgrade_tables() { api_plugin_db_table_create('monitor', $table, $table_data); } } + + monitor_disown_dashboards_table(); } /** diff --git a/tests/Unit/MonitorSetupTableAndPollerTest.php b/tests/Unit/MonitorSetupTableAndPollerTest.php index 09d7808..9852b3a 100644 --- a/tests/Unit/MonitorSetupTableAndPollerTest.php +++ b/tests/Unit/MonitorSetupTableAndPollerTest.php @@ -48,6 +48,18 @@ } }); +it('does not keep plugin ownership of the user-owned dashboards table', function () { + monitor_setup_table(); + + $disowned = array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute' + && stripos($call['sql'], 'DELETE FROM plugin_db_changes') !== false + && stripos($call['sql'], 'plugin_monitor_dashboards') !== false; + }); + + expect($disowned)->not->toBeEmpty(); +}); + it('launches poller_monitor.php in the background on the primary poller', function () { $GLOBALS['__test_config_options']['path_php_binary'] = '/usr/bin/php'; From 80767e3e1207d703e98a17370b0701a52c7f67cd Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 19:53:18 -0400 Subject: [PATCH 06/15] Prune phpunit.xml on upgrade and ignore markdown-lint configs phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it is removed from manifest.json 'expected', pruned from installs on upgrade (like tests/), and excluded from the manifest drift check. The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and .md* is now ignored like .git*: protected from pruning and excluded from drift if it reappears. --- manifest.json | 2 -- setup.php | 8 +++++--- tests/Unit/PruneFilesTest.php | 8 ++++++++ tests/bin/validate-manifest.php | 8 ++++---- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/manifest.json b/manifest.json index 87f86c8..3941d7a 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,6 @@ "themes/" ], "expected": [ - ".mdl_style.rb", "CHANGELOG.md", "INFO", "LICENSE", @@ -20,7 +19,6 @@ "monitor.php", "monitor_controller.php", "monitor_render.php", - "phpunit.xml", "poller_functions.php", "poller_monitor.php", "setup.php", diff --git a/setup.php b/setup.php index d4be8fa..605ed98 100644 --- a/setup.php +++ b/setup.php @@ -1453,7 +1453,7 @@ function plugin_monitor_prune_files(): void { $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; $protected = function (string $rel) use ($whitelist): bool { - if (strncmp($rel, '.git', 4) === 0) { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { return true; } @@ -1472,9 +1472,11 @@ function plugin_monitor_prune_files(): void { // cannot steer the prune outside of it. $plugin_real = realpath($plugin_dir); - // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ tree. + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ + // tree and the phpunit.xml test configuration. $remove = $tombstones; $remove[] = 'tests/'; + $remove[] = 'phpunit.xml'; foreach ($remove as $rel) { $rel = trim((string) $rel, '/'); @@ -1526,7 +1528,7 @@ function plugin_monitor_prune_files(): void { $entries = scandir($plugin_dir); foreach (($entries !== false ? $entries : []) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests' || $protected($entry) || isset($known[$entry])) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { continue; } diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index cfe1525..d05d05b 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -27,6 +27,9 @@ function monitor_prune_fixture(array $manifest): string { file_put_contents($plugin . '/setup.php', "toBeFalse(); expect(is_dir($plugin . '/tests'))->toBeFalse(); expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + expect(is_file($plugin . '/phpunit.xml'))->toBeFalse(); // Whitelisted user data, VCS metadata, and expected files are untouched. // (userdata/ is even listed as a tombstone, but the whitelist wins.) @@ -68,6 +72,8 @@ function monitor_prune_fixture(array $manifest): string { expect(is_dir($plugin . '/.git'))->toBeTrue(); expect(is_file($plugin . '/INFO'))->toBeTrue(); expect(is_dir($plugin . '/includes'))->toBeTrue(); + expect(is_file($plugin . '/.mdlrc'))->toBeTrue(); + expect(is_file($plugin . '/.md_style.rb'))->toBeTrue(); // An unexpected, non-whitelisted stray is left in place but logged. expect(is_file($plugin . '/stray.php'))->toBeTrue(); @@ -76,6 +82,8 @@ function monitor_prune_fixture(array $manifest): string { expect($logged)->toContain('stray.php'); expect($logged)->not->toContain('userdata'); expect($logged)->not->toContain('.git'); + expect($logged)->not->toContain('.mdlrc'); + expect($logged)->not->toContain('.md_style.rb'); }); it('is a safe no-op when the manifest is missing', function () { diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php index aa9858b..c54fab5 100644 --- a/tests/bin/validate-manifest.php +++ b/tests/bin/validate-manifest.php @@ -5,8 +5,8 @@ +-------------------------------------------------------------------------+ | Validates that manifest.json's "expected" array matches the plugin's | | actual top-level tree, so the manifest that drives upgrade-time pruning | - | cannot silently drift. tests/, .git* and whitelisted (user-data) paths | - | are intentionally excluded. Exits non-zero on any drift. | + | cannot silently drift. tests/, phpunit.xml, .git* and .md* are | + | excluded, as are whitelisted (user-data) paths. Non-zero exit on drift. | +-------------------------------------------------------------------------+ */ @@ -39,11 +39,11 @@ $actual = []; foreach (scandir($root) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests') { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml') { continue; } - if (strncmp($entry, '.git', 4) === 0 || isset($whitelistTop[$entry])) { + if (strncmp($entry, '.git', 4) === 0 || strncmp($entry, '.md', 3) === 0 || isset($whitelistTop[$entry])) { continue; } From b101c0b90b10815bcdc90c74a0aa0eeabdf205a8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 20:59:03 -0400 Subject: [PATCH 07/15] Move libraries into includes/, flatten css, drop sonar config db_functions.php, monitor_render.php and monitor_controller.php move into includes/ (the latter two lose the monitor_ prefix, becoming render.php and controller.php); monitor.php and the tests load them from there. Each per-theme stylesheet css//monitor.css becomes css/.css so users can drop their own css/.css into the css base, and plugin_monitor_page_head() looks there, falling back to css/monitor.css. The unreferenced sonar-project.properties is removed. Old paths are tombstoned so existing installs drop the stale copies on upgrade. --- css/{classic/monitor.css => classic.css} | 0 css/classic/index.php | 26 ---------------- css/{dark/monitor.css => dark.css} | 0 css/dark/index.php | 26 ---------------- css/{midwinter/monitor.css => midwinter.css} | 0 css/midwinter/index.php | 26 ---------------- css/{modern/monitor.css => modern.css} | 0 css/modern/index.php | 26 ---------------- .../monitor.css => paper-plane.css} | 0 css/paper-plane/index.php | 26 ---------------- css/{paw/monitor.css => paw.css} | 0 css/paw/index.php | 26 ---------------- css/{sunrise/monitor.css => sunrise.css} | 0 css/sunrise/index.php | 26 ---------------- .../controller.php | 0 db_functions.php => includes/db_functions.php | 0 monitor_render.php => includes/render.php | 0 manifest.json | 8 ++--- monitor.php | 6 ++-- phpunit.xml | 2 +- setup.php | 4 +-- sonar-project.properties | 10 ------- .../MonitorRequestOutputWiringTest.php | 16 +++++----- tests/Integration/OutputEscapingTest.php | 30 +++++++++---------- tests/Unit/DbFunctionsTest.php | 4 +-- tests/Unit/MonitorPageHeadTest.php | 10 +++---- tests/bin/patch-coverage.php | 2 +- tests/bootstrap-unit.php | 2 +- 28 files changed, 42 insertions(+), 234 deletions(-) rename css/{classic/monitor.css => classic.css} (100%) delete mode 100644 css/classic/index.php rename css/{dark/monitor.css => dark.css} (100%) delete mode 100644 css/dark/index.php rename css/{midwinter/monitor.css => midwinter.css} (100%) delete mode 100644 css/midwinter/index.php rename css/{modern/monitor.css => modern.css} (100%) delete mode 100644 css/modern/index.php rename css/{paper-plane/monitor.css => paper-plane.css} (100%) delete mode 100644 css/paper-plane/index.php rename css/{paw/monitor.css => paw.css} (100%) delete mode 100644 css/paw/index.php rename css/{sunrise/monitor.css => sunrise.css} (100%) delete mode 100644 css/sunrise/index.php rename monitor_controller.php => includes/controller.php (100%) rename db_functions.php => includes/db_functions.php (100%) rename monitor_render.php => includes/render.php (100%) delete mode 100644 sonar-project.properties diff --git a/css/classic/monitor.css b/css/classic.css similarity index 100% rename from css/classic/monitor.css rename to css/classic.css diff --git a/css/classic/index.php b/css/classic/index.php deleted file mode 100644 index 362957c..0000000 --- a/css/classic/index.php +++ /dev/null @@ -1,26 +0,0 @@ - setup.php - db_functions.php + includes/db_functions.php poller_functions.php diff --git a/setup.php b/setup.php index 605ed98..b989492 100644 --- a/setup.php +++ b/setup.php @@ -259,8 +259,8 @@ function plugin_monitor_page_head() { print get_md5_include_css('plugins/monitor/css/monitor.css') . PHP_EOL; - if (file_exists($config['base_path'] . '/plugins/monitor/css/' . get_selected_theme() . '/monitor.css')) { - print get_md5_include_css('plugins/monitor/css/' . get_selected_theme() . '/monitor.css') . PHP_EOL; + if (file_exists($config['base_path'] . '/plugins/monitor/css/' . get_selected_theme() . '.css')) { + print get_md5_include_css('plugins/monitor/css/' . get_selected_theme() . '.css') . PHP_EOL; } } diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 5ed8db9..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,10 +0,0 @@ -sonar.projectKey=plugin_monitor -sonar.sources=. -sonar.exclusions=vendor/** - -# php:S4833 - "Use namespaces instead of include/require" -# This is a Cacti plugin with a required procedural architecture. -# Namespaces cannot be used; include_once is the correct and only mechanism. -sonar.issue.ignore.multicriteria=e1 -sonar.issue.ignore.multicriteria.e1.ruleKey=php:S4833 -sonar.issue.ignore.multicriteria.e1.resourceKey=**/*.php diff --git a/tests/Integration/MonitorRequestOutputWiringTest.php b/tests/Integration/MonitorRequestOutputWiringTest.php index eacc2e8..bf19036 100644 --- a/tests/Integration/MonitorRequestOutputWiringTest.php +++ b/tests/Integration/MonitorRequestOutputWiringTest.php @@ -8,14 +8,14 @@ */ dataset('output_hardening', [ - 'controller downhosts' => ['monitor_controller.php', "html_escape(get_request_var('downhosts'))"], - 'controller mute' => ['monitor_controller.php', "html_escape(get_request_var('mute'))"], - 'controller tree' => ['monitor_controller.php', "html_escape(get_request_var('tree'))"], - 'controller site' => ['monitor_controller.php', "html_escape(get_request_var('site'))"], - 'controller template' => ['monitor_controller.php', "html_escape(get_request_var('template'))"], - 'controller size' => ['monitor_controller.php', "html_escape(get_request_var('size'))"], - 'controller trim' => ['monitor_controller.php', "html_escape(get_request_var('trim'))"], - 'render rfilter' => ['monitor_render.php', "rawurlencode(get_request_var('rfilter'))"], + 'controller downhosts' => ['includes/controller.php', "html_escape(get_request_var('downhosts'))"], + 'controller mute' => ['includes/controller.php', "html_escape(get_request_var('mute'))"], + 'controller tree' => ['includes/controller.php', "html_escape(get_request_var('tree'))"], + 'controller site' => ['includes/controller.php', "html_escape(get_request_var('site'))"], + 'controller template' => ['includes/controller.php', "html_escape(get_request_var('template'))"], + 'controller size' => ['includes/controller.php', "html_escape(get_request_var('size'))"], + 'controller trim' => ['includes/controller.php', "html_escape(get_request_var('trim'))"], + 'render rfilter' => ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], ]); it('keeps expected output hardening in place', function (string $file, string $pattern) { diff --git a/tests/Integration/OutputEscapingTest.php b/tests/Integration/OutputEscapingTest.php index 81932e8..2cb6818 100644 --- a/tests/Integration/OutputEscapingTest.php +++ b/tests/Integration/OutputEscapingTest.php @@ -16,14 +16,14 @@ $root = dirname(__DIR__, 2); dataset('escaped_outputs', [ - ['monitor_controller.php', "html_escape(get_request_var('downhosts'))"], - ['monitor_controller.php', "html_escape(get_request_var('mute'))"], - ['monitor_controller.php', "html_escape(get_request_var('tree'))"], - ['monitor_controller.php', "html_escape(get_request_var('site'))"], - ['monitor_controller.php', "html_escape(get_request_var('template'))"], - ['monitor_controller.php', "html_escape(get_request_var('size'))"], - ['monitor_controller.php', "html_escape(get_request_var('trim'))"], - ['monitor_render.php', "rawurlencode(get_request_var('rfilter'))"], + ['includes/controller.php', "html_escape(get_request_var('downhosts'))"], + ['includes/controller.php', "html_escape(get_request_var('mute'))"], + ['includes/controller.php', "html_escape(get_request_var('tree'))"], + ['includes/controller.php', "html_escape(get_request_var('site'))"], + ['includes/controller.php', "html_escape(get_request_var('template'))"], + ['includes/controller.php', "html_escape(get_request_var('size'))"], + ['includes/controller.php', "html_escape(get_request_var('trim'))"], + ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], ]); it('escapes request values before printing them', function (string $file, string $pattern) use ($root) { @@ -31,13 +31,13 @@ })->with('escaped_outputs'); dataset('raw_reuse', [ - ['monitor_controller.php', "get_request_var('downhosts') . '\">'"], - ['monitor_controller.php', "get_request_var('site') . '\">'"], - ['monitor_controller.php', "get_request_var('template') . '\">'"], - ['monitor_controller.php', "get_request_var('size') . '\">'"], - ['monitor_controller.php', "get_request_var('trim') . '\">'"], - ['monitor_render.php', "monitor.php?rfilter=' . get_request_var('rfilter')"], + ['includes/controller.php', "get_request_var('downhosts') . '\">'"], + ['includes/controller.php', "get_request_var('site') . '\">'"], + ['includes/controller.php', "get_request_var('template') . '\">'"], + ['includes/controller.php', "get_request_var('size') . '\">'"], + ['includes/controller.php', "get_request_var('trim') . '\">'"], + ['includes/render.php', "monitor.php?rfilter=' . get_request_var('rfilter')"], ]); it('never concatenates a raw request value into markup', function (string $file, string $pattern) use ($root) { diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 3f69cd0..3e84a6e 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -3,13 +3,13 @@ +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | +-------------------------------------------------------------------------+ - | Exercises db_functions.php through the Cacti stubs in bootstrap-unit.php.| + | Exercises includes/db_functions.php through the Cacti stubs in bootstrap-unit.php.| | These load and run plugin code, unlike the source-matching rules in | | tests/Integration. | +-------------------------------------------------------------------------+ */ -require_once dirname(__DIR__, 2) . '/db_functions.php'; +require_once dirname(__DIR__, 2) . '/includes/db_functions.php'; it('selects the breached threshold clause for status 2', function () { test_set_request(['status' => '2']); diff --git a/tests/Unit/MonitorPageHeadTest.php b/tests/Unit/MonitorPageHeadTest.php index a726297..f35aa3c 100644 --- a/tests/Unit/MonitorPageHeadTest.php +++ b/tests/Unit/MonitorPageHeadTest.php @@ -7,7 +7,7 @@ /* * Unit coverage for plugin_monitor_page_head()'s stylesheet selection in - * setup.php, including the per-theme override loaded from css//monitor.css. + * setup.php, including the per-theme override loaded from css/.css. */ beforeAll(function () { @@ -18,11 +18,11 @@ unset($GLOBALS['__test_selected_theme']); }); -it('links the per-theme stylesheet from css// when it exists', function () { +it('links the per-theme stylesheet from css/ when it exists', function () { $restore = $GLOBALS['config']['base_path']; $base = sys_get_temp_dir() . '/monitor-ph-' . uniqid(); - mkdir($base . '/plugins/monitor/css/modern', 0777, true); - file_put_contents($base . '/plugins/monitor/css/modern/monitor.css', ''); + mkdir($base . '/plugins/monitor/css', 0777, true); + file_put_contents($base . '/plugins/monitor/css/modern.css', ''); $GLOBALS['__test_selected_theme'] = 'modern'; $GLOBALS['config']['base_path'] = $base; @@ -35,7 +35,7 @@ $GLOBALS['config']['base_path'] = $restore; } - expect($output)->toContain('plugins/monitor/css/modern/monitor.css'); + expect($output)->toContain('plugins/monitor/css/modern.css'); }); it('emits only the base stylesheet when no per-theme override exists', function () { diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index fb650c8..059d044 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -162,7 +162,7 @@ function changed_lines($base_ref) { $unmeasured_allowlist = [ 'includes/database.php', // install/upgrade/drop schema bootstrap; require_once's the live Cacti library, only runs during a real install/upgrade 'monitor.php', // web UI entry point (chdir + require auth.php); not loadable in the isolated unit process - 'monitor_controller.php', // web UI controller entry point (chdir + require auth.php); not loadable in the isolated unit process + 'includes/controller.php', // request-controller library included by monitor.php after auth.php; not loadable in the isolated unit process ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index a3a4c90..24914ca 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -367,7 +367,7 @@ function test_set_request(array $vars): void { $GLOBALS['__test_request'] = $vars; } -// validateRequestVars() is not stubbed here: monitor_controller.php declares +// validateRequestVars() is not stubbed here: includes/controller.php declares // it unconditionally, so a global stub would fatal with a redeclare error // once a test loads that file via loadPluginSource()/monitor_test_load(). From b4bc6fc940b616b04ce64035b2eb56f3dc93f59a Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 21:23:02 -0400 Subject: [PATCH 08/15] Rename includes/db_functions.php to includes/functions.php Follows the fleet convention for the plugin's shared library filename; monitor.php, the measured-source list and the unit test load it from the new name. --- includes/{db_functions.php => functions.php} | 0 monitor.php | 2 +- phpunit.xml | 2 +- tests/Unit/DbFunctionsTest.php | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename includes/{db_functions.php => functions.php} (100%) diff --git a/includes/db_functions.php b/includes/functions.php similarity index 100% rename from includes/db_functions.php rename to includes/functions.php diff --git a/monitor.php b/monitor.php index 59775ba..956e96a 100644 --- a/monitor.php +++ b/monitor.php @@ -126,7 +126,7 @@ $_SESSION['monitor_muted_hosts'] = []; } -include_once __DIR__ . '/includes/db_functions.php'; +include_once __DIR__ . '/includes/functions.php'; include_once __DIR__ . '/includes/render.php'; include_once __DIR__ . '/includes/controller.php'; diff --git a/phpunit.xml b/phpunit.xml index b2d711a..e07992d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,7 +28,7 @@ setup.php - includes/db_functions.php + includes/functions.php poller_functions.php diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 3e84a6e..f60b799 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -3,13 +3,13 @@ +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | +-------------------------------------------------------------------------+ - | Exercises includes/db_functions.php through the Cacti stubs in bootstrap-unit.php.| + | Exercises includes/functions.php through the Cacti stubs in bootstrap-unit.php.| | These load and run plugin code, unlike the source-matching rules in | | tests/Integration. | +-------------------------------------------------------------------------+ */ -require_once dirname(__DIR__, 2) . '/includes/db_functions.php'; +require_once dirname(__DIR__, 2) . '/includes/functions.php'; it('selects the breached threshold clause for status 2', function () { test_set_request(['status' => '2']); From 22e30788a7918e6ff6c45f00f13496994adc89fe Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 21:27:55 -0400 Subject: [PATCH 09/15] Regenerate cacti.pot for the includes/ file relocations Moving the plugin's library files into includes/ changed the source-reference lines in the translation template; regenerate it so the 'Verify translation template' CI check passes. --- locales/po/cacti.pot | 554 +++++++++++++++++++++---------------------- 1 file changed, 277 insertions(+), 277 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index d66b5ee..ee1c27c 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-09-28 16:06-0400\n" +"POT-Creation-Date: 2026-09-30 21:27-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -17,615 +17,615 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: monitor.php poller_functions.php setup.php -msgid "Disabled" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Low" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Medium" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "High" -msgstr "" - -#: monitor.php poller_functions.php setup.php -msgid "Mission Critical" -msgstr "" - -#: monitor.php monitor_render.php -msgid "Unknown" -msgstr "" - -#: monitor.php -msgid "Down" -msgstr "" - -#: monitor.php -msgid "Recovering" -msgstr "" - -#: monitor.php -msgid "Up" -msgstr "" - -#: monitor.php -msgid "Triggered" -msgstr "" - -#: monitor.php -msgid "Triggered (Muted/Acked)" -msgstr "" - -#: monitor.php -msgid "Down (Muted/Acked)" -msgstr "" - -#: monitor.php -msgid "No Availability Check" -msgstr "" - -#: monitor.php -msgid "Warning Ping" +#: includes/controller.php +msgid "New Dashboard" msgstr "" -#: monitor.php -msgid "Alert Ping" +#: includes/controller.php +msgid "Dashboard" msgstr "" -#: monitor.php -msgid "Extra Small" +#: includes/controller.php +msgid "Enter the Dashboard Name and then press 'Save' to continue, else press 'Cancel'" msgstr "" -#: monitor.php -msgid "Small" +#: includes/controller.php +msgid "Create New Dashboard" msgstr "" -#: monitor.php -msgid "Large" +#: includes/controller.php includes/render.php +msgid "Monitored Devices" msgstr "" -#: monitor.php -msgid "Extra Large" +#: includes/controller.php monitor.php +msgid "Devices without Thresholds" msgstr "" -#: monitor.php -msgid "Zoom" +#: includes/controller.php +msgid "Not Monitored Devices" msgstr "" -#: monitor.php monitor_controller.php +#: includes/controller.php monitor.php msgid "All Devices" msgstr "" -#: monitor.php monitor_controller.php +#: includes/controller.php monitor.php msgid "All Monitored Devices" msgstr "" -#: monitor.php -msgid "Not Up" -msgstr "" - -#: monitor.php -msgid "Not Up or Triggered" -msgstr "" - -#: monitor.php -msgid "Not Up, Triggered or Breached" -msgstr "" - -#: monitor.php monitor_controller.php -msgid "Devices without Thresholds" -msgstr "" - -#: monitor.php -msgid "Devices not Monitored" -msgstr "" - -#: monitor.php setup.php -msgid "Default" -msgstr "" - -#: monitor.php setup.php -msgid "List" -msgstr "" - -#: monitor.php setup.php -msgid "Names only" -msgstr "" - -#: monitor.php setup.php -msgid "Tiles" -msgstr "" - -#: monitor.php -msgid "Tiles & Time" -msgstr "" - -#: monitor.php monitor_controller.php setup.php -msgid "Tree" -msgstr "" - -#: monitor.php monitor_render.php -msgid "Site" -msgstr "" - -#: monitor.php setup.php -msgid "Device Template" -msgstr "" - -#: monitor.php -msgid "Full" -msgstr "" - -#: monitor.php -msgid "10 Chars" -msgstr "" - -#: monitor.php -msgid "20 Chars" -msgstr "" - -#: monitor.php -msgid "30 Chars" -msgstr "" - -#: monitor.php -msgid "40 Chars" -msgstr "" - -#: monitor.php -msgid "50 Chars" -msgstr "" - -#: monitor.php -msgid "75 Chars" -msgstr "" - -#: monitor.php -msgid "100 Chars" -msgstr "" - -#: monitor_controller.php -msgid "New Dashboard" -msgstr "" - -#: monitor_controller.php -msgid "Dashboard" -msgstr "" - -#: monitor_controller.php -msgid "Enter the Dashboard Name and then press 'Save' to continue, else press 'Cancel'" -msgstr "" - -#: monitor_controller.php -msgid "Create New Dashboard" -msgstr "" - -#: monitor_controller.php monitor_render.php -msgid "Monitored Devices" -msgstr "" - -#: monitor_controller.php -msgid "Not Monitored Devices" -msgstr "" - -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down or Recovering" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down, Recovering, or with Triggered Thresholds" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Monitored Devices either Down, Recovering, or with Breached or Triggered Thresholds" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Unknown monitoring status (%s)" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and All Criticalities" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Low Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Medium Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of High Criticality or Higher" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid ", and of Mission Critical Status" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "
Remember to first select eligible Devices to be Monitored from the Devices page!
" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Unsaved" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Layout" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Status" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "View" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "Grouping" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Refresh" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Refresh the Device List" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Clear" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Clear the set filter" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save Filter Settings" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "New" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Save New Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Rename" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Rename Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Delete" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Delete Dashboard" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "%s Alert for downed Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "%s Alerts for downed Devices" msgstr "" -#: monitor_controller.php monitor_render.php poller_functions.php setup.php +#: includes/controller.php includes/render.php poller_functions.php setup.php msgid "Criticality" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Size" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Trim" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "All Trees" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Non-Tree Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php monitor.php setup.php +msgid "Tree" +msgstr "" + +#: includes/controller.php msgid "All Sites" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Non-Site Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Sites" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "All Templates" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Non-Templated Devices" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Template" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid " [ Filter Settings Saved ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Cancel" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid " [ Refresh Again in %d Seconds ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Monitor Filter [ Last Refresh: %s ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "[ Showing only first 30 Devices ]" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Search" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Mute" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Acknowledge" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Un-Mute" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Reset" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' Removed." msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' is not owned by you." msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' has been Saved!" msgstr "" -#: monitor_controller.php +#: includes/controller.php #, php-format msgid "Dashboard '%s' could not be Saved!" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Edit Device" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Graphs" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Thresholds/Alerts" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Device Syslog Alerts" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "View Device Syslog Entries" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Device Status Information" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Device:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Site:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Location:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Criticality:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Status:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Admin Note:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "IP/Hostname:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Curr/Avg:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php #, php-format msgid "%d ms" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Warn/Alert:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php #, php-format msgid "%0.2d ms" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Last Fail:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Time In State:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Availability:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Agent Uptime:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "N/A" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Sys Description:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Contact:" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Notes:" msgstr "" -#: monitor_controller.php monitor_render.php +#: includes/controller.php includes/render.php msgid "Never" msgstr "" -#: monitor_controller.php +#: includes/controller.php msgid "Unspecified" msgstr "" -#: monitor_controller.php setup.php +#: includes/controller.php setup.php msgid "None" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Non-Site Device" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Root Branch" msgstr "" -#: monitor_render.php +#: includes/render.php #, php-format msgid "Tree: %s" msgstr "" -#: monitor_render.php +#: includes/render.php #, php-format msgid "Branch: %s" msgstr "" -#: monitor_render.php +#: includes/render.php monitor.php +msgid "Unknown" +msgstr "" + +#: includes/render.php msgid "Just Up" msgstr "" -#: monitor_render.php poller_functions.php +#: includes/render.php poller_functions.php msgid "Hostname" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Hostname of device" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "ID" msgstr "" -#: monitor_render.php poller_functions.php +#: includes/render.php poller_functions.php msgid "Description" msgstr "" -#: monitor_render.php +#: includes/render.php monitor.php +msgid "Site" +msgstr "" + +#: includes/render.php msgid "Length in Status" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Averages" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Warning" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Admin" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Monitor Text Column represents 'Admin'" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Notes" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Availability" msgstr "" -#: monitor_render.php +#: includes/render.php msgid "Last Fail" msgstr "" +#: monitor.php poller_functions.php setup.php +msgid "Disabled" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Low" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Medium" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "High" +msgstr "" + +#: monitor.php poller_functions.php setup.php +msgid "Mission Critical" +msgstr "" + +#: monitor.php +msgid "Down" +msgstr "" + +#: monitor.php +msgid "Recovering" +msgstr "" + +#: monitor.php +msgid "Up" +msgstr "" + +#: monitor.php +msgid "Triggered" +msgstr "" + +#: monitor.php +msgid "Triggered (Muted/Acked)" +msgstr "" + +#: monitor.php +msgid "Down (Muted/Acked)" +msgstr "" + +#: monitor.php +msgid "No Availability Check" +msgstr "" + +#: monitor.php +msgid "Warning Ping" +msgstr "" + +#: monitor.php +msgid "Alert Ping" +msgstr "" + +#: monitor.php +msgid "Extra Small" +msgstr "" + +#: monitor.php +msgid "Small" +msgstr "" + +#: monitor.php +msgid "Large" +msgstr "" + +#: monitor.php +msgid "Extra Large" +msgstr "" + +#: monitor.php +msgid "Zoom" +msgstr "" + +#: monitor.php +msgid "Not Up" +msgstr "" + +#: monitor.php +msgid "Not Up or Triggered" +msgstr "" + +#: monitor.php +msgid "Not Up, Triggered or Breached" +msgstr "" + +#: monitor.php +msgid "Devices not Monitored" +msgstr "" + +#: monitor.php setup.php +msgid "Default" +msgstr "" + +#: monitor.php setup.php +msgid "List" +msgstr "" + +#: monitor.php setup.php +msgid "Names only" +msgstr "" + +#: monitor.php setup.php +msgid "Tiles" +msgstr "" + +#: monitor.php +msgid "Tiles & Time" +msgstr "" + +#: monitor.php setup.php +msgid "Device Template" +msgstr "" + +#: monitor.php +msgid "Full" +msgstr "" + +#: monitor.php +msgid "10 Chars" +msgstr "" + +#: monitor.php +msgid "20 Chars" +msgstr "" + +#: monitor.php +msgid "30 Chars" +msgstr "" + +#: monitor.php +msgid "40 Chars" +msgstr "" + +#: monitor.php +msgid "50 Chars" +msgstr "" + +#: monitor.php +msgid "75 Chars" +msgstr "" + +#: monitor.php +msgid "100 Chars" +msgstr "" + #: poller_functions.php msgid "Description: " msgstr "" From 0132b01519e1deba393e1e15d8402fcd2e3dba40 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:13:06 -0400 Subject: [PATCH 10/15] Harden prune against tampered-manifest path traversal The upgrade-time prune now rejects any tombstone containing '.'/'..' segments (which could escape the plugin directory or resolve to its root) and treats ancestors of whitelist entries as protected, so a tombstone on a parent directory can no longer delete a whitelisted file beneath it. --- setup.php | 14 ++++++++- tests/Unit/PruneFilesTest.php | 59 ++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/setup.php b/setup.php index b989492..8984f05 100644 --- a/setup.php +++ b/setup.php @@ -1460,7 +1460,9 @@ function plugin_monitor_prune_files(): void { foreach ($whitelist as $entry) { $entry = trim((string) $entry, '/'); - if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0)) { + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { return true; } } @@ -1485,6 +1487,16 @@ function plugin_monitor_prune_files(): void { continue; } + // A tombstone must never contain '.'/'..' segments; a tampered manifest + // could use them to escape the plugin directory or target its root. + $segments = explode('/', $rel); + + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + cacti_log(sprintf('WARNING: monitor prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'MONITOR'); + + continue; + } + $path = $plugin_dir . '/' . $rel; if (!is_link($path) && !file_exists($path)) { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index d05d05b..ba58eb2 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -146,7 +146,7 @@ function monitor_prune_fixture(array $manifest): string { // The out-of-tree file is untouched and the refusal is logged. expect(is_file($outside))->toBeTrue(); - expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); }); it('warns when a tombstoned path cannot be removed', function () { @@ -179,3 +179,60 @@ function monitor_prune_fixture(array $manifest): string { })->skip(function () { return function_exists('posix_getuid') && posix_getuid() === 0; }, 'permission checks are bypassed for the root user'); + +it('refuses a tombstone that escapes through a symlinked directory', function () { + $manifest = [ + 'tombstones' => ['escdir/secret.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $outside = $base . '/outside'; + mkdir($outside, 0777, true); + file_put_contents($outside . '/secret.txt', 'precious user data'); + @symlink($outside, $plugin . '/escdir'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file reached through the symlink is untouched and logged. + expect(is_file($outside . '/secret.txt'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +})->skip(function () { + $probe = sys_get_temp_dir() . '/.prune-symlink-probe-' . uniqid(); + $ok = @symlink(__FILE__, $probe); + @unlink($probe); + + return $ok === false; +}, 'symlinks are not supported on this filesystem'); + +it('protects a whitelisted file from a tombstone on its parent directory', function () { + $manifest = [ + 'tombstones' => ['userdata/'], + 'expected' => ['manifest.json'], + 'whitelist' => ['userdata/keep.dat'], + ]; + + $base = monitor_prune_fixture($manifest); + $plugin = $base . '/plugins/monitor'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_monitor_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A whitelisted file shields its parent directory from a tombstone. + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); +}); From 3f2e859b53e899b17da9cfd6d415dd3eec8d6ed9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:36:32 -0400 Subject: [PATCH 11/15] docs: update and align the Project Structure block Point the structure tree at the relocated files (libraries now under includes/, data files under docs/, stylesheets under css/) and align the trailing '# ...' comments to a single column so they no longer drift right. --- .github/copilot-instructions.md | 34 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d044536..7087d52 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,32 +20,30 @@ When generating code for this repository: ### Key Dependencies - Cacti core framework (`api_plugin_*`, `db_*`, `read_config_option()`, `get_filter_request_var()`) -- `themes/` CSS overlays, `sounds/` audible alert assets, `js/` client-side dashboard code +- `css/` CSS overlays, `sounds/` audible alert assets, `js/` client-side dashboard code ## Project Structure ``` -monitor/ # Repository root (install to plugins/monitor/ in Cacti) -├── js/ # Dashboard client-side logic -├── sounds/ # Alert sound assets -├── themes/ # CSS theme overlays (monitor.css) -├── tests/ # Test suite -├── db_functions.php # SQL filter/join helpers and status/device query utilities -├── monitor.php # Web entrypoint/bootstrap (session/request setup) -├── monitor_controller.php # Action flow, filter handling, page orchestration -├── monitor_render.php # Dashboard/group rendering and view-specific output -├── poller_functions.php # Poller helper logic (uptime checks, notifications, email payloads) -├── poller_monitor.php # Background poller entry point (CLI: --help, --version, --debug) -├── INFO # Plugin metadata (name, version, compat) +monitor/ # Repository root (install to plugins/monitor/ in Cacti) +├── js/ # Dashboard client-side logic +├── sounds/ # Alert sound assets +├── css/ # CSS theme overlays (monitor.css) +├── tests/ # Test suite +├── includes/ # functions.php (DB/query helpers), controller.php (action flow), render.php (rendering), database.php (schema) +├── monitor.php # Web entrypoint/bootstrap (session/request setup) +├── poller_functions.php # Poller helper logic (uptime checks, notifications, email payloads) +├── poller_monitor.php # Background poller entry point (CLI: --help, --version, --debug) +├── INFO # Plugin metadata (name, version, compat) ├── README.md -└── setup.php # Plugin install/uninstall/upgrade hooks, hook registration, config +└── setup.php # Plugin install/uninstall/upgrade hooks, hook registration, config ``` ## Naming Conventions ### Function Names - Procedural functions use **lowerCamelCase**, the `monitor_` prefix, or the required `plugin_monitor_` lifecycle prefix, matching current file conventions — keep plugin hook callback names exactly synchronized between their definitions and their `api_plugin_register_hook()` registration strings. -- Keep top-level entrypoints lightweight; place reusable logic in the appropriate helper file (`db_functions.php`, `poller_functions.php`). +- Keep top-level entrypoints lightweight; place reusable logic in the appropriate helper file (`includes/functions.php`, `poller_functions.php`). ### Database Tables Plugin tables are prefixed `plugin_monitor_`. Reuse existing table names and avoid introducing parallel schema variants; keep schema evolution inside the existing setup/upgrade lifecycle functions in `setup.php`. @@ -100,9 +98,9 @@ Use gettext calls with the `monitor` domain for user-facing strings: `__('Text', ### File Responsibilities - `setup.php`: plugin lifecycle, hook registration, config arrays/settings, install/upgrade table management. - `monitor.php`: web entrypoint/bootstrap, includes, session/request setup. -- `monitor_controller.php`: action flow, filter handling, page orchestration. -- `monitor_render.php`: dashboard/group rendering and view-specific output. -- `db_functions.php`: SQL filter/join helpers and status/device query utilities. +- `includes/controller.php`: action flow, filter handling, page orchestration. +- `includes/render.php`: dashboard/group rendering and view-specific output. +- `includes/functions.php`: SQL filter/join helpers and status/device query utilities. - `poller_monitor.php`: CLI poller entrypoint. - `poller_functions.php`: poller helper logic (uptime checks, notifications, email payload building). From dda3b2d57bcc9271c500bccca494244dd9efbfc0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:00:26 -0400 Subject: [PATCH 12/15] docs/tests: standardize test header and correct manifest-contract notes - Use the full license header (from the plugin's own setup.php) in tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner. - Document that the manifest drift check and the upgrade-time prune also handle phpunit.xml and .md* files, matching the implemented behavior. --- .github/copilot-instructions.md | 2 +- tests/Unit/MonitorLifecycleTest.php | 10 +++++----- tests/Unit/PruneFilesTest.php | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7087d52..a589b68 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -176,4 +176,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## File manifest & upgrade pruning -The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_monitor_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_monitor_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/tests/Unit/MonitorLifecycleTest.php b/tests/Unit/MonitorLifecycleTest.php index d0ded90..253b3a8 100644 --- a/tests/Unit/MonitorLifecycleTest.php +++ b/tests/Unit/MonitorLifecycleTest.php @@ -107,11 +107,11 @@ }); it('launches the poller with a php fallback when no binary is configured', function () { -$GLOBALS['config']['poller_id'] = 1; -$GLOBALS['__test_exec_calls'] = array(); + $GLOBALS['config']['poller_id'] = 1; + $GLOBALS['__test_exec_calls'] = array(); -monitor_poller_bottom(); + monitor_poller_bottom(); -expect($GLOBALS['__test_exec_calls'])->not->toBeEmpty(); -expect($GLOBALS['__test_exec_calls'][0]['command'])->toBe('php'); + expect($GLOBALS['__test_exec_calls'])->not->toBeEmpty(); + expect($GLOBALS['__test_exec_calls'][0]['command'])->toBe('php'); }); diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index ba58eb2..254b1da 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -2,6 +2,23 @@ /* +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | + | | + | This program is free software; you can redistribute it and/or | + | modify it under the terms of the GNU General Public License | + | as published by the Free Software Foundation; either version 2 | + | of the License, or (at your option) any later version. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDTool-based Graphing Solution | + +-------------------------------------------------------------------------+ + | This code is designed, written, and maintained by the Cacti Group. See | + | about.php and/or the AUTHORS file for specific developer information. | + +-------------------------------------------------------------------------+ + | http://www.cacti.net/ | +-------------------------------------------------------------------------+ */ From 8d7cf060519f1044b026c455a775fc7dc9a7da92 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:18:21 -0400 Subject: [PATCH 13/15] refactor: rename prune helpers to the plugin-slug function prefix The repository naming contract reserves the plugin__ prefix for plugin lifecycle / hook-registration functions; all other functions use the plain _ prefix. Rename the internal upgrade helpers accordingly: plugin__prune_files() -> _prune_files() plugin__rmtree() -> _rmtree() The call site, unit tests, and the copilot-instructions.md references are updated to match. No behavioral change. --- .github/copilot-instructions.md | 2 +- setup.php | 12 ++++++------ tests/Unit/MonitorLifecycleTest.php | 2 +- tests/Unit/PruneFilesTest.php | 16 ++++++++-------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a589b68..9b84745 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -176,4 +176,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## File manifest & upgrade pruning -The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_monitor_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `monitor_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/setup.php b/setup.php index 8984f05..a9611db 100644 --- a/setup.php +++ b/setup.php @@ -356,7 +356,7 @@ function monitor_check_upgrade() { } // Remove files tombstoned in manifest.json plus the dev-only tests/ tree. - plugin_monitor_prune_files(); + monitor_prune_files(); } } @@ -1430,7 +1430,7 @@ function monitor_poller_bottom() { * @global array $config Cacti global configuration array; used to resolve * the plugin directory. */ -function plugin_monitor_prune_files(): void { +function monitor_prune_files(): void { global $config; $plugin_dir = $config['base_path'] . '/plugins/monitor'; @@ -1516,7 +1516,7 @@ function plugin_monitor_prune_files(): void { } if (is_dir($path) && !is_link($path)) { - $removed = plugin_monitor_rmtree($path); + $removed = monitor_rmtree($path); } else { $removed = @unlink($path); } @@ -1550,14 +1550,14 @@ function plugin_monitor_prune_files(): void { /** * Recursively deletes a directory and its contents. Symlinks are removed - * without being followed. Helper for plugin_monitor_prune_files(). + * without being followed. Helper for monitor_prune_files(). * * @param string $dir Absolute path to the directory to remove. * * @return bool True if the directory and everything under it was removed; * false if any entry could not be deleted. */ -function plugin_monitor_rmtree(string $dir): bool { +function monitor_rmtree(string $dir): bool { $entries = scandir($dir); $ok = true; @@ -1569,7 +1569,7 @@ function plugin_monitor_rmtree(string $dir): bool { $path = $dir . '/' . $entry; if (is_dir($path) && !is_link($path)) { - if (!plugin_monitor_rmtree($path)) { + if (!monitor_rmtree($path)) { $ok = false; } } elseif (!@unlink($path)) { diff --git a/tests/Unit/MonitorLifecycleTest.php b/tests/Unit/MonitorLifecycleTest.php index 253b3a8..73f1f53 100644 --- a/tests/Unit/MonitorLifecycleTest.php +++ b/tests/Unit/MonitorLifecycleTest.php @@ -42,7 +42,7 @@ $_SERVER['PHP_SELF'] = '/monitor.php'; // Sandbox base_path (with a minimal INFO + empty includes/database.php stub) - // so any upgrade-path test runs plugin_monitor_prune_files() against a + // so any upgrade-path test runs monitor_prune_files() against a // throwaway tree, never the real checkout. $GLOBALS['__monitor_base_restore'] = $GLOBALS['config']['base_path']; $base = sys_get_temp_dir() . '/monitor-test-' . uniqid(); diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index 254b1da..ca30cdd 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -23,7 +23,7 @@ */ /* - * Unit coverage for plugin_monitor_prune_files(): tombstone/tests removal, + * Unit coverage for monitor_prune_files(): tombstone/tests removal, * whitelist and .git protection, and logging of unaccounted-for entries. */ @@ -72,7 +72,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -111,7 +111,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -130,7 +130,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -156,7 +156,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -185,7 +185,7 @@ function monitor_prune_fixture(array $manifest): string { set_error_handler(static fn () => true); // swallow the expected unlink warning try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { restore_error_handler(); $GLOBALS['config']['base_path'] = $restore; @@ -215,7 +215,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -245,7 +245,7 @@ function monitor_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_monitor_prune_files(); + monitor_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } From ec01c1c1c035a6b7ccfeb3ea6f75a0fda093a648 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:29:40 -0400 Subject: [PATCH 14/15] chore: set the Cacti compatibility floor to 1.2.29 Align the compat metadata (and the contributor-guide references) with the team decision to standardize the minimum supported Cacti version at 1.2.29; the previously proposed 1.2.32 floor was not adopted. --- INFO | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INFO b/INFO index 8642661..6acfb64 100644 --- a/INFO +++ b/INFO @@ -26,6 +26,6 @@ longname = Device Monitoring author = The Cacti Group email = homepage = http://www.cacti.net -compat = 1.2.32 +compat = 1.2.29 requires = thold:1.2.1 capabilities = online_view:1, online_mgmt:1, offline_view:0, offline_mgmt:0, remote_collect:0 From 916577f479f49d8112061a99fd83e5adb00631e1 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 30 Sep 2026 23:59:34 -0400 Subject: [PATCH 15/15] Bump plugin version to 3.0 so the upgrade-time cleanup migration runs Add a 3.0 CHANGELOG section for this cycle's schema/manifest work and set INFO version = 3.0 so existing 2.9 installs detect the version change and run the schema refresh and manifest-based file prune. --- CHANGELOG.md | 8 ++++++++ INFO | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1e51e..bd799cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Changelog +--- 3.0 --- + +* feature: Adopt the Cacti plugin table API and relocate schema provisioning into includes/database.php +* feature: Add a fleet-wide manifest.json with upgrade-time file pruning +* feature: Consolidate per-theme override stylesheets from themes/ into css/ +* dev: Rename includes/db_functions.php to includes/functions.php and move bundled libraries under includes/ +* dev: Set the Cacti compatibility floor to 1.2.29 + --- 2.9 --- * dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure) diff --git a/INFO b/INFO index 6acfb64..c5bc2f0 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = monitor -version = 2.9 +version = 3.0 longname = Device Monitoring author = The Cacti Group email =