From 515ae13e326ef2c484cb2bd95d9be5adc1351d62 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 21 Jun 2023 15:24:52 +0200 Subject: [PATCH] CheckStatistics: Make progress animatable and enhance overall layout --- .../Widget/Detail/CheckStatistics.php | 335 +++++++++++------- public/css/mixin/progress-bar.less | 152 +++++--- public/css/widget/check-statistics.less | 164 ++++++--- 3 files changed, 428 insertions(+), 223 deletions(-) diff --git a/library/Icingadb/Widget/Detail/CheckStatistics.php b/library/Icingadb/Widget/Detail/CheckStatistics.php index a3e78e62..d06004e1 100644 --- a/library/Icingadb/Widget/Detail/CheckStatistics.php +++ b/library/Icingadb/Widget/Detail/CheckStatistics.php @@ -8,10 +8,13 @@ use Icinga\Date\DateFormatter; use Icinga\Module\Icingadb\Widget\CheckAttempt; use Icinga\Module\Icingadb\Widget\EmptyState; use Icinga\Util\Format; +use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; -use ipl\Html\Html; +use ipl\Html\FormattedString; +use ipl\Html\HtmlElement; +use ipl\Html\HtmlString; +use ipl\Html\Text; use ipl\Web\Common\Card; -use ipl\Web\Widget\HorizontalKeyValue; use ipl\Web\Widget\StateBall; use ipl\Web\Widget\TimeAgo; use ipl\Web\Widget\TimeSince; @@ -20,11 +23,28 @@ use ipl\Web\Widget\VerticalKeyValue; class CheckStatistics extends Card { + const TOP_LEFT_BUBBLE_FLAG = <<<'SVG' + + + + +SVG; + + const TOP_RIGHT_BUBBLE_FLAG = <<<'SVG' + + + + +SVG; + + protected $object; protected $tag = 'div'; - protected $defaultAttributes = ['class' => 'progress-bar check-statistics']; + protected $defaultAttributes = ['class' => ['progress-bar', 'check-statistics']]; public function __construct($object) { @@ -35,96 +55,155 @@ class CheckStatistics extends Card { $hPadding = 10; $durationScale = 80; + $checkInterval = $this->getCheckInterval(); - $timeline = Html::tag('div', ['class' => 'check-timeline timeline']); - + $timeline = new HtmlElement('div', Attributes::create(['class' => ['check-timeline', 'timeline']])); + $above = new HtmlElement('ul', Attributes::create(['class' => 'above'])); + $below = new HtmlElement('ul', Attributes::create(['class' => 'below'])); + $progressBar = new HtmlElement('div', Attributes::create(['class' => 'bar'])); $overdueBar = null; - $nextCheckTime = $this->object->state->next_check->getTimestamp(); - $checkInterval = $this->getCheckInterval(); + $now = time(); + $executionTime = ($this->object->state->execution_time / 1000) + ($this->object->state->latency / 1000); + + $nextCheckTime = $this->object->state->next_check !== null + ? $this->object->state->next_check->getTimestamp() + : null; if ($this->object->state->is_overdue) { $nextCheckTime = $this->object->state->next_update->getTimestamp(); - $leftNow = $durationScale + $hPadding / 2; - $overdueScale = ($durationScale / 2) * (time() - $nextCheckTime) / (10 * $checkInterval); - if ($overdueScale > $durationScale / 2) { - $overdueScale = $durationScale / 2; - } + $durationScale = 60; - $durationScale -= $overdueScale; - $overdueBar = Html::tag('div', [ - 'class' => 'timeline-overlay check-overdue', - 'style' => sprintf( - 'left: %F%%; width: %F%%;', - $hPadding + $durationScale, - $overdueScale + $hPadding / 2 + $overdueBar = new HtmlElement( + 'div', + Attributes::create(['class' => 'timeline-overlay']), + new HtmlElement('div', Attributes::create(['class' => 'now'])) + ); + + $above->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'now']), + new HtmlElement( + 'div', + Attributes::create(['class' => 'bubble']), + new HtmlElement('strong', null, Text::create(t('Now'))) ) - ]); + )); + + $this->getAttributes()->add('class', 'check-overdue'); } else { - $leftNow = $durationScale * (1 - ($nextCheckTime - time()) / $checkInterval); - if ($leftNow > $durationScale) { - $leftNow = $durationScale; + $progressBar->addHtml(new HtmlElement('div', Attributes::create(['class' => 'now']))); + } + + if ($nextCheckTime !== null && $nextCheckTime < $now) { + $lastUpdateTime = $nextCheckTime; + $nextCheckTime = $this->object->state->next_update->getTimestamp() - $executionTime; + $executionEndTime = $lastUpdateTime + $executionTime; + } else { + $lastUpdateTime = $this->object->state->last_update !== null + ? $this->object->state->last_update->getTimestamp() - $executionTime + : null; + $executionEndTime = $this->object->state->last_update !== null + ? $this->object->state->last_update->getTimestamp() + : null; + } + + if ($this->object->state->is_overdue) { + $leftNow = 100; + } elseif ($nextCheckTime === null) { + $leftNow = 0; + } else { + $leftNow = 100 * (1 - ($nextCheckTime - time()) / ($nextCheckTime - $lastUpdateTime)); + if ($leftNow > 100) { + $leftNow = 100; } elseif ($leftNow < 0) { $leftNow = 0; } } - $above = Html::tag('ul', ['class' => 'above']); - $now = Html::tag( - 'li', - [ - 'class' => 'now positioned', - 'style' => sprintf('left: %F%%', $hPadding + $leftNow) - ], - Html::tag( - 'div', - ['class' => 'bubble'], - Html::tag( - 'strong', - t('Now') - ) - ) - ); - $above->add($now); + $progressBar->getAttributes()->add('style', sprintf('width: %s%%', $leftNow)); - $markerLast = Html::tag('div', [ - 'class' => 'marker start', - 'style' => 'left: ' . $hPadding . '%', - 'title' => $this->object->state->last_update !== null - ? DateFormatter::formatDateTime($this->object->state->last_update->getTimestamp()) - : null - ]); - $markerNext = Html::tag('div', [ - 'class' => 'marker end', - 'style' => sprintf('left: %F%%', $hPadding + $durationScale), + $leftExecutionEnd = $nextCheckTime !== null ? $durationScale * ( + 1 - ($nextCheckTime - $executionEndTime) / ($nextCheckTime - $lastUpdateTime) + ) : 0; + + $markerLast = new HtmlElement('div', Attributes::create([ + 'class' => ['highlighted', 'marker', 'left'], + 'title' => $lastUpdateTime !== null ? DateFormatter::formatDateTime($lastUpdateTime) : null + ])); + $markerNext = new HtmlElement('div', Attributes::create([ + 'class' => ['highlighted', 'marker', 'right'], 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null - ]); - $markerNow = Html::tag('div', [ - 'class' => 'marker now', - 'style' => sprintf('left: %F%%', $hPadding + $leftNow), - ]); + ])); + $markerExecutionEnd = new HtmlElement('div', Attributes::create([ + 'class' => ['highlighted', 'marker'], + 'style' => sprintf('left: %F%%', $hPadding + $leftExecutionEnd), + ])); - $timeline->add([ + $progress = new HtmlElement('div', Attributes::create([ + 'class' => ['progress', time() < $executionEndTime ? 'running' : null] + ]), $progressBar); + if ($nextCheckTime !== null) { + $progress->addAttributes([ + 'data-animate-progress' => true, + 'data-start-time' => $lastUpdateTime, + 'data-end-time' => $nextCheckTime, + 'data-switch-after' => $executionTime, + 'data-switch-class' => 'running' + ]); + } + + $timeline->addHtml( + $progress, $markerLast, - $markerNow, - $markerNext, - $overdueBar - ]); + $markerExecutionEnd, + $markerNext + )->add($overdueBar); - $lastUpdate = Html::tag( + $executionStart = new HtmlElement( 'li', - ['class' => 'start'], - Html::tag( + Attributes::create(['class' => 'left']), + new HtmlElement( 'div', - ['class' => 'bubble upwards'], - new VerticalKeyValue(t('Last update'), $this->object->state->last_update !== null - ? new TimeAgo($this->object->state->last_update->getTimestamp()) - : t('PENDING')) + Attributes::create(['class' => ['bubble', 'upwards', 'top-right-aligned']]), + new VerticalKeyValue( + t('Execution Start'), + $lastUpdateTime ? new TimeAgo($lastUpdateTime) : t('PENDING') + ), + HtmlString::create(self::TOP_RIGHT_BUBBLE_FLAG) ) ); - $interval = Html::tag( + $executionEnd = new HtmlElement( 'li', - ['class' => 'interval'], + Attributes::create([ + 'class' => 'positioned', + 'style' => sprintf('left: %F%%', $hPadding + $leftExecutionEnd) + ]), + new HtmlElement( + 'div', + Attributes::create(['class' => ['bubble', 'upwards', 'top-left-aligned']]), + new VerticalKeyValue( + t('Execution End'), + $executionEndTime !== null + ? ($executionEndTime > $now + ? new TimeUntil($executionEndTime) + : new TimeAgo($executionEndTime)) + : t('PENDING') + ), + HtmlString::create(self::TOP_LEFT_BUBBLE_FLAG) + ) + ); + + $intervalLine = new HtmlElement( + 'li', + Attributes::create([ + 'class' => 'interval-line', + 'style' => sprintf( + 'left: %F%%; width: %F%%;', + $hPadding + $leftExecutionEnd, + $durationScale - $leftExecutionEnd + ) + ]), new VerticalKeyValue( t('Interval'), $checkInterval @@ -132,6 +211,29 @@ class CheckStatistics extends Card : (new EmptyState(t('n. a.')))->setTag('span') ) ); + $executionLine = new HtmlElement( + 'li', + Attributes::create([ + 'class' => ['interval-line', 'execution-line'], + 'style' => sprintf('left: %F%%; width: %F%%;', $hPadding, $leftExecutionEnd) + ]), + new VerticalKeyValue( + sprintf('%s / %s', t('Execution Time'), t('Latency')), + FormattedString::create( + '%s / %s', + $this->object->state->execution_time !== null + ? Format::seconds($this->object->state->execution_time / 1000) + : (new EmptyState(t('n. a.')))->setTag('span'), + $this->object->state->latency !== null + ? Format::seconds($this->object->state->latency / 1000) + : (new EmptyState(t('n. a.')))->setTag('span') + ) + ) + ); + if ($executionEndTime !== null) { + $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'start']))); + $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'end']))); + } if ($this->isChecksDisabled()) { $nextCheckBubbleContent = new VerticalKeyValue( @@ -145,34 +247,34 @@ class CheckStatistics extends Card ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime)) : new VerticalKeyValue( t('Next Check'), - $nextCheckTime !== null ? new TimeUntil($nextCheckTime) : t('PENDING') + $nextCheckTime !== null + ? ($nextCheckTime > $now + ? new TimeUntil($nextCheckTime) + : new TimeAgo($nextCheckTime)) + : t('PENDING') ); } - $nextCheck = Html::tag( + $nextCheck = new HtmlElement( 'li', - ['class' => 'end'], - Html::tag( + Attributes::create(['class' => 'right']), + new HtmlElement( 'div', - ['class' => 'bubble upwards'], + Attributes::create(['class' => ['bubble', 'upwards']]), $nextCheckBubbleContent ) ); - $below = Html::tag( - 'ul', - [ - 'class' => 'below', - 'style' => sprintf('width: %F%%;', $durationScale) - ] - ); - $below->add([ - $lastUpdate, - $interval, - $nextCheck - ]); + $above->addHtml($executionLine); - $body->add([$above, $timeline, $below]); + $below->addHtml( + $executionStart, + $executionEnd, + $intervalLine, + $nextCheck + ); + + $body->addHtml($above, $timeline, $below); } /** @@ -185,23 +287,6 @@ class CheckStatistics extends Card return ! ($this->object->active_checks_enabled || $this->object->passive_checks_enabled); } - protected function assembleFooter(BaseHtmlElement $footer) - { - $footer->add(new HorizontalKeyValue( - t('Scheduling Source') . ':', - $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span') - )); - - if ($this->object->timeperiod->id) { - $footer->add(new HorizontalKeyValue( - t('Timeperiod') . ':', - $this->object->timeperiod->display_name ?? $this->object->timeperiod->name - )); - - $footer->addAttributes(['class' => 'space-between']); - } - } - protected function assembleHeader(BaseHtmlElement $header) { $checkSource = (new EmptyState(t('n. a.')))->setTag('span'); @@ -213,26 +298,28 @@ class CheckStatistics extends Card ]; } - $header->add([ + $header->addHtml( new VerticalKeyValue(t('Command'), $this->object->checkcommand_name), + new VerticalKeyValue( + t('Scheduling Source'), + $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span') + ) + ); + + if ($this->object->timeperiod->id) { + $header->addHtml(new VerticalKeyValue( + t('Timeperiod'), + $this->object->timeperiod->display_name ?? $this->object->timeperiod->name + )); + } + + $header->addHtml( new VerticalKeyValue( t('Attempts'), new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts) ), - new VerticalKeyValue(t('Check Source'), $checkSource), - new VerticalKeyValue( - t('Execution time'), - $this->object->state->execution_time - ? Format::seconds($this->object->state->execution_time / 1000) - : (new EmptyState(t('n. a.')))->setTag('span') - ), - new VerticalKeyValue( - t('Latency'), - $this->object->state->latency - ? Format::seconds($this->object->state->latency / 1000) - : (new EmptyState(t('n. a.')))->setTag('span') - ) - ]); + new VerticalKeyValue(t('Check Source'), $checkSource) + ); } /** @@ -268,13 +355,13 @@ class CheckStatistics extends Card parent::assemble(); if ($this->isChecksDisabled()) { - $this->add(Html::tag( + $this->addHtml(new HtmlElement( 'div', - ['class' => 'checks-disabled-overlay'], - Html::tag( + Attributes::create(['class' => 'checks-disabled-overlay']), + new HtmlElement( 'strong', - ['class' => 'notes'], - t('active and passive checks are disabled') + Attributes::create(['class' => 'notes']), + Text::create(t('active and passive checks are disabled')) ) )); } diff --git a/public/css/mixin/progress-bar.less b/public/css/mixin/progress-bar.less index 532ca2c3..74bb83b9 100644 --- a/public/css/mixin/progress-bar.less +++ b/public/css/mixin/progress-bar.less @@ -1,29 +1,36 @@ .progress-bar() { &.progress-bar { + --hPadding: 10%; + --duration-scale: 80%; + .above, .below { list-style-type: none; - position: relative; margin: 0; - } + padding: 0; - .above { - padding: ~"calc(1em + 1px) 10%"; + position: relative; + height: ~"calc(2em + 2px)"; } .below { - padding: 1.25em 10%; + > .left { + position: absolute; + left: var(--hPadding); + top: 0; + } + + > .right { + position: absolute; + left: ~"calc(var(--hPadding) + var(--duration-scale))"; + top: 0; + } } .positioned { position: absolute; } - :not(.positioned).end .bubble { - // to move the center of the bubble to the end of the wrapper (for check-statistics end bubble only). - transform: translate(50%, 0); - } - .bubble { .rounded-corners(.25em); background-color: @body-bg-color; @@ -48,7 +55,7 @@ z-index: 5; } - &:before { + &::before { background-color: @body-bg-color; border-bottom: 1px solid @gray-light; border-right: 1px solid @gray-light; @@ -65,33 +72,33 @@ left: 50%; } - &.upwards:before { + &.upwards::before { bottom: auto; - top: -.5em; + top: -7/12em; transform: rotate(225deg); } - &.right { + &.right-aligned { // This is (.675em (:before placement) + .5em (half :before width)) + 1px (:before border) transform: translate(~"calc(-1.175em - 1px)", 0); + + &::before { + top: auto; + left: 1.175em; + bottom: -.5em; + } } - &.right:before { - bottom: auto; - left: 1.175em; - top: -.5em; - } - - &.left { + &.left-aligned { // entire width (moves the right border in place of the left) + (.675em (:before placement) + .5em (half :before width)) + 1px (:before border) transform: translate(~"calc(-100% + 1.175em + 1px)", 0); - } - &.left:before { - bottom: auto; - left: auto; - right: .675em; - top: -.5em; + &::before { + top: auto; + left: auto; + right: .675em; + bottom: -.5em; + } } } @@ -116,6 +123,8 @@ } .timeline { + @marker-gap: 1/12em; + .rounded-corners(.5em); background-color: @gray-lighter; height: 1em; @@ -129,50 +138,79 @@ background-color: @gray-light; height: .857em; margin-left: -.857/2em; - margin-top: -1/12em; width: .857em; z-index: 2; position: absolute; - top: 2/12em; + top: @marker-gap; - &.now { - background-color: @gray; - } - - &.start, - &.end { + &.highlighted { background-color: @icinga-blue; } + + &.left { + left: var(--hPadding); + } + + &.right { + left: ~"calc(var(--hPadding) + var(--duration-scale))"; + } } - } - - .timeline-overlay { - height: 100%; - opacity: .6; - position: absolute; - - &:before, - &:after { - content: ""; - display: block; - height: 1em; - width: .5em; + .progress { position: absolute; - top: 0; + left: var(--hPadding); + width: var(--duration-scale); + + &::before { + content: ""; + display: block; + width: .5em + @marker-gap; + height: 1em + (@marker-gap * 2); + margin-top: -@marker-gap; + + .rounded-corners(.5em); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + position: absolute; + left: -.5em - @marker-gap; + } + + > .bar { + width: 0; // set by progress-bar.js + height: 1em + (@marker-gap * 2); + margin-top: -@marker-gap; + } + + &::before, + > .bar { + background-color: @gray-light; + } } - &:before { - border-bottom-left-radius: .5em; - border-top-left-radius: .5em; - left: -.5em; + .timeline-overlay { + position: absolute; + left: ~"calc(var(--hPadding) + var(--duration-scale))"; + width: var(--overlay-scale); + height: 1em + (@marker-gap * 2); + margin-top: -@marker-gap; + + opacity: .6; } - &:after { - border-bottom-right-radius: .5em; - border-top-right-radius: .5em; - right: -.5em; + .progress > .bar, + .timeline-overlay { + display: flex; + justify-content: flex-end; + + .now { + width: .25em; + + border: solid @default-bg; + border-width: 1px 0 1px 0; + background-color: red; + } } } } diff --git a/public/css/widget/check-statistics.less b/public/css/widget/check-statistics.less index 47db8185..ee7ba043 100644 --- a/public/css/widget/check-statistics.less +++ b/public/css/widget/check-statistics.less @@ -2,68 +2,148 @@ position: relative; .card(); .progress-bar(); - .card-footer { - display: flex; - justify-content: center; - border-top: 1px solid @gray-light; - - &.space-between { - justify-content: space-between; - padding: 0 0.5em; - } - - .key { - width: auto; - margin-right: .28125em; //calculated   width - font-size: .83333333em; - } - } .check-attempt { display: inline-flex; } - &.progress-bar .below { - padding: 0; - margin-left: 10%; - margin-right: auto; + .bubble { + &.top-left-aligned, + &.top-right-aligned { + &::before { + display: none; + } - display: flex; - align-items: center; - justify-content: space-between; + svg { + position: absolute; + top: -1em; + width: 1em; + height: 1em; - &:before { - background-color: @gray; - content: ""; - display: block; - height: .25em; - width: 100%; + .bg { + fill: @body-bg-color; + } - position: absolute; - top: ~"calc(50% - .125em)"; + .border { + fill: @gray-light; + } + } + } + + &.top-left-aligned { + transform: unset; + border-top-left-radius: 0; + + svg { + left: -1px; + } + } + + &.top-right-aligned { + transform: translate(-100%); + border-top-right-radius: 0; + + svg { + right: -1px; + } } } - .interval { - background-color: @body-bg-color; - position: relative; + // ATTENTION!: `&.progress-bar {` must not be used here, seems to confuse the less parser!!!!111 + + &.progress-bar .timeline .progress.running { + &::before, + > .bar { + background: @state-ok; + } } - .check-overdue { - background-color: @color-down; - opacity: 1; + &.progress-bar .check-timeline { + margin-top: .5em; + } + &.progress-bar .above { + margin-top: .5em; + } - &:before, - &:after { - background-color: @color-down; + .interval-line { + position: absolute; + height: 100%; + + &::before { + position: absolute; + top: ~"calc(50% - .125em)"; + display: block; + height: .25em; + width: 100%; + content: ""; + + background-color: @gray-light; + } + + .vertical-key-value { + position: absolute; + left: 50%; + transform: translate(-50%, 0); + + padding: 0 .2em; + background-color: @body-bg-color; + } + + .start, + .end { + position: absolute; + top: 50%; + width: .25em; + height: 1em; + background-color: @gray; + } + + .start { + left: 0; + transform: translate(-50%, -50%); + } + + .end { + right: 0; + transform: translate(50%, -50%); + } + } + + .execution-line .vertical-key-value { + z-index: 1; + } + + &.check-overdue { + --duration-scale: 60%; + --overlay-scale: 20%; + + .above { + .now { + position: absolute; + right: var(--hPadding); + bottom: 0; + + .bubble { + // to move the center of the bubble to the end of the wrapper. + transform: translate(50%, 0); + } + } + } + + .timeline-overlay { + background: linear-gradient(90deg, @gray-light 0, @color-down 2em); + opacity: 1; + + &::after { + background-color: @color-down; + } } } &.checks-disabled.progress-bar { .timeline { .marker { - &.start, - &.end { + &.highlighted { background-color: @gray; } }