CheckStatistics: Make progress animatable and enhance overall layout

This commit is contained in:
Johannes Meyer 2023-06-21 15:24:52 +02:00
parent 5bbf2932d3
commit 515ae13e32
3 changed files with 428 additions and 223 deletions

View file

@ -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 viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
<path class='bg' d='M0 0L13 13L3.15334e-06 13L0 0Z'/>
<path class='border' fill-rule='evenodd' clip-rule='evenodd'
d='M0 0L3.3959e-06 14L14 14L0 0ZM1 2.41421L1 13L11.5858 13L1 2.41421Z'/>
</svg>
SVG;
const TOP_RIGHT_BUBBLE_FLAG = <<<'SVG'
<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
<path class='bg' d="M12 0L-1 13L12 13L12 0Z"/>
<path class='border' fill-rule="evenodd" clip-rule="evenodd"
d="M12 0L12 14L-2 14L12 0ZM11 2.41421L11 13L0.414213 13L11 2.41421Z"/>
</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'))
)
));
}

View file

@ -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;
}
}
}
}

View file

@ -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 &nbsp; 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;
}
}