diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index de2458007..a2e1a21b2 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -54,6 +54,22 @@ jobs: php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] os: ['ubuntu-latest'] + services: + mysql: + image: mariadb + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: icingadb_web_unittest + MYSQL_USER: icingadb_web_unittest + MYSQL_PASSWORD: icingadb_web_unittest + options: >- + --health-cmd "mysql -s -uroot -proot -e'SHOW DATABASES;' 2> /dev/null | grep icingadb_web_unittest > test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306/tcp + steps: - name: Checkout code base uses: actions/checkout@v3 @@ -63,6 +79,7 @@ jobs: with: php-version: ${{ matrix.php }} tools: phpunit:${{ matrix.phpunit-version || env.phpunit-version }} + extensions: mysql - name: Setup Icinga Web run: | @@ -78,4 +95,5 @@ jobs: - name: PHPUnit env: ICINGAWEB_LIBDIR: _libraries + ICINGADBWEB_TEST_MYSQL_PORT: ${{ job.services.mysql.ports['3306'] }} run: phpunit --verbose --bootstrap _icingaweb2/test/php/bootstrap.php diff --git a/library/Icingadb/Model/Host.php b/library/Icingadb/Model/Host.php index a76cb3655..f8307566a 100644 --- a/library/Icingadb/Model/Host.php +++ b/library/Icingadb/Model/Host.php @@ -222,6 +222,11 @@ public function createRelations(Relations $relations) $relations->belongsToMany('hostgroup', Hostgroup::class) ->through(HostgroupMember::class); + $relations->hasMany('sla_history_state', SlaHistoryState::class) + ->setJoinType('LEFT'); + $relations->hasMany('sla_history_downtime', SlaHistoryDowntime::class) + ->setJoinType('LEFT'); + $relations->hasOne('state', HostState::class)->setJoinType('LEFT'); $relations->hasMany('comment', Comment::class)->setJoinType('LEFT'); $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT'); diff --git a/library/Icingadb/Model/HostSlaHistory.php b/library/Icingadb/Model/HostSlaHistory.php new file mode 100644 index 000000000..dc8ed5787 --- /dev/null +++ b/library/Icingadb/Model/HostSlaHistory.php @@ -0,0 +1,137 @@ +getModel()->setSlaEndTimes($slaEndTimes); + + $downtimeFilter = Filter::unlike('sla_history_downtime.service_id', '*'); + + $unions = $query->getUnions(); + $unions[0]->filter($downtimeFilter); + $unions[1]->filter($downtimeFilter); + $unions[2]->filter(Filter::unlike('sla_history_state.service_id', '*')); + + return $query; + } + + public function getUnions() + { + $unions = [ + [ + Host::class, + [ + 'sla_history_downtime' + ], + [ + 'display_name' => 'host.display_name', + 'event_time' => 'sla_history_downtime.downtime_start', + 'event_type' => new Expression(SlaTimeline::DOWNTIME_START), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ], + [ + Host::class, + [ + 'sla_history_downtime' + ], + [ + 'display_name' => 'host.display_name', + 'event_time' => 'sla_history_downtime.downtime_end', + 'event_type' => new Expression(SlaTimeline::DOWNTIME_END), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ], + [ + Host::class, + [ + 'sla_history_state' + ], + [ + 'display_name' => 'display_name', + 'event_time' => 'sla_history_state.event_time', + 'event_type' => new Expression(SlaTimeline::STATE_CHANGE), + 'hard_state' => 'sla_history_state.hard_state', + 'host_id' => 'id', + 'previous_hard_state' => 'sla_history_state.previous_hard_state', + ] + ] + ]; + + // Create a union part for all sla interval end times which is supposed to identify the end of the interval. + foreach ($this->slaEndTimes as $timerange) { + $unions[] = [ + Host::class, + [], + [ + 'display_name' => 'host.display_name', + 'event_time' => new Expression($timerange->end->format('Uv')), + 'event_type' => new Expression(SlaTimeline::END_RESULT), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ]; + } + + return $unions; + } + + public function getDefaultSort() + { + return ['event_time', 'display_name', 'event_type']; + } + + public function createRelations(Relations $relations) + { + (new Host())->createRelations($relations); + } + + public function setSlaEndTimes(array $times): self + { + $this->slaEndTimes = $times; + + return $this; + } +} diff --git a/library/Icingadb/Model/Service.php b/library/Icingadb/Model/Service.php index 74eda0e0c..e197f6ce2 100644 --- a/library/Icingadb/Model/Service.php +++ b/library/Icingadb/Model/Service.php @@ -214,6 +214,11 @@ public function createRelations(Relations $relations) $relations->belongsToMany('hostgroup', Hostgroup::class) ->through(HostgroupMember::class); + $relations->hasMany('sla_history_state', SlaHistoryState::class) + ->setJoinType('LEFT'); + $relations->hasMany('sla_history_downtime', SlaHistoryDowntime::class) + ->setJoinType('LEFT'); + $relations->hasOne('state', ServiceState::class)->setJoinType('LEFT'); $relations->hasMany('comment', Comment::class)->setJoinType('LEFT'); $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT'); diff --git a/library/Icingadb/Model/ServiceSlaHistory.php b/library/Icingadb/Model/ServiceSlaHistory.php new file mode 100644 index 000000000..e92170a57 --- /dev/null +++ b/library/Icingadb/Model/ServiceSlaHistory.php @@ -0,0 +1,151 @@ +getModel()->setSlaEndTimes($timeranges); + + return $query; + } + + public function getUnions() + { + $unions = [ + [ + Service::class, + [ + 'host', + 'sla_history_downtime' + ], + [ + 'display_name' => 'display_name', + 'host_display_name' => 'host.display_name', + 'event_time' => 'sla_history_downtime.downtime_start', + 'event_type' => new Expression(SlaTimeline::DOWNTIME_START), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'host.id', + 'service_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ], + [ + Service::class, + [ + 'host', + 'sla_history_downtime' + ], + [ + 'display_name' => 'display_name', + 'host_display_name' => 'host.display_name', + 'event_time' => 'sla_history_downtime.downtime_end', + 'event_type' => new Expression(SlaTimeline::DOWNTIME_END), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'host.id', + 'service_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ], + [ + Service::class, + [ + 'host', + 'sla_history_state' + ], + [ + 'display_name' => 'display_name', + 'host_display_name' => 'host.display_name', + 'event_time' => 'sla_history_state.event_time', + 'event_type' => new Expression(SlaTimeline::STATE_CHANGE), + 'hard_state' => 'sla_history_state.hard_state', + 'host_id' => 'host.id', + 'service_id' => 'id', + 'previous_hard_state' => 'sla_history_state.previous_hard_state', + ] + ] + ]; + + // Create a union part for all sla interval end times which is supposed to identify the end of the interval. + foreach ($this->slaEndTimes as $timerange) { + $unions[] = [ + Service::class, + [ + 'host' + ], + [ + 'display_name' => 'display_name', + 'host_display_name' => 'host.display_name', + 'event_time' => new Expression($timerange->end->format('Uv')), + 'event_type' => new Expression(SlaTimeline::END_RESULT), + 'hard_state' => new Expression('NULL'), + 'host_id' => 'host.id', + 'service_id' => 'id', + 'previous_hard_state' => new Expression('NULL'), + ] + ]; + } + + return $unions; + } + + public function getDefaultSort() + { + return ['event_time', 'host_display_name', 'display_name', 'event_type']; + } + + public function createRelations(Relations $relations) + { + (new Service())->createRelations($relations); + } + + /** + * Set all the sla interval end time to be part of the union query + * + * @param array $times + * + * @return $this + */ + public function setSlaEndTimes(array $times): self + { + $this->slaEndTimes = $times; + + return $this; + } +} diff --git a/library/Icingadb/Model/ServiceState.php b/library/Icingadb/Model/ServiceState.php index 9de2a21e1..9f5ff49dd 100644 --- a/library/Icingadb/Model/ServiceState.php +++ b/library/Icingadb/Model/ServiceState.php @@ -19,6 +19,14 @@ public function getKeyName() return 'service_id'; } + public function getColumns() + { + $columns = parent::getColumns(); + $columns[] = 'host_id'; + + return $columns; + } + public function getColumnDefinitions() { return [ diff --git a/library/Icingadb/Model/SlaHistoryDowntime.php b/library/Icingadb/Model/SlaHistoryDowntime.php new file mode 100644 index 000000000..d8cc30dc8 --- /dev/null +++ b/library/Icingadb/Model/SlaHistoryDowntime.php @@ -0,0 +1,61 @@ +add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id', + 'downtime_id' + ])); + + $behaviors->add(new MillisecondTimestamp([ + 'downtime_start', + 'downtime_end' + ])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class); + } +} diff --git a/library/Icingadb/Model/SlaHistoryState.php b/library/Icingadb/Model/SlaHistoryState.php new file mode 100644 index 000000000..a6a41d4ee --- /dev/null +++ b/library/Icingadb/Model/SlaHistoryState.php @@ -0,0 +1,62 @@ +add(new Binary([ + 'id', + 'environment_id', + 'endpoint_id', + 'host_id', + 'service_id' + ])); + + $behaviors->add(new MillisecondTimestamp(['event_time'])); + } + + public function createRelations(Relations $relations) + { + $relations->belongsTo('host', Host::class); + $relations->belongsTo('service', Service::class); + } + + public function getDefaultSort() + { + return ['event_time']; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/Common/ReportData.php b/library/Icingadb/ProvidedHook/Reporting/Common/ReportData.php new file mode 100644 index 000000000..deb2c14ff --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/Common/ReportData.php @@ -0,0 +1,41 @@ +getAllTimelines() as $name => $timelines) { + $totalTime = 0; + $problemTime = 0; + + /** @var SlaTimeline $timeline */ + foreach ($timelines as $timeline) { + $totalTime += $timeline->getTotalTime(); + $problemTime += $timeline->getProblemTime(); + } + + if ($totalTime <= 0) { + continue; + } + + ++$count; + $totals += 100 * ($totalTime - $problemTime) / $totalTime; + } + + if ($count === 0) { + return [null]; + } + + return [$totals / $count]; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/Common/SlaReportUtils.php b/library/Icingadb/ProvidedHook/Reporting/Common/SlaReportUtils.php new file mode 100644 index 000000000..6d90cc746 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/Common/SlaReportUtils.php @@ -0,0 +1,353 @@ +getReportType() === 'host') { + $query = HostSlaHistory::on($this->getDb(), $timeranges); + } else { + $query = ServiceSlaHistory::on($this->getDb(), $timeranges); + } + + $unions = $query->getUnions(); + $slaDowntimeFilter = Filter::all( + Filter::lessThan('sla_history_downtime.downtime_start', $end), + Filter::greaterThanOrEqual('sla_history_downtime.downtime_end', $start) + ); + + $unions[0] + ->filter($slaDowntimeFilter) + ->columns( + array_merge( + $unions[0]->getColumns(), + [ + 'event_time' => new Expression( + sprintf( + 'GREATEST(%s_sla_history_downtime.downtime_start, %s)', + $this->getReportType(), + $start->format('Uv') + ) + ) + ] + ) + ); + + $unions[1] + ->filter($slaDowntimeFilter) + ->filter(Filter::lessThan('sla_history_downtime.downtime_end', $end)); + + $unions[2]->filter(Filter::all( + Filter::greaterThan('sla_history_state.event_time', $start), + Filter::lessThan('sla_history_state.event_time', $end) + )); + + if ($filter !== null) { + foreach ($unions as $union) { + $union->filter($filter); + } + } + + if (method_exists($this, 'applyRestrictions')) { + $this->applyRestrictions($query); + } + + return $query; + } + + protected function fetchReportData(DateTime $start, DateTime $end, array $config = null) + { + $rd = $this->createReportData(); + + $filter = trim((string) $config['filter']) ?: '*'; + $filter = $filter !== '*' ? QueryString::parse($filter) : null; + $isHostQuery = $this->getReportType() === 'host'; + + if (isset($config['breakdown']) && $config['breakdown'] !== 'none') { + switch ($config['breakdown']) { + case 'day': + $interval = new DateInterval('P1D'); + $format = 'Y-m-d'; + $boundary = 'tomorrow midnight'; + + break; + case 'week': + $interval = new DateInterval('P1W'); + $format = 'Y-\WW'; + $boundary = 'monday next week midnight'; + + break; + case 'month': + $interval = new DateInterval('P1M'); + $format = 'Y-m'; + $boundary = 'first day of next month midnight'; + + break; + } + + $dimensions = $rd->getDimensions(); + $dimensions[] = ucfirst($config['breakdown']); + $rd->setDimensions($dimensions); + + $slaWithBreakdown = true; + $timeranges = []; + foreach ($this->yieldTimerange($start, $end, $interval, $boundary) as list($begin, $endTime)) { + $timerange = (object) []; + $timerange->start = $begin; + $timerange->end = $endTime; + + $timeranges[] = $timerange; + } + } else { + $timeranges[] = (object) ['start' => $start, 'end' => $end]; + $slaWithBreakdown = false; + } + + $timelines = []; + $rows = []; + $objectInfo = []; + foreach ($this->fetchSla($start, $end, $filter, $timeranges) as $row) { + $key = $isHostQuery ? bin2hex($row->host_id) : bin2hex($row->service_id); + foreach ($timeranges as $timerange) { + $time = (int) $row->event_time; + if ($time >= (int) $timerange->start->format('Uv') && $time <= (int) $timerange->end->format('Uv')) { + $start = $timerange->start; + $end = $timerange->end; + + break; + } + } + + if (isset($timelines[$key])) { + $timeline = $timelines[$key]; + } else { + $timeline = new SlaTimeline($start, $end, $this->getReportType()); + if (isset($objectInfo[$key]->lastState)) { + // No need to retrieve the initial hard state from the database, as we have already cached + // the last hard state from the previous timeline interval of this object. + $initialHardState = $objectInfo[$key]->lastState; + } else { + $serviceId = ! $isHostQuery ? $row->service_id : null; + list($initialHardState, $isBefore) = $this->fetchInitialHardState( + $start, + $row->host_id, + $serviceId + ); + + // Cache whether the current initial hard state retrieved from the database + // is from before the beginning of this timeline interval. + $objectInfo[$key] = (object) ['isFromBeforeInterval' => $isBefore]; + } + + $timeline->setInitialHardState($initialHardState); + } + + $timeline->addEvent( + (object) [ + 'type' => $row->event_type, + 'time' => (int)$row->event_time, + 'hardState' => $row->hard_state === null ? null : (int)$row->hard_state, + 'previousHardState' => $row->previous_hard_state === null ? null : (int)$row->previous_hard_state, + ] + ); + + if ($row->hard_state !== null) { + // Cache the current object last hard_state, which may be used as the initial hard state for + // the next timeline interval. + $objectInfo[$key]->lastState = (int) $row->hard_state; + // Obviously, this state can always be from before the beginning of the next timeline interval + $objectInfo[$key]->isFromBeforeInterval = true; + } + + if ($row->event_type === SlaTimeline::END_RESULT) { + $report = (object) []; + $report->sla = $timeline->getResult(); + + unset($timelines[$key]); + + $info = $objectInfo[$key]; + if ( + $slaWithBreakdown + && ( + $report->sla === null + || ( + ! $info->isFromBeforeInterval + && count($timeline) <= 1 + ) + ) + ) { + // This is only the case when the object doesn't have any history events in a given + // timeframe or the timeline contains only the fake end event of the specified timeframe. + // Either way, we have to skip this timeline. + continue; + } + + $rd->addTimeline($key, $timeline); + + $report->display_name = $row->display_name; + if (! $isHostQuery) { + $report->host_display_name = $row->host_display_name; + } + + $report = $this->createReportRow($report); + if ($slaWithBreakdown) { + // We create these dimensions only when using report breakdown + $dimensions = $report->getDimensions(); + $dimensions[] = $start->format($format); + $report->setDimensions($dimensions); + } + + $rows[] = $report; + + continue; + } + + $timelines[$key] = $timeline; + } + + $rd->setRows($rows); + + return $rd; + } + + /** + * Yield start and end times that recur at the specified interval over the given time range + * + * @param DateTime $start + * @param DateTime $end + * @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(DateTime $start, DateTime $end, DateInterval $interval, $boundary = null) + { + $start = clone $start; + $end = clone $end; + $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]; + } + + /** + * Get the initial hard state of the given host/service object + * + * @param DateTime $start The start time of the generated sla + * @param string $hostId Host binary/hex id to fetch the initial hard state for + * + * @return array + */ + protected function fetchInitialHardState(DateTime $start, string $hostId, string $serviceId = null): array + { + $serviceFilter = $serviceId === null + ? Filter::unlike('service_id', '*') + : Filter::equal('service_id', $serviceId); + + // Use the latest event at or before the beginning of the SLA interval as the initial state. + $hardState = SlaHistoryState::on($this->getDb()) + ->columns(['hard_state']) + ->filter( + Filter::all( + Filter::equal('host_id', $hostId), + $serviceFilter, + Filter::lessThanOrEqual('event_time', $start) + ) + ) + ->resetOrderBy() + ->orderBy('event_time', 'DESC') + ->limit(1); + + $isBefore = true; + $hardState = $hardState->first(); + + // If this doesn't exist, use the previous state from the first event after the beginning of the SLA interval. + if (! $hardState) { + $isBefore = false; + $hardState = SlaHistoryState::on($this->getDb()) + ->columns(['hard_state' => 'previous_hard_state']) + ->filter( + Filter::all( + Filter::equal('host_id', $hostId), + $serviceFilter, + Filter::greaterThan('event_time', $start) + ) + ) + ->limit(1); + + $hardState = $hardState->first(); + + // If this also doesn't exist, use the current host/service state. + if (! $hardState) { + if ($serviceId !== null) { + $hardState = ServiceState::on($this->getDb()) + ->filter(Filter::equal('service_id', $serviceId)); + } else { + $hardState = HostState::on($this->getDb()); + } + + $hardState + ->columns(['hard_state']) + ->filter(Filter::equal('host_id', $hostId)); + + $hardState = $hardState->first(); + } + } + + // Use OK/UP as initial hard state, when neither of the above queries could determine a correct state + return $hardState === null ? [0, $isBefore] : [(int) $hardState->hard_state, $isBefore]; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimeline.php b/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimeline.php new file mode 100644 index 000000000..4b913d91a --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimeline.php @@ -0,0 +1,173 @@ +start = clone $start; + $this->end = clone $end; + $this->objectType = $objectType; + } + + /** + * Set the initial hard state of this timeline + * + * @param int $state + * + * @return $this + */ + public function setInitialHardState(int $state): self + { + $this->initialHardState = $state; + + return $this; + } + + /** + * Get the calculated SLA result of this timeline + * + * @return ?float + */ + public function getResult(): ?float + { + $problemTime = 0; + $activeDowntimes = 0; + $lastEventTime = (int) $this->start->format('Uv'); + $totalTime = (int) $this->end->format('Uv') - $lastEventTime; + + $lastHardState = $this->initialHardState; + foreach ($this->events as $event) { + if ($event->previousHardState === 99 || ($lastHardState === 99 && $event->type !== static::STATE_CHANGE)) { + $totalTime -= $event->time - $lastEventTime; + } elseif ( + ( + ( + $this->objectType === 'host' + && $lastHardState > 0 + ) + || ( + $this->objectType === 'service' + && $lastHardState > 1 + ) + ) + && $lastHardState !== 99 + && $activeDowntimes === 0 + ) { + $problemTime += $event->time - $lastEventTime; + } + + $lastEventTime = $event->time; + if ($event->type === static::STATE_CHANGE) { + $lastHardState = $event->hardState; + } elseif ($event->type === static::DOWNTIME_START) { + ++$activeDowntimes; + } elseif ($event->type === static::DOWNTIME_END) { + --$activeDowntimes; + } + } + + $this->problemTime = $problemTime; + $this->totalTime = $totalTime; + + if ($totalTime <= 0) { + return null; + } + + return 100 * ($totalTime - $problemTime) / $totalTime; + } + + /** + * Add history event to this timeline + * + * @param object $event + * + * @return $this + */ + public function addEvent(object $event): self + { + $this->events[] = $event; + + return $this; + } + + /** + * Get the problem time of this timeline + * + * @return int + */ + public function getProblemTime(): int + { + return $this->problemTime; + } + + /** + * Get the total time of this timeline + * + * @return int + */ + public function getTotalTime(): int + { + return $this->totalTime; + } + + public function count(): int + { + return count($this->events); + } + + public function __toString() + { + $timeline = ''; + foreach ($this->events as $event) { + $timeline .= 'time: ' . $event->time . ' | event: ' . $event->type; + $timeline .= ' | hard_state: ' . $event->hardState . '| previous_hard_state: ' . $event->previousHardState; + + $timeline .= PHP_EOL; + } + + return $timeline; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimelines.php b/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimelines.php new file mode 100644 index 000000000..c43989170 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Reporting/Common/SlaTimelines.php @@ -0,0 +1,98 @@ +timelines; + } + + /** + * Get sla timelines for the given hosts/service name, if any + * + * @param string $key + * + * @return array + */ + public function getTimelines(string $key): array + { + if (! $this->hasTimelines($key)) { + throw new InvalidArgumentException(sprintf('No timeline found for "%s"', $key)); + } + + return $this->timelines[$key]; + } + + /** + * Add a timeline for the given host/service name + * + * @param string $key + * @param SlaTimeline $timeline + * + * @return $this + */ + public function addTimeline(string $key, SlaTimeline $timeline): self + { + $this->timelines[$key][] = $timeline; + + return $this; + } + + /** + * Override all timelines of the given host/service by the specified timeline + * + * @param string $key + * @param SlaTimeline $timeline + * + * @return $this + */ + public function setTimeline(string $key, SlaTimeline $timeline): self + { + $this->timelines[$key] = []; + $this->addTimeline($key, $timeline); + + return $this; + } + + /** + * Get whether the given host/service has any timelines + * + * @param string $key + * + * @return bool + */ + public function hasTimelines(string $key): bool + { + return ! empty($this->getAllTimelines()) && isset($this->timelines[$key]); + } + + public function getTimelineString(): string + { + $string = ''; + foreach ($this->getAllTimelines() as $name => $timelines) { + $index = 1; + $string .= 'Name: ' . $name; + foreach ($timelines as $timeline) { + $string .= ' Timeline: ' . $index++ . PHP_EOL . $timeline; + } + + $string .= PHP_EOL; + } + + return $string; + } +} diff --git a/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php index d9c4f4f13..31d470951 100644 --- a/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php +++ b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php @@ -5,12 +5,8 @@ namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; use Icinga\Application\Icinga; -use Icinga\Module\Icingadb\Model\Host; -use Icinga\Module\Reporting\ReportData; +use Icinga\Module\Icingadb\ProvidedHook\Reporting\Common\ReportData; use Icinga\Module\Reporting\ReportRow; -use Icinga\Module\Reporting\Timerange; -use ipl\Sql\Expression; -use ipl\Stdlib\Filter\Rule; use function ipl\I18n\t; @@ -35,34 +31,8 @@ protected function createReportData() protected function createReportRow($row) { - if ($row->sla === null) { - return null; - } - return (new ReportRow()) ->setDimensions([$row->display_name]) - ->setValues([(float) $row->sla]); - } - - protected function fetchSla(Timerange $timerange, Rule $filter = null) - { - $sla = Host::on($this->getDb()) - ->columns([ - '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; + ->setValues([$row->sla]); } } diff --git a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php index 46a068437..dbb59e146 100644 --- a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php +++ b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php @@ -5,12 +5,8 @@ namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; use Icinga\Application\Icinga; -use Icinga\Module\Icingadb\Model\Service; -use Icinga\Module\Reporting\ReportData; +use Icinga\Module\Icingadb\ProvidedHook\Reporting\Common\ReportData; use Icinga\Module\Reporting\ReportRow; -use Icinga\Module\Reporting\Timerange; -use ipl\Sql\Expression; -use ipl\Stdlib\Filter\Rule; use function ipl\I18n\t; @@ -35,38 +31,8 @@ protected function createReportData() protected function createReportRow($row) { - if ($row->sla === null) { - return null; - } - return (new ReportRow()) - ->setDimensions([$row->host->display_name, $row->display_name]) - ->setValues([(float) $row->sla]); - } - - protected function fetchSla(Timerange $timerange, Rule $filter = null) - { - $sla = Service::on($this->getDb()) - ->columns([ - '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; + ->setDimensions([$row->host_display_name, $row->display_name]) + ->setValues([$row->sla]); } } diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaReport.php b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php index b5898fd83..c2e2616da 100644 --- a/library/Icingadb/ProvidedHook/Reporting/SlaReport.php +++ b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php @@ -4,19 +4,16 @@ namespace Icinga\Module\Icingadb\ProvidedHook\Reporting; -use DateInterval; -use DatePeriod; use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\ProvidedHook\Reporting\Common\ReportData; +use Icinga\Module\Icingadb\ProvidedHook\Reporting\Common\SlaReportUtils; use Icinga\Module\Icingadb\Widget\EmptyState; use Icinga\Module\Reporting\Hook\ReportHook; -use Icinga\Module\Reporting\ReportData; use Icinga\Module\Reporting\ReportRow; use Icinga\Module\Reporting\Timerange; use ipl\Html\Form; use ipl\Html\Html; -use ipl\Stdlib\Filter\Rule; -use ipl\Web\Filter\QueryString; use function ipl\I18n\t; @@ -27,12 +24,13 @@ abstract class SlaReport extends ReportHook { use Auth; use Database; + use SlaReportUtils; /** @var float If an SLA value is lower than the threshold, it is considered not ok */ - const DEFAULT_THRESHOLD = 99.5; + protected const DEFAULT_THRESHOLD = 99.5; /** @var int The amount of decimal places for the report result */ - const DEFAULT_REPORT_PRECISION = 2; + protected const DEFAULT_REPORT_PRECISION = 2; /** * Create and return a {@link ReportData} container @@ -51,113 +49,6 @@ abstract protected function createReportData(); */ abstract protected function createReportRow($row); - /** - * Fetch SLA according to specified time range and filter - * - * @param Timerange $timerange - * @param Rule|null $filter - * - * @return iterable - */ - abstract protected function fetchSla(Timerange $timerange, Rule $filter = null); - - protected function fetchReportData(Timerange $timerange, array $config = null) - { - $rd = $this->createReportData(); - $rows = []; - - $filter = trim((string) $config['filter']) ?: '*'; - $filter = $filter !== '*' ? QueryString::parse($filter) : null; - - if (isset($config['breakdown']) && $config['breakdown'] !== 'none') { - switch ($config['breakdown']) { - case 'day': - $interval = new DateInterval('P1D'); - $format = 'Y-m-d'; - $boundary = 'tomorrow midnight'; - - break; - case 'week': - $interval = new DateInterval('P1W'); - $format = 'Y-\WW'; - $boundary = 'monday next week midnight'; - - break; - case 'month': - $interval = new DateInterval('P1M'); - $format = 'Y-m'; - $boundary = 'first day of next month midnight'; - - break; - } - - $dimensions = $rd->getDimensions(); - $dimensions[] = ucfirst($config['breakdown']); - $rd->setDimensions($dimensions); - - foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) { - foreach ($this->fetchSla(new Timerange($start, $end), $filter) as $row) { - $row = $this->createReportRow($row); - - if ($row === null) { - continue; - } - - $dimensions = $row->getDimensions(); - $dimensions[] = $start->format($format); - $row->setDimensions($dimensions); - - $rows[] = $row; - } - } - } else { - foreach ($this->fetchSla($timerange, $filter) as $row) { - $rows[] = $this->createReportRow($row); - } - } - - $rd->setRows($rows); - - return $rd; - } - - /** - * 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', [ @@ -192,7 +83,7 @@ public function initConfigForm(Form $form) public function getData(Timerange $timerange, array $config = null) { - return $this->fetchReportData($timerange, $config); + return $this->fetchReportData($timerange->getStart(), $timerange->getEnd(), $config); } public function getHtml(Timerange $timerange, array $config = null) @@ -228,13 +119,19 @@ public function getHtml(Timerange $timerange, array $config = null) // We only have one metric $sla = $row->getValues()[0]; - if ($sla < $threshold) { + if ($sla === null) { + $slaClass = 'unknown'; + } elseif ($sla < $threshold) { $slaClass = 'nok'; } else { $slaClass = 'ok'; } - $cells[] = Html::tag('td', ['class' => "sla-column $slaClass"], round($sla, $precision)); + $cells[] = Html::tag( + 'td', + ['class' => "sla-column $slaClass"], + $sla === null ? t('N/A') : round($sla, $precision) + ); $tableRows[] = Html::tag('tr', null, $cells); } @@ -242,7 +139,9 @@ public function getHtml(Timerange $timerange, array $config = null) // We only have one average $average = $data->getAverages()[0]; - if ($average < $threshold) { + if ($average === null) { + $slaClass = 'unknown'; + } elseif ($average < $threshold) { $slaClass = 'nok'; } else { $slaClass = 'ok'; @@ -254,7 +153,11 @@ public function getHtml(Timerange $timerange, array $config = null) $tableRows[] = Html::tag('tr', null, [ Html::tag('td', ['colspan' => count($data->getDimensions())], $total), - Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision)) + Html::tag( + 'td', + ['class' => "sla-column $slaClass"], + $average === null ? t('N/A') : round($average, $precision) + ) ]); $table = Html::tag( @@ -274,6 +177,7 @@ public function getHtml(Timerange $timerange, array $config = null) ] ); + // echo '
' . nl2br($data->getTimelineString()) . ''; return $table; } } diff --git a/test/php/Lib/FakeReportData.php b/test/php/Lib/FakeReportData.php new file mode 100644 index 000000000..756da1929 --- /dev/null +++ b/test/php/Lib/FakeReportData.php @@ -0,0 +1,23 @@ + [ + 'db' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'dbname' => 'icingadb_web_unittest', + 'username' => 'icingadb_web_unittest', + 'password' => 'icingadb_web_unittest' + ] + ]; + + public function resetConn() + { + static::$conn = null; + } + + public function getDb(): Connection + { + if (! static::$conn) { + $config = static::$dbConfiguration['mysql']; + $host = getenv('ICINGADBWEB_TEST_MYSQL_HOST'); + if ($host) { + $config['host'] = $host; + } + + $port = getenv('ICINGADBWEB_TEST_MYSQL_PORT'); + if ($port) { + $config['port'] = $port; + } + + static::$conn = new Connection($config); + $fixtures = file_get_contents(__DIR__ . '/fixtures.sql'); + static::$conn->exec($fixtures); + } + + return static::$conn; + } + + public function getReportType(): string + { + return $this->reportType; + } + + public function getSlaTimeline(DateTime $start, DateTime $end, string $type, string $id): SlaTimeline + { + $this->reportType = $type; + + return $this->fetchReportData($start, $end, ['filter' => null])->getTimelines(bin2hex($id))[0]; + } + + protected function createReportData() + { + return new FakeReportData(); + } + + protected function createReportRow($_) + { + return 'NOPE!'; + } +} diff --git a/test/php/Lib/fixtures.sql b/test/php/Lib/fixtures.sql new file mode 100644 index 000000000..7c08dcd8c --- /dev/null +++ b/test/php/Lib/fixtures.sql @@ -0,0 +1,55 @@ +CREATE TABLE host ( + id binary(20) NOT NULL PRIMARY KEY, + display_name varchar(254) NOT NULL +); + +CREATE TABLE host_state ( + id binary(20) NOT NULL, + host_id binary(20) NOT NULL, + hard_state TINYINT UNSIGNED NOT NULL, + previous_hard_state TINYINT UNSIGNED DEFAULT NULL, + + PRIMARY KEY(id, host_id) +); + +CREATE TABLE service ( + id binary(20) NOT NULL, + host_id binary(20) NOT NULL, + display_name varchar(254) NOT NULL, + + PRIMARY KEY(id, host_id) +); + +CREATE TABLE service_state ( + id binary(20) NOT NULL PRIMARY KEY, + host_id binary(20) NOT NULL, + service_id binary(20) NOT NULL, + hard_state TINYINT UNSIGNED NOT NULL, + previous_hard_state TINYINT UNSIGNED DEFAULT NULL +); + +CREATE TABLE sla_history_state ( + id binary(20) NOT NULL PRIMARY KEY, + environment_id binary(20) DEFAULT NULL, + endpoint_id binary(20) DEFAULT NULL, + object_type enum('host', 'service') NOT NULL, + host_id binary(20) NOT NULL, + service_id binary(20) DEFAULT NULL, + + event_time bigint unsigned NOT NULL, + hard_state TINYINT UNSIGNED NOT NULL, + previous_hard_state TINYINT UNSIGNED NOT NULL +); + +CREATE TABLE sla_history_downtime ( + id binary(20) NOT NULL PRIMARY KEY, + environment_id binary(20) DEFAULT NULL, + endpoint_id binary(20) DEFAULT NULL, + object_type enum('host', 'service') NOT NULL, + host_id binary(20) NOT NULL, + service_id binary(20) DEFAULT NULL, + + downtime_id binary(20) NOT NULL, + downtime_start BIGINT UNSIGNED NOT NULL, + downtime_end BIGINT UNSIGNED NOT NULL +); diff --git a/test/php/library/Icingadb/ProvidedHook/Reporting/SlaTest.php b/test/php/library/Icingadb/ProvidedHook/Reporting/SlaTest.php new file mode 100644 index 000000000..27a1a2e9c --- /dev/null +++ b/test/php/library/Icingadb/ProvidedHook/Reporting/SlaTest.php @@ -0,0 +1,352 @@ +report = new SlaReportWithCustomDb(); + $this->conn = $this->report->getDb(); + + $this->start = (new DateTime())->setTimestamp(1000); + $this->end = (new DateTime())->setTimestamp(2000); + + $this->insertHostAndService(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->conn->exec('DROP TABLE IF EXISTS host'); + $this->conn->exec('DROP TABLE IF EXISTS host_state'); + $this->conn->exec('DROP TABLE IF EXISTS service'); + $this->conn->exec('DROP TABLE IF EXISTS service_state'); + $this->conn->exec('DROP TABLE IF EXISTS sla_history_state'); + $this->conn->exec('DROP TABLE IF EXISTS sla_history_downtime'); + + $this->report->resetConn(); + } + + public function testEmptyHistoryEvents() + { + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(100.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(100.0, $timeline->getResult()); + } + + public function testMultipleStateChanges() + { + $this->insertSlaHistoryEvents([ + 'state' => [ + ['event_time' => 1000000, 'hard_state' => 2, 'previous_hard_state' => 99], // -10% + ['event_time' => 1100000, 'hard_state' => 0, 'previous_hard_state' => 2], // OK + ['event_time' => 1300000, 'hard_state' => 2, 'previous_hard_state' => 0], // -10% + ['event_time' => 1400000, 'hard_state' => 0, 'previous_hard_state' => 2], // OK + ['event_time' => 1600000, 'hard_state' => 2, 'previous_hard_state' => 0], // -10% + ['event_time' => 1700000, 'hard_state' => 0, 'previous_hard_state' => 2], // OK + ['event_time' => 1900000, 'hard_state' => 2, 'previous_hard_state' => 0], // -10% + ] + ]); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(60.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(60.0, $timeline->getResult()); + } + + public function testOverlappingDowntimesAndProblems() + { + $this->insertSlaHistoryEvents([ + 'state' => [ + ['event_time' => 1200000, 'hard_state' => 2, 'previous_hard_state' => 0], + ['event_time' => 1500000, 'hard_state' => 0, 'previous_hard_state' => 2] + ], + 'downtime' => [ + ['downtime_id' => $this->makeId(), 'downtime_start' => 1100000, 'downtime_end' => 1300000], + ['downtime_id' => $this->makeId(), 'downtime_start' => 1400000, 'downtime_end' => 1600000] + ], + ]); + + // 1000..1100: OK, no downtime + // 1100..1200: OK, in downtime + // 1200..1300: CRITICAL, in downtime + // 1300..1400: CRITICAL, no downtime (only period counting for SLA, -10%) + // 1400..1500: CRITICAL, in downtime + // 1500..1600: OK, in downtime + // 1600..2000: OK, no downtime + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(90.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(90.0, $timeline->getResult()); + } + + public function testCriticalBeforeInterval() + { + $this->insertSlaHistoryEvents( + ['state' => [['event_time' => 0, 'hard_state' => 2, 'previous_hard_state' => 99]]] + ); + + // If there is no event within the SLA interval, the last state from before the interval should be used. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(0.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(0.0, $timeline->getResult()); + } + + public function testCriticalBeforeIntervalWithDowntime() + { + $this->insertSlaHistoryEvents([ + 'state' => [['event_time' => 800000, 'hard_state' => 2, 'previous_hard_state' => 99]], + 'downtime' => [['downtime_id' => $this->makeId(), 'downtime_start' => 600000, 'downtime_end' => 1500000]] + ]); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(50.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(50.0, $timeline->getResult()); + } + + public function testCriticalBeforeIntervalWithOverlappingDowntimes() + { + $this->insertSlaHistoryEvents([ + 'state' => [['event_time' => 800000, 'hard_state' => 2, 'previous_hard_state' => 99]], + 'downtime' => [ + ['downtime_id' => $this->makeId(), 'downtime_start' => 600000, 'downtime_end' => 1000000], + ['downtime_id' => $this->makeId(), 'downtime_start' => 800000, 'downtime_end' => 1200000], + ['downtime_id' => $this->makeId(), 'downtime_start' => 1000000, 'downtime_end' => 1400000], + ['downtime_id' => $this->makeId(), 'downtime_start' => 1600000, 'downtime_end' => 2000000], + // Everything except 1400-1600 is covered by downtimes, -20% + ['downtime_id' => $this->makeId(), 'downtime_start' => 1800000, 'downtime_end' => 2200000] + ] + ]); + + // Test that overlapping downtimes are properly accounted for. + // The period from 1400 to 1600 represents 20% of the total time, and since there was only + // one state change 2 (DOWN) before the sla interval, that 20% is a problem time. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(80.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(80.0, $timeline->getResult()); + } + + public function testFallbackToPreviousState() + { + $this->insertSlaHistoryEvents( + ['state' => [['event_time' => 1100000, 'hard_state' => 0, 'previous_hard_state' => 2]]] + ); + + // If there is no state event from before the SLA interval, the previous hard state from the first event + // after the beginning of the SLA interval should be used as the initial state. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(90.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(90.0, $timeline->getResult()); + } + + public function testFallbackToCurrentState() + { + $this->insertObjectCurrentState(2); + + // If there are no state history events, the current state of the checkable should be used. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(0.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(0.0, $timeline->getResult()); + } + + public function testPreferInitialStateFromBeforeOverLaterState() + { + $this->insertSlaHistoryEvents([ + 'state' => [ + ['event_time' => 800000, 'hard_state' => 2, 'previous_hard_state' => 99], + ['event_time' => 1600000, 'hard_state' => 0, 'previous_hard_state' => 0], + ] + ]); + + // The previous_hard_state should only be used as a fallback + // when there is no event from before the SLA interval. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(40.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(40.0, $timeline->getResult()); + } + + public function testPreferInitialStateFromBeforeOverCurrentState() + { + $this->insertObjectCurrentState(0); + $this->insertSlaHistoryEvents( + ['state' => [['event_time' => 800000, 'hard_state' => 2, 'previous_hard_state' => 99]]] + ); + + // The current state should only be used as a fallback when there is no state history event. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(0.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(0.0, $timeline->getResult()); + } + + public function testPreferLaterStateOverCurrentState() + { + $this->insertObjectCurrentState(2); + $this->insertSlaHistoryEvents( + ['state' => [['event_time' => 1300000, 'hard_state' => 0, 'previous_hard_state' => 2]]] + ); + + // The current state should only be used as a fallback when there is no state history event. + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(70.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(70.0, $timeline->getResult()); + } + + public function testInitialPendingStateReducesTotalTime() + { + $this->insertObjectCurrentState(0); + $this->insertSlaHistoryEvents([ + 'state' => [ + ['event_time' => 1600000, 'hard_state' => 2, 'previous_hard_state' => 99], + ['event_time' => 1700000, 'hard_state' => 0, 'previous_hard_state' => 2] + ] + ]); + + // 1000..1600: PENDING (600s) + // 1600..1700: DOWN|CRITICAL (100s) + // 1700..2000: OK + // Total: 2000 - 1000 = 1000 + // total -= 600s PENDING time = 400 + // sla = 100 * (total - 100s PROBLEM TIME) / 400 TOTAL = 75% + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(75.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(75.0, $timeline->getResult()); + } + + public function testIntermediatePendingStateReducesTotalTime() + { + $this->insertObjectCurrentState(0); + $this->insertSlaHistoryEvents([ + 'state' => [ + ['event_time' => 1000000, 'hard_state' => 0, 'previous_hard_state' => 2], + ['event_time' => 1100000, 'hard_state' => 2, 'previous_hard_state' => 0], + ['event_time' => 1600000, 'hard_state' => 0, 'previous_hard_state' => 99], + ['event_time' => 1800000, 'hard_state' => 2, 'previous_hard_state' => 0] + ] + ]); + + // 1000..1100: OK|UP + // 1100..1600: PENDING (500s) + // 1600..1800: OK|UP + // 1800..2000: DOWN|CRITICAL (200s PROBLEM TIME) + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertSame(60.0, $timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertSame(60.0, $timeline->getResult()); + } + + public function testPendingStateAfterIntervalEndReducesTotalTime() + { + $this->insertSlaHistoryEvents([ + 'state' => [['event_time' => 2500000, 'hard_state' => 0, 'previous_hard_state' => 99]] + ]); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'host', $this->hostId); + $this->assertNull($timeline->getResult()); + + $timeline = $this->report->getSlaTimeline($this->start, $this->end, 'service', $this->serviceId); + $this->assertNull($timeline->getResult()); + } + + protected function makeId(): string + { + return random_bytes(20); + } + + protected function insertHostAndService() + { + $this->hostId = $this->makeId(); + $this->conn->insert('host', [ + 'id' => $this->hostId, + 'display_name' => 'icinga2' + ]); + + $this->serviceId = $this->makeId(); + $this->conn->insert('service', [ + 'id' => $this->serviceId, + 'host_id' => $this->hostId, + 'display_name' => 'disk' + ]); + } + + protected function insertObjectCurrentState(int $state) + { + $this->conn->insert('host_state', [ + 'id' => $this->makeId(), + 'host_id' => $this->hostId, + 'hard_state' => $state + ]); + $this->conn->insert('service_state', [ + 'id' => $this->makeId(), + 'host_id' => $this->hostId, + 'service_id' => $this->serviceId, + 'hard_state' => $state + ]); + } + + protected function insertSlaHistoryEvents(array $histories) + { + foreach ($histories as $eventType => $vents) { + $table = $eventType === 'state' ? 'sla_history_state' : 'sla_history_downtime'; + foreach ($vents as $vent) { + foreach (['host', 'service'] as $objectType) { + $vent['id'] = $this->makeId(); + $vent['host_id'] = $this->hostId; + $vent['object_type'] = $objectType; + if ($objectType === 'service') { + $vent['service_id'] = $this->serviceId; + } + + $this->conn->insert($table, $vent); + } + } + } + } +}