diff --git a/configuration.php b/configuration.php index 16a25f46b..131e02f7c 100644 --- a/configuration.php +++ b/configuration.php @@ -574,4 +574,5 @@ $this->provideJsFile('loadmore.js'); $this->provideJsFile('migrate.js'); $this->provideJsFile('progress-bar.js'); + $this->provideJsFile('billboard.js'); } diff --git a/library/Icingadb/ProvidedHook/Reporting/HostSlaChartReport.php b/library/Icingadb/ProvidedHook/Reporting/HostSlaChartReport.php new file mode 100644 index 000000000..40c7ce979 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/HostSlaChartReport.php @@ -0,0 +1,49 @@ +getModuleManager()->hasEnabled('idoreports')) { + $name .= ' (Icinga DB)'; + } + + return $name; + } + + protected function fetchSla(Timerange $timerange, Rule $filter = null) + { + $sla = Host::on($this->getDb()) + ->columns([ + 'id', + 'display_name', + 'sla' => new Expression(sprintf( + "get_sla_ok_percent(%s, NULL, '%s', '%s')", + 'host.id', + $timerange->getStart()->format('Uv'), + $timerange->getEnd()->format('Uv') + )), + ]); + + $this->applyRestrictions($sla); + + if ($filter !== null) { + $sla->filter($filter); + } + + return $sla; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaChartReport.php b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaChartReport.php new file mode 100644 index 000000000..e1de114f6 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaChartReport.php @@ -0,0 +1,53 @@ +getModuleManager()->hasEnabled('idoreports')) { + $name .= ' (Icinga DB)'; + } + + return $name; + } + + protected function fetchSla(Timerange $timerange, Rule $filter = null) + { + $sla = Service::on($this->getDb()) + ->columns([ + 'id', + 'host.display_name', + 'display_name', + 'sla' => new Expression(sprintf( + "get_sla_ok_percent(%s, %s, '%s', '%s')", + 'service.host_id', + 'service.id', + $timerange->getStart()->format('Uv'), + $timerange->getEnd()->format('Uv') + )) + ]); + + $sla->resetOrderBy()->orderBy('host.display_name')->orderBy('display_name'); + + $this->applyRestrictions($sla); + + if ($filter !== null) { + $sla->filter($filter); + } + + return $sla; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaChartReport.php b/library/Icingadb/ProvidedHook/Reporting/SlaChartReport.php new file mode 100644 index 000000000..60b3f3d4a --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/SlaChartReport.php @@ -0,0 +1,309 @@ +fetchReportData($timerange, $config); + } + protected function fetchReportData(Timerange $timerange, array $config = null) + { + $filter = trim((string)$config['filter']) ?: '*'; + $filter = $filter !== '*' ? QueryString::parse($filter) : null; + + $interval = null; + $boundary = null; + + switch ($config['breakdown']) { + case 'hour': + $interval = new DateInterval('PT1H'); + $boundary = '+1 hour'; + + break; + case 'day': + $interval = new DateInterval('P1D'); + $boundary = 'tomorrow midnight'; + + break; + case 'week': + $interval = new DateInterval('P1W'); + $boundary = 'monday next week midnight'; + + break; + case 'month': + $interval = new DateInterval('P1M'); + $boundary = 'first day of next month midnight'; + + break; + } + + $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION; + $data = []; + foreach ($this->yieldTimerange($timerange, $interval, $boundary) as [$start, $end]) { + foreach ($this->fetchSla(new Timerange($start, $end), $filter) as $row) { + if ($row->sla === null) { + continue; + } + + if (! isset($data[$row->id]['title'])) { + if ($row instanceof Service) { + $title = sprintf( + t('%s on %s', ' on '), + $row->display_name, + $row->host->display_name + ); + } else { + $title = $row->display_name; + } + + $data[$row->id]['title'] = $title; + } + + $data[$row->id]['xAxisTicks'][] = (int) $start->format('Uv'); + $data[$row->id]['dataPoints'][] = round($row->sla, $precision); + } + } + + return $data; + } + + /** + * Yield start and end times that recur at the specified interval over the given time range + * + * @param Timerange $timerange + * @param DateInterval $interval + * @param string|null $boundary English text datetime description for calculating bounds to get + * calendar days, weeks or months instead of relative times according to interval + * + * @return \Generator + */ + protected function yieldTimerange(Timerange $timerange, DateInterval $interval, $boundary = null) + { + $start = clone $timerange->getStart(); + $end = clone $timerange->getEnd(); + $oneSecond = new DateInterval('PT1S'); + + if ($boundary !== null) { + $intermediate = (clone $start)->modify($boundary); + if ($intermediate < $end) { + yield [clone $start, $intermediate->sub($oneSecond)]; + + $start->modify($boundary); + } + } + + $period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE); + + foreach ($period as $date) { + /** @var \DateTime $date */ + yield [$start, (clone $date)->sub($oneSecond)]; + + $start = $date; + } + + yield [$start, $end]; + } + + public function initConfigForm(Form $form) + { + $form->addElement('text', 'filter', [ + 'label' => t('Filter') + ]); + + $form->addElement('select', 'chart_type', [ + 'label' => t('Chart Type'), + 'options' => [ + null => t('Please choose'), + 'line' => t('Line'), + 'bar' => t('Bar') + ], + 'disabledOptions' => [null], + 'required' => true + ]); + + $breakdownOptions = [ + null => t('Please choose'), + 'hour' => t('Hour'), + 'day' => t('Day'), + 'week' => t('Week'), + 'month' => t('Month') + ]; + + $form->addElement('select', 'breakdown', [ + 'label' => t('Breakdown'), + 'options' => $breakdownOptions, + 'disabledOptions' => [null], + 'required' => true, + ]); + + $timeframe = $form->getPopulatedValue('timeframe_instance'); + if ($timeframe) { + $diffDays = (new DateTime($timeframe->start)) + ->diff(new DateTime($timeframe->end)) + ->days; + + $toDisable = []; + // maximum 150 data points should be visible, disable breakdowns that would exceed this + if ($diffDays > 5) { + $toDisable[] = 'hour'; + + if ($diffDays > 150) { + $toDisable[] = 'day'; + } + + if ($diffDays / 7 > 150) { + $toDisable[] = 'week'; + } + + if ($diffDays / 30 > 150) { + $toDisable[] = 'month'; + } + + $disableDescription = t('The selected timeframe is too large for this breakdown'); + foreach ($toDisable as $key) { + $breakdownOptions[$key] = sprintf('%s (%s)', $breakdownOptions[$key], $disableDescription); + } + + $form->getElement('breakdown') + ->addAttributes([ + 'options' => $breakdownOptions, + 'disabledOptions' => array_merge([null], $toDisable) + ]); + } + } + + $form->addElement('number', 'threshold', [ + 'label' => t('Threshold'), + 'placeholder' => static::DEFAULT_THRESHOLD, + 'step' => '0.01', + 'min' => '1', + 'max' => '100' + ]); + + $form->addElement('number', 'sla_precision', [ // TODO: required? + 'label' => t('Amount Decimal Places'), + 'placeholder' => static::DEFAULT_REPORT_PRECISION, + 'min' => '1', + 'max' => '12' + ]); + } + + public function getHtml(Timerange $timerange, array $config = null) + { + $data = $this->fetchReportData($timerange, $config); + + if (! count($data)) { + return new EmptyState(t('No data found.')); + } + + $threshold = isset($config['threshold']) ? (float)$config['threshold'] : static::DEFAULT_THRESHOLD; + $charts = []; + foreach ($data as $chartData) { + $title = new HtmlElement( + 'div', + Attributes::create(['class' => 'icinga-chart-title']), + HtmlString::create($chartData['title']) + ); + unset($chartData['title']); + $charts[] = new HtmlElement( + 'div', + Attributes::create(['class' => 'sla-chart-wrapper']), + (new SlaTimeseriesChart($config['chart_type'], $title, $chartData)) + ->setXAxisTicksType($config['breakdown']) + ->setThreshold($threshold) + ); + } + + $table = new HtmlElement('table', Attributes::create(['class' => ['sla-chart-table']])); + + $table->addHtml( + new HtmlElement( + 'thead', + null, + new HtmlElement( + 'tr', + null, + new HtmlElement( + 'th', + Attributes::create(['class' => 'legend']), + new HtmlElement( + 'span', + Attributes::create(['class' => 'above-threshold']), + new StateBall('ok', StateBall::SIZE_MEDIUM_LARGE), + HtmlString::create('SLA in > ' . $threshold) + ), + new HtmlElement( + 'span', + Attributes::create(['class' => 'below-threshold']), + new StateBall('down', StateBall::SIZE_MEDIUM_LARGE), + HtmlString::create('SLA in <= ' . $threshold) + ) + ) + ) + ) + ); + + $table->addHtml( + new HtmlElement( + 'tbody', + null, + new HtmlElement( + 'tr', + null, + new HtmlElement( + 'td', + Attributes::create(['data-pdfexport-page-breaks-at' => '.sla-chart-wrapper']), + ...$charts + ) + ) + ) + ); + + return $table; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaTimeseriesChart.php b/library/Icingadb/ProvidedHook/Reporting/SlaTimeseriesChart.php new file mode 100644 index 000000000..f8d003bd8 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/SlaTimeseriesChart.php @@ -0,0 +1,104 @@ + ['icinga-chart'], + 'data-chart-show-legend' => false, + 'data-chart-x-axis-type' => 'timeseries', + 'data-chart-y-axis-min' => 0, + 'data-chart-y-axis-max' => 100, + 'data-chart-below-threshold-color' => '#ff5566', + 'data-chart-above-threshold-color' => '#44bb77', + 'data-chart-line-color' => 'rgba(85,85,85,0.5)' + ]; + + /** @var string The chart type (line, bar) */ + protected $type; + + /** @var ValidHtml The title */ + protected $title; + + /** @var array The chart data ['dataPoints' => [...], 'xAxisTicks' => [...]] */ + protected $data; + + /** @var string The locale to format timeseries */ + protected $locale; + + /** @var string The type of the x-axis ticks (hour, week, month, year) */ + protected $xAxisTicksType = 'hour'; + + /** @var float|int The threshold value for the chart */ + protected $threshold = SlaChartReport::DEFAULT_THRESHOLD; + + /** + * @param string $type The type of the chart (line, bar) + * @param ValidHtml $title The title of the chart + * @param array $data The data for the chart + * + */ + public function __construct(string $type, ValidHtml $title, array $data = []) + { + $this->type = $type; + $this->title = $title; + $this->data = $data; + + if (method_exists(StaticTranslator::$instance, 'getLocale')) { + $this->locale = str_replace('_', '-', StaticTranslator::$instance->getLocale()); + } + } + + /** + * Set the type of the x-axis ticks (hour, week, month, year) + * + * @param string $xAxisTicksType + * + * @return $this + */ + public function setXAxisTicksType(string $xAxisTicksType): self + { + $this->xAxisTicksType = $xAxisTicksType; + + return $this; + } + + /** + * Set the threshold value for the chart + * + * @param float|int $threshold + * + * @return $this + */ + public function setThreshold(float $threshold): self + { + $this->threshold = $threshold; + + return $this; + } + + protected function assemble(): void + { + $this->addAttributes([ + 'data-chart-type' => $this->type, + 'data-chart-data' => Json::encode($this->data), + 'data-chart-x-axis-label' => t('Time'), + 'data-chart-y-axis-label' => t('Percent'), + 'data-chart-time-format-locale' => $this->locale, + 'data-chart-x-axis-ticks-type' => $this->xAxisTicksType, + 'data-chart-threshold' => $this->threshold + ]); + + $this->addHtml($this->title); + } +} \ No newline at end of file diff --git a/public/js/billboard.js b/public/js/billboard.js new file mode 100644 index 000000000..b58e8fe69 --- /dev/null +++ b/public/js/billboard.js @@ -0,0 +1,222 @@ +;(function (Icinga) { + + "use strict"; + + try { + var bb = require('icinga/icinga-php-library/vendor/billboard'); + } catch (e) { + console.warn('Unable to provide billboard feature. Libraries not available:', e); + return; + } + + const FALLBACK_COLOR = 'green'; + + /** + * ## Supported data attributes: + * + * #### Required: + * - chartData {json}: Chart data (dataPoints: [int|float], xAxisTicks: [int|float|string]) + * + * #### Optional: + * - chartType {string}: Chart type [line, bar, area...] (default: line) + * - chartXAxisType {string}: X-axis type [timeseries, category, indexed, log] (default: indexed) + * - chartXAxisTicksType {string}: X-axis ticks type (hour, day, week, month) [only for chartXAxisType: timeseries] (default: detected automatically) + * - chartTimeFormatLocale {string}: Locale for the time format [only for chartXAxisType: timeseries] (default: 'en-US') + * - chartYAxisLabel {string}: Label for the y-axis (default: null) + * - chartXAxisLabel {string}: Label for the x-axis (default: null) + * - chartShowLegend {boolean}: Show legend (default: false) + * - chartYAxisMax {int|float}: Max value for the y-axis (default: 100) + * - chartYAxisMin {int|float}: Min value for the y-axis (default: 0) + * - chartThreshold {int|float}: Threshold value for the chart (default: null) + * - chartLineColor {string}: Color for the line (only for line chart) (default: FALLBACK_COLOR) + * - chartBelowThresholdColor {string}: Color for values below the threshold (default: FALLBACK_COLOR) + * - chartAboveThresholdColor {string}: Color for values above the threshold (default: FALLBACK_COLOR) + * - chartTooltipLabel {string}: Datapoint label for the tooltip (default: '') + */ + + class BillboardBehavior extends Icinga.EventListener { + constructor(icinga) + { + super(icinga); + + this.on('rendered', '#main > .container', this.onRendered, this); + } + + onRendered(event) + { + let _this = event.data.self; + + event.currentTarget.querySelectorAll('.icinga-chart').forEach(element => { + let attrs = element.dataset; + let chartData = JSON.parse(attrs.chartData); + + let dataPoints = chartData.dataPoints; + let xAxisTicks = chartData.xAxisTicks; + + let lineColor = attrs.chartLineColor ?? FALLBACK_COLOR; + let threshold = attrs.chartThreshold; + let belowThresholdColor = attrs.chartBelowThresholdColor ?? FALLBACK_COLOR; + let aboveThresholdColor = attrs.chartAboveThresholdColor ?? FALLBACK_COLOR; + let yAxisMax = Number(attrs.chartYAxisMax ?? 100); + let yAxisMin = Number(attrs.chartYAxisMin ?? 0); + + var grid = {}; + if (threshold) { + grid = { + y: { + lines: [ + { + value: threshold, + text: "threshold", + class: "threshold-mark", + }, + ] + }, + }; + } + + let chartElement = document.createElement('div'); + chartElement.classList.add('chart-element'); + + element.appendChild(chartElement); + + bb.default.generate({ + bindto: chartElement, + clipPath: false, // show line on 0 and 100 + zoom: { + enabled: true, + type: "drag" + }, + data: { + labels: { + rotate: 90, + format: (v, id, i, texts)=> { + return (v === yAxisMin || v === yAxisMax) ? '' : v; + }, + }, + type: attrs.chartType, + x: "xAxisTicks", + columns: [ + ["xAxisTicks"].concat(xAxisTicks), + [attrs.chartTooltipLabel ?? ''].concat(dataPoints) + ], + color: (color, datapoint) => { + if (! ("value" in datapoint)) { + return lineColor; + } + + return datapoint.value <= threshold ? belowThresholdColor : aboveThresholdColor; + }, + }, + axis: { + y: { + max: yAxisMax, + min: yAxisMin, + padding: { + top: 0, + bottom: 0 + }, + label: { + text: attrs.chartYAxisLabel, + position: "outer-middle", + }, + }, + x: { + type: attrs.chartXAxisType, + label: { + text: attrs.chartXAxisLabel, + position: "outer-center", + }, + tick: { + multiline: true, + rotate: 60, + culling: { + max: (xAxisTicks.length / 2) < 50 ? xAxisTicks.length + 1 : (xAxisTicks.length / 2) + }, + format: _this.getFormaterFunction(attrs, xAxisTicks), + }, + clipPath: false, + padding: { + right: 30, + unit: "px" + } + } + }, + legend: { + show: 'chartShowLegend' in attrs + }, + grid : grid, + }); + }); + } + + /** + * Get the formatter function based on the chart's x-axis type + * @param attrs + * @param xAxisTicks + * @return {null} + */ + getFormaterFunction(attrs, xAxisTicks) { + let formatterFunc = null; + switch (attrs.chartXAxisType) { + case 'timeseries': + formatterFunc = (dateObj) => { + let options = {timeZone: this.icinga.config.timezone}; + switch (attrs.chartXAxisTicksType) { + case 'hour': + options.hour = 'numeric'; + options.minute = 'numeric'; + + if (xAxisTicks.length > 24) { + options.year = '2-digit'; + options.month = 'short'; + options.day = 'numeric'; + } + + break; + case 'day': + case 'week': + options.year = '2-digit'; + options.month = 'short'; + options.day = 'numeric'; + break; + case 'month': + options.year = '2-digit'; + options.month = 'short'; + break; + } + + let locale = attrs.chartTimeFormatLocale ?? 'en-US'; + + let localeFormatter = Intl.DateTimeFormat(locale, options); + + if (attrs.chartXAxisTicksType === 'week') { + var current = dateObj.getTime(); + var next = xAxisTicks[xAxisTicks.indexOf(current) + 1]; + + return localeFormatter.formatRange(current, next ? next - 1 : current); + } + + return localeFormatter.format(dateObj); + }; + break; + case 'category': + formatterFunc = (index, categoryName) => { + return categoryName; + }; + break; + case "indexed": + case "log": + formatterFunc = (logOrIndex) => { + return logOrIndex; + }; + } + + return formatterFunc; + } + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + Icinga.Behaviors.BillboardBehavior = BillboardBehavior; +})(Icinga); diff --git a/run.php b/run.php index b4c803222..1dc3e1dc6 100644 --- a/run.php +++ b/run.php @@ -12,6 +12,8 @@ $this->provideHook('Reporting/Report', 'Reporting/TotalHostSlaReport'); $this->provideHook('Reporting/Report', 'Reporting/ServiceSlaReport'); $this->provideHook('Reporting/Report', 'Reporting/TotalServiceSlaReport'); +$this->provideHook('Reporting/Report', 'Reporting/HostSlaChartReport'); +$this->provideHook('Reporting/Report', 'Reporting/ServiceSlaChartReport'); if ($this::exists('reporting')) { $this->provideHook('Icingadb/HostActions', 'CreateHostSlaReport');