form_header = $form_header; $this->form_action = $form_action; $this->form_id = $form_id; $this->session_var = $session_var; $this->action_url = $action_url; $this->action_label = $action_label; $this->show_columns = $show_columns; $this->item_rows = $item_rows; $this->timespans = $graph_timespans; $this->timeshifts = $graph_timeshifts; $this->rows_label = __('Rows'); $this->frequencies = [ 5 => __('%d Seconds', 5), 10 => __('%d Seconds', 10), 20 => __('%d Seconds', 20), 30 => __('%d Seconds', 30), 45 => __('%d Seconds', 45), 60 => __('%d Minute', 1), 120 => __('%d Minutes', 2), 300 => __('%d Minutes', 5) ]; if ($this->session_var == '') { $action = gnrv('action'); $tab = gnrv('tab'); if ($action != '') { $this->session_var .= basename(get_current_page(), '.php') . '_' . $action; } elseif ($tab != '') { $this->session_var .= basename(get_current_page(), '.php') . '_' . $tab; } else { $this->session_var .= basename(get_current_page(), '.php'); } } if ($this->action_url != '' && $this->action_label == '') { $this->action_label = __('Add'); } // Preset pages (CDEFs, VDEFs, Colors, ...) manage system presets, not // per-user record lists, so they do not offer saved filter layouts. $page = basename($this->form_action != '' ? $this->form_action : get_current_page()); if (in_array($page, filter_layouts_preset_pages(), true)) { $this->render_layouts = false; } $this->filter_format = filter_layouts_user_format(); $this->filter_array = $this->create_default(); } private function create_default() : array { // default filter return [ 'rows' => [ [ 'filter' => [ 'method' => 'textbox', 'friendly_name' => __('Search'), 'filter' => FILTER_CALLBACK, 'filter_options' => ['options' => 'sanitize_search_string'], 'placeholder' => __('Enter a search term'), 'size' => '30', 'default' => '', 'pageset' => true, 'max_length' => '120', 'value' => '' ], 'rows' => [ 'method' => 'drop_array', 'friendly_name' => $this->rows_label, 'filter' => FILTER_VALIDATE_INT, 'default' => '-1', 'pageset' => true, 'array' => $this->item_rows, 'value' => '-1' ] ] ], 'buttons' => [ 'go' => [ 'method' => 'submit', 'display' => __('Go'), 'title' => __('Apply filter to table'), ], 'clear' => [ 'method' => 'button', 'display' => __('Clear'), 'title' => __('Reset filter to default values'), ] ], 'sort' => [ 'sort_column' => 'name', 'sort_direction' => 'ASC' ] ]; } public function __destruct() { } public function set_filter_row(array $array, bool $index = false) : void { if ($index === false) { $this->filter_array['rows'][] = $array; } else { $this->filter_array['rows'][$index] = $array; } } public function get_filter_row(string $index) : bool { if ($index === false) { return false; } if (array_key_exists($index, $this->filter_array['rows'])) { return $this->filter_array['rows'][$index]; } else { return false; } } public function set_filter_array(array $array) : void { $this->filter_array = $array; } public function get_filter() : array { return $this->filter_array; } public function set_sort_array(string $sort_column, string $sort_direction) : void { $this->sort_array = [ 'sort_column' => $sort_column, 'sort_direction' => $sort_direction ]; } public function add_button(string $id, array $button) : void { $this->button_array[$id] = $button; } public function add_link(string $id, string $link) : void { $this->link_array[$id] = $link; } public function add_row_element(int $row, string $id, array $filter) : void { $this->append_array[$row][$id] = $filter; } public function render() : bool { if (!$this->initialized) { $this->initialize_filter(); } // validate filter variables $this->sanitize_filter_variables(); // create the filter for the page $filter = $this->create_filter(); // if validation succeeds, print output the data print $filter; // create javascript to operate of the filter print $this->create_javascript(); return true; } public function sanitize() : void { if (!$this->initialized) { $this->initialize_filter(); } // validate filter variables $this->sanitize_filter_variables(); } private function initialize_filter() : void { if (!cacti_sizeof($this->filter_array)) { $this->filter_array = $this->create_default(); } if (cacti_sizeof($this->sort_array)) { $this->filter_array['sort'] = $this->sort_array; } if (cacti_sizeof($this->button_array)) { if (cacti_sizeof($this->filter_array['buttons'])) { $this->filter_array['buttons'] += $this->button_array; } else { $this->filter_array['buttons'] = $this->button_array; } } if (cacti_sizeof($this->link_array)) { if (cacti_sizeof($this->filter_array['links'])) { $this->filter_array['links'] += $this->link_array; } else { $this->filter_array['links'] = $this->link_array; } } if (cacti_sizeof($this->append_array)) { foreach ($this->append_array as $row => $data) { foreach ($data as $id => $filter) { $this->filter_array['rows'][$row][$id] = $filter; } } } // Make common adjustments if ($this->has_refresh) { if (isrv('refresh')) { $value = gnrv('refresh'); } else { $value = $this->def_refresh; } $this->filter_array['rows'][0] += [ 'refresh' => [ 'method' => 'drop_array', 'friendly_name' => __('Refresh'), 'filter' => FILTER_VALIDATE_INT, 'default' => $this->def_refresh, 'array' => $this->frequencies, 'value' => $value ] ]; } if ($this->has_graphs) { if (isrv('has_graphs')) { $value = gnrv('has_graphs'); } else { $value = read_config_option('default_has') == 'on' ? 'true' : 'false'; } $this->filter_array['rows'][0] += [ 'has_graphs' => [ 'method' => 'filter_checkbox', 'friendly_name' => __('Has Graphs'), 'filter' => FILTER_VALIDATE_REGEXP, 'filter_options' => ['options' => ['regexp' => '/^(true|false)$/']], 'default' => read_config_option('default_has') == 'on' ? 'true' : 'false', 'pageset' => true, 'value' => $value ] ]; } if ($this->has_data) { if (isrv('has_data')) { $value = gnrv('has_data'); } else { $value = read_config_option('default_has') == 'on' ? 'true' : 'false'; } $this->filter_array['rows'][0] += [ 'has_data' => [ 'method' => 'filter_checkbox', 'friendly_name' => __('Has Data Sources'), 'filter' => FILTER_VALIDATE_REGEXP, 'filter_options' => ['options' => ['regexp' => '/^(true|false)$/']], 'default' => read_config_option('default_has') == 'on' ? 'true' : 'false', 'pageset' => true, 'value' => $value ] ]; } if ($this->has_named) { if (isrv('named')) { $value = gnrv('named'); } else { $value = read_config_option('default_has') == 'on' ? 'true' : 'false'; } $this->filter_array['rows'][0] += [ 'named' => [ 'method' => 'filter_checkbox', 'friendly_name' => __('Named Colors'), 'filter' => FILTER_VALIDATE_REGEXP, 'filter_options' => ['options' => ['regexp' => '/^(true|false)$/']], 'default' => read_config_option('default_has') == 'on' ? 'true' : 'false', 'pageset' => true, 'value' => $value ] ]; } if ($this->has_associated) { if (isrv('associated')) { $value = gnrv('associated'); } else { $value = read_config_option('default_has') == 'on' ? 'true' : 'false'; } $this->filter_array['rows'][0] += [ 'associated' => [ 'method' => 'filter_checkbox', 'friendly_name' => ($this->associated_label != '' ? $this->associated_label : __('Associated')), 'filter' => FILTER_VALIDATE_REGEXP, 'filter_options' => ['options' => ['regexp' => '/^(true|false)$/']], 'default' => read_config_option('default_has') == 'on' ? 'true' : 'false', 'pageset' => true, 'value' => $value ] ]; } if ($this->has_save) { $this->filter_array['buttons']['save'] = [ 'method' => 'button', 'display' => __('Save'), 'title' => __('Save Filter Defaults'), 'status' => __('Saving Filter') ]; } if ($this->has_import) { $this->filter_array['buttons']['import'] = [ 'method' => 'button', 'display' => __('Import'), 'title' => __('Import Data'), ]; } if ($this->has_export) { $this->filter_array['buttons']['export'] = [ 'method' => 'button', 'display' => __('Export'), 'title' => __('Export Data'), 'callback' => 'document.location = \'' . get_current_page() . '?action=export\'', ]; } if ($this->has_purge) { $this->filter_array['buttons']['purge'] = [ 'method' => 'button', 'display' => __('Purge'), 'title' => __('Purge Data'), 'status' => __('Purging Data') ]; } if (isset($this->filter_array['buttons']) && cacti_sizeof($this->filter_array['buttons'])) { $this->filter_array['rows'][0] += $this->filter_array['buttons']; } $this->initialized = true; } /** * Whether this filter should render in the modern (Layouts + Edit dialog) * layout. Modern requires saved layouts to be active on the page; preset * pages and an explicit legacy user preference fall back to the inline * filter. * * @return bool */ private function use_modern_filter() : bool { return $this->render_layouts && $this->filter_format === 'modern'; } /** * Whether a field stays on the modern filter bar rather than moving into the * Edit dialog. Time controls and the auto-refresh selector remain visible. * * @param string $field_name The field key. * @param array $field_array The field definition. * * @return bool */ private function field_is_bar(string $field_name, array $field_array) : bool { $method = $field_array['method'] ?? ''; // Search stays on the bar (right of Rows) so it is reachable without opening the Edit dialog. return $method === 'timespan' || $field_name === 'refresh' || $field_name === 'rows' || ($method === 'textbox' && ($field_name === 'filter' || $field_name === 'rfilter')); } /** * Render a single filter field (label + control) to HTML. Shared by the * legacy inline layout and the modern Edit dialog so field rendering lives * in one place. * * @param string $field_name The field key. * @param array $field_array The field definition. * * @return string */ private function emit_field(string $field_name, array $field_array) : string { ob_start(); // The row-count selector is always labeled "Rows", regardless of any // page-specific name (Devices, Trees, ...). if ($field_name === 'rows' && isset($field_array['friendly_name'])) { $field_array['friendly_name'] = __('Rows'); } // Every page's Go submit action renders as a compact refresh glyph button. if ($field_name === 'go' && ($field_array['method'] ?? '') === 'submit') { $field_array['glyph'] = 'ti ti-refresh'; $field_array['title'] = __('Refresh page'); } // The Clear action renders as a compact eraser glyph button across every filter. if ($field_name === 'clear' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-eraser'; if (empty($field_array['title'])) { $field_array['title'] = __('Clear'); } } // Import/Export actions render as table-import / table-export glyph buttons. if ($field_name === 'import' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-table-import'; } if ($field_name === 'export' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-table-export'; } // The Save Filter Defaults action renders as a floppy glyph button. if ($field_name === 'save' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-device-floppy'; } // The Purge action renders as a flame glyph button. if ($field_name === 'purge' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-flame'; } // The Rotate (log rotation) action renders as a rotate glyph button. if ($field_name === 'rotate' && ($field_array['method'] ?? '') === 'button') { $field_array['glyph'] = 'ti ti-rotate'; } if (isset($field_array['class'])) { $class = ' ' . $field_array['class']; } else { $class = ''; } if (!isset($field_array['value']) && $field_array['method'] != 'validate' && $field_array['method'] != 'submit' && $field_array['method'] != 'content' && $field_array['method'] != 'button' && $field_array['method'] != 'timespan') { cacti_log("WARNING: The Filter Class value field $field_name is missing"); $field_array['value'] = ''; } switch($field_array['method']) { case 'content': print '
' . $field_array['content'] . '
'; break; case 'validate': // Just for validating other request variables break; case 'button': print '
' . PHP_EOL; if (!empty($field_array['glyph'])) { print ''; } elseif (isset($field_array['display'])) { print ''; } else { print ''; } print '
' . PHP_EOL; break; case 'submit': print '
' . PHP_EOL; print ''; print '
' . PHP_EOL; break; case 'filter_checkbox': $cb_title = html_escape_attr($field_array['title'] ?? $field_array['friendly_name']); print '
' . $field_array['friendly_name'] . '
' . PHP_EOL; print '
' . PHP_EOL; break; case 'timespan': print '
' . __('Presets') . '
' . PHP_EOL; print '
'; print ''; print '
'; // From data print '
'; print __('From'); print '
'; print '
'; print ''; print ''; print ''; print ''; print '
'; // To Data print '
'; print __('From'); print '
'; print '
'; print ''; print ''; print ''; print ''; print '
'; if (isset($field_array['shifter']) && $field_array['shifter'] === true) { print '
'; print ''; print ''; print ''; print ''; print ''; print '
'; } if ((isset($field_array['refresh']) && $field_array['refresh'] === true) || (isset($field_array['clear']) && $field_array['clear'] === true)) { print '
'; print ''; if (isset($field_array['refresh'])) { print ''; } if (isset($field_array['clear'])) { print ''; } print ''; print '
'; } break; case 'hidden': print '' . PHP_EOL; break; default: if (isset($field_array['friendly_name'])) { print '
' . PHP_EOL; } if (isrv($field_name) && !str_contains($field_array['method'], 'callback')) { $field_array['value'] = gnrv($field_name); } print '
' . PHP_EOL; draw_edit_control($field_name, $field_array); print '
' . PHP_EOL; } return (string) ob_get_clean(); } private function create_filter() : string { // Buffer output ob_start(); $text_appended = false; if (isset($this->filter_array['links']) && cacti_sizeof($this->filter_array['links'])) { $linkButtons = []; if ($this->action_url != '') { $linkButtons[] = [ 'id' => 'add', 'href' => $this->action_url, 'title' => $this->action_label, 'callback' => true, 'class' => 'ti ti-plus plusAdd' ]; } foreach ($this->filter_array['links'] as $index => $link) { $linkButtons[] = [ 'id' => 'dynamic' . $index, 'href' => $link['url'], 'title' => $link['display'], 'callback' => true, 'class' => $link['class'] ]; } html_filter_start_box($this->form_header, $linkButtons, true, $this->show_columns, $this->action_label); } else { html_filter_start_box($this->form_header, $this->action_url, true, $this->show_columns, $this->action_label); } if ($this->use_modern_filter()) { print $this->create_modern_filter(); html_filter_end_box(); return (string) ob_get_clean(); } if (isset($this->filter_array['rows'])) { print "
"; foreach ($this->filter_array['rows'] as $index => $row) { if ($index > 0 && !$text_appended) { print "
"; $text_appended = true; } print "
"; print "
"; foreach ($row as $field_name => $field_array) { print $this->emit_field($field_name, $field_array); } if ($index == 0) { print "
"; } print '
' . PHP_EOL; print '
' . PHP_EOL; } if ($this->inject_content !== false) { print $this->inject_content; } print '
' . PHP_EOL; } html_filter_end_box(); return (string) ob_get_clean(); } /** * Render the modern filter: a compact bar (Layouts selector, time controls, * and layout action buttons) plus a hidden Edit dialog that holds the filter * name and all filterable fields. The dialog is activated by create_modern_ * javascript(). * * @return string */ private function create_modern_filter() : string { $page = filter_layouts_page_key($this->form_action != '' ? $this->form_action : get_current_page()); $layouts = filter_layouts_get_available($page); $can_glob = filter_layouts_can_manage_global(); // Split the configured fields: time controls, the refresh selector, and // page action buttons (Import, Export, Purge, Save, sort, ...) stay on the // bar; the filter inputs move into the Edit dialog. go/clear are replaced // by the dialog's Search/Clear footer actions. $bar_fields = []; $bar_actions = []; $amalgam_fields = []; $timespan_fields = []; $dialog_fields = []; if (isset($this->filter_array['rows'])) { foreach ($this->filter_array['rows'] as $row) { foreach ($row as $field_name => $field_array) { $method = $field_array['method'] ?? ''; if (!empty($field_array['bar'])) { $amalgam_fields[$field_name] = $field_array; } elseif ($method === 'timespan') { $timespan_fields[$field_name] = $field_array; } elseif ($this->field_is_bar($field_name, $field_array)) { // Bar-routed Search needs a persistent label; some pages (clog) omit friendly_name, relying on an adjacent field's label. if (($field_name === 'filter' || $field_name === 'rfilter') && empty($field_array['friendly_name'])) { $field_array['friendly_name'] = __('Search'); } $bar_fields[$field_name] = $field_array; } elseif ($method === 'submit' || $method === 'button') { if ($field_name !== 'go' && $field_name !== 'clear') { $bar_actions[$field_name] = $field_array; } } else { $dialog_fields[$field_name] = $field_array; } } } } // Mirror the bar Search into the Edit dialog so a saved layout's search term is visible and // editable there too; the bar input stays canonical and the mirror is synced to it in JS. foreach (['rfilter', 'filter'] as $search_name) { if (isset($bar_fields[$search_name])) { $mirror = $bar_fields[$search_name]; if (empty($mirror['friendly_name'])) { $mirror['friendly_name'] = __('Search'); } // The value is synced from the live bar input when the dialog opens, avoiding a stale copy. $mirror['value'] = ''; $dialog_fields[$search_name . '_dialog'] = $mirror; break; } } // Keep the bar selectors in a stable order: Search first (matching the legacy filters), then Rows, then Refresh. if (cacti_sizeof($bar_fields)) { $ordered = []; foreach (['filter', 'rfilter', 'rows', 'refresh'] as $pref) { if (isset($bar_fields[$pref])) { $ordered[$pref] = $bar_fields[$pref]; unset($bar_fields[$pref]); } } $bar_fields = $ordered + $bar_fields; } ob_start(); // Filter bar. print "
"; print "
"; print "
" . __('Layouts') . '
'; print "
" . $this->layout_select($layouts, $can_glob) . '
'; foreach ($bar_fields as $field_name => $field_array) { print $this->emit_field($field_name, $field_array); } // Separate the always-present Layouts selector (plus any Rows/Refresh) from the layout buttons. print "
"; print $this->layout_button('layout_edit', __('Edit'), __('Edit the current filter'), false, 'ti ti-edit'); print $this->layout_button('layout_rename', __('Rename'), __('Rename the selected layout'), true, 'ti ti-forms'); print $this->layout_button('layout_delete', __('Delete'), __('Delete the selected layout'), true, 'ti ti-trash'); print $this->layout_button('layout_saveas', __('Save As'), __('Save this layout as a new personal layout'), true, 'ti ti-device-floppy'); // Refresh (reload the current view, keeping the selected layout) as a glyph button, right of the Edit group. print "
" . PHP_EOL; // Clear (reset the filter to its defaults) as an eraser glyph button, right of Refresh. print "
" . PHP_EOL; // Page actions (Import, Export, Purge, Save, sort asc/desc, ...) belong on // the bar beside the Layouts selector, not inside the Edit dialog. if (cacti_sizeof($bar_actions)) { print "
"; foreach ($bar_actions as $field_name => $field_array) { print $this->emit_field($field_name, $field_array); } } print '
'; print '
' . PHP_EOL; // The Graph Template amalgam (Template + Source/Order/CF/Measure) gets its // own second filter line. if (cacti_sizeof($amalgam_fields)) { print "
"; print "
"; foreach ($amalgam_fields as $field_name => $field_array) { print $this->emit_field($field_name, $field_array); } print '
'; print '
' . PHP_EOL; } // Timespan controls get their own third filter line. if (cacti_sizeof($timespan_fields)) { print "
"; print "
"; foreach ($timespan_fields as $field_name => $field_array) { print $this->emit_field($field_name, $field_array); } print '
'; print '
' . PHP_EOL; } // Edit dialog. $title = $this->form_header != '' ? $this->form_header : __('Edit Filter'); print "' . PHP_EOL; return (string) ob_get_clean(); } /** * Render the Layouts "; print "'; if (cacti_sizeof($layouts)) { foreach ($layouts as $layout) { $document = filter_layouts_decode($layout['data']); if ($document === false) { continue; } $url = filter_layouts_document_url($document); if ($url === '') { continue; } $sep = (strpos($url, '?') !== false) ? '&' : '?'; $nav = $url . $sep . 'filter_layout=' . $layout['id']; $label = $layout['name'] . ($layout['user_id'] == 0 ? ' (' . __('Global') . ')' : ''); // A user may overwrite/rename/delete their own rows; global rows // (user_id 0) only when they can manage global layouts. $editable = ($layout['user_id'] != 0 || $can_glob) ? '1' : '0'; $global = ($layout['user_id'] == 0) ? '1' : '0'; print "'; } } print ''; return (string) ob_get_clean(); } private function make_function(string $buttonId, array $buttonArray, string $buttonAction) : string { $func_nl = "\n\t\t\t"; $func_el = "\n\t\t"; $buttonFunction = ''; if (isset($buttonArray['url'])) { if (!isset($buttonArray['status'])) { $buttonFunction .= PHP_EOL . "\t\tfunction {$buttonId}Function () {" . $func_nl . 'clearTimeout(myRefresh);' . $func_nl . "loadUrl({ url: '{$buttonArray['url']}' });" . $func_el . '};' . PHP_EOL; } else { $buttonFunction .= PHP_EOL . "\t\tfunction {$buttonId}Function () {" . $func_nl . 'clearTimeout(myRefresh);' . $func_nl . "$('#text').text('{$buttonArray['status']}');" . $func_nl . "pulsate('#text');" . $func_nl . "loadUrl({ url: '{$buttonArray['url']}', funcEnd: 'finishFinalize' });" . $func_el . '};' . PHP_EOL; } } else { if (!isset($buttonArray['status'])) { if (isset($buttonArray['callback'])) { $callbackFunction = $buttonArray['callback']; } else { $callbackFunction = "loadUrl({ url: $buttonAction })"; } $buttonFunction .= PHP_EOL . "\t\tfunction {$buttonId}Function () {" . $func_nl . 'clearTimeout(myRefresh);' . $func_nl . $callbackFunction . ';' . $func_el . 'Pace.stop();' . $func_el . '};' . PHP_EOL; } else { if (isset($buttonArray['callback'])) { $callbackFunction = $buttonArray['callback']; } else { $callbackFunction = "loadUrl({ url: $buttonAction, funcEnd: 'finishFinalize' })"; } $buttonFunction .= PHP_EOL . "\t\tfunction {$buttonId}Function () {" . $func_nl . 'clearTimeout(myRefresh);' . $func_nl . "$('#text').text('{$buttonArray['status']}');" . $func_nl . "pulsate('#text');" . $func_nl . $callbackFunction . ';' . $func_el . 'Pace.stop();' . $func_el . '};' . PHP_EOL; } } return $buttonFunction; } private function create_javascript() : string { $applyFilter = "'" . $this->form_action; $clearFilter = $applyFilter; $defaultFilter = $applyFilter; if (!str_contains($applyFilter, '?')) { $separator = '?'; } else { $separator = '&'; } $applyFilter .= $separator; $clearFilter .= $separator . "clear=true'"; $defaultFilter .= $separator . "action=noaction'"; $changeChain = ''; $clickChain = ''; if (isset($this->filter_array['buttons']['go']['callback'])) { $changeFunction = $this->filter_array['buttons']['go']['callback']; } else { $changeFunction = 'applyFilter()'; } if (isset($this->filter_array['buttons']['clear']['callback'])) { $clearFunction = $this->filter_array['buttons']['clear']['callback']; } else { $clearFunction = 'clearFilter()'; } $filterLength = 0; $refreshMSeconds = 9999999; $buttonFunctions = ''; $buttonReady = ''; $readyAdd = ''; $globalAdd = ''; if (isset($this->filter_array['rows'])) { foreach ($this->filter_array['rows'] as $row) { foreach ($row as $field_name => $field_array) { switch($field_array['method']) { case 'content': case 'validate': case 'hidden': // Just for validating other request variables break; case 'button': switch($field_name) { case 'go': case 'clear': break; default: $buttonAction = str_replace('noaction', $field_name, $defaultFilter); $buttonFunctions .= $this->make_function($field_name, $field_array, $buttonAction); $buttonReady .= PHP_EOL . "\t\t\t$('#{$field_name}').click(function() { {$field_name}Function(); });"; } break; case 'filter_checkbox': if ($this->dynamic && !$this->use_modern_filter()) { $clickChain .= ($clickChain != '' ? ', ' : '') . '#' . $field_name; } $applyFilter .= ($filterLength == 0 ? '' : "+'&") . $field_name . "='+$('#" . $field_name . "').is(':checked')"; $filterLength++; break; case 'timespan': // Delegated bindings survive ajax content reloads that replace the timespan controls; direct .change()/.click() handlers would be lost after the first filter apply. if (!isset($field_array['span_function'])) { $readyAdd .= "$(document).rebind('change.cactiTimespan', '#predefined_timespan', function() { applyGraphTimespan(); });" . PHP_EOL; } else { $readyAdd .= "$(document).rebind('change.cactiTimespan', '#predefined_timespan', function() { " . $field_array['span_function'] . '; });' . PHP_EOL; } if (isset($field_array['shifter']) && $field_array['shifter'] === true) { if (!isset($field_array['lshift_function'])) { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#shift_left', function() { timeshiftGraphFilterLeft(); });" . PHP_EOL; } else { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#shift_left', function() { " . $field_array['lshift_function'] . '; });' . PHP_EOL; } if (!isset($field_array['rshift_function'])) { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#shift_right', function() { timeshiftGraphFilterRight(); });" . PHP_EOL; } else { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#shift_right', function() { " . $field_array['rshift_function'] . '; });' . PHP_EOL; } } if (!isset($field_array['refresh_function'])) { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#tsrefresh', function() { $(this).find('i').addClass('icon-rotate'); refreshGraphTimespanFilter(); });" . PHP_EOL; } else { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#tsrefresh', function() { $(this).find('i').addClass('icon-rotate'); " . $field_array['refresh_function'] . '; });' . PHP_EOL; } if (!isset($field_array['clear_function'])) { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#tsclear', function() { clearGraphTimespanFilter(); });" . PHP_EOL; } else { $readyAdd .= "$(document).rebind('click.cactiTimespan', '#tsclear', function() { " . $field_array['clear_function'] . '; });' . PHP_EOL; } break; case 'textbox': case 'drop_array': case 'drop_files': case 'drop_sql': case 'drop_callback': case 'drop_multi': case 'drop_color': case 'drop_tree': if ($field_array['method'] != 'textbox' && $this->dynamic) { if (!isset($field_array['dynamic']) || $field_array['dynamic'] === true) { if (!$this->use_modern_filter() || $this->field_is_bar($field_name, $field_array) || !empty($field_array['bar'])) { $changeChain .= ($changeChain != '' ? ', ' : '') . '#' . $field_name; } } } if ($field_name != 'rfilter') { $applyFilter .= ($filterLength == 0 ? '' : "+'&") . $field_name . "='+$('#" . $field_name . "').val()"; } else { $applyFilter .= ($filterLength == 0 ? '' : "+'&") . $field_name . "='+base64_encode($('#" . $field_name . "').val())"; } $filterLength++; break; case 'submit': break; default: break; } if ($this->has_refresh && $field_name == 'refresh') { $refreshMSeconds = $field_array['value'] * 1000; } } } if ($filterLength == 0) { $applyFilter .= "';"; } else { $applyFilter .= ';'; } } if (isset($this->filter_array['javascript']['ready']) && $this->filter_array['javascript']['ready'] != '') { $readyAdd .= "\t\t" . trim($this->filter_array['javascript']['ready']) . PHP_EOL; } if (isset($this->filter_array['javascript']['global']) && $this->filter_array['javascript']['global'] != '') { $globalAdd .= "\t\t" . trim($this->filter_array['javascript']['global']) . PHP_EOL; } if (!$this->has_refresh && isrv('refresh') && grv('refresh') > 0) { $refreshMSeconds = grv('refresh') * 1000; } if ($clickChain != '') { $clickReady = "$('" . $clickChain . "').click(function() {\n\t\t\t\t" . "$changeFunction;\n\t\t\t" . '});' . PHP_EOL; } else { $clickReady = ''; } if ($changeChain != '') { $changeReady = "$('" . $changeChain . "').change(function() {\n\t\t\t\t" . "$changeFunction;\n\t\t\t" . '});' . PHP_EOL; } else { $changeReady = ''; } $script = PHP_EOL . "" . PHP_EOL; if ($this->render_layouts) { if ($this->use_modern_filter()) { $script .= $this->create_modern_javascript($applyFilter, $changeFunction, $clearFunction); } } return $script; } private function sanitize_filter_variables() : void { $filters = []; if (isset($this->filter_array['rows'])) { foreach ($this->filter_array['rows'] as $row) { foreach ($row as $field_name => $field_array) { switch($field_array['method']) { case 'timespan': case 'button': case 'submit': break; default: $filters[$field_name]['filter'] = $field_array['filter']; if (isset($field_array['filter_options'])) { $filters[$field_name]['options'] = $field_array['filter_options']; } if (isset($field_array['pageset'])) { $filters[$field_name]['pageset'] = $field_array['pageset']; } if (isset($field_array['default'])) { $filters[$field_name]['default'] = $field_array['default']; } else { $filters[$field_name]['default'] = ''; } break; } } } } $filters['page']['filter'] = FILTER_VALIDATE_INT; $filters['page']['default'] = 1; $filters['layout']['filter'] = FILTER_VALIDATE_INT; $filters['layout']['default'] = 0; if (!isrv('page')) { srv('page', 1); } if (isset($this->filter_array['sort'])) { $filters['sort_column']['filter'] = FILTER_CALLBACK; $filters['sort_column']['options'] = ['options' => 'sanitize_search_string']; $filters['sort_column']['default'] = $this->filter_array['sort']['sort_column']; $filters['sort_direction']['filter'] = FILTER_CALLBACK; $filters['sort_direction']['options'] = ['options' => 'sanitize_search_string']; $filters['sort_direction']['default'] = $this->filter_array['sort']['sort_direction']; } validate_store_request_vars($filters, $this->session_var); } private function layout_button(string $id, string $display, string $title, bool $hidden = false, string $glyph = '') : string { $content = $glyph !== '' ? '' : '' . html_escape($display) . ''; return '' . PHP_EOL; } /** * Emit the modern filter JavaScript: the Layouts selector navigation, the * bar action buttons, and the Edit dialog. The dialog's apply and clear * actions reuse the page's configured go/clear button labels (e.g. Go), so * they match the inline filter and do not collide with a filter field. * * @param string $applyFilter The JS expression that builds the filter url. * @param string $changeFunction The JS apply callback (e.g. applyFilter()). * @param string $clearFunction The JS clear callback (e.g. clearFilter()). * * @return string */ private function create_modern_javascript(string $applyFilter, string $changeFunction, string $clearFunction) : string { $page = filter_layouts_page_key($this->form_action != '' ? $this->form_action : get_current_page()); $can_glob = filter_layouts_can_manage_global(); $title = $this->form_header != '' ? $this->form_header : __('Edit Filter'); $applyLabel = __('Apply'); $js = PHP_EOL . "' . PHP_EOL; return $js; } } /** * Page basenames (the Console > Presets menu) that manage system presets rather * than per-user record lists. Saved filter layouts default to off on these. * * @return array */ function filter_layouts_preset_pages() : array { return [ 'data_source_profiles.php', 'automation_snmp.php', 'cdef.php', 'vdef.php', 'color.php', 'gprint_presets.php', 'layouts.php', ]; } /** * Reduce a page filename or stored layout url to its bare page basename. * * @param string $page A page filename or a 'page.php?query' style url. * * @return string The page basename, e.g. 'host.php'. */ function filter_layouts_page_key(string $page) : string { $page = trim($page); $qpos = strpos($page, '?'); if ($qpos !== false) { $page = substr($page, 0, $qpos); } return basename($page); } /** * Whether the current user may publish or manage global (user_id = 0) layouts. * This is gated on the Settings/Utilities realm. * * @return bool */ function filter_layouts_can_manage_global() : bool { return is_realm_allowed(15); } /** * The current user's preferred page filter format. Modern renders the Layouts * selector with an Edit dialog; legacy renders the classic inline filter. * Defaults to modern for CLI and not-yet-authenticated contexts. * * @return string Either 'modern' or 'legacy'. */ function filter_layouts_user_format() : string { if (function_exists('read_user_setting') && isset($_SESSION['sess_user_id'])) { if (read_user_setting('page_filter_format', 'modern') === 'legacy') { return 'legacy'; } } return 'modern'; } /** * Whether a bare page basename is a safe, same-site page reference (no scheme, * host, query, or path traversal) before a layout url is rebuilt from it. * * @param string $page Candidate page basename. * * @return bool */ function filter_layouts_valid_page(string $page) : bool { return preg_match('/^[a-z0-9_]+\.php$/i', trim($page)) === 1; } /** * Parse a 'page.php?query' filter url into a versioned layout document. Only the * page basename and flat scalar (or scalar-list) request vars are kept; the * navigable url is rebuilt server side from this document, so no raw url is ever * stored or trusted. The filter_layout re-selection marker is never persisted. * * @param string $url Full 'page.php?query' filter url. * * @return array{version:int,page:string,filter:array}|false */ function filter_layouts_build_document(string $url) { $url = trim($url); $qpos = strpos($url, '?'); $page = $qpos !== false ? substr($url, 0, $qpos) : $url; // The path must already be a bare same-site page; a scheme, host, or // traversal is rejected outright rather than normalized away. if (!filter_layouts_valid_page($page)) { return false; } $filter = []; if ($qpos !== false) { parse_str(substr($url, $qpos + 1), $parsed); foreach ($parsed as $key => $value) { if ($key === 'filter_layout' || !is_string($key) || preg_match('/^[a-zA-Z0-9_]+$/', $key) !== 1) { continue; } if (is_array($value)) { $flat = []; foreach ($value as $sub_key => $sub_value) { if (is_scalar($sub_value)) { $flat[$sub_key] = (string) $sub_value; } } $filter[$key] = $flat; } elseif (is_scalar($value)) { $filter[$key] = (string) $value; } } } if (cacti_sizeof($filter) > 128) { return false; } return [ 'version' => 1, 'page' => $page, 'filter' => $filter, ]; } /** * Rebuild the navigable 'page.php?query' url for a layout document. * * @param array $document A decoded layout document. * * @return string The rebuilt url, or '' when the document is invalid. */ function filter_layouts_document_url(array $document) : string { $page = (isset($document['page']) && is_string($document['page'])) ? $document['page'] : ''; if (!filter_layouts_valid_page($page)) { return ''; } $filter = (isset($document['filter']) && is_array($document['filter'])) ? $document['filter'] : []; $query = http_build_query($filter); return $query !== '' ? $page . '?' . $query : $page; } /** * Decode and validate a stored layout document. Rejects anything that is not a * version 1 document naming a safe same-site page, bounding the byte size the * same way the plugin query builder bounds its own filter documents. * * @param mixed $json The stored data column. * * @return array|false The decoded document, or false when invalid. */ function filter_layouts_decode($json) { if (!is_string($json) || $json === '' || strlen($json) > 8192) { return false; } $document = json_decode($json, true); if (!is_array($document) || ($document['version'] ?? null) !== 1) { return false; } if (!isset($document['page']) || !is_string($document['page']) || !filter_layouts_valid_page($document['page'])) { return false; } if (isset($document['filter']) && !is_array($document['filter'])) { return false; } return $document; } /** * Fetch a single layout row by id. * * @param int $id Layout id. * * @return array|false The row, or false when not found. */ function filter_layouts_get(int $id) { if ($id <= 0) { return false; } $row = db_fetch_row_prepared('SELECT id, user_id, page, name, data FROM user_layouts WHERE id = ?', [$id]); return is_array($row) ? $row : false; } /** * The layouts a user may see on a page: their own plus any published ones. * * @param string $page Page filename or url. * @param int $user_id User id, or -1 for the current session user. * * @return array> */ function filter_layouts_get_available(string $page, int $user_id = -1) : array { if (!db_table_exists('user_layouts')) { return []; } if ($user_id < 0) { $user_id = isset($_SESSION['sess_user_id']) ? (int) $_SESSION['sess_user_id'] : 0; } $page = filter_layouts_page_key($page); return db_fetch_assoc_prepared('SELECT id, user_id, page, name, data FROM user_layouts WHERE page = ? AND (user_id = ? OR user_id = 0) ORDER BY (user_id = 0), name', [$page, $user_id]); } /** * Whether the current user may modify a given layout row. Users own their own * layouts; global layouts and other users' layouts require the Settings/ * Utilities realm. * * @param array|false $layout Layout row. * * @return bool */ function filter_layouts_user_can_edit($layout) : bool { if (!is_array($layout)) { return false; } $user_id = isset($_SESSION['sess_user_id']) ? (int) $_SESSION['sess_user_id'] : 0; if ($layout['user_id'] == 0) { return filter_layouts_can_manage_global(); } return ($layout['user_id'] == $user_id) || filter_layouts_can_manage_global(); } /** * Create or update a layout. New layouts are owned by the current user; updates * are permitted only where filter_layouts_user_can_edit() allows. The posted * url is normalized into a stored JSON document rather than persisted verbatim. * * @param string $name Layout name. * @param string $url Full 'page.php?query' filter url. * @param int $id Existing layout id to overwrite, or 0 to create. * * @return array|false The saved row, or false on error. */ function filter_layouts_save(string $name, string $url, int $id = 0) { $user_id = isset($_SESSION['sess_user_id']) ? (int) $_SESSION['sess_user_id'] : 0; $name = trim($name); $document = filter_layouts_build_document($url); if ($document === false) { return false; } $json = json_encode($document); if ($json === false || strlen($json) > 8192) { return false; } $save = []; if ($id > 0) { $existing = filter_layouts_get($id); if ($existing === false || !filter_layouts_user_can_edit($existing)) { return false; } $save['id'] = $id; $save['user_id'] = $existing['user_id']; if ($name == '') { $name = $existing['name']; } } else { if ($name == '') { return false; } $save['user_id'] = $user_id; } $save['page'] = $document['page']; $save['name'] = mb_substr($name, 0, 128); $save['data'] = $json; $saved_id = sql_save($save, 'user_layouts'); if ($saved_id === false) { return false; } return filter_layouts_get($saved_id); } /** * Rename a layout. * * @param int $id Layout id. * @param string $name New name. * * @return bool */ function filter_layouts_rename(int $id, string $name) : bool { $layout = filter_layouts_get($id); $name = trim($name); if ($layout === false || !filter_layouts_user_can_edit($layout) || $name == '') { return false; } db_execute_prepared('UPDATE user_layouts SET name = ? WHERE id = ?', [mb_substr($name, 0, 128), $id]); return true; } /** * Delete a layout. * * @param int $id Layout id. * * @return bool */ function filter_layouts_delete(int $id) : bool { $layout = filter_layouts_get($id); if ($layout === false || !filter_layouts_user_can_edit($layout)) { return false; } db_execute_prepared('DELETE FROM user_layouts WHERE id = ?', [$id]); return true; } /** * Publish a layout to all users by moving it to user_id = 0. Requires the * Settings/Utilities realm. * * @param int $id Layout id. * * @return bool */ function filter_layouts_publish(int $id) : bool { if (!filter_layouts_can_manage_global()) { return false; } $layout = filter_layouts_get($id); if ($layout === false) { return false; } db_execute_prepared('UPDATE user_layouts SET user_id = 0 WHERE id = ?', [$id]); return true; } /** * Unpublish a global layout by assigning it to a specific user. Requires the * Settings/Utilities realm. * * @param int $id Layout id. * @param int $user_id Target owner user id. * * @return bool */ function filter_layouts_unpublish(int $id, int $user_id) : bool { if (!filter_layouts_can_manage_global()) { return false; } $layout = filter_layouts_get($id); if ($layout === false || $user_id <= 0) { return false; } db_execute_prepared('UPDATE user_layouts SET user_id = ? WHERE id = ?', [$user_id, $id]); return true; } /** * Emit a JSON response for a layout request and end the request. * * @param array $data Response payload. * * @return never */ function filter_layouts_json(array $data) { header('Content-type: application/json'); print json_encode($data); exit; } /** * Handle a saved-filter-layout AJAX request posted to any page. Invoked once * per request from include/auth.php after the user has been authenticated and * authorized for the current page, so every user can manage their own layouts * from any filter while publishing stays gated on the Settings/Utilities realm. * * Only POST requests are acted on (Cacti validates the CSRF token on POST). * Non-layout requests return immediately so normal page loads are unaffected. * * @return void */ function filter_layouts_handle_request() : void { if (!isset($_SERVER['REQUEST_METHOD']) || strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST') { return; } if (!isset_request_var('action')) { return; } $action = get_nfilter_request_var('action'); $id = isset_request_var('id') ? (int) get_nfilter_request_var('id') : 0; switch ($action) { case 'layout_save': $name = isset_request_var('name') ? get_nfilter_request_var('name') : ''; $url = isset_request_var('url') ? get_nfilter_request_var('url') : ''; $row = filter_layouts_save($name, $url, $id); if ($row === false) { $result = ['ok' => false]; } else { $document = filter_layouts_decode($row['data']); $nav = $document !== false ? filter_layouts_document_url($document) : ''; $sep = (strpos($nav, '?') !== false) ? '&' : '?'; $result = [ 'ok' => true, 'id' => (int) $row['id'], 'name' => $row['name'], 'url' => $nav . $sep . 'filter_layout=' . $row['id'], ]; } break; case 'layout_rename': $name = isset_request_var('name') ? get_nfilter_request_var('name') : ''; $result = ['ok' => filter_layouts_rename($id, $name)]; break; case 'layout_delete': $result = ['ok' => filter_layouts_delete($id)]; break; case 'layout_publish': $result = ['ok' => filter_layouts_publish($id)]; break; case 'layout_unpublish': $user_id = isset_request_var('user_id') ? (int) get_nfilter_request_var('user_id') : 0; $result = ['ok' => filter_layouts_unpublish($id, $user_id)]; break; default: return; } filter_layouts_json($result); }