<?php /* ** Copyright (C) 2001-2025 Zabbix SIA ** ** This program is free software: you can redistribute it and/or modify it under the terms of ** the GNU Affero General Public License as published by the Free Software Foundation, version 3. ** ** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; ** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. ** See the GNU Affero General Public License for more details. ** ** You should have received a copy of the GNU Affero General Public License along with this program. ** If not, see <https://www.gnu.org/licenses/>. **/ ?> <script> const view = { _app: null, _filter_form: null, _data: null, _resize_observer: null, _container: null, _filter_tags: new Map(), _filter_tagnames: new Set(), init({filter_form_name, data, timeline}) { this._filter_form = document.querySelector(`[name="${filter_form_name}"]`); this._container = document.querySelector('main'); this._data = data; this.initSubfilter(); this.initCharts(); timeControl.addObject('charts_view', timeline, { id: 'timeline_1', domid: 'charts_view', loadSBox: 0, loadImage: 0, dynamic: 0 }); timeControl.processObjects(); }, initSubfilter() { for (const element of this._filter_form.querySelectorAll('.js-subfilter-unset')) { this.setSubfilter(element.dataset.tag, element.dataset.value || null); } this._filter_form.addEventListener('click', (e) => { const link = e.target; if (link.classList.contains('js-subfilter-set')) { this.setSubfilter(link.getAttribute('data-tag'), link.getAttribute('data-value')); this.submitSubfilter(); } else if (link.classList.contains('js-subfilter-unset')) { this.unsetSubfilter(link.getAttribute('data-tag'), link.getAttribute('data-value')); this.submitSubfilter(); } }); }, initCharts() { this._$tmpl_row = $('<tr>').append( $('<div>', {class: 'flickerfreescreen'}).append( $('<div>', {class: '<?= ZBX_STYLE_CENTER ?>', style: 'min-height: 300px;'}).append( $('<img>') ) ) ); this._app = new ChartList( $('#charts'), this._data.timeline, this._data.config, this._container); this._app.setCharts(this._data.charts); this._app.refresh(); this._resize_observer = new ResizeObserver(this._app.onResize.bind(this._app)); this._resize_observer.observe(this._container); $.subscribe('timeselector.rangeupdate', (e, data) => { this._app.timeline = data; this._app.updateCharts(); }); }, replacePaging(paging) { document.querySelector('.<?= ZBX_STYLE_TABLE_PAGING ?>').outerHTML = paging; }, replaceSubfilter(subfilter) { if (document.getElementById('subfilter') !== null) { document.getElementById('subfilter').outerHTML = subfilter; } }, setSubfilter(tag, value) { if (value !== null) { const tag_values = this._filter_tags.has(tag) ? this._filter_tags.get(tag) : []; tag_values.push(value); this._filter_tags.set(tag, tag_values); } else { this._filter_tagnames.add(tag); } }, unsetSubfilter(tag, value) { if (value !== null) { const values = this._filter_tags.get(tag); this._filter_tags.set(tag, values.filter((tag_value) => value !== tag_value)); } else { this._filter_tagnames.delete(tag); } }, filterAddVar(name, value) { const input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = value; this._filter_form.appendChild(input); }, submitSubfilter() { this.filterAddVar('subfilter_set', '1'); for (const element of this._filter_form.querySelectorAll('[name^="subfilter_tag"]')) { element.remove(); } this._filter_tags.forEach((values, tag) => { for (let value of values) { this.filterAddVar(`subfilter_tags[${encodeURIComponent(tag)}][]`, value); } }); this._filter_tagnames.forEach(tag => { this.filterAddVar(`subfilter_tagnames[]`, tag); }); this._filter_form.submit(); } }; </script> <script type="text/javascript"> /** * @var {number} App will only show loading indicator, if loading takes more than DELAY_LOADING seconds. */ Chart.DELAY_LOADING = 3; /** * Represents chart, it can be refreshed. * * @param {object} chart Chart object prepared in server. * @param {object} timeline Timeselector data. * @param {jQuery} $tmpl Template object to be used for new chart $el. * @param {Node} wrapper Dom node in respect to which resize must be done. */ function Chart(chart, timeline, $tmpl, wrapper) { this.$el = $tmpl.clone(); this.$img = this.$el.find('img'); this.chartid = chart.chartid; this.timeline = timeline; this.dimensions = chart.dimensions; this.curl = new Curl(chart.src); if ('graphid' in chart) { this.curl.setArgument('graphid', chart.graphid); } else { this.curl.setArgument('itemids', [chart.itemid]); } this.use_sbox = !!chart.sbox; this.wrapper = wrapper; } /** * Set visual indicator of "loading state". * * @param {number} delay_loading (optional) Add loader only when request exceeds this many seconds. * * @return {function} Function that would cancel scheduled indicator or remove existing. */ Chart.prototype.setLoading = function(delay_loading) { const timeoutid = setTimeout(function(){ this.$img.parent().addClass('is-loading') }.bind(this), delay_loading * 1000); return function() { clearTimeout(timeoutid); this.unsetLoading(); }.bind(this); }; /** * Remove visual indicator of "loading state". */ Chart.prototype.unsetLoading = function() { this.$img.parent().removeClass('is-loading'); }; /** * Remove chart. */ Chart.prototype.destruct = function() { this.$img.off(); this.$el.off(); this.$el.remove(); }; /** * Updates image $.data for gtlc.js to handle selection box. */ Chart.prototype.refreshSbox = function() { if (this.use_sbox) { this.$img.data('zbx_sbox', { left: this.dimensions.shiftXleft, right: this.dimensions.shiftXright, top: this.dimensions.shiftYtop, height: this.dimensions.graphHeight, from_ts: this.timeline.from_ts, to_ts: this.timeline.to_ts, from: this.timeline.from, to: this.timeline.to }); } }; /** * Update chart. * * @param {number} delay_loading (optional) Add "loading indicator" only when request exceeds delay. * * @return {Promise} */ Chart.prototype.refresh = function(delay_loading) { let width = this.wrapper.clientWidth - 20; if (this.use_sbox) { width -= this.dimensions.shiftXright + this.dimensions.shiftXleft + 1; } this.curl.setArgument('from', this.timeline.from); this.curl.setArgument('to', this.timeline.to); this.curl.setArgument('height', this.dimensions.graphHeight); this.curl.setArgument('width', Math.max(1000, width)); this.curl.setArgument('profileIdx', 'web.charts.filter'); this.curl.setArgument('resolve_macros', 1); this.curl.setArgument('_', (+new Date).toString(34)); const unsetLoading = this.setLoading(delay_loading); const promise = new Promise((resolve, reject) => { this.$img.one('error', () => reject()); this.$img.one('load', () => resolve()); }) .catch(() => this.setLoading(0)) .finally(unsetLoading) .then(() => this.refreshSbox()); this.$img.attr('src', this.curl.getUrl()); return promise; }; /** * @param {jQuery} $el A container where charts are maintained. * @param {object} timeline Time control object. * @param {object} config * @param {Node} wrapper Dom node in respect to which resize must be done. */ function ChartList($el, timeline, config, wrapper) { this.curl = new Curl('zabbix.php'); this.curl.setArgument('action', 'charts.view.json'); this.$el = $el; this.timeline = timeline; this.charts = []; this.charts_map = {}; this.config = config; this.wrapper = wrapper; } ChartList.prototype.updateSubfilters = function(subfilter_tagnames, subfilter_tags) { this.config.subfilter_tagnames = subfilter_tagnames; this.config.subfilter_tags = subfilter_tags; } /** * Update currently listed charts. * * @return {Promise} Resolves once all charts are refreshed. */ ChartList.prototype.updateCharts = function() { const updates = []; for (const chart of this.charts) { chart.timeline = this.timeline; updates.push(chart.refresh()); } return Promise.all(updates); }; /** * Fetches, then sets new list, then updates each chart. * * @param {number} delay_loading (optional) Add "loading indicator" only when request exceeds delay. * * @return {Promise} Resolves once list is fetched and each of new charts is fetched. */ ChartList.prototype.updateListAndCharts = function(delay_loading) { return this.fetchList() .then(list => { this.setCharts(list); return this.charts; }) .then(new_charts => { const loading_charts = []; for (const chart of new_charts) { loading_charts.push(chart.refresh(delay_loading)); } return Promise.all(loading_charts) }); }; /** * Fetches new list of charts. * * @return {Promise} */ ChartList.prototype.fetchList = function() { // Timeselector. this.curl.setArgument('from', this.timeline.from); this.curl.setArgument('to', this.timeline.to); // Filter. this.curl.setArgument('filter_hostids', this.config.filter_hostids); this.curl.setArgument('filter_name', this.config.filter_name); this.curl.setArgument('filter_show', this.config.filter_show); this.curl.setArgument('subfilter_tagnames', this.config.subfilter_tagnames); this.curl.setArgument('subfilter_tags', this.config.subfilter_tags); this.curl.setArgument('page', this.config.page); return fetch(this.curl.getUrl()) .then((response) => response.json()) .then((response) => { this.timeline = response.timeline; view.replaceSubfilter(response.subfilter); view.replacePaging(response.paging); return response.charts; }); }; /** * Update app state according with configuration. Either update individual chart item schedulers or re-fetch * list and update list scheduler. * * @param {number} delay_loading (optional) Add "loading indicator" only when request exceeds delay. */ ChartList.prototype.refresh = function(delay_loading) { const {refresh_interval} = this.config; if (this._timeoutid) { clearTimeout(this._timeoutid); } this.updateListAndCharts(delay_loading) .finally(_ => { if (refresh_interval) { this._timeoutid = setTimeout(_ => this.refresh(Chart.DELAY_LOADING), refresh_interval * 1000 ); } }) .catch(_ => { for (const chart of this.charts) { chart.setLoading(); } }); }; /** * Constructs new charts and removes missing, reorders existing charts. * * @param {array} raw_charts */ ChartList.prototype.setCharts = function(raw_charts) { const charts = []; const charts_map = {}; raw_charts.forEach(function(chart) { chart = this.charts_map[chart.chartid] ? this.charts_map[chart.chartid] : new Chart(chart, this.timeline, view._$tmpl_row, view._container); // Existing chart nodes are assured to be in correct order. this.$el.append(chart.$el); charts_map[chart.chartid] = chart; charts.push(chart); }.bind(this)); // Charts that was not in new list are to be deleted. this.charts.forEach(function(chart) { !charts_map[chart.chartid] && chart.destruct(); }); this.charts = charts; this.charts_map = charts_map; }; /** * A response to horizontal window resize is to refresh charts (body min width is taken into account). * Chart update is debounced for a half second. */ ChartList.prototype.onResize = function() { const width = this.wrapper.clientWidth; if (this._prev_width === undefined) { this._prev_width = width; return; } clearTimeout(this._resize_timeoutid); if (this._prev_width != width) { this._resize_timeoutid = setTimeout(() => { this._prev_width = width; this.updateCharts(); }, 500); } }; </script>