diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index dee97073..da534361 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -103,7 +103,11 @@ jobs: - name: Setup Permissions run: | - sudo chown -R www-data:runner ${{ github.workspace }}/cacti + # Composer runs as the runner user in a later step and calls touch() + # while generating the fixture autoloader. utime() only succeeds for the + # file owner, so the runner user must own the tree here; www-data keeps + # group access. Ownership is handed back to www-data before polling. + sudo chown -R runner:www-data ${{ github.workspace }}/cacti sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \; sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \; sudo chmod +x ${{ github.workspace }}/cacti/cmd.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e51a84..5cf3ffdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ --- develop --- +* issue: Remove the Listener and Stream from the report page. They are part of what a Filter materializes, so changing them there ran the report against data that was never materialized. They are now set only in the Filter editor, and the report page reads them straight from the saved Filter. The New button now follows Edit. + +* issue: Start a materialization automatically when a Filter is saved with a change that affects its cached results, from either the Filter editor or the FlowView tab, and say so, instead of asking the user to start one by hand from the Filters page. + +* issue: Queue a fresh materialization when a Filter is saved while one is already running for it. The active run is working from the settings as they were, so leaving the save to it left the new settings uncached; the save now says the rebuild is queued rather than started, and the poller launches it once no materialization is running. Each queued Filter is kept in its own settings row with a token the poller compare-and-deletes, so concurrent saves never overwrite one another and a Filter re-queued while the poller is draining is not dropped. + +* issue: Point the NetFlow graph drilldown at a saved Filter scoped to the graph's exporter. The report page now reads the Listener and Stream from the Filter, so the icon's old ex_addr and listener overrides were ignored and it showed every exporter under the default Filter; it now links to a saved Filter matching both the graph's Listener and Stream, picks the most specific one deterministically when a Listener has more than one, and is omitted when none matches rather than pointing at a Filter for the wrong Stream. + * issue: Stop a scheduled report whose saved Filter has been deleted from filling the PHP log with warnings on every run. The schedule poller read the timespan and name of a query that was no longer there, and ran the empty result through the report, so each run logged a burst of "Undefined array key" and "array offset on bool" warnings. The runner now logs one warning naming the schedule and the missing query and stops, and run_flow_query() returns nothing for a Filter that is gone before reading any of its columns * issue: Materialize the newest partition that has rotated. A partition is only cached as a full scan when the materialization range covers all of its flows, but the range ended only a second past the last partition's nominal window, while a flow is written to the partition of the time it was captured and can end after that window (a flow still open as the partition rotates, or an exporter whose clock is ahead). The newest rotated partition, the one most likely to hold such a flow and still on the InnoDB engine, was therefore never a full scan and never cached, so its Filter stayed Required even right after a materialization. The range now pads the end by a day, as it already did the start diff --git a/flowview.php b/flowview.php index f49259e0..6b739cec 100644 --- a/flowview.php +++ b/flowview.php @@ -176,9 +176,8 @@ function save_filter_as(): void { } } + /* the Listener and Stream stay as the source Filter has them, they are not on the page */ $overrides = [ - 'device_id', - 'ex_addr', 'excluded', 'sortfield', 'cutofflines', @@ -412,17 +411,8 @@ function load_session_for_filter() { break; case 'device_id': - if (!isset_request_var('device_id') || get_nfilter_request_var('device_id') == '-1') { - set_request_var('device_id', $value); - } - - break; case 'ex_addr': - /* the page lists no Stream as 'All' */ - if (!isset_request_var('ex_addr') || get_nfilter_request_var('ex_addr') == '') { - set_request_var('ex_addr', ($value === '' || $value == '-1') ? '0' : $value); - } - + /* the Listener and Stream are read straight from the saved Filter, not the page */ break; case 'panel_table': case 'panel_bytes': @@ -519,11 +509,8 @@ function flowview_request_vars(): void { } /* ================= input validation and session storage ================= */ + /* the Listener and Stream are part of the Filter, so the report page neither shows nor sends them */ $filters = array( - 'device_id' => [ - 'filter' => FILTER_VALIDATE_INT, - 'default' => '0' - ], 'sortfield' => array( 'filter' => FILTER_CALLBACK, 'options' => ['options' => 'sanitize_search_string'], @@ -560,11 +547,6 @@ function flowview_request_vars(): void { 'options' => ['options' => 'sanitize_search_string'], 'default' => '' ), - 'ex_addr' => array( - 'filter' => FILTER_CALLBACK, - 'options' => ['options' => 'sanitize_search_string'], - 'default' => '0' - ), 'domains' => array( 'filter' => FILTER_VALIDATE_REGEXP, 'options' => array('options' => array('regexp' => '(true|false)')), diff --git a/flowview_process.php b/flowview_process.php index d9cad799..f49dc3b2 100644 --- a/flowview_process.php +++ b/flowview_process.php @@ -198,6 +198,9 @@ /* partitions that went cold this cycle are materialized for every filter */ flowview_materialize_rotated_partitions(); +/* Filters saved while a materialization had them are rebuilt now the queue is quiet */ +flowview_launch_queued_rematerializations(); + if ($maint) { flowview_debug('Performing Table Maintenance'); diff --git a/includes/functions.php b/includes/functions.php index 2a9845f7..40d4cb55 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -349,11 +349,9 @@ function edit_filter(): void { $js = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; print "'; - flowview_show_saved_dialog(); - ?> - get_request_var('device_id'), - 'ex_addr' => $ex_addr, 'statistics' => $statistical, 'printed' => $printed, 'sortfield' => get_nfilter_request_var('sortfield'), @@ -843,8 +781,6 @@ function save_filter_form(): void { SET timespan = ?, startdate = ?, enddate = ?, - device_id = ?, - ex_addr = ?, statistics = ?, printed = ?, sortfield = ?, @@ -862,8 +798,6 @@ function save_filter_form(): void { get_request_var('timespan'), $startdate, $enddate, - get_request_var('device_id'), - $ex_addr, $statistical, $printed, get_nfilter_request_var('sortfield'), @@ -883,7 +817,21 @@ function save_filter_form(): void { /* the next visit takes the Filter as it is saved, such as dates saved without pressing Go */ flowview_reset_filter_session(get_request_var('query')); - print json_encode(['rematerialize' => $material]); + /* a change to what is cached starts a materialization of the Filter in the background */ + $queued = false; + + if ($material) { + /* a Filter that is already being materialized is left to the active run, + * which is working from the old settings, so queue a fresh rebuild that + * the poller starts once that run finishes */ + if (!cacti_sizeof(flowview_launch_materialize([(int) get_request_var('query')]))) { + flowview_queue_rematerialize((int) get_request_var('query')); + + $queued = true; + } + } + + print json_encode(['rematerialize' => $material, 'queued' => $queued]); } /** @@ -911,9 +859,18 @@ function save_filter(): void { flowview_exit(); } - /* a change to what is cached is said in a dialog, with the note to materialize it again */ + /* a change to what is cached starts a materialization of the Filter in the background, and says so */ if ($material) { - $_SESSION['flowview_rematerialize_saved'] = (int) $id; + /* a Filter that is already being materialized is left to the active run, + * which is working from the old settings, so queue a fresh rebuild that + * the poller starts once that run finishes, and say which happened */ + if (cacti_sizeof(flowview_launch_materialize([(int) $id]))) { + raise_message('flowview_materialize_started', __('The changes to this Filter affect its cached results, so a materialization of it has been started in the background. Its progress is shown on the Filters page and its results are reported in the Cacti log.', 'flowview'), MESSAGE_LEVEL_INFO); + } else { + flowview_queue_rematerialize((int) $id); + + raise_message('flowview_materialize_queued', __('The changes to this Filter affect its cached results. A materialization of it is already running, so a fresh one is queued and will start when that finishes. Its progress is shown on the Filters page and its results are reported in the Cacti log.', 'flowview'), MESSAGE_LEVEL_INFO); + } } else { raise_message(1); } @@ -1020,9 +977,8 @@ function flowview_seed_filter_request_vars(array $filter): void { return; } + /* the Listener and Stream are read from the saved Filter at query time, so they are not seeded here */ $columns = [ - 'device_id' => 'device_id', - 'ex_addr' => 'ex_addr', 'sortfield' => 'sortfield', 'cutofflines' => 'cutofflines', 'cutoffoctets' => 'cutoffoctets', @@ -1037,12 +993,7 @@ function flowview_seed_filter_request_vars(array $filter): void { $value = $filter[$column]; - /* the page lists no Stream as 'All' */ - if ($variable == 'ex_addr' && ($value === '' || $value == '-1')) { - $value = '0'; - } - - if ($value === '' && $variable != 'ex_addr') { + if ($value === '') { continue; } @@ -1201,9 +1152,6 @@ function flowview_display_filter(): void { } } - /* a Filter saved from its editor, that was opened from this page, comes back here */ - flowview_show_saved_dialog(); - html_start_box($title . ' ', '100%', false, 3, 'center', ''); ?> @@ -1231,63 +1179,12 @@ function flowview_display_filter(): void { ?> - - - - - - - - - - - - - + @@ -1683,7 +1580,7 @@ function flowview_display_filter(): void { changeQuery(true); }); - $('#domains, #usenat, #exclude, #graph_type, #graph_height, #device_id, #ex_addr, #report, #sortfield, #cutofflines, #cutoffoctets').off('change').on('change', function() { + $('#domains, #usenat, #exclude, #graph_type, #graph_height, #report, #sortfield, #cutofflines, #cutoffoctets').off('change').on('change', function() { applyFilter(false); }); @@ -2049,8 +1946,6 @@ function renderBarChart(type, bindto, title, label, height, width) { '&usenat=' + $('#usenat').is(':checked') + '&query=' + $('#query').val() + reportParam() + - '&device_id=' + $('#device_id').val() + - '&ex_addr=' + ($('#ex_addr').val() != null ? $('#ex_addr').val():'') + '&sortfield=' + ($('#sortfield').val() != null ? $('#sortfield').val():'') + '&cutofflines=' + $('#cutofflines').val() + '&cutoffoctets=' + $('#cutoffoctets').val() + @@ -2114,8 +2009,6 @@ function renderTreemapChart(type, bindto, title, label, height, width) { '&usenat=' + $('#usenat').is(':checked') + '&query=' + $('#query').val() + reportParam() + - '&device_id=' + $('#device_id').val() + - '&ex_addr=' + ($('#ex_addr').val() != null ? $('#ex_addr').val():'') + '&sortfield=' + ($('#sortfield').val() != null ? $('#sortfield').val():'') + '&cutofflines=' + $('#cutofflines').val() + '&cutoffoctets=' + $('#cutoffoctets').val() + @@ -2180,8 +2073,6 @@ function renderPieChart(type, bindto, title, label, height, width) { '&usenat=' + $('#usenat').is(':checked') + '&query=' + $('#query').val() + reportParam() + - '&device_id=' + $('#device_id').val() + - '&ex_addr=' + ($('#ex_addr').val() != null ? $('#ex_addr').val():'') + '&sortfield=' + ($('#sortfield').val() != null ? $('#sortfield').val():'') + '&cutofflines=' + $('#cutofflines').val() + '&cutoffoctets=' + $('#cutoffoctets').val() + @@ -2276,8 +2167,6 @@ function saveFilter() { '&usenat=' + $('#usenat').is(':checked') + '×pan=' + $('#predefined_timespan').val() + reportParam() + - '&device_id=' + $('#device_id').val() + - '&ex_addr=' + ($('#ex_addr').val() != null ? $('#ex_addr').val():'') + '&sortfield=' + ($('#sortfield').val() != null ? $('#sortfield').val():'') + '&cutofflines=' + $('#cutofflines').val() + '&cutoffoctets=' + $('#cutoffoctets').val() + @@ -2293,7 +2182,11 @@ function saveFilter() { Pace.stop(); if (data['rematerialize']) { - $('#text').show().text('[ ]').fadeOut(10000); + if (data['queued']) { + $('#text').show().text('[ ]').fadeOut(10000); + } else { + $('#text').show().text('[ ]').fadeOut(10000); + } } else { $('#text').show().text('[ ]').fadeOut(2000); } @@ -2379,7 +2272,6 @@ function applyFilter(reset) { if (reset) { report = '&report=0'; - var device_id = 0; } else { /* only a Filter that has no Report has one on the page */ report = reportParam(); @@ -2387,10 +2279,9 @@ function applyFilter(reset) { if ($('#report').length && $('#report').val().indexOf('p') >= 0) { extra = '&table=true&bytes=false&packets=false&flows=false'; } - - var device_id = $('#device_id').val(); } + /* the Listener and Stream are part of the Filter, so they are not sent from the page */ loadPageNoHeader(urlPath+'plugins/flowview/flowview.php' + '?action=view' + '&domains=' + $('#domains').is(':checked') + @@ -2398,8 +2289,6 @@ function applyFilter(reset) { '&query=' + $('#query').val() + '&predefined_timespan=' + $('#predefined_timespan').val() + report + - '&device_id=' + device_id + - '&ex_addr=' + ($('#ex_addr').val() != null ? $('#ex_addr').val():'') + '&sortfield=' + ($('#sortfield').val() != null ? $('#sortfield').val():'') + '&cutofflines=' + $('#cutofflines').val() + '&cutoffoctets=' + $('#cutoffoctets').val() + @@ -2585,7 +2474,6 @@ function get_flowview_session_key($id, $start, $end) { $key = $id . '_' . $start . '_' . $end . '_' . get_request_var('report') . '_' . get_request_var('timespan') . '_' . - get_request_var('device_id') . '_' . get_request_var('sortfield') . '_' . get_request_var('cutofflines') . '_' . get_request_var('cutoffoctets') . '_' . @@ -3397,22 +3285,13 @@ function run_flow_query($session, $query_id, $start, $end, bool $plan_only = fal $sql_having = ''; } - /* device id override */ - if (isset_request_var('device_id') && get_filter_request_var('device_id') >= 0) { - if (get_request_var('device_id') > 0) { - $sql_where = get_numeric_filter($sql_where, $sql_params, get_request_var('device_id'), 'listener_id'); - } - } elseif (isset($data['device_id']) && $data['device_id'] > 0) { + /* the Listener is part of the Filter and what is materialized, so it is taken from the saved Filter, not the page */ + if (isset($data['device_id']) && $data['device_id'] > 0) { $sql_where = get_numeric_filter($sql_where, $sql_params, $data['device_id'], 'listener_id'); } - /* ex_addr override, an exporter address, so anything else is not one */ - $ex_addr = isset_request_var('ex_addr') ? filter_var(get_nfilter_request_var('ex_addr'), FILTER_VALIDATE_IP) : false; - - if ($ex_addr !== false) { - $sql_where .= ($sql_where != '' ? ' AND ':'') . 'ex_addr = ?'; - $sql_params[] = $ex_addr; - } elseif (isset($data['ex_addr']) && $data['ex_addr'] != '-1' && $data['ex_addr'] != '' && $data['ex_addr'] != '0') { + /* the Stream is part of the Filter too, so it is also taken from the saved Filter */ + if (isset($data['ex_addr']) && $data['ex_addr'] != '-1' && $data['ex_addr'] != '' && $data['ex_addr'] != '0') { $sql_where .= ($sql_where != '' ? ' AND ':'') . 'ex_addr = ?'; $sql_params[] = $data['ex_addr']; } @@ -4745,6 +4624,193 @@ function flowview_launch_materialize(array $query_ids = [], array $partitions = return $targets; } +/** + * flowview_graph_button_filter - The saved Filter the NetFlow graph button + * drills into for a graph host, scoped to both the host's Listener and its + * Stream. The report page now reads the Listener and Stream from the Filter + * itself, so the drilldown has to land on a Filter that is actually scoped to + * that host rather than any Filter for the right Listener. + * + * A Filter matches when its Listener is the host's Listener or the All-listener + * sentinel (0), and its Stream is the host's Stream or an All-stream sentinel + * ('-1', '' or '0'). A Filter scoped to a different specific Stream is not a + * match, so the button no longer silently points at the wrong exporter. When + * more than one Filter matches, the most specific one wins deterministically: + * an exact Listener first, then an exact Stream, then the lowest id. An empty + * array is returned when nothing matches, so the caller can omit the link. + * + * @param int $device_id The graph host's Listener (device) id + * @param string $ex_addr The graph host's Stream (exporter) address + * + * @return array The matching Filter row, or an empty array when none matches + */ +function flowview_graph_button_filter(int $device_id, string $ex_addr): array { + $filter = flowview_db_fetch_row_prepared('SELECT * + FROM plugin_flowview_queries + WHERE (device_id = ? OR device_id = 0) + AND (ex_addr = ? OR ex_addr = ? OR ex_addr = ? OR ex_addr = ?) + ORDER BY (device_id = ?) DESC, (ex_addr = ?) DESC, id ASC + LIMIT 1', + [$device_id, $ex_addr, '-1', '', '0', $device_id, $ex_addr]); + + return is_array($filter) ? $filter : []; +} + +/** + * flowview_rematerialize_queue_key - The settings row name that a single + * queued Filter is stored under. Each Filter has its own row so concurrent + * saves never race on one shared value. + * + * @param int $query_id The FlowView query id + * + * @return string + */ +function flowview_rematerialize_queue_key(int $query_id): string { + return 'flowview_rematerialize_queue_' . $query_id; +} + +/** + * flowview_queue_rematerialize - Record that a Filter needs a fresh + * materialization once the run that currently has it finishes. A save that + * changes what a Filter caches starts a materialization for it, but + * flowview_launch_materialize() leaves a Filter that is already being + * materialized to the active run, which is working from the settings as they + * were. The Filter is queued here so the poller rebuilds it under the new + * settings once no materialization is running. + * + * Each Filter is stored as its own settings row whose value is a fresh token, + * rather than as one shared comma-separated list. Two saves can therefore + * queue different Filters at once without overwriting one another, and a save + * that re-queues a Filter after the poller has read it writes a new token that + * the poller's compare-and-delete will not clear, so the rebuild is never lost. + * + * @param int $query_id The FlowView query id to rematerialize + * + * @return void + */ +function flowview_queue_rematerialize(int $query_id): void { + if ($query_id <= 0) { + return; + } + + db_execute_prepared('INSERT INTO settings (name, value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)', + [flowview_rematerialize_queue_key($query_id), uniqid('', true)]); +} + +/** + * flowview_dequeue_rematerialize - Clear a Filter's queue entry, but only when + * its token still matches the one that was read. A save that re-queued the + * Filter after the read has replaced the token, so its entry is left in place + * for the next cycle rather than being cleared out from under it. + * + * @param int $query_id The FlowView query id to clear + * @param string $token The token that was read for the entry + * + * @return void + */ +function flowview_dequeue_rematerialize(int $query_id, string $token): void { + db_execute_prepared('DELETE FROM settings + WHERE name = ? + AND value = ?', + [flowview_rematerialize_queue_key($query_id), $token]); +} + +/** + * flowview_get_queued_rematerialization_tokens - The queued Filters as a map of + * Filter id to the token its entry currently holds. The token is what the + * poller compares against when it clears an entry, so a Filter that was + * re-queued after the read is not dropped. + * + * @return array The queued FlowView query ids mapped to their tokens + */ +function flowview_get_queued_rematerialization_tokens(): array { + $rows = db_fetch_assoc_prepared('SELECT name, value + FROM settings + WHERE name LIKE ?', + ['flowview\_rematerialize\_queue\_%']); + + $tokens = []; + + foreach ($rows as $row) { + if (strncmp($row['name'], 'flowview_rematerialize_queue_', 29) !== 0) { + continue; + } + + $id = (int) substr($row['name'], 29); + + if ($id > 0) { + $tokens[$id] = (string) $row['value']; + } + } + + ksort($tokens); + + return $tokens; +} + +/** + * flowview_get_queued_rematerializations - The Filter ids that are waiting for + * a fresh materialization once the runs that currently have them finish. + * + * @return array The queued FlowView query ids + */ +function flowview_get_queued_rematerializations(): array { + return array_keys(flowview_get_queued_rematerialization_tokens()); +} + +/** + * flowview_launch_queued_rematerializations - Start the Filters that were + * queued for a fresh materialization while an earlier run had them. Called + * from the poller, it waits for a moment when no materialization is running so + * the whole Filter is rebuilt under its saved settings, drops any queued + * Filter that no longer exists, and leaves a Filter whose run has not yet + * ended queued for a later cycle. + * + * @return array The Filter ids a materialization was started for + */ +function flowview_launch_queued_rematerializations(): array { + $pending = flowview_get_queued_rematerialization_tokens(); + + if (!cacti_sizeof($pending)) { + return []; + } + + /* wait for a quiet moment so the Filter is rebuilt in full, not raced */ + if (flowview_materialization_running()) { + return []; + } + + $ids = array_keys($pending); + + /* a Filter that was deleted while queued is dropped rather than retried forever */ + $existing = array_map('intval', array_column(flowview_db_fetch_assoc_prepared('SELECT id + FROM plugin_flowview_queries + WHERE id IN (' . implode(', ', array_fill(0, cacti_sizeof($ids), '?')) . ')', + $ids), 'id')); + + foreach (array_diff($ids, $existing) as $gone) { + flowview_dequeue_rematerialize((int) $gone, $pending[$gone]); + } + + $ids = array_values(array_intersect($ids, $existing)); + + if (!cacti_sizeof($ids)) { + return []; + } + + $started = flowview_launch_materialize($ids); + + /* clear only the entries actually started, and only when the token still + * matches, so a save that re-queued a Filter after the read is kept */ + foreach ($started as $id) { + flowview_dequeue_rematerialize((int) $id, $pending[$id]); + } + + return $started; +} + /** * parallel_database_query_map_query - The map query of a request as the JSON * that is stored and hashed. The time range is left out, so the same query diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index fe3d2f15..c7bf772b 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-10-22 09:18-0400\n" +"POT-Creation-Date: 2026-10-23 16:08-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -804,7 +804,6 @@ msgid "Ex Addr" msgstr "" #: flowview_devices.php flowview_filters.php includes/arrays.php -#: includes/functions.php msgid "All" msgstr "" @@ -1003,7 +1002,7 @@ msgstr "" msgid "Materialization pre-computes the results of a Filter for each flow partition, with NAT on and off, and stores them in the Parallel Query cache, so reports over older data open quickly. New partitions are materialized automatically once they stop receiving flows. Required means partitions that have rotated are not materialized yet, Running means a materialization is in progress, and Up to date means every rotated partition is materialized. Running: N is how many queries of the Filter are working now. The partition still receiving flows is not materialized, so it does not count." msgstr "" -#: flowview_filters.php includes/arrays.php includes/functions.php +#: flowview_filters.php includes/arrays.php msgid "Listener" msgstr "" @@ -1969,23 +1968,11 @@ msgid "Cached Results Affected" msgstr "" #: includes/functions.php -msgid "These changes affect the cached results of this Filter. Once it is saved they no longer match it, and the Filter has to be materialized again. Press 'Continue' to save the Filter, or 'Cancel' to keep editing it." -msgstr "" - -#: includes/functions.php -msgid "The changes to this Filter affect its cached results, so it has to be materialized again. To start a materialization, select the Filter on the Filters page and choose the 'Materialize' action." +msgid "These changes affect the cached results of this Filter. Saving it starts a materialization in the background to rebuild them. Press 'Continue' to save the Filter, or 'Cancel' to keep editing it." msgstr "" #: includes/functions.php -msgid "Save Succeeded" -msgstr "" - -#: includes/functions.php -msgid "Save Succeeded." -msgstr "" - -#: includes/functions.php -msgid "OK" +msgid "The changes to this Filter affect its cached results, so a materialization of it has been started in the background. Its progress is shown on the Filters page and its results are reported in the Cacti log." msgstr "" #: includes/functions.php @@ -2006,18 +1993,6 @@ msgstr "" msgid "Select a Filter" msgstr "" -#: includes/functions.php -msgid "Select a Listener" -msgstr "" - -#: includes/functions.php -msgid "Stream" -msgstr "" - -#: includes/functions.php -msgid "Select a Stream Host" -msgstr "" - #: includes/functions.php msgid "Apply Filter" msgstr "" @@ -2027,19 +2002,19 @@ msgid "Clear Filter" msgstr "" #: includes/functions.php -msgid "Create new Flow Filter" +msgid "Edit" msgstr "" #: includes/functions.php -msgid "New" +msgid "Edit the Flow Filter" msgstr "" #: includes/functions.php -msgid "Edit" +msgid "Create new Flow Filter" msgstr "" #: includes/functions.php -msgid "Edit the Flow Filter" +msgid "New" msgstr "" #: includes/functions.php @@ -2255,7 +2230,7 @@ msgid " " msgstr "" #: includes/functions.php -msgid "Filter Settings Saved. A forced re-materialization of this Filter is recommended, use the Materialize action on the Filters page." +msgid "Filter Settings Saved. A materialization of this Filter has been started in the background." msgstr "" #: includes/functions.php diff --git a/setup.php b/setup.php index 3f6a28fd..9a655639 100644 --- a/setup.php +++ b/setup.php @@ -1495,6 +1495,8 @@ function flowview_graph_button($data): bool { /* if the setting is not already cached, search */ if (!isset($flow_hosts_map[$host_id])) { + $flow_hosts_map[$host_id] = false; + foreach($host_data as $ex_addr => $hd_host_id) { /* trim the domains from the ex_addr */ if (!is_ipaddress($ex_addr)) { @@ -1503,34 +1505,43 @@ function flowview_graph_button($data): bool { foreach($flow_hosts as $host) { if ($host['ex_addr'] == $ex_addr) { - $flow_hosts_map[$host_id] = flowview_db_fetch_row_prepared('SELECT * - FROM plugin_flowview_queries - WHERE device_id = ? - LIMIT 1', - [$host['id']]); - - $flow_hosts_map[$host_id]['ex_addr'] = $ex_addr; + /* scope the drilldown to this host's Listener and Stream, not + * just any Filter for the Listener, and leave it false when no + * scoped Filter matches so the link is omitted below */ + $filter = flowview_graph_button_filter((int) $host['id'], (string) $host['ex_addr']); - $query_data = $flow_hosts_map[$host_id]; + if (cacti_sizeof($filter)) { + $flow_hosts_map[$host_id] = $filter; + } break; } } + + if ($flow_hosts_map[$host_id] !== false) { + break; + } } + + $query_data = $flow_hosts_map[$host_id]; } else { $query_data = $flow_hosts_map[$host_id]; } - if ($query_data === false) { + if ($query_data === false || empty($query_data['id'])) { return false; } - $url = $config['url_path'] . "plugins/flowview/flowview.php?action=view&query=$flowview_default_filter×pan=session&ex_addr={$query_data['ex_addr']}"; + /* Link to the saved Filter that is scoped to this graph's exporter. The + * report page now reads the Listener and Stream from the Filter itself, + * so the old ex_addr/device_id overrides are no longer sent, and the link + * is omitted above when no Filter matches rather than showing every + * exporter under the default Filter. */ + $url = $config['url_path'] . "plugins/flowview/flowview.php?action=view&query={$query_data['id']}×pan=session"; /* initialize settings from the database if they are not set already */ if (cacti_sizeof($query_data)) { $columns = [ - 'device_id', 'includeif', 'sortfield', 'cutofflines', diff --git a/tests/Unit/CoreFunctionsTest.php b/tests/Unit/CoreFunctionsTest.php index f2adf7bc..be3b77bd 100644 --- a/tests/Unit/CoreFunctionsTest.php +++ b/tests/Unit/CoreFunctionsTest.php @@ -376,11 +376,6 @@ ), )); -it('tells the user to start a materialization with the Materialize action', function () { - expect(flowview_rematerialize_recommendation())->toContain('materialized again') - ->and(flowview_rematerialize_recommendation())->toContain("'Materialize' action"); -}); - describe('warning before a Filter is saved with a change to what is cached', function () { beforeEach(function () { plugin_test_reset(); @@ -421,33 +416,6 @@ }))->toBeEmpty(); }); - it('says the save succeeded and to materialize again, once, in a dialog', function () { - $_SESSION['flowview_rematerialize_saved'] = 4; - - ob_start(); - flowview_show_saved_dialog(); - $first = ob_get_clean(); - - ob_start(); - flowview_show_saved_dialog(); - $second = ob_get_clean(); - - expect($first)->toContain("id='flowview_saved_dialog'") - ->and($first)->toContain('Save Succeeded') - ->and($first)->toContain('Materialize') - ->and($first)->toContain("\$('#flowview_saved_dialog').dialog(") - ->and($first)->toContain('modal: false') - ->and($second)->toBe('') - ->and(isset($_SESSION['flowview_rematerialize_saved']))->toBeFalse(); - }); - - it('shows nothing when nothing was saved with a change to what is cached', function () { - ob_start(); - flowview_show_saved_dialog(); - - expect(ob_get_clean())->toBe(''); - }); - it('warns from the Save button with Cancel and Continue, and no longer raises a warning message', function () { $source = plugin_test_read_source('includes/functions.php'); @@ -525,6 +493,201 @@ function cacti_escapeshellarg($string) { )); }); +it('scopes the graph drilldown Filter to both the listener and the exporter', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', array('id' => 9, 'device_id' => 3, 'ex_addr' => '10.1.1.1')); + + $filter = flowview_graph_button_filter(3, '10.1.1.1'); + + $call = $GLOBALS['__test_db_calls'][0]; + + /* the match is on both the listener and the exporter (plus the All-stream + * sentinels), and is deterministic, so a second Filter on the same listener + * for a different stream is never chosen, and the exact exporter wins */ + expect($filter)->toBe(array('id' => 9, 'device_id' => 3, 'ex_addr' => '10.1.1.1')) + ->and($call['fn'])->toBe('db_fetch_row_prepared') + ->and($call['sql'])->toContain('(device_id = ? OR device_id = 0)') + ->and($call['sql'])->toContain('(ex_addr = ? OR ex_addr = ? OR ex_addr = ? OR ex_addr = ?)') + ->and($call['sql'])->toContain('ORDER BY (device_id = ?) DESC, (ex_addr = ?) DESC, id ASC') + ->and($call['sql'])->toContain('LIMIT 1') + ->and($call['params'])->toBe(array(3, '10.1.1.1', '-1', '', '0', 3, '10.1.1.1')); +}); + +it('omits the graph drilldown when no Filter is scoped to the host', function () { + plugin_test_queue_db_result('db_fetch_row_prepared', array()); + + expect(flowview_graph_button_filter(3, '10.1.1.1'))->toBe(array()); +}); + +it('queues a Filter for a fresh materialization under its own settings row', function () { + flowview_queue_rematerialize(4); + flowview_queue_rematerialize(7); + + $inserts = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'INSERT INTO settings') === 0; + })); + + expect($inserts)->toHaveCount(2) + ->and($inserts[0]['sql'])->toContain('ON DUPLICATE KEY UPDATE') + ->and($inserts[0]['params'][0])->toBe('flowview_rematerialize_queue_4') + ->and($inserts[1]['params'][0])->toBe('flowview_rematerialize_queue_7') + ->and($inserts[0]['params'][1])->not->toBe($inserts[1]['params'][1]); +}); + +it('lists each queued Filter once, in id order, from its settings rows', function () { + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'flowview_rematerialize_queue_7', 'value' => 'tok7'), + array('name' => 'flowview_rematerialize_queue_4', 'value' => 'tok4'), + )); + + expect(flowview_get_queued_rematerializations())->toBe(array(4, 7)); +}); + +it('skips a settings row that matches the case-insensitive query but not the exact prefix', function () { + /* the SQL LIKE can match case-insensitively, so a row whose name only + * differs in case passes the query but must be rejected by the stricter + * case-sensitive prefix guard rather than being treated as a queue entry */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'FLOWVIEW_REMATERIALIZE_QUEUE_9', 'value' => 'tok9'), + array('name' => 'flowview_rematerialize_queue_4', 'value' => 'tok4'), + )); + + expect(flowview_get_queued_rematerializations())->toBe(array(4)); +}); + +it('starts the queued Filters once no materialization is running, and clears their entries', function () { + $original_base_path = $GLOBALS['config']['base_path']; + $GLOBALS['config']['base_path'] = '/var/www/cacti'; + $GLOBALS['__test_config']['path_php_binary'] = '/usr/bin/php'; + $GLOBALS['__test_exec'] = array(); + + /* the queued entries, then: nothing materializing (filters, then running + * requests), the queued Filters still exist, and the launch finds nothing + * materializing either */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'flowview_rematerialize_queue_4', 'value' => 'tok4'), + array('name' => 'flowview_rematerialize_queue_7', 'value' => 'tok7'), + )); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array(array('id' => 4), array('id' => 7))); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + + try { + $started = flowview_launch_queued_rematerializations(); + } finally { + $GLOBALS['config']['base_path'] = $original_base_path; + } + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'DELETE FROM settings') === 0; + })); + + expect($started)->toBe(array(4, 7)) + ->and($GLOBALS['__test_exec'])->toBe(array( + array('/usr/bin/php', "'/var/www/cacti/plugins/flowview/cli/materialize_queries.php' --query-id=4,7"), + )) + ->and($deletes)->toHaveCount(2) + ->and($deletes[0]['params'])->toBe(array('flowview_rematerialize_queue_4', 'tok4')) + ->and($deletes[1]['params'])->toBe(array('flowview_rematerialize_queue_7', 'tok7')); +}); + +it('clears started entries with a compare-and-delete so a re-queue after the read survives', function () { + $original_base_path = $GLOBALS['config']['base_path']; + $GLOBALS['config']['base_path'] = '/var/www/cacti'; + $GLOBALS['__test_config']['path_php_binary'] = '/usr/bin/php'; + $GLOBALS['__test_exec'] = array(); + + /* the poller reads Filter 4 holding the token tok4-old; a concurrent save + * that re-queues 4 after this read writes a new token, so the delete keyed + * on tok4-old no longer matches and the rebuild is kept for the next cycle */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'flowview_rematerialize_queue_4', 'value' => 'tok4-old'), + )); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array(array('id' => 4))); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + + try { + $started = flowview_launch_queued_rematerializations(); + } finally { + $GLOBALS['config']['base_path'] = $original_base_path; + } + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'DELETE FROM settings') === 0; + })); + + expect($started)->toBe(array(4)) + ->and($deletes)->toHaveCount(1) + ->and($deletes[0]['sql'])->toContain('AND value = ?') + ->and($deletes[0]['params'])->toBe(array('flowview_rematerialize_queue_4', 'tok4-old')); +}); + +it('leaves the queue alone while a materialization is still running', function () { + $GLOBALS['__test_exec'] = array(); + + /* the queued entry, then a materialization is registered, so the rebuild waits */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'flowview_rematerialize_queue_4', 'value' => 'tok4'), + )); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array(array('taskid' => 4))); + + $started = flowview_launch_queued_rematerializations(); + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'DELETE FROM settings') === 0; + })); + + expect($started)->toBe(array()) + ->and($GLOBALS['__test_exec'])->toBe(array()) + ->and($deletes)->toBe(array()); +}); + +it('drops a queued Filter that was deleted rather than retrying it forever', function () { + $GLOBALS['__test_exec'] = array(); + + /* the queued entry, then nothing materializing (filters, then running + * requests), and the Filter is gone, so the existence lookup returns nothing */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array( + array('name' => 'flowview_rematerialize_queue_8', 'value' => 'tok8'), + )); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + + $started = flowview_launch_queued_rematerializations(); + + $deletes = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'DELETE FROM settings') === 0; + })); + + expect($started)->toBe(array()) + ->and($GLOBALS['__test_exec'])->toBe(array()) + ->and($deletes)->toHaveCount(1) + ->and($deletes[0]['params'])->toBe(array('flowview_rematerialize_queue_8', 'tok8')); +}); + +it('ignores a non-positive id when queueing a rematerialization', function () { + flowview_queue_rematerialize(0); + + $inserts = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' && strpos($call['sql'], 'INSERT INTO settings') === 0; + })); + + expect($inserts)->toBe(array()) + ->and(flowview_get_queued_rematerializations())->toBe(array()); +}); + +it('does nothing when no Filter is queued for rematerialization', function () { + $GLOBALS['__test_exec'] = array(); + + plugin_test_queue_db_result('db_fetch_assoc_prepared', array()); + + expect(flowview_launch_queued_rematerializations())->toBe(array()) + ->and($GLOBALS['__test_exec'])->toBe(array()); +}); + it('keys the cached shards on the map query without the time range', function () { $inner = array( 'sql_query' => 'SELECT src_addr, SUM(bytes) AS bytes', @@ -607,8 +770,8 @@ function array_rekey($array, $key, $value) { expect(flowview_plan_filter(99, true))->toBe(array()); }); -it('only takes an exporter address as the stream override', function ($value, $used) { - $filter = array( +it('takes the exporter address from the saved Filter, not the page', function () { + $base = array( 'id' => 4, 'name' => 'Top Talkers', 'device_id' => 0, 'ex_addr' => '', 'template_id' => -1, 'statistics' => 10, 'printed' => 0, 'sortfield' => 'bytes', 'cutofflines' => 20, 'cutoffoctets' => 0, 'includeif' => 0, 'usenat' => '', 'protocols' => '', 'tosfields' => '', 'tcpflags' => '', @@ -616,25 +779,26 @@ function array_rekey($array, $key, $value) { plugin_test_reset(); - plugin_test_queue_db_result('db_fetch_row_prepared', $filter); + plugin_test_queue_db_result('db_fetch_row_prepared', $base); $none = flowview_plan_filter(4, true); - $_REQUEST['ex_addr'] = $value; + /* a Stream on the page is ignored, it is no longer a filter field */ + $_REQUEST['ex_addr'] = '10.1.2.3'; + plugin_test_queue_db_result('db_fetch_row_prepared', $base); + $ignored = flowview_plan_filter(4, true); - plugin_test_queue_db_result('db_fetch_row_prepared', $filter); + plugin_test_reset(); + + /* the Stream saved on the Filter does change the plan */ + plugin_test_queue_db_result('db_fetch_row_prepared', array_merge($base, array('ex_addr' => '10.1.2.3'))); $with = flowview_plan_filter(4, true); plugin_test_reset(); expect($none)->toHaveCount(1) - ->and($with != $none)->toBe($used); -})->with(array( - 'an IPv4 address' => array('10.1.2.3', true), - 'an IPv6 address' => array('2001:db8::1', true), - 'markup' => array('', false), - 'All' => array('0', false), - 'a host and a port' => array('10.1.2.3:2055', false), -)); + ->and($ignored)->toBe($none) + ->and($with != $none)->toBeTrue(); +}); it('finds the partitions of a filter that are cached with and without NAT', function () { if (!function_exists('array_rekey')) { diff --git a/tests/Unit/FilterEditAndSaveTest.php b/tests/Unit/FilterEditAndSaveTest.php index 8475efe0..f2cb5ec8 100644 --- a/tests/Unit/FilterEditAndSaveTest.php +++ b/tests/Unit/FilterEditAndSaveTest.php @@ -71,7 +71,6 @@ function plugin_test_stored_filter(array $override = array()): array { describe('the Filter editor', function () { it('warns before a save that changes the cache, says when it succeeded, and picks the multi-select', function () { $_REQUEST['id'] = 4; - $_SESSION['flowview_rematerialize_saved'] = 4; plugin_test_queue_db_result('db_fetch_row_prepared', plugin_test_stored_filter()); @@ -81,12 +80,11 @@ function plugin_test_stored_filter(array $override = array()): array { expect($output)->toContain("id='flowview_save_warning'") ->and($output)->toContain('Cached Results Affected') + ->and($output)->toContain('a materialization in the background') ->and($output)->toContain("var useSelect2 = " . (version_compare(CACTI_VERSION, '1.2.32', '>=') ? 'true' : 'false') . ';') ->and($output)->toContain("\$('#chk #submit').off('click.flowview').on('click.flowview'") ->and($output)->toContain("{ name: 'action', value: 'check_material' }") ->and($output)->toContain("\$('#protocols').multiselect();") - ->and($output)->toContain("id='flowview_saved_dialog'") - ->and(isset($_SESSION['flowview_rematerialize_saved']))->toBeFalse() ->and($GLOBALS['__test_form_start'][1])->toBe('chk') ->and($GLOBALS['__test_edit_form']['fields']['protocols']['select_all_text'])->toBe('All Protocols'); }); @@ -103,15 +101,42 @@ function plugin_test_stored_filter(array $override = array()): array { unset($_REQUEST['panel_bytes'], $_REQUEST['panel_packets'], $_REQUEST['panel_flows'], $_REQUEST['usenat']); }); - it('leaves a dialog to say it was saved and has to be materialized again when the cache is affected', function () { + it('starts a materialization in the background and says so when the cache is affected', function () { $_REQUEST['sourceip'] = '10.0.0.2'; plugin_test_queue_db_result('db_fetch_row_prepared', plugin_test_stored_filter()); expect(fn () => save_filter())->toThrow(FlowviewTestExit::class); - expect($_SESSION['flowview_rematerialize_saved'])->toBe(4) - ->and($GLOBALS['__test_messages'])->toBe(array()); + /* the materialization of the saved Filter was launched */ + expect($GLOBALS['__test_exec'])->toHaveCount(1) + ->and($GLOBALS['__test_exec'][0][1])->toContain('--query-id=4') + ->and($GLOBALS['__test_messages'][0][0])->toBe('flowview_materialize_started') + ->and($GLOBALS['__test_messages'][0][1])->toContain('materialization') + ->and(isset($_SESSION['flowview_rematerialize_saved']))->toBeFalse(); + }); + + it('queues a fresh materialization and says so when one is already running for the Filter', function () { + $_REQUEST['sourceip'] = '10.0.0.2'; + + plugin_test_queue_db_result('db_fetch_row_prepared', plugin_test_stored_filter()); + + /* the Filter already has a materialization, so launching is left to it */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array(array('taskid' => 4))); + + $GLOBALS['__test_exec'] = array(); + + expect(fn () => save_filter())->toThrow(FlowviewTestExit::class); + + /* nothing was launched, the Filter was queued, and the message says so */ + expect($GLOBALS['__test_exec'])->toBe(array()) + ->and($GLOBALS['__test_messages'][0][0])->toBe('flowview_materialize_queued') + ->and($GLOBALS['__test_messages'][0][1])->toContain('queued') + ->and(array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' + && strpos($call['sql'], 'INSERT INTO settings') === 0 + && $call['params'][0] === 'flowview_rematerialize_queue_4'; + })))->toHaveCount(1); }); it('saves the timespan that is picked in the editor', function () { @@ -190,7 +215,7 @@ function plugin_test_stored_filter(array $override = array()): array { expect($params['statistics'])->toBe(10) ->and($params['printed'])->toBe(0) - ->and($answer)->toBe(array('rematerialize' => false)); + ->and($answer)->toBe(array('rematerialize' => false, 'queued' => false)); }); it('takes a statistical report for a Filter that has none', function () use ($save) { @@ -198,7 +223,7 @@ function plugin_test_stored_filter(array $override = array()): array { expect($params['statistics'])->toBe(11) ->and($params['printed'])->toBe(0) - ->and($answer)->toBe(array('rematerialize' => true)); + ->and($answer)->toBe(array('rematerialize' => true, 'queued' => false)); }); it('takes a printed report for a Filter that has none', function () use ($save) { @@ -208,27 +233,44 @@ function plugin_test_stored_filter(array $override = array()): array { ->and($params['printed'])->toBe(1); }); - it('says to materialize again when the listener changes', function () use ($save) { - list($answer) = $save(array('device_id' => '3'), plugin_test_stored_filter()); + it('starts a materialization when the printed sort field changes, as it is part of the cache', function () use ($save) { + $GLOBALS['__test_exec'] = array(); - expect($answer)->toBe(array('rematerialize' => true)); + list($answer) = $save(array('sortfield' => 'packets'), plugin_test_stored_filter(array('statistics' => '0', 'printed' => '1', 'sortfield' => 'bytes'))); + + expect($answer)->toBe(array('rematerialize' => true, 'queued' => false)) + ->and($GLOBALS['__test_exec'])->toHaveCount(1) + ->and($GLOBALS['__test_exec'][0][1])->toContain('--query-id=4'); }); - it('saves the listener, sort field and stream that are on the page', function () use ($save) { - list($answer, $params) = $save(array('device_id' => '0', 'sortfield' => 'flows', 'ex_addr' => '10.1.1.1'), plugin_test_stored_filter(array('device_id' => '1'))); + it('queues a fresh materialization when one is already running for the Filter', function () use ($save) { + $GLOBALS['__test_exec'] = array(); - expect($params['device_id'])->toBe('0') - ->and($params['sortfield'])->toBe('flows') - ->and($params['ex_addr'])->toBe('10.1.1.1') - ->and($params['id'])->toBe('4') - ->and($answer)->toBe(array('rematerialize' => true)); + /* the Filter already has a materialization, so launching is left to it */ + plugin_test_queue_db_result('db_fetch_assoc_prepared', array(array('taskid' => 4))); + + list($answer) = $save(array('sortfield' => 'packets'), plugin_test_stored_filter(array('statistics' => '0', 'printed' => '1', 'sortfield' => 'bytes'))); + + expect($answer)->toBe(array('rematerialize' => true, 'queued' => true)) + ->and($GLOBALS['__test_exec'])->toBe(array()) + ->and(array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute_prepared' + && strpos($call['sql'], 'INSERT INTO settings') === 0 + && $call['params'][0] === 'flowview_rematerialize_queue_4'; + })))->toHaveCount(1); }); - it('saves no stream as All, and that is not a change to the cache', function () use ($save) { - list($answer, $params) = $save(array('ex_addr' => '-1'), plugin_test_stored_filter(array('ex_addr' => ''))); + it('saves the sort field and never the Listener or Stream, which are not on the page', function () use ($save) { + $GLOBALS['__test_exec'] = array(); - expect($params['ex_addr'])->toBe('0') - ->and($answer)->toBe(array('rematerialize' => false)); + list($answer, $params) = $save(array('device_id' => '3', 'ex_addr' => '10.1.1.1', 'sortfield' => 'flows'), plugin_test_stored_filter(array('device_id' => '1', 'ex_addr' => '10.9.9.9'))); + + expect($params['sortfield'])->toBe('flows') + ->and($params['id'])->toBe('4') + ->and(array_key_exists('device_id', $params))->toBeFalse() + ->and(array_key_exists('ex_addr', $params))->toBeFalse() + ->and($answer)->toBe(array('rematerialize' => false, 'queued' => false)) + ->and($GLOBALS['__test_exec'])->toBe(array()); }); it('saves the dates of a custom timespan', function () use ($save) { @@ -320,8 +362,9 @@ function plugin_test_stored_filter(array $override = array()): array { 'graph_type' => 'pie', 'graph_height' => '300', 'panel_packets' => 'on', 'usenat' => 'on', 'timespan' => '3', ))); - expect($_REQUEST['device_id'])->toBe('1') - ->and($_REQUEST['ex_addr'])->toBe('0') + /* the Listener and Stream are read from the saved Filter at query time, not seeded onto the page */ + expect(isset($_REQUEST['device_id']))->toBeFalse() + ->and(isset($_REQUEST['ex_addr']))->toBeFalse() ->and($_REQUEST['sortfield'])->toBe('flows') ->and($_REQUEST['cutofflines'])->toBe('50') ->and($_REQUEST['cutoffoctets'])->toBe('1024') @@ -391,10 +434,9 @@ function plugin_test_stored_filter(array $override = array()): array { }); it('leaves a setting the Filter has no value for to the page default', function () { - flowview_seed_filter_request_vars(plugin_test_stored_filter(array('sortfield' => '', 'ex_addr' => ''))); + flowview_seed_filter_request_vars(plugin_test_stored_filter(array('sortfield' => ''))); - expect(isset($_REQUEST['sortfield']))->toBeFalse() - ->and($_REQUEST['ex_addr'])->toBe('0'); + expect(isset($_REQUEST['sortfield']))->toBeFalse(); }); it('keeps what the request asks for over the saved Filter', function () { diff --git a/tests/Unit/FlowviewFilterControlsTest.php b/tests/Unit/FlowviewFilterControlsTest.php index d3b64b26..c4503c86 100644 --- a/tests/Unit/FlowviewFilterControlsTest.php +++ b/tests/Unit/FlowviewFilterControlsTest.php @@ -62,6 +62,30 @@ function array_rekey($array, $key, $value) { expect($output)->not->toMatch('/\son(?:click|change)\s*=/i'); }); +it('does not show the Listener or Stream, which are part of the Filter, and orders New after Edit', function () { + plugin_test_reset(); + + $_REQUEST['query'] = 4; + + plugin_test_queue_db_result('db_fetch_row_prepared', array('name' => 'Top Talkers', 'statistics' => 10, 'printed' => 0)); + + ob_start(); + + try { + flowview_display_filter(); + } catch (\Throwable $e) { + // the controls that are checked are emitted before any optional helper + } + + $output = ob_get_clean(); + + expect($output)->not->toContain("id='device_id'") + ->and($output)->not->toContain("id='ex_addr'") + ->and($output)->not->toContain('>Listener<') + ->and($output)->not->toContain('>Stream<') + ->and(strpos($output, "id='edit'"))->toBeLessThan(strpos($output, "id='new'")); +}); + it('does not put the Report on the report page for a filter that has one', function ($query, $row) { plugin_test_reset(); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 8b28464f..d3f1589f 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -95,6 +95,7 @@ function plugin_test_reset() { $GLOBALS['__test_db_results'] = array(); $GLOBALS['__test_config'] = array(); $GLOBALS['__test_messages'] = array(); + $GLOBALS['__test_exec'] = array(); $_GET = array(); $_POST = array(); $_REQUEST = array(); @@ -102,6 +103,18 @@ function plugin_test_reset() { } } +if (!function_exists('exec_background')) { + function exec_background($filename, $args = '', $redirect_args = '') { + $GLOBALS['__test_exec'][] = array($filename, $args); + } +} + +if (!function_exists('cacti_escapeshellarg')) { + function cacti_escapeshellarg($string) { + return "'" . str_replace("'", "'\\''", (string) $string) . "'"; + } +} + if (!function_exists('plugin_test_queue_db_result')) { function plugin_test_queue_db_result($function, $result) { if (!isset($GLOBALS['__test_db_results'][$function])) {