diff --git a/application/controllers/DowntimeController.php b/application/controllers/DowntimeController.php index a0a7fa07..d796df2c 100644 --- a/application/controllers/DowntimeController.php +++ b/application/controllers/DowntimeController.php @@ -10,7 +10,7 @@ use Icinga\Module\Icingadb\Common\Links; use Icinga\Module\Icingadb\Model\Downtime; use Icinga\Module\Icingadb\Web\Controller; use Icinga\Module\Icingadb\Widget\Detail\DowntimeDetail; -use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList; +use Icinga\Module\Icingadb\Widget\Detail\ObjectHeader; use ipl\Stdlib\Filter; use ipl\Web\Url; @@ -61,11 +61,7 @@ class DowntimeController extends Controller { $detail = new DowntimeDetail($this->downtime); - $this->addControl((new DowntimeList([$this->downtime])) - ->setViewMode('minimal') - ->setDetailActionsDisabled() - ->setCaptionDisabled() - ->setNoSubjectLink()); + $this->addControl(new ObjectHeader($this->downtime)); $this->addContent($detail); diff --git a/application/controllers/DowntimesController.php b/application/controllers/DowntimesController.php index c045ffb4..e2b05f44 100644 --- a/application/controllers/DowntimesController.php +++ b/application/controllers/DowntimesController.php @@ -10,8 +10,8 @@ use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm; use Icinga\Module\Icingadb\Model\Downtime; use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Icingadb\Web\Controller; -use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList; use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; +use Icinga\Module\Icingadb\Widget\ItemList\ObjectList; use Icinga\Module\Icingadb\Widget\ShowMore; use ipl\Web\Control\LimitControl; use ipl\Web\Control\SortControl; @@ -87,7 +87,12 @@ class DowntimesController extends Controller $results = $downtimes->execute(); - $this->addContent((new DowntimeList($results))->setViewMode($viewModeSwitcher->getViewMode())); + $this->addContent( + (new ObjectList($results)) + ->setViewMode($viewModeSwitcher->getViewMode()) + ->setMultiselectUrl(Links::downtimesDetails()) + ->setDetailUrl(Url::fromPath('icingadb/downtime')) + ); if ($compact) { $this->addContent( @@ -162,7 +167,7 @@ class DowntimesController extends Controller $rs = $downtimes->execute(); - $this->addControl((new DowntimeList($rs))->setViewMode('minimal')); + $this->addControl((new ObjectList($rs))->setViewMode('minimal')); $this->addControl(new ShowMore( $rs, diff --git a/library/Icingadb/View/DowntimeRenderer.php b/library/Icingadb/View/DowntimeRenderer.php new file mode 100644 index 00000000..94158efc --- /dev/null +++ b/library/Icingadb/View/DowntimeRenderer.php @@ -0,0 +1,255 @@ + */ +class DowntimeRenderer implements ItemRenderer +{ + use Translation; + use TicketLinks; + use HostLink; + use ServiceLink; + + /** @var int Current Time */ + protected $currentTime; + + /** @var int Duration */ + protected $duration; + + /** @var int Downtime end time */ + protected $endTime; + + /** @var bool Whether the downtime is active */ + protected $isActive; + + /** @var int Downtime start time */ + protected $startTime; + + /** @var bool Whether the state has been loaded */ + protected $stateLoaded = false; + + /** + * @var bool Whether the item is being rendered in the detail view of the associated object (host/service) + * + * When true: + * + * - Creation of the link of the associated object (host/service) is omitted from the title + * - The ticket link will be created + */ + protected $isDetailView = false; + + /** + * Set whether the item is being rendered in the detail view of the associated object (host/service) + * + * @param bool $state + * + * @return $this + */ + public function setIsDetailView(bool $state = true): self + { + $this->isDetailView = $state; + + return $this; + } + + /** + * Load the state of the downtime + * + * @param Downtime $item + * + * @return void + */ + protected function loadState(Downtime $item): void + { + if ($this->stateLoaded) { + return; + } + + if ( + isset($item->start_time, $item->end_time) + && $item->is_flexible + && $item->is_in_effect + ) { + $this->startTime = $item->start_time->getTimestamp(); + $this->endTime = $item->end_time->getTimestamp(); + } else { + $this->startTime = $item->scheduled_start_time->getTimestamp(); + $this->endTime = $item->scheduled_end_time->getTimestamp(); + } + + $this->currentTime = time(); + + $this->isActive = $item->is_in_effect + || ($item->is_flexible && $item->scheduled_start_time->getTimestamp() <= $this->currentTime); + + $until = ($this->isActive ? $this->endTime : $this->startTime) - $this->currentTime; + $this->duration = explode(' ', DateFormatter::formatDuration( + $until <= 3600 ? $until : $until + (3600 - ((int) $until % 3600)) + ), 2)[0]; + + $this->stateLoaded = true; + } + + public function assembleAttributes($item, Attributes $attributes, string $layout): void + { + $attributes->add(new Attributes(['class' => ['downtime', $item->is_in_effect ? 'in-effect' : '']])); + } + + public function assembleVisual($item, HtmlDocument $visual, string $layout): void + { + $this->loadState($item); + + $dateTime = DateFormatter::formatDateTime($this->endTime); + + if ($this->isActive) { + $visual->addHtml(Html::sprintf( + $this->translate('%s left', '..'), + Html::tag( + 'strong', + Html::tag( + 'time', + [ + 'datetime' => $dateTime, + 'title' => $dateTime + ], + $this->duration + ) + ) + )); + } else { + $visual->addHtml(Html::sprintf( + $this->translate('in %s', '..'), + Html::tag('strong', $this->duration) + )); + } + } + + public function assembleTitle($item, HtmlDocument $title, string $layout): void + { + if ($this->isDetailView) { + $link = null; + } elseif ($item->object_type === 'host') { + $link = $this->createHostLink($item->host, true); + } else { + $link = $this->createServiceLink($item->service, $item->service->host, true); + } + + if ($item->is_flexible) { + if ($link !== null) { + $template = $this->translate('{{#link}}Flexible Downtime{{/link}} for %s'); + } else { + $template = $this->translate('Flexible Downtime'); + } + } else { + if ($link !== null) { + $template = $this->translate('{{#link}}Fixed Downtime{{/link}} for %s'); + } else { + $template = $this->translate('Fixed Downtime'); + } + } + + if ($layout === 'header') { + if ($link === null) { + $title->addHtml(HtmlElement::create('span', [ 'class' => 'subject'], $template)); + } else { + $title->addHtml(TemplateString::create( + $template, + ['link' => HtmlElement::create('span', [ 'class' => 'subject'])], + $link + )); + } + } else { + if ($link === null) { + $title->addHtml(new Link($template, Links::downtime($item))); + } else { + $title->addHtml(TemplateString::create( + $template, + ['link' => new Link('', Links::downtime($item))], + $link + )); + } + } + } + + public function assembleCaption($item, HtmlDocument $caption, string $layout): void + { + $markdownLine = new MarkdownLine( + $this->isDetailView ? $this->createTicketLinks($item->comment) : $item->comment + ); + $caption->getAttributes()->add($markdownLine->getAttributes()); + $caption->addHtml( + new HtmlElement( + 'span', + null, + new Icon(Icons::USER), + Text::create($item->author) + ), + Text::create(': ') + )->addFrom($markdownLine); + } + + public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void + { + $this->loadState($item); + + $dateTime = DateFormatter::formatDateTime($this->isActive ? $this->endTime : $this->startTime); + + $info->addHtml(Html::tag( + 'time', + [ + 'datetime' => $dateTime, + 'title' => $dateTime + ], + sprintf( + $this->isActive + ? $this->translate('expires in %s', '..') + : $this->translate('starts in %s', '..'), + $this->duration + ) + )); + } + + public function assembleFooter($item, HtmlDocument $footer, string $layout): void + { + } + + public function assemble($item, string $name, HtmlDocument $element, string $layout): bool + { + if ($name === 'progress' && ($layout === 'detailed' || $layout === 'common')) { + $this->loadState($item); + + $element + ->addAttributes(Attributes::create([ + 'data-animate-progress' => true, + 'data-start-time' => $this->startTime, + 'data-end-time' => $this->endTime + ])) + ->addHtml(new HtmlElement('div', Attributes::create(['class' => 'bar']))); + + return true; + } + + return false; + } +} diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php index 9e50f7f2..345c7871 100644 --- a/library/Icingadb/Widget/Detail/DowntimeDetail.php +++ b/library/Icingadb/Widget/Detail/DowntimeDetail.php @@ -10,12 +10,13 @@ use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\Database; use Icinga\Module\Icingadb\Common\HostLink; use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Widget\ItemList\ObjectList; use Icinga\Module\Icingadb\Widget\MarkdownText; use Icinga\Module\Icingadb\Common\ServiceLink; use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm; use Icinga\Module\Icingadb\Model\Downtime; -use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList; use Icinga\Module\Icingadb\Widget\ShowMore; +use ipl\Web\Url; use ipl\Web\Widget\EmptyState; use ipl\Web\Widget\HorizontalKeyValue; use ipl\Html\BaseHtmlElement; @@ -180,7 +181,9 @@ class DowntimeDetail extends BaseHtmlElement if ($children->hasResult()) { $this->addHtml( new HtmlElement('h2', null, Text::create(t('Children'))), - new DowntimeList($children), + (new ObjectList($children)) + ->setMultiselectUrl(Links::downtimesDetails()) + ->setDetailUrl(Url::fromPath('icingadb/downtime')), (new ShowMore($children, Links::downtimes()->setQueryString( QueryString::render(Filter::any( Filter::equal('downtime.parent.name', $this->downtime->name), diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php index 6661c7f5..e22a2d21 100644 --- a/library/Icingadb/Widget/Detail/ObjectDetail.php +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -285,7 +285,9 @@ class ObjectDetail extends BaseHtmlElement $content = [Html::tag('h2', t('Downtimes'))]; if ($downtimes->hasResult()) { - $content[] = (new DowntimeList($downtimes))->setObjectLinkDisabled()->setTicketLinkEnabled(); + $content[] = (new TicketLinkObjectList($downtimes)) + ->setMultiselectUrl(Links::downtimesDetails()) + ->setDetailUrl(Url::fromPath('icingadb/downtime')); $content[] = (new ShowMore($downtimes, $link))->setBaseTarget('_next'); } else { $content[] = new EmptyState(t('No downtimes scheduled.')); diff --git a/library/Icingadb/Widget/Detail/ObjectHeader.php b/library/Icingadb/Widget/Detail/ObjectHeader.php index 7f279c3f..e95da021 100644 --- a/library/Icingadb/Widget/Detail/ObjectHeader.php +++ b/library/Icingadb/Widget/Detail/ObjectHeader.php @@ -6,12 +6,14 @@ namespace Icinga\Module\Icingadb\Widget\Detail; use Icinga\Exception\NotImplementedError; use Icinga\Module\Icingadb\Model\Comment; +use Icinga\Module\Icingadb\Model\Downtime; use Icinga\Module\Icingadb\Model\Host; use Icinga\Module\Icingadb\Model\RedundancyGroup; use Icinga\Module\Icingadb\Model\Service; use Icinga\Module\Icingadb\Model\User; use Icinga\Module\Icingadb\Model\Usergroup; use Icinga\Module\Icingadb\View\CommentRenderer; +use Icinga\Module\Icingadb\View\DowntimeRenderer; use Icinga\Module\Icingadb\View\HostRenderer; use Icinga\Module\Icingadb\View\RedundancyGroupRenderer; use Icinga\Module\Icingadb\View\ServiceRenderer; @@ -59,6 +61,10 @@ class ObjectHeader extends BaseHtmlElement case $this->object instanceof Comment: $renderer = new CommentRenderer(); + break; + case $this->object instanceof Downtime: + $renderer = new DowntimeRenderer(); + break; default: throw new NotImplementedError('Not implemented'); diff --git a/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php deleted file mode 100644 index dedaa721..00000000 --- a/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php +++ /dev/null @@ -1,216 +0,0 @@ -item->start_time, $this->item->end_time) - && $this->item->is_flexible - && $this->item->is_in_effect - ) { - $this->startTime = $this->item->start_time->getTimestamp(); - $this->endTime = $this->item->end_time->getTimestamp(); - } else { - $this->startTime = $this->item->scheduled_start_time->getTimestamp(); - $this->endTime = $this->item->scheduled_end_time->getTimestamp(); - } - - $this->currentTime = time(); - - $this->isActive = $this->item->is_in_effect - || $this->item->is_flexible && $this->item->scheduled_start_time->getTimestamp() <= $this->currentTime; - - $until = ($this->isActive ? $this->endTime : $this->startTime) - $this->currentTime; - $this->duration = explode(' ', DateFormatter::formatDuration( - $until <= 3600 ? $until : $until + (3600 - ((int) $until % 3600)) - ), 2)[0]; - - $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name)); - $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name)); - $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled()); - $this->setNoSubjectLink($this->list->getNoSubjectLink()); - $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled()); - - if ($this->item->is_in_effect) { - $this->getAttributes()->add('class', 'in-effect'); - } - } - - protected function createProgress(): BaseHtmlElement - { - return new HtmlElement( - 'div', - Attributes::create([ - 'class' => 'progress', - 'data-animate-progress' => true, - 'data-start-time' => $this->startTime, - 'data-end-time' => $this->endTime - ]), - new HtmlElement( - 'div', - Attributes::create(['class' => 'bar']) - ) - ); - } - - protected function assembleCaption(BaseHtmlElement $caption): void - { - $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment)); - $caption->getAttributes()->add($markdownLine->getAttributes()); - $caption->addHtml( - new HtmlElement( - 'span', - null, - new Icon(Icons::USER), - Text::create($this->item->author) - ), - Text::create(': ') - )->addFrom($markdownLine); - } - - protected function assembleTitle(BaseHtmlElement $title): void - { - if ($this->getObjectLinkDisabled()) { - $link = null; - } elseif ($this->item->object_type === 'host') { - $link = $this->createHostLink($this->item->host, true); - } else { - $link = $this->createServiceLink($this->item->service, $this->item->service->host, true); - } - - if ($this->item->is_flexible) { - if ($link !== null) { - $template = t('{{#link}}Flexible Downtime{{/link}} for %s'); - } else { - $template = t('Flexible Downtime'); - } - } else { - if ($link !== null) { - $template = t('{{#link}}Fixed Downtime{{/link}} for %s'); - } else { - $template = t('Fixed Downtime'); - } - } - - if ($this->getNoSubjectLink()) { - if ($link === null) { - $title->addHtml(HtmlElement::create('span', [ 'class' => 'subject'], $template)); - } else { - $title->addHtml(TemplateString::create( - $template, - ['link' => HtmlElement::create('span', [ 'class' => 'subject'])], - $link - )); - } - } else { - if ($link === null) { - $title->addHtml(new Link($template, Links::downtime($this->item))); - } else { - $title->addHtml(TemplateString::create( - $template, - ['link' => new Link('', Links::downtime($this->item))], - $link - )); - } - } - } - - protected function assembleVisual(BaseHtmlElement $visual): void - { - $dateTime = DateFormatter::formatDateTime($this->endTime); - - if ($this->isActive) { - $visual->addHtml(Html::sprintf( - t('%s left', '..'), - Html::tag( - 'strong', - Html::tag( - 'time', - [ - 'datetime' => $dateTime, - 'title' => $dateTime - ], - $this->duration - ) - ) - )); - } else { - $visual->addHtml(Html::sprintf( - t('in %s', '..'), - Html::tag('strong', $this->duration) - )); - } - } - - protected function createTimestamp(): ?BaseHtmlElement - { - $dateTime = DateFormatter::formatDateTime($this->isActive ? $this->endTime : $this->startTime); - - return Html::tag( - 'time', - [ - 'datetime' => $dateTime, - 'title' => $dateTime - ], - sprintf( - $this->isActive - ? t('expires in %s', '..') - : t('starts in %s', '..'), - $this->duration - ) - ); - } -} diff --git a/library/Icingadb/Widget/ItemList/DowntimeList.php b/library/Icingadb/Widget/ItemList/DowntimeList.php deleted file mode 100644 index 591ad984..00000000 --- a/library/Icingadb/Widget/ItemList/DowntimeList.php +++ /dev/null @@ -1,49 +0,0 @@ - 'downtime-list']; - - protected function getItemClass(): string - { - $viewMode = $this->getViewMode(); - - $this->addAttributes(['class' => $viewMode]); - - if ($viewMode === 'minimal') { - return DowntimeListItemMinimal::class; - } elseif ($viewMode === 'detailed') { - $this->removeAttribute('class', 'default-layout'); - } - - return DowntimeListItem::class; - } - - protected function init(): void - { - $this->initializeDetailActions(); - $this->setMultiselectUrl(Links::downtimesDetails()); - $this->setDetailUrl(Url::fromPath('icingadb/downtime')); - } -} diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItem.php b/library/Icingadb/Widget/ItemList/DowntimeListItem.php deleted file mode 100644 index cb7e9b38..00000000 --- a/library/Icingadb/Widget/ItemList/DowntimeListItem.php +++ /dev/null @@ -1,23 +0,0 @@ -item->is_in_effect) { - $main->add($this->createProgress()); - } - - $main->add($this->createHeader()); - $main->add($this->createCaption()); - } -} diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php deleted file mode 100644 index b8581d29..00000000 --- a/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php +++ /dev/null @@ -1,21 +0,0 @@ -list->isCaptionDisabled()) { - $this->setCaptionDisabled(); - } - } -} diff --git a/library/Icingadb/Widget/ItemList/ObjectList.php b/library/Icingadb/Widget/ItemList/ObjectList.php index 0787e7ba..0ce90f89 100644 --- a/library/Icingadb/Widget/ItemList/ObjectList.php +++ b/library/Icingadb/Widget/ItemList/ObjectList.php @@ -8,6 +8,7 @@ use Icinga\Exception\NotImplementedError; use Icinga\Module\Icingadb\Common\DetailActions; use Icinga\Module\Icingadb\Model\Comment; use Icinga\Module\Icingadb\Model\DependencyNode; +use Icinga\Module\Icingadb\Model\Downtime; use Icinga\Module\Icingadb\Model\Host; use Icinga\Module\Icingadb\Model\RedundancyGroup; use Icinga\Module\Icingadb\Model\Service; @@ -16,6 +17,7 @@ use Icinga\Module\Icingadb\Model\User; use Icinga\Module\Icingadb\Model\Usergroup; use Icinga\Module\Icingadb\Redis\VolatileStateResults; use Icinga\Module\Icingadb\View\CommentRenderer; +use Icinga\Module\Icingadb\View\DowntimeRenderer; use Icinga\Module\Icingadb\View\HostRenderer; use Icinga\Module\Icingadb\View\RedundancyGroupRenderer; use Icinga\Module\Icingadb\View\ServiceRenderer; @@ -61,6 +63,8 @@ class ObjectList extends ItemList return new UserRenderer(); } elseif ($item instanceof Comment) { return new CommentRenderer(); + } elseif ($item instanceof Downtime) { + return new DowntimeRenderer(); } throw new NotImplementedError('Not implemented'); @@ -132,6 +136,10 @@ class ObjectList extends ItemList $layout->after(ItemLayout::VISUAL, 'icon-image'); } + if ($item instanceof Downtime) { + $layout->before(ItemLayout::HEADER, 'progress'); + } + return $layout; } @@ -195,7 +203,7 @@ class ObjectList extends ItemList $this->addDetailFilterAttribute($item, Filter::equal('name', $object->name)); break; - case $object instanceof Comment: + case $object instanceof Comment || $object instanceof Downtime: $this->addDetailFilterAttribute($item, Filter::equal('name', $object->name)); $this->addMultiSelectFilterAttribute($item, Filter::equal('name', $object->name)); diff --git a/library/Icingadb/Widget/ItemList/TicketLinkObjectList.php b/library/Icingadb/Widget/ItemList/TicketLinkObjectList.php index 35ff7ab2..d4d9f17c 100644 --- a/library/Icingadb/Widget/ItemList/TicketLinkObjectList.php +++ b/library/Icingadb/Widget/ItemList/TicketLinkObjectList.php @@ -6,7 +6,9 @@ namespace Icinga\Module\Icingadb\Widget\ItemList; use Icinga\Exception\NotImplementedError; use Icinga\Module\Icingadb\Model\Comment; +use Icinga\Module\Icingadb\Model\Downtime; use Icinga\Module\Icingadb\View\CommentRenderer; +use Icinga\Module\Icingadb\View\DowntimeRenderer; use ipl\Orm\Model; use ipl\Web\Widget\ItemList; @@ -24,6 +26,8 @@ class TicketLinkObjectList extends ObjectList ItemList::__construct($data, function (Model $item) { if ($item instanceof Comment) { return (new CommentRenderer())->setIsDetailView(); + } elseif ($item instanceof Downtime) { + return (new DowntimeRenderer())->setIsDetailView(); } throw new NotImplementedError('Not implemented'); diff --git a/public/css/list/downtime-list.less b/public/css/item/downtime.less similarity index 73% rename from public/css/list/downtime-list.less rename to public/css/item/downtime.less index 7e537e78..b55a3d87 100644 --- a/public/css/list/downtime-list.less +++ b/public/css/item/downtime.less @@ -1,17 +1,22 @@ // Style +.item-layout.downtime { + .visual { + background-color: @gray-lighter; + } -.downtime-list .list-item, -.downtime-detail .list-item { + &.in-effect .visual { + background-color: @color-ok; + color: @text-color-on-icinga-blue; + } +} + +.list-item.downtime { .progress { > .bar { background-color: @color-ok; } } - .visual { - background-color: @gray-lighter; - } - .main { border-top: 1px solid @gray-light; } @@ -23,44 +28,14 @@ border-top: 1px solid @gray-light; } } - - &.in-effect { - .visual { - background-color: @color-ok; - color: @text-color-on-icinga-blue; - } - - .main { - padding-top: 0; // If active the progress bar represents the padding top - } - } } // Layout -.downtime-list .list-item { +.item-layout.downtime { .caption > * { display: inline; } -} - -.downtime-list .list-item, -.downtime-detail .list-item { - .progress { - height: 2px; - margin-bottom: ~"calc(.5em - 2px)"; - min-width: 100%; - position: relative; - - > .bar { - height: 100%; - max-width: 100%; - } - } - - &:first-child .main .progress > .bar { - height: ~"calc(100% + 1px)"; // +1px due to the border added exclusively for the first item - } .visual { justify-content: center; @@ -77,7 +52,29 @@ } } -.item-list.downtime-list.minimal .list-item { +.list-item.downtime { + .progress { + height: 2px; + margin-bottom: ~"calc(.5em - 2px)"; + min-width: 100%; + position: relative; + + > .bar { + height: 100%; + max-width: 100%; + } + } + + &:first-child .main .progress > .bar { + height: ~"calc(100% + 1px)"; // +1px due to the border added exclusively for the first item + } + + .main:has(.progress) { + padding-top: 0; + } +} + +.minimal-item-layout.downtime { .visual { display: block; line-height: 1.5;