Add one-click RRDfile repair (Apply Suggested Fixes) to Data Source Info mode (#8401)

* Add one-click RRDfile repair to Data Source Info mode

The Data Source Info mode already compares the RRDfile against the Cacti
definition and prints the rrdtool tune recommendations (add a missing Data
Source with DS:name:type:heartbeat:min:max, remove an extra one with DEL:name,
and correct the type, heartbeat, minimum and maximum). It did not, however,
offer a way to apply them from the page the way data_debug.php does.

- Add an "Apply Suggested Fixes" link to the "RRDtool Tune Info" box header when
  the web server can write the RRDfile; when it cannot, the header falls back to
  "Run Fix from Command Line" and the commands remain listed for manual use.
- Add a ds_repair action that runs the same tune recommendations server-side
  (gated on data_source_authorized() and on the RRDfile being writable, and
  skipping any recommendation carrying control characters), logs each command to
  DSDEBUG, then reloads the Data Source so the result is visible. This mirrors
  data_debug.php's run_repair / "Apply Suggested Fixes".
- Harden the Info-mode guard so a missing/unreadable RRDfile (rrdtool info
  returning false) no longer triggers an array-offset warning.

* Address review: POST-guard ds_repair and harden repair result check

- Route the "Apply Suggested Fixes" action through a CSRF-protected POST: the
  link now uses the cactiPostAction handler (href="#", data-url) instead of a
  GET navigation, and ds_repair is added to the $bad_actions guard in
  include/global.php so a tokenless request fails closed with 405. ds_repair is
  added to the curated CSRF action test for regression coverage.
- Use a strict comparison on the rrdtool_execute() result: it can return null
  on a failed exec/proxy connection, and null == '' would have reported a failed
  repair as "All RRDfile repairs succeeded."
- Refresh the sink inventory baseline for the new header redirects in ds_repair.

* Add request-coverage probe for the ds_repair RRDfile repair handler

The Data Source Info mode repair handler ds_repair() is a POST action that the
page-level e2e suites do not drive (it needs a Data Source whose RRDfile
mismatches its Cacti definition). Add a request-coverage fixture that loads the
handler in isolation via cacti_fixture_source_function(), stubs its I/O
boundaries, and drives every branch (unauthorized, unreadable RRDfile, no RRA,
nothing to tune, not writable, a control-character recommendation that is
skipped, and both tune success and failure). Register it in
run_request_coverage.sh for data_sources.php changes so the changed-line
coverage gate sees the handler exercised.

* Address review: use defined helpers and boolean repair result

- Replace the undefined cacti_has_control_chars() check with a comparison
  against rrd_strip_control_chars() (loaded from lib/rrd.php) to skip any
  tune recommendation that carries control characters.
- Execute each tune with RRDTOOL_OUTPUT_BOOLEAN and treat a strict true as
  success so proxy storage (which returns the raw 'OK u:...' status under
  RRDTOOL_OUTPUT_RETURN_STDERR) is not misreported as a failure.
- Add the new translatable strings to locales/po/cacti.pot and reuse the
  existing failure msgid.
- Update the request-coverage fixture to the new boundary contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
TheWitnessandCopilot authored and GitHub committed 2026-10-15 20:47:31 -04:00
1 parent 3bcb4e4f49
commit 5afe0f8f68
7 files changed
+264 -1

No files matched your search

+96 -1
View File
@@ -82,6 +82,10 @@ switch (grv('action')) {
case 'ds_enable':
ds_enable();
break;
case 'ds_repair':
ds_repair();
break;
case 'ds_edit':
ds_edit();
@@ -932,6 +936,87 @@ function ds_enable() : void {
header('Location: data_sources.php?action=ds_edit&id=' . grv('id'));
}
/**
* Repairs the RRDfile for a Data Source so that it matches its Cacti
* definition. The 'Apply Suggested Fixes' link in the Data Source Info mode
* invokes this action, which runs the same rrdtool tune recommendations that
* the Info mode displays: missing Data Sources are added ('DS:...'), extra Data
* Sources are removed ('DEL:...') and the type, heartbeat, minimum and maximum
* are corrected. The repair only runs when the web server can write the
* RRDfile; otherwise the Info mode presents the commands to run by hand.
*
* @return void No value is returned.
*/
function ds_repair() : void {
// ================= input validation =================
gfrv('id');
// ====================================================
$id = (int) grv('id');
if (!data_source_authorized($id)) {
raise_message('permission_denied');
header('Location: data_sources.php');
return;
}
$rrd_info = rrdtool_function_info($id);
if (!is_array($rrd_info) || !isset($rrd_info['rra']) || !cacti_sizeof($rrd_info['rra'])) {
raise_message('ds_repair', __('The RRDfile could not be read, so no repairs were performed.'), MESSAGE_LEVEL_ERROR);
header('Location: data_sources.php?action=ds_edit&id=' . $id);
return;
}
$diff = rrdtool_cacti_compare($id, $rrd_info);
if (!isset($diff['tune']) || !cacti_sizeof($diff['tune'])) {
raise_message('ds_repair', __('No RRDfile repairs were necessary.'), MESSAGE_LEVEL_INFO);
header('Location: data_sources.php?action=ds_edit&id=' . $id);
return;
}
$path = get_data_source_path($id, true);
if (!is_writeable($path)) {
raise_message('ds_repair', __('The RRDfile is not writable by the web server. Run the suggested commands from the command line instead.'), MESSAGE_LEVEL_ERROR);
header('Location: data_sources.php?action=ds_edit&id=' . $id);
return;
}
$rrdtool_path = read_config_option('path_rrdtool');
$failures = 0;
foreach ($diff['tune'] as $options) {
// never execute a recommendation carrying control characters
if (rrd_strip_control_chars($options) !== $options) {
continue;
}
$command = $rrdtool_path . ' tune ' . $options;
$result = rrdtool_execute('tune ' . $options, false, RRDTOOL_OUTPUT_BOOLEAN);
if ($result === true) {
cacti_log("RRDfile repair command succeeded for DS[$id] Command[$command]", false, 'DSDEBUG');
} else {
cacti_log("ERROR: RRDfile repair command failed for DS[$id] Command[$command]", false, 'DSDEBUG');
$failures++;
}
}
if ($failures == 0) {
raise_message('ds_repair', __('All RRDfile repairs succeeded.'), MESSAGE_LEVEL_INFO);
} else {
raise_message('ds_repair', __('One or more RRDfile repairs failed. See Cacti log for errors.'), MESSAGE_LEVEL_ERROR);
}
header('Location: data_sources.php?action=ds_edit&id=' . $id);
}
function ds_edit() : void {
global $struct_data_source, $struct_data_source_item;
@@ -1405,7 +1490,17 @@ function ds_edit() : void {
rrdtool_info2html($rrd_info, $diff);
if (cacti_sizeof($diff)) {
html_start_box(__('RRDtool Tune Info'), '100%', false, 3, 'center', '');
$has_tune = isset($diff['tune']) && cacti_sizeof($diff['tune']);
// offer a one-click repair when the web server can write the
// RRDfile, otherwise present the commands to run by hand
if ($has_tune && is_writeable(get_data_source_path(grv('id'), true))) {
html_start_box(__('RRDtool Tune Info [ %s ]', '<a href="#" class="cactiPostAction linkEditMain" data-url="' . html_escape_url('data_sources.php?action=ds_repair&id=' . grv('id')) . '" style="cursor:pointer;">' . __('Apply Suggested Fixes') . '</a>'), '100%', false, 3, 'center', '');
} elseif ($has_tune) {
html_start_box(__('RRDtool Tune Info [ %s ]', __('Run Fix from Command Line')), '100%', false, 3, 'center', '');
} else {
html_start_box(__('RRDtool Tune Info'), '100%', false, 3, 'center', '');
}
rrdtool_tune($rrd_info['filename'], $diff, true);
+1
View File
@@ -848,6 +848,7 @@ if ($config['is_web']) {
'gt_remove', 'query_remove', 'remove', 'change_leaf',
'moveup', 'movedown', 'tree_up', 'tree_down',
'move_page_up', 'move_page_down', 'rrd_add', 'rrd_remove',
'ds_repair',
'accept',
];
+21
View File
@@ -3174,6 +3174,27 @@ msgstr ""
msgid "One or more RRDfile repairs failed. See Cacti log for errors."
msgstr ""
#: data_sources.php
msgid "The RRDfile could not be read, so no repairs were performed."
msgstr ""
#: data_sources.php
msgid "No RRDfile repairs were necessary."
msgstr ""
#: data_sources.php
msgid "The RRDfile is not writable by the web server. Run the suggested commands from the command line instead."
msgstr ""
#: data_sources.php
#, php-format
msgid "RRDtool Tune Info [ %s ]"
msgstr ""
#: data_sources.php
msgid "Run Fix from Command Line"
msgstr ""
#: data_debug.php
msgid "Automatic Data Source debug being rerun after repair."
msgstr ""
@@ -40,6 +40,7 @@ function guarded_actions() {
'moveup', 'movedown',
'tree_up', 'tree_down', 'move_page_up', 'move_page_down',
'rrd_add', 'rrd_remove',
'ds_repair',
];
}
+138
View File
@@ -0,0 +1,138 @@
<?php
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
namespace CactiDataSourceRrdRepairFixture;
use RuntimeException;
/*
* Request-coverage probe for the Data Source Info mode RRDfile repair handler.
*
* ds_repair() (data_sources.php) backs the "Apply Suggested Fixes" POST action.
* It runs the rrdtool tune recommendations (add/remove Data Sources, correct
* min/max/type/heartbeat) when the web server can write the RRDfile. The page
* level e2e suites do not drive a Data Source whose RRDfile mismatches its Cacti
* definition, so the handler is loaded in isolation with its I/O boundaries
* stubbed and driven through every branch; RequestCoverage maps the hits back to
* the source.
*/
require dirname(__DIR__, 2) . '/Helpers/RequestCoverage.php';
require dirname(__DIR__, 2) . '/Helpers/SourceSlice.php';
if (!defined('MESSAGE_LEVEL_INFO')) {
define('MESSAGE_LEVEL_INFO', 3);
}
if (!defined('MESSAGE_LEVEL_ERROR')) {
define('MESSAGE_LEVEL_ERROR', 1);
}
if (!defined('RRDTOOL_OUTPUT_BOOLEAN')) {
define('RRDTOOL_OUTPUT_BOOLEAN', 4);
}
/* Mutable boundary state the stubs read, reset per scenario below. */
$GLOBALS['ds_repair_state'] = [
'authorized' => true,
'rrd_info' => ['rra' => [0 => []], 'filename' => '/tmp/fixture.rrd', 'ds' => []],
'diff' => ['tune' => ['/tmp/fixture.rrd DS:traffic_in:COUNTER:600:0:U']],
'writable' => true,
'exec_result' => true,
];
$GLOBALS['ds_repair_messages'] = [];
function gfrv($name, $filter = FILTER_VALIDATE_INT, $options = []) {
return 5;
}
function grv($name, $default = '') {
return 5;
}
function data_source_authorized($id) {
return $GLOBALS['ds_repair_state']['authorized'];
}
function raise_message($id, $message = '', $level = 0) {
$GLOBALS['ds_repair_messages'][] = $id;
}
function header($location, $replace = true, $response_code = 0) {
// swallow the redirect; this probe runs on the CLI
}
function rrdtool_function_info($id) {
return $GLOBALS['ds_repair_state']['rrd_info'];
}
function cacti_sizeof($value) {
return is_countable($value) ? count($value) : 0;
}
function rrdtool_cacti_compare($id, &$info) {
return $GLOBALS['ds_repair_state']['diff'];
}
function get_data_source_path($id, $expand) {
return '/tmp/fixture.rrd';
}
function is_writeable($path) {
return $GLOBALS['ds_repair_state']['writable'];
}
function read_config_option($name, $force = false) {
return 'rrdtool';
}
function rrd_strip_control_chars($command_line) {
return preg_replace('/[\x00-\x1f\x7f]/', '', $command_line);
}
function rrdtool_execute($command, $log_to_stdout, $output_flag, $rrdtool_pipe = false, $logopt = 'WEBLOG') {
return $GLOBALS['ds_repair_state']['exec_result'];
}
function cacti_log($message, $print = false, $type = '', $level = 0) {
// no-op
}
function __($text, ...$args) {
return $args !== [] ? @vsprintf($text, $args) : $text;
}
cacti_fixture_source_function(dirname(__DIR__, 3) . '/data_sources.php', 'ds_repair', __NAMESPACE__);
/**
* Reset the boundary state, overriding individual keys, then run the handler.
*/
function run_scenario(array $overrides) : void {
$GLOBALS['ds_repair_state'] = array_merge([
'authorized' => true,
'rrd_info' => ['rra' => [0 => []], 'filename' => '/tmp/fixture.rrd', 'ds' => []],
'diff' => ['tune' => ['/tmp/fixture.rrd DS:traffic_in:COUNTER:600:0:U']],
'writable' => true,
'exec_result' => true,
], $overrides);
ds_repair();
}
/* unauthorized -> permission_denied, no repair */
run_scenario(['authorized' => false]);
/* RRDfile unreadable (rrdtool info returns false) */
run_scenario(['rrd_info' => false]);
/* RRDfile present but no RRA data */
run_scenario(['rrd_info' => ['rra' => [], 'filename' => '/tmp/fixture.rrd']]);
/* nothing to tune */
run_scenario(['diff' => []]);
/* tune needed but the RRDfile is not writable by the web server */
run_scenario(['writable' => false]);
/* writable: one control-character recommendation is skipped, one succeeds */
run_scenario([
'diff' => ['tune' => ["/tmp/fixture.rrd DEL:control\x01", '/tmp/fixture.rrd DS:traffic_in:COUNTER:600:0:U']],
'exec_result' => true,
]);
/* writable: the tune command fails, so the repair is reported as failed */
run_scenario(['exec_result' => false]);
$messages = $GLOBALS['ds_repair_messages'];
foreach (['permission_denied', 'ds_repair'] as $expected) {
if (!in_array($expected, $messages, true)) {
throw new RuntimeException('ds_repair did not raise the expected "' . $expected . '" message');
}
}
print json_encode(['ds_repair_scenarios' => count($messages)], JSON_THROW_ON_ERROR) . "\n";
@@ -389,8 +389,13 @@ header_redirect ./data_sources.php header('Location: data_sources.php');
header_redirect ./data_sources.php header('Location: data_sources.php');
header_redirect ./data_sources.php header('Location: data_sources.php');
header_redirect ./data_sources.php header('Location: data_sources.php');
header_redirect ./data_sources.php header('Location: data_sources.php');
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&host_id=' . grv('host_id') . '&new=1');
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . $id);
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . $id);
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . $id);
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . (empty($local_data_id) ? gfrv('local_data_id') : $local_data_id) . '&host_id=' . grv('host_id') . '&view_rrd=' . (isrv('current_rrd') ? gnrv('current_rrd') : '0'));
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . $id);
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . grv('id') . "&view_rrd=$data_template_rrd_id");
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . grv('id'));
header_redirect ./data_sources.php header('Location: data_sources.php?action=ds_edit&id=' . grv('id'));
Can't render this file because it contains an unexpected character in line 22 and column 251.
+2
View File
@@ -42,4 +42,6 @@ run_php_probe tests/fixtures/Core/GraphViewCrossFilterCoverage.php '^(lib/html\.
run_php_probe tests/fixtures/Core/TreeFilterDeviceTemplateCoverage.php '^(lib/html_tree\.php|tests/fixtures/Core/TreeFilterDeviceTemplateCoverage\.php)'
run_php_probe tests/fixtures/Core/DataSourceRrdRepairCoverage.php '^(data_sources\.php|tests/fixtures/Core/DataSourceRrdRepairCoverage\.php)'
run_probe tests/e2e/installer-coverage.sh '^(lib/installer\.php|cli/(install_cacti|upgrade_database)\.php|install/(functions|preflight|report|background|install)\.php|tests/e2e/installer)' 18277