diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:44:46 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:44:46 +0000 |
commit | b18bc644404e02b57635bfcc8258e85abb141146 (patch) | |
tree | 686512eacb2dba0055277ef7ec2f28695b3418ea /public | |
parent | Initial commit. (diff) | |
download | icingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.tar.xz icingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.zip |
Adding upstream version 1.1.1.upstream/1.1.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
42 files changed, 4426 insertions, 0 deletions
diff --git a/public/css/common.less b/public/css/common.less new file mode 100644 index 0000000..4cf5cfc --- /dev/null +++ b/public/css/common.less @@ -0,0 +1,405 @@ +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +@exports: { + @iplWebAssets: "../lib/icinga/icinga-php-library"; +}; + +& > .content.full-width { + padding-left: 0; + padding-right: 0; + + .list-item, + .table-layout .table-row { + padding-left: 1em; + padding-right: 1em; + } +} + +& > .content.full-height { + padding-top: 0; + padding-bottom: 0; +} + +.plugin-output { + .monospace(); + word-break: break-word; +} + +div.show-more { + .clearfix(); + float: right; +} + +.state-ball.state-not-available { + .ball-solid(@gray-light); + .animate(pulse 1.5s infinite both); +} + +.icon-ball { + .ball(); + .ball-outline(@gray); + text-align: center; + + i:before { + margin-right: 0; + } +} + +.user-ball { + .ball(); + .ball-size-xl(); + .ball-solid(@gray-semilight); + font-weight: bold; + line-height: 1.75; + text-transform: uppercase; +} + +.usergroup-ball { + .ball(); + .ball-outline(@gray); + .ball-size-xl(); + line-height: 1.75; + text-transform: uppercase; +} + +.ack-badge { + text-transform: uppercase; + line-height: 1; + margin-top: -.25em; + align-self: flex-start; + + i { + vertical-align: baseline; + } +} + +.controls { + .box-shadow(0, 0, 0, 1px, @gray-lighter); + flex-shrink: 0; + position: relative; // Required for the host meta info control + z-index: 1; // The content may clip, this ensures the separator is always visible + + > :not(:only-child) { + margin-bottom: .5em; + } + + #object-meta-info { + margin-bottom: 0; + + .object-meta-info { + margin-bottom: .5em; + } + } + + &.overdue, + &.overdue .tabs li.active a, + &.overdue .object-meta-info-control { + background-color: @gray-lighter; + } + + .limit-control, + .view-mode-switcher, + .sort-control { + margin-left: .5em; + float: right; + } + + .toggle-switch { + margin-top: 0; + margin-bottom: 0; + } + + .item-list { + width: 100%; + + .list-item .main { + border-top: none; + } + + .list-item .visual { + width: auto; + margin-top: 0; + } + + .list-item .visual, + .list-item .main { + padding-bottom: .25em; + padding-top: .25em; + } + } + + .search-controls .continue-with { + margin-right: -.5em; + margin-left: .5em; + } + + .show-more { + margin-top: .25em; + } + + .notice { + display: none; + } + + // TODO: Remove once ipl-web v0.7.0 is required + &:not(.default-layout) { + .pagination-control { + float: left; + } + + .sort-control { + display: flex; + justify-content: flex-end; + + :not(.form-element) > label { + margin-right: 0; + } + + .control-button { + margin: 0; + } + } + + > :not(:only-child) { + margin-bottom: 0.5em; + } + + .search-suggestions { + margin-bottom: 2.5em; + } + + .search-controls { + clear: both; + display: flex; + min-width: 100%; + + .search-bar { + flex: 1 1 auto; + + & ~ .control-button:last-child { + margin-right: -.5em; + } + + & ~ .control-button { + margin-left: .5em; + } + } + } + } +} + +.content > h2:first-child, +.object-detail > h2:first-child { + margin-top: 0; +} + +.content.full-width > h2 { + margin-left: 1em / 1.333em; // 1em / h2 font size +} + +.object-detail .plugin-output { + .rounded-corners(.25em); + background-color: @gray-lighter; + padding: .5em; +} + +.object-detail .item-list { + &.action-list .list-item { + margin-right: -1em; + margin-left: -1em; + padding-right: 1em; + padding-left: 1em; + } + + .list-item:last-of-type .caption { + min-height: 1.5em; + max-height: 3em; + height: auto; + } +} + +.perfdata-wrapper { + svg { + width: 100%; + } + + svg:not(:last-child) { + margin-bottom: 1em; + } +} + +.text-center { + text-align: center; +} + +.text-muted { + color: @gray; +} + +.accompanying-text { + color: @text-color-light; + + .subject { + color: @text-color; + .user-select(all); + } +} + +.comment-detail { + > form, + > h2:not(:first-child) { + margin-top: 1em; + } +} + +.downtime-detail { + .downtime-progress { + margin-bottom: 1em; + } +} + +.footer { + display: flex; + .box-shadow(0, -1px, 0, 0, @gray-lighter); + color: @text-color-light; + + .selection-count { + flex: 1 1 auto; + + .selected-items { + font-size: 1.25em; + + &.hint { + opacity: 0.75; + } + } + } + + .status-bar, .selection-count { + font-size: .857em; + padding: .25em 1em; + line-height: 1.7; + } +} + +.status-bar { + margin-left: auto; + + .item-count { + font-size: 1.25em; + } + + .state-badges { + display: inline-block; + margin: 0 0 0 .417em; + } +} + +.multiselect-summary { + display: flex; + align-items: center; + justify-content: flex-start; + + // Donut + > div:first-child { + height: 4em; + width: 4em; + + > svg { + height: auto; + max-width: 100%; + } + } + + > .vertical-key-value { + padding: 0 .5em; + } +} + +.item-table { + &.table-layout { + &.hostgroup-table { + --columns: 2; + } + + &.servicegroup-table { + --columns: 1; + } + + &.user-table, // TODO: make them lists..... + &.usergroup-table { + --columns: 0; + } + } +} + +.hostgroup-table, +.servicegroup-table, +.usergroup-table, +.user-table { + .title .content > * { + display: block; + max-width: fit-content; + } + + .object-statistics-total { + width: 3.75em; + } +} + +.controls .hostgroup-table-row, +.controls .servicegroup-table-row, +.controls .usergroup-table-row, +.controls .user-table-row { + .title .content { + display: inline-flex; + align-items: center; + + > :first-child { + flex: 0 1 auto; + } + + > :last-child { + flex: 1 1 auto; + } + + .subject { + margin-right: .5em; + } + } + + .vertical-key-value { + br { + display: none; + } + + .key { + padding-left: .417em; + } + + .value { + vertical-align: middle; + } + } +} + +.history-list, +.objectHeader { + .visual.small-state-change .state-change { + padding-top: .25em; + } +} + +.comment-popup { + .comment-list .main { + // This is necessary to limit the visible comment lines + // because the popup is shown in detailed list mode only + .caption { + height: 3em; + } + } +} + +form[name="form_confirm_removal"] { + text-align: center; +} diff --git a/public/css/form/schedule-service-downtime-form.less b/public/css/form/schedule-service-downtime-form.less new file mode 100644 index 0000000..a65264d --- /dev/null +++ b/public/css/form/schedule-service-downtime-form.less @@ -0,0 +1,21 @@ +.downtime-duration { + > label { + display: flex; + flex: 1 1 auto; + flex-flow: row-reverse; + + input { + flex: 1 1 auto; + } + + span { + margin-left: .5em; + padding: .5625em 0; + line-height: 1.1em; + } + + &:not(:last-child) { + margin-right: 1.5em; + } + } +} diff --git a/public/css/list/action-list.less b/public/css/list/action-list.less new file mode 100644 index 0000000..5bb08f4 --- /dev/null +++ b/public/css/list/action-list.less @@ -0,0 +1,14 @@ +.action-list { + [data-action-item]:hover { + background-color: @tr-hover-color; + cursor: pointer; + } + + [data-action-item].active { + background-color: @tr-active-color; + } + + &[data-icinga-multiselect-url] * { + user-select: none; + } +} diff --git a/public/css/list/comment-list.less b/public/css/list/comment-list.less new file mode 100644 index 0000000..46b194d --- /dev/null +++ b/public/css/list/comment-list.less @@ -0,0 +1,50 @@ +// Style + +// Layout + +.comment-list:not(.detailed) .list-item { + .title > i:first-child { + margin-right: 0; + } + + .title > .subject + .badge, + .title > .badge + .subject, + .title > .badge:last-of-type { + margin-left: 0; + } + + .title a { + &:not(.subject) { + .text-ellipsis(); + } + } + + .title .subject:not(:last-child) { + margin-left: 0; + } + + .title .subject:nth-child(3):last-child { + margin-left: 0; + } +} + +.comment-list.minimal .list-item { + .user-ball { + font-size: .857em; + height: 1.75em; + line-height: 1.5em; + width: 1.75em; + } +} + +.comment-list.detailed .list-item { + .title > .subject:nth-child(3), + .title > .badge + .subject:last-child { + margin-left: .3em; + } + + .caption { + max-height: 4.5em; + white-space: normal; + } +} diff --git a/public/css/list/downtime-list.less b/public/css/list/downtime-list.less new file mode 100644 index 0000000..8768097 --- /dev/null +++ b/public/css/list/downtime-list.less @@ -0,0 +1,93 @@ +// Style + +.downtime-list .list-item, +.downtime-detail .list-item { + .progress { + > .bar { + background-color: @color-ok; + } + } + + .visual { + background-color: @gray-lighter; + } + + .main { + border-top: 1px solid @gray-light; + } + + &:first-child .main { + border-top: none; + + .progress > .bar { + 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 { + .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; + flex-shrink: 0; + line-height: 1em; + margin-right: .5em; + padding: .5em .25em; + text-align: center; + width: 6em; + + strong { + font-size: 1.5em; + line-height: 1em; + } + } +} + +.item-list.downtime-list.minimal .list-item { + .visual { + display: block; + line-height: 1.5; + width: 8em; + white-space: nowrap; + + strong { + display: inline-block; + font-size: 1em; + } + } +} diff --git a/public/css/list/item-list.less b/public/css/list/item-list.less new file mode 100644 index 0000000..251eec3 --- /dev/null +++ b/public/css/list/item-list.less @@ -0,0 +1,154 @@ +// Style + +.item-list { + .load-more:hover, + .page-separator:hover { + background: none; + } + + > .load-more a { + .rounded-corners(.25em); + background: @low-sat-blue; + text-align: center; + + &:hover { + opacity: .8; + text-decoration: none; + } + } + + > .page-separator:after { + content: ""; + display: block; + width: 100%; + height: 1px; + background: @gray; + align-self: center; + margin-left: .25em; + } + + > .page-separator a { + color: @gray; + font-weight: bold; + + &:hover { + text-decoration: none; + } + } + + > .page-separator + .list-item .main { + border-top: none; + } +} + +// Layout + +.item-list .list-item { + &.load-more a { + flex: 1; + margin: 1.5em 0; + padding: .5em 0; + } +} + +.item-list.minimal { + > .empty-state { + padding: .25em; + } + + .list-item { + header { + max-width: 100%; + } + + .visual { + width: 2.2em; + } + + .check-attempt { + display: none; + } + + .title { + p { + display: inline; + + & + p { + margin-left: .417em; + } + } + } + + .caption { + flex: 1 1 auto; + height: 1.5em; + margin-right: 1em; + width: 0; + + .line-clamp("reset"); + } + + .caption, + .caption .plugin-output { + .text-ellipsis(); + } + } +} + +.item-list.detailed .list-item { + .title { + word-break: break-word; + -webkit-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto; + } + + .caption { + display: block; + height: auto; + max-height: 7.5em; /* 5 lines */ + position: relative; + + .line-clamp(4) + } +} + +.item-list { + .icon-image { + width: 3em; + height: 3em; + text-align: center; + margin-top: .5em; + margin-left: .5em; + overflow: hidden; + + img { + max-height: 100%; + max-width: 100%; + height: auto; + width: auto; + } + } + + &.minimal { + .icon-image { + height: 2em; + width: 2em; + line-height: 2; + } + } +} + +.controls .item-list:not(.detailed):not(.minimal) .list-item { + .plugin-output { + line-height: 1.5 + } + + .caption { + height: 2.5em; + } +} + +.controls .item-list.minimal .icon-image { + margin-top: 0; +} diff --git a/public/css/list/list-item.less b/public/css/list/list-item.less new file mode 100644 index 0000000..2e67e3d --- /dev/null +++ b/public/css/list/list-item.less @@ -0,0 +1,77 @@ +// Style + +.list-item { + &.overdue { + background-color: @gray-lighter; + } + + &.overdue header > *:not(time), + &.overdue .caption { + opacity: 0.6; + } + + &.overdue time { + .rounded-corners(); + background-color: @color-critical; + color: @text-color-on-icinga-blue; + } + + .title { + .state-text { + color: @text-color; + text-transform: uppercase; + } + } + + footer { + .status-icons { + color: @text-color-light; + + .icon { + opacity: .5; + } + } + } +} + +// Layout + +.list-item { + &.overdue time { + margin-right: -.5em; + padding: 0 0.5em; + } + + .visual .check-attempt { + margin-top: .5em; + } + + .caption { + &.plugin-output, .plugin-output { + font-size: 11/12em; + line-height: 1.5*12/11em; + } + } + + footer { + .status-icons { + display: flex; + align-items: center; + } + + .performance-data { + margin-left: auto; + + .inline-pie { + display: inline-block; + line-height: 1.5*.857em; + height: 1em; + width: 1em; + + &:not(:last-child) { + margin-right: .209em; + } + } + } + } +} diff --git a/public/css/list/state-item-table.less b/public/css/list/state-item-table.less new file mode 100644 index 0000000..9c2ee62 --- /dev/null +++ b/public/css/list/state-item-table.less @@ -0,0 +1,201 @@ +// Style + +.state-item-table { + padding: 0; + + thead { + th { + font-weight: normal; + + // Border styles start + form { + padding: 0 0 0 1px; + border-bottom: 1px solid @gray-light; + background: linear-gradient(to top, @gray-light, @body-bg-color); + + button { + background: @body-bg-color; + } + } + &:first-child form { + padding-left: 0; + } + // Border styles end + } + + button { + .appearance(none); + border: none; + background: none; + padding: .1em .5em; + + text-align: left; + color: @text-color-light; + + > .icon { + opacity: 0; + width: 0; + transition: opacity .25s linear, width .25s ease; + } + &:hover .icon, + &:focus .icon, + &.active .icon { + opacity: 1; + width: 1em; + } + + &.active { + font-weight: bold; + } + } + } + + .list-item:not(:last-child) > *:not(.visual), + .row-item:not(:last-child) { + border-bottom: 1px solid @gray-light; + } +} + +@media print { + .list-item.page-break-follows { + &:not(:last-child) > *:not(.visual) { + border-bottom: none; + } + } +} + +// Layout + +table.state-item-table { + table-layout: fixed; +} + +.state-item-table { + display: table; + width: 100%; + margin: 0; + + thead { + position: sticky; + top: 0; + + th { + // That's layout, yes, controls overflow when scrolling + padding: 1em 0 0 0; + background: @body-bg-color; + } + + th button { + width: 100%; + display: inline-flex; + align-items: baseline; + justify-content: space-between; + + span { + .text-ellipsis(); + } + } + } + + th.has-visual { + width: 3em; + } + + tbody td { + .text-ellipsis(); + vertical-align: top; + } + + .list-item { + display: table-row; + } + + .list-item > .col { + display: table-cell; + vertical-align: middle; + white-space: nowrap; + + &:not(:last-child) { + padding-right: 1em; + } + + &.title { + .text-ellipsis(); + width: 100%; + } + + > * { + display: inline-block; + + &:not(:last-child) { + margin-right: .5em; + } + } + } + + .list-item > *:not(.visual) { + padding: .5em 0; + } + + .list-item > .visual { + display: table-cell; + padding: .5em 1em 0 0; + } + + > .empty-state-bar, + > tbody > tr:first-child .empty-state-bar { + margin: 0 1em; + } +} + +.content.full-width .state-item-table .list-item { + // The .list-item itself can't have padding because of `display:table-row` + &:before, &:after { + display: inline-block; + content: '\00a0'; + width: 1em; + } +} + +#layout.twocols table.state-item-table { + > thead > tr > th, + > tbody > tr > td { + &:nth-child(n+6) { + display: none; + width: 0; + } + } +} + +#layout.wide-layout .item-table th.has-plugin-output { + width: 50em; +} +#layout.default-layout .item-table th.has-plugin-output { + width: 30em; +} +#layout.compact-layout .item-table th.has-plugin-output { + width: 10em; +} + +#layout.twocols table.item-table { + .has-plugin-output { + width: auto; + } +} + +table.item-table { + th.has-visual { + button { + display: inline-flex; + justify-content: center; + } + + span > .icon:before { + margin: 0; + } + } + + .visual { + text-align: center; + } +} diff --git a/public/css/list/state-row-item.less b/public/css/list/state-row-item.less new file mode 100644 index 0000000..31c7e5a --- /dev/null +++ b/public/css/list/state-row-item.less @@ -0,0 +1,42 @@ +// Style + +.row-item { + .plugin-output { + overflow: hidden; + .line-clamp(); + } + + .subject { + font-weight: bold; + } +} + +// Layout + +.row-item { + .performance-data { + overflow: hidden; + .line-clamp(2); + white-space: normal; + margin-left: -.25em; + + .inline-pie { + display: inline-block; + width: 1em; + height: 1em; + margin-left: .25em; + } + } + + .has-icon-images { + height: 2.5em; + vertical-align: middle; + + img { + max-height: 100%; + max-width: 100%; + height: auto; + width: auto; + } + } +} diff --git a/public/css/list/user-list.less b/public/css/list/user-list.less new file mode 100644 index 0000000..078d1b9 --- /dev/null +++ b/public/css/list/user-list.less @@ -0,0 +1,26 @@ +// Layout + +.controls .user-list, +.controls .usergroup-list { + .usergroup-ball, + .user-ball { + height: 1.5em; + width: 1.5em; + line-height: ~"calc(1.5em - 4px)"; + display: block; + } + + .title br { + display: none; + } + + .list-item { + & > .visual { + padding-top: 0.25em; + } + + & > .col { + padding: .25em 0; + } + } +} diff --git a/public/css/markdown.less b/public/css/markdown.less new file mode 100644 index 0000000..bebede5 --- /dev/null +++ b/public/css/markdown.less @@ -0,0 +1,80 @@ +.markdown { + > p, + > hr, + > ul, + > ol, + > table, + > pre, + > blockquote, + li > ul, + li > ol { + margin: 1em 0 1em 0; + + &:first-child { + margin-top: 0; + } + } + + p { + &:last-child { + margin-bottom: 0; + } + } + + img { + max-width: 100%; + height: auto; + } + + a { + border-bottom: 1px dotted @text-color-light; + + &:hover, &:focus { + border-bottom: 1px solid @text-color; + text-decoration: none; + } + + img { + max-width: 32em; + } + + &.with-thumbnail { + img { + padding: 1px; + } + + &:hover, &:focus { + img { + padding: 0; + } + } + } + } + + table { + border-collapse: collapse; + + th { + text-align: left; + background-color: @gray-lighter; + } + + &, th, td { + border: 1px solid; + border-color: @gray-light; + } + } +} + +.markdown.inline { + img { + max-height: 100%; + vertical-align: middle; + } + + a.with-thumbnail { + &, &:hover, &:focus { + border-bottom: none; + } + } +} diff --git a/public/css/mixin/progress-bar.less b/public/css/mixin/progress-bar.less new file mode 100644 index 0000000..d15b4c3 --- /dev/null +++ b/public/css/mixin/progress-bar.less @@ -0,0 +1,217 @@ +.progress-bar() { + &.progress-bar { + --hPadding: 10%; + --duration-scale: 80%; + + .above, + .below { + list-style-type: none; + margin: 0; + padding: 0; + + position: relative; + height: ~"calc(2em + 2px)"; + } + + .below { + > .left { + position: absolute; + left: var(--hPadding); + top: 0; + } + + > .right { + position: absolute; + left: ~"calc(var(--hPadding) + var(--duration-scale))"; + top: 0; + } + } + + .positioned { + position: absolute; + } + + .bubble { + .rounded-corners(.25em); + background-color: @body-bg-color; + border: 1px solid; + border-color: @gray-light; + position: relative; + box-shadow: 0 0 1em 0 rgba(0, 0, 0, .1); + padding: .25em .5em; + text-align: center; + width: auto; + // The wrapper of .bubble is dynamically moved to the left based on the value of the progress bar + // This moves the center of the bubble to the beginning of the wrapper regardless of the size of the content. + transform: translate(-50%, 0); + z-index: 1; + + > * { + position: relative; + z-index: 2; + } + + &:hover { + z-index: 5; + } + + &::before { + background-color: @body-bg-color; + border-bottom: 1px solid @gray-light; + border-right: 1px solid @gray-light; + content: ""; + display: block; + height: 1em; + margin-left: -.5em; + transform: rotate(45deg); + width: 1em; + z-index: 1; + + position: absolute; + bottom: -.5em; + left: 50%; + } + + &.upwards::before { + bottom: auto; + top: -7/12em; + transform: rotate(225deg); + } + + &.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; + } + } + + &.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); + + &::before { + top: auto; + left: auto; + right: .675em; + bottom: -.5em; + } + } + } + + .above .positioned { + bottom: 0; + } + + .below .positioned { + top: 0; + } + + .vertical-key-value { + .key { + white-space: nowrap; + } + + .value { + white-space: nowrap; + font-size: 1em; + line-height: 1; + } + } + + .timeline { + @marker-gap: 1/12em; + + .rounded-corners(.5em); + background-color: @gray-lighter; + height: 1em; + margin: 1em 0; + position: relative; + width: 100%; + z-index: 1; + + .marker { + .rounded-corners(50%); + background-color: @gray-light; + height: .857em; + margin-left: -.857/2em; + width: .857em; + z-index: 2; + + position: absolute; + top: @marker-gap; + + &.highlighted { + background-color: @icinga-blue; + } + + &.left { + left: var(--hPadding); + } + + &.right { + left: ~"calc(var(--hPadding) + var(--duration-scale))"; + } + } + + .progress { + position: absolute; + left: var(--hPadding); + width: var(--duration-scale); + + &[data-animate-progress]::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; + } + } + + .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; + } + + .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/mixin/state-badges.less b/public/css/mixin/state-badges.less new file mode 100644 index 0000000..4be2d07 --- /dev/null +++ b/public/css/mixin/state-badges.less @@ -0,0 +1,31 @@ +.state-badges() { + &.state-badges { + padding: 0; + + ul { + padding: 0; + } + + li { + display: inline-block; + } + + li > ul > li:first-child:not(:last-child) .state-badge { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + + li > ul > li:last-child:not(:first-child) .state-badge { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + > li:not(:last-child) { + margin-right: .25em; + } + + li > ul > li + li { + margin-left: 1px; + } + } +} diff --git a/public/css/mixins.less b/public/css/mixins.less new file mode 100644 index 0000000..326bf46 --- /dev/null +++ b/public/css/mixins.less @@ -0,0 +1,5 @@ +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +.monospace() { + font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; +} diff --git a/public/css/view/service-grid.less b/public/css/view/service-grid.less new file mode 100644 index 0000000..41400e5 --- /dev/null +++ b/public/css/view/service-grid.less @@ -0,0 +1,60 @@ +.service-grid-table { + width: 0; + white-space: nowrap; + + td { + color: @gray-light; + padding: 0.2em; + text-align: center; + width: 1em; + } + + .rotate-45 { + height: 10em; + + div { + .transform(translate(0.4em, 2.8em) rotate(315deg)); + width: 1.5em; + } + } + + .service-grid-table-more { + text-align: center; + a { + display: inline; + } + } +} + +.joystick-pagination { + margin: 0 auto; + font-size: 130%; + + a { + color: @text-color; + outline: none; + + &:hover { + color: @text-color-light; + } + &:focus, &:active { + color: @icinga-blue; + } + } + + i { + display: block; + height: 1.5em; + width: 1.5em; + } +} + +.service-grid-link { + .bg-stateful(); + .rounded-corners(); + + display: inline-block; + height: 1.5em; + vertical-align: middle; + width: 1.5em; +} diff --git a/public/css/widget/actions.less b/public/css/widget/actions.less new file mode 100644 index 0000000..796fc38 --- /dev/null +++ b/public/css/widget/actions.less @@ -0,0 +1,20 @@ +.object-detail-actions a { + border-bottom: 1px solid @gray-light; + display: inline-block; + margin-bottom: .25em; + + .text-ellipsis(); + max-width: 32em; + + &:hover { + border-color: @icinga-blue-light; + color: @icinga-blue; + text-decoration: none; + } +} + +ul.object-detail-actions { + list-style-type: none; + padding: 0; + margin: 0; +} diff --git a/public/css/widget/check-attempt.less b/public/css/widget/check-attempt.less new file mode 100644 index 0000000..1042a08 --- /dev/null +++ b/public/css/widget/check-attempt.less @@ -0,0 +1,17 @@ +.check-attempt { + display: flex; + align-items: center; + justify-content: center; +} + +.check-attempt .ball { + background-color: @gray-light; + + &:not(:last-child) { + margin-right: 1/6em; + } + + &.taken { + background-color: @gray-semilight; + } +} diff --git a/public/css/widget/check-statistics.less b/public/css/widget/check-statistics.less new file mode 100644 index 0000000..4bd34c2 --- /dev/null +++ b/public/css/widget/check-statistics.less @@ -0,0 +1,192 @@ +.check-statistics { + position: relative; + .card(); + .progress-bar(); + + .check-attempt { + display: inline-flex; + } + + .bubble { + &.top-left-aligned, + &.top-right-aligned { + &::before { + visibility: hidden; + } + + svg { + position: absolute; + top: -1em; + width: 1em; + height: 1em; + + .bg { + fill: @body-bg-color; + } + + .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; + } + } + } + + // 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; + } + } + + &.progress-bar .check-timeline { + margin-top: .5em; + } + &.progress-bar .above { + margin-top: .5em; + } + + .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 { + &.highlighted { + background-color: @gray; + } + } + } + } + + .checks-disabled-overlay { + border-radius: 0.4em; + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + background-color: ~"@{disabled-gray}20"; + z-index: 1; + + .notes { + color: @text-color-light; + margin-top: -4em; + text-shadow: 0 0 1px rgba(0, 0, 0, 0.25); + } + } +} + +#layout.twocols &#col1, +#layout.minimal-layout, +#layout.poor-layout, +#layout.twocols.compact-layout, +#layout.twocols.default-layout { + .check-statistics .bubble.top-right-aligned { + transform: translate(-50%, 0); // default what progress-bar() defined + border-top-right-radius: .25em; // default what progress-bar() defined + + &::before { + visibility: visible; + } + + svg { + display: none; + } + } +} diff --git a/public/css/widget/comment-popup.less b/public/css/widget/comment-popup.less new file mode 100644 index 0000000..4012697 --- /dev/null +++ b/public/css/widget/comment-popup.less @@ -0,0 +1,74 @@ +.comment-popup { + font-size: 1em / .857em; // // default font size / footer font size = 12px + height: 6em; + border-radius: 0.25em; + border: 1px solid; + border-color: @gray-light; + background-color: @body-bg-color; +} + +.comment-wrapper { + position: relative; + + .comment-popup { + position: absolute; + top: 2.5em; + left: -1.6em; + z-index: 1; + display: none; + width: 50em; + } + + .comment-popup:before { + content: ''; + position: absolute; + display: inline-block; + width: 1.5em; + height: 1.5em; + left: 1.5em; + top: ~"calc(-0.75em - 1px)"; + border-left: 1px solid @gray-light; + border-top: 1px solid @gray-light; + background-color: @body-bg-color; + z-index: -1; + transform: rotate(45deg); + } +} + +ul.item-list li:last-child .comment-wrapper { + .comment-popup { + top: -7em; + } + + .comment-popup:before { + bottom: ~"calc(-0.75em - 1px)"; + top: unset; + transform: rotate(225deg); + } +} + +.comment-wrapper:hover .comment-popup { + display: block +} + +#layout { + &.twocols { + .comment-popup { + width: 35em; + } + + &.poor-layout, + &.compact-layout { + .comment-popup { + width: 25em; + } + } + } + + &.poor-layout, + &.minimal-layout { + .comment-popup { + width: 25em; + } + } +} diff --git a/public/css/widget/custom-var-table.less b/public/css/widget/custom-var-table.less new file mode 100644 index 0000000..46f1984 --- /dev/null +++ b/public/css/widget/custom-var-table.less @@ -0,0 +1,60 @@ +.custom-var-table { + .level-1 th { + padding-left: .5em; + } + + .level-2 th { + padding-left: 1em; + } + + .level-3 th { + padding-left: 1.5em; + } + + .level-4 th { + padding-left: 2em; + } + + .level-5 th { + padding-left: 2.5em; + } + + .level-6 th { + padding-left: 3em; + } + + thead th { + padding-left: 0; + text-align: left; + font-weight: bold; + font-size: 1.167em; + + > span { + :nth-child(1), + :nth-child(2) { + display: none; + } + } + } + + &.can-collapse thead th > span, // Icinga Web 2 < 2.12 + &[data-can-collapse] thead th > span { // >= 2.12 + :nth-child(1) { + display: none; + } + + :nth-child(2) { + display: inline-block; + } + } + + &.collapsed thead th > span { + :nth-child(1) { + display: inline-block; + } + + :nth-child(2) { + display: none; + } + } +} diff --git a/public/css/widget/donut-container.less b/public/css/widget/donut-container.less new file mode 100644 index 0000000..6fc4466 --- /dev/null +++ b/public/css/widget/donut-container.less @@ -0,0 +1,24 @@ +.donut-container { + .card(); + + h2 { + margin: 0; + } + + .state-badges { + text-align: center; + } + + &:not(:last-of-type) { + margin-right: 1em; + margin-bottom: 1em; + } + + .donut { + margin: 0 auto; + } +} + +#layout.minimal-layout .donut-container { + width: 100%; +} diff --git a/public/css/widget/downtime-card.less b/public/css/widget/downtime-card.less new file mode 100644 index 0000000..37dd8a5 --- /dev/null +++ b/public/css/widget/downtime-card.less @@ -0,0 +1,10 @@ +.downtime-progress { + .progress-bar(); + + &.progress-bar .timeline .downtime-elapsed { + &::before, + > .bar { + background-color: @state-ok; + } + } +} diff --git a/public/css/widget/group-grid.less b/public/css/widget/group-grid.less new file mode 100644 index 0000000..94b5b13 --- /dev/null +++ b/public/css/widget/group-grid.less @@ -0,0 +1,42 @@ +// HostGroup- and -ServiceGroupGrid styles + +ul.item-table.group-grid { + grid-template-columns: repeat(auto-fit, 15em); + grid-gap: 1em 2em; + + .table-row { + margin: -.25em; + padding: .25em; + border-radius: .5em; + } + + li.group-grid-cell { + .title { + align-items: center; + } + + .visual { + margin-right: 1em; + } + + .content { + line-height: 1; + + a { + display: inline-block; + max-width: 10em; + text-align: center; + } + } + + .state-badge { + width: 2.5em; + height: 2.5em; + line-height: 2; + } + } +} + +.content.full-width ul.item-table.group-grid { + margin: 0 1em; +} diff --git a/public/css/widget/host-state-badges.less b/public/css/widget/host-state-badges.less new file mode 100644 index 0000000..d55a45c --- /dev/null +++ b/public/css/widget/host-state-badges.less @@ -0,0 +1,3 @@ +.host-state-badges { + .state-badges(); +} diff --git a/public/css/widget/key-value-list.less b/public/css/widget/key-value-list.less new file mode 100644 index 0000000..cd99f5f --- /dev/null +++ b/public/css/widget/key-value-list.less @@ -0,0 +1,19 @@ +.key-value-list { + list-style-type: none; + margin: 0; + padding: 0; + + li { + display: flex; + } + + li > span { + padding: .25em; + + &.label { + display: block; + padding-left: 0; + width: 12em; + } + } +} diff --git a/public/css/widget/migrate-popup.less b/public/css/widget/migrate-popup.less new file mode 100644 index 0000000..8f9586b --- /dev/null +++ b/public/css/widget/migrate-popup.less @@ -0,0 +1,181 @@ +#migrate-popup { + @transitionLength: 350ms; + + display: flex; + min-width: 16em; + z-index: 1000; + position: fixed; + top: 0; + right: 4em; + pointer-events: none; + line-height: 1.5em; + + .transform(translateY(-100%)); + .transition(transform @transitionLength ease-in); + + &.active { + .transform(translateY(0%)); + .transition(transform @transitionLength ease-out); + } + + .suggestion-area { + .transform(translateY(0%)); + .transition(transform 0s linear @transitionLength); + } + + &.active .suggestion-area { + .transition(transform @transitionLength ease-out); + } + + &.minimized .suggestion-area { + .transform(translateY(-100%)); + .transition(transform @transitionLength ease-in); + } + + &.hidden .suggestion-area { + .transition(none); + } + + .minimizer { + width: 1.25em; + height: 1.5em; + margin-left: -1px; + z-index: 1; + pointer-events: auto; + + border-bottom-right-radius: 4px; + background-color: @body-bg-color; + + .transition(none); + + i:before { + width: 1em; + margin: .1em 0 0 0; + content: '\f102'; + font-size: 1.25em; + cursor: pointer; + color: @gray-light; + } + + i:hover:before { + color: @menu-highlight-color; + } + } + + &.minimized .minimizer { + border-bottom-left-radius: 4px; + .transition(border-bottom-left-radius 0s linear @transitionLength); + } + + &.hidden .minimizer i:before { + content: '\f103'; + } + + &:not(.active) .suggestion-area, &.hidden .suggestion-area { + box-shadow: none; + } + + .suggestion-area { + display: flex; + flex-direction: column-reverse; + + padding: .75em; + flex-grow: 1; + pointer-events: auto; + font-size: .75em; + + background: @body-bg-color; + border-radius: 0 0 4px 4px; + box-shadow: 0 0 1em 0 rgba(0, 0, 0, 0.3); + + button { + .link-button(); + } + + p { + display: none; + margin-bottom: .5em; + color: @text-color-light; + } + + form ~ .monitoring-migration-hint, + .search-migration-suggestions:not(:empty) + .search-migration-hint, + .monitoring-migration-suggestions:not(:empty) + .monitoring-migration-hint { + display: block; + } + + & > button.close { + margin-left: auto; + margin-top: 1em; + + &:hover { + text-decoration: underline; + } + } + + ul { + padding: 0; + margin: 0; + list-style-type: none; + } + + li { + margin: .5em 0; + display: flex; + + &:last-of-type { + margin-bottom: 0; + } + + &:first-of-type { + margin-top: 0; + } + } + + li { + :not(:last-child) { + margin-right: .5em; + } + + button:hover{ + opacity: 0.8; + } + + button[value="1"] { + flex-grow: 1; + + color: @text-color; + text-decoration: underline; + } + + button[value="0"] { + i:before { + margin: 0; + content: '\e804'; + } + } + } + + form { + width: 100%; + + .control-group { + display: flex; + align-items: center; + + .control-label-group { + margin-right: .5em; + } + + label { + margin-left: auto; + } + } + } + + .search-migration-suggestions:not(:empty) ~ form, + .search-migration-suggestions:not(:empty) ~ .monitoring-migration-suggestions:not(:empty) { + margin-bottom: .5em; + } + } +} diff --git a/public/css/widget/monitoring-health.less b/public/css/widget/monitoring-health.less new file mode 100644 index 0000000..f0ed252 --- /dev/null +++ b/public/css/widget/monitoring-health.less @@ -0,0 +1,136 @@ +.monitoring-health { + max-width: 65em; + + > section:not(:last-child) { + margin-bottom: 4em; + } + + .vertical-key-value .value { + display: inline-block; + margin-bottom: .25em; + } + + .check-summary { + padding: .5em 0; + + .col { + padding: 0 1em .5em 1em; + } + + .col:not(:last-child) { + border-right: 1px solid @gray-light; + } + } + + .check-summary, + .instance-features { + .rounded-corners(); + border: 1px solid; + border-color: @gray-light; + display: flex; + width: 100%; + } + + .check-summary, + .col-content, + .icinga-info { + width: 100%; + + display: flex; + flex-wrap: wrap; + justify-content: space-around; + } + + + .icinga-info { + margin-bottom: -2em; + + .vertical-key-value { + margin-bottom: 2em; + } + } + + .col { + flex: 1 1 auto; + text-align: center; + + > h3 { + margin: 0 0 1em; + } + } + + .icinga-health { + .rounded-corners(); + margin-bottom: 2em; + padding: 1em; + text-align: center; + width: 100%; + font-weight: bold; + + &.up { + border: 1px solid; + border-color: @color-up; + color: @color-up; + } + + &.down { + background-color: @color-down; + color: @body-bg-color; + } + } + + .instance-features { + .control-group { + flex: 1 1 auto; + margin: .5em 0; + padding: .5em; + width: 0; + + display: flex; + align-items: center; + flex-direction: column-reverse; + justify-content: flex-end; + + &:not(:last-of-type) { + border-right: 1px solid @gray-light; + } + + .control-label-group { + font-size: 10/12em; + margin-right: 0; + margin-top: 1em; + padding: 0; + text-align: center; + width: auto; + + label { + text-align: center; + } + } + + .toggle-switch { + margin: 0; + } + } + } +} + +#layout.minimal-layout { + .icinga-info { + .vertical-key-value { + width: 100%; + } + } + + .instance-features { + flex-wrap: wrap; + + .control-group { + width: 33%; + + &:nth-child(3n) { + border-right: none; + } + } + } +} diff --git a/public/css/widget/notice.less b/public/css/widget/notice.less new file mode 100644 index 0000000..7067665 --- /dev/null +++ b/public/css/widget/notice.less @@ -0,0 +1,23 @@ +// Style + +.notice { + @margin: 1em / 1.25; + @padding: .75em / 1.25; + + .rounded-corners(); + padding: @padding; + color: @text-color-on-icinga-blue; + background-color: @state-warning; + font-weight: bold; + font-size: 1.25em; + + // Layout + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 0 @margin @margin @margin; + + > span { + .text-ellipsis(); + } +} diff --git a/public/css/widget/object-features.less b/public/css/widget/object-features.less new file mode 100644 index 0000000..b39414d --- /dev/null +++ b/public/css/widget/object-features.less @@ -0,0 +1,53 @@ +form.object-features { + span.description { + text-align: left; + } + + .control-label-group { + text-align: left; + margin-right: 0; + width: @name-value-table-name-width; + color: @text-color-light; + + label { + font-size: inherit; + } + } + + .control-group { + margin-top: 0; + margin-bottom: 0; + + &.indeterminate { + justify-content: flex-start; + + .control-label-group { + flex: 0 1 auto; + } + + select { + width: auto; + flex: 0 1 auto; + + & + span.hint { + flex: 0 1 auto; + } + } + } + } + + .toggle-switch { + margin-left: @table-column-padding; + } + + select { + margin-right: .5em; + margin-left: @table-column-padding; + + & + span.hint { + margin: .35em; + color: @gray-light; + font-style: italic; + } + } +} diff --git a/public/css/widget/object-inspection.less b/public/css/widget/object-inspection.less new file mode 100644 index 0000000..60a99bf --- /dev/null +++ b/public/css/widget/object-inspection.less @@ -0,0 +1,17 @@ +// Style + +// Layout + +.inspection-detail { + th { + width: 16em; + } + + pre, td { + white-space: break-spaces; + word-break: break-word; + -webkit-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto; + } +} diff --git a/public/css/widget/object-meta-info.less b/public/css/widget/object-meta-info.less new file mode 100644 index 0000000..82ad044 --- /dev/null +++ b/public/css/widget/object-meta-info.less @@ -0,0 +1,95 @@ +// Style + +.object-meta-info { + .vertical-key-value .value { + font-weight: normal; + } + + .vertical-key-value:first-child { + text-align: left; + } + + .vertical-key-value:last-child { + text-align: right; + } +} + +.object-meta-info-control { + .link-button(); + + .rounded-corners(0 0 .5em .5em); + border: 1px solid; + border-color: @gray-lighter; + border-top-width: 0; + background: @body-bg-color; +} + +// Layout + +.object-meta-info { + display: flex; + justify-content: space-between; + + .horizontal-key-value { + padding: 0; + + .key { + width: 9em; + } + } + + .vertical-key-value { + &:first-child { + .text-ellipsis(); + margin-right: 1em; + } + + &:last-child { + white-space: nowrap; + margin-left: 1em; + } + } + + .horizontal-key-value .value, + .horizontal-key-value .key, + .vertical-key-value .value, + .vertical-key-value .key { + font-size: 16/18em; + line-height: 18/16; // compensate smaller font-size (1/font-size) + } +} + +.object-meta-info-control { + position: absolute; + right: 2.25em; + bottom: -2.5em; // height + margin-bottom + height: 2em; + padding: .25em; + + .collapse-icon, + .expand-icon { + font-size: 1.2em; + + &:before { + margin-right: 0; + } + } + + .collapse-icon { + display: block; + } + + .expand-icon { + display: none; + } +} + +.collapsed + .object-meta-info-control { + .collapse-icon { + display: none; + } + + .expand-icon { + display: block; + } +} diff --git a/public/css/widget/object-statistics.less b/public/css/widget/object-statistics.less new file mode 100644 index 0000000..5a9c97a --- /dev/null +++ b/public/css/widget/object-statistics.less @@ -0,0 +1,44 @@ +ul.object-statistics { + // Reset defaults + list-style-type: none; + margin: 0; + padding: 0; + + display: flex; + align-items: center; + + > li:not(:last-child) { + margin-right: 1em; + } +} + +.object-statistics-graph .donut-graph { + height: 2em; + width: 2em; + vertical-align: middle; +} + +.object-statistics-total a { + display: inline-block; + line-height: 1; + position: relative; + + &:hover:before { + .rounded-corners(); + background-color: @gray-lighter; + content: ""; + display: block; + z-index: 1; + + position: absolute; + bottom: -.4em; + left: -.4em; + top: -.4em; + right: -.4em; + } + + .vertical-key-value { + position: relative; + z-index: 2; + } +} diff --git a/public/css/widget/performance-data-table.less b/public/css/widget/performance-data-table.less new file mode 100644 index 0000000..26c18c8 --- /dev/null +++ b/public/css/widget/performance-data-table.less @@ -0,0 +1,62 @@ +/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */ + +.performance-data-table { + width: 100%; + overflow-x: auto; + display: block; + + tr:not(:last-child) { + border-bottom: 1px solid @gray-lighter; + } + + td { + text-align: right; + .text-ellipsis(); + } + + th { + font-size: .857em; + font-weight: normal; + text-transform: uppercase; + letter-spacing: .05em; + } + + thead { + border-bottom: 1px solid @gray-light; + } + + th:first-child, + td:first-child { + padding-left: 0; + } + + .title { + text-align: left; + width: 100%; + } + + td.title { + font-weight: bold; + } + + .sparkline-col { + min-width: 1.75em; + width: 1.75em; + padding: 2/12em 0; + .invalid-perfdata { + font-size: 1.25em; + vertical-align: text-bottom; + color: @color-warning; + } + } + + .inline-pie > svg { + vertical-align: middle; + } + + .no-value { + color: @gray-semilight; + text-align: center; + display: block; + } +} diff --git a/public/css/widget/quick-actions.less b/public/css/widget/quick-actions.less new file mode 100644 index 0000000..bddea43 --- /dev/null +++ b/public/css/widget/quick-actions.less @@ -0,0 +1,47 @@ +.quick-actions { + display: flex; + flex-wrap: wrap; + list-style-type: none; + margin: 0 -.5em; + padding: 0; + + a { + text-decoration: none; + } + + a, + button { + padding: .25em; + .rounded-corners(); + display: inline-flex; + align-items: baseline; + + &:hover { + background: @gray-lighter; + } + } + + li { + margin: 0 .25em .5em .25em; + vertical-align: middle; + white-space: nowrap; + } +} + +.controls:not(.default-layout) > .quick-actions:last-child, +.controls > .quick-actions:last-child { + margin-bottom: 0; +} + +#layout.twocols:not(.wide-layout) { + .quick-actions { + justify-content: space-between; + min-width: 100%; + } +} + +#layout.wide-layout .controls { + .quick-actions { + float: left; + } +} diff --git a/public/css/widget/service-state-badges.less b/public/css/widget/service-state-badges.less new file mode 100644 index 0000000..8a97faa --- /dev/null +++ b/public/css/widget/service-state-badges.less @@ -0,0 +1,3 @@ +.service-state-badges { + .state-badges(); +} diff --git a/public/css/widget/state-change.less b/public/css/widget/state-change.less new file mode 100644 index 0000000..adc8d42 --- /dev/null +++ b/public/css/widget/state-change.less @@ -0,0 +1,128 @@ +.state-change { + display: inline-flex; + + &.reversed-state-balls { + // This is needed, because with ~ we can address only subsequent nodes + flex-direction: row-reverse; + } + + .state-ball { + .box-shadow(0, 0, 0, 1px, @body-bg-color); + } + + // Same on same + .state-ball ~ .state-ball { + &.ball-size-xs { + margin-left: -.05em; + } + + &.ball-size-s { + margin-left: -.15em; + } + + &.ball-size-m { + margin-left: -.275em; + } + + &.ball-size-ml { + margin-left: -.375em; + } + + &.ball-size-l, + &.ball-size-xl { + margin-left: -.875em; + } + } + + // big left, smaller right + &:not(.reversed-state-balls) .ball-size-l ~ .state-ball { + &.ball-size-ml { + margin-top: .25em; + margin-left: -.5em; + margin-right: .25em; + } + } + + // smaller left, big right + &.reversed-state-balls .ball-size-l ~ .state-ball { + &.ball-size-ml { + z-index: -1; + margin-top: .25em; + margin-right: -.5em; + } + } + + .state-ball.state-ok, + .state-ball.state-up, + .state-pending { + &.ball-size-l, + &.ball-size-xl { + background-color: @body-bg-color; + } + } + + // Avoid transparency on overlapping solid state-change state-balls + .state-ball.handled { + position: relative; + opacity: 1; + + i { + position: relative; + z-index: 3; + } + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border-radius: 50%; + opacity: .6; + z-index: 2 + } + + &:after { + content: ""; + display: block; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + border-radius: 50%; + background-color: @body-bg-color; + z-index: 1; + } + + &.state-pending:before { + background-color: @color-pending; + } + + &.state-down:before { + background-color: @color-down; + } + + &.state-warning:before { + background-color: @color-warning; + } + + &.state-critical:before { + background-color: @color-critical; + } + + &.state-unknown:before { + background-color: @color-unknown; + } + } +} + +.overdue .state-change .state-ball { + .box-shadow(0, 0, 0, 1px, @gray-lighter); + + &.handled:after { + background-color: @gray-lighter; + } +} diff --git a/public/css/widget/table-layout.less b/public/css/widget/table-layout.less new file mode 100644 index 0000000..f67ec0f --- /dev/null +++ b/public/css/widget/table-layout.less @@ -0,0 +1,72 @@ +// HostGroup- and -ServiceGroupTable styles + +.item-table.table-layout { + --columns: 1; +} + +ul.item-table.table-layout { + grid-template-columns: 1fr repeat(var(--columns), auto); + + > li { + display: contents; + + &:hover, + &.active { + .col, &::before, &::after { + // The li might get a background on hover. Though, this won't be visible + // as it has no box model since we apply display:contents to it. + background-color: inherit; + } + } + } + + li:not(:last-of-type) { + .col { + border-bottom: 1px solid @gray-light; + } + + .visual { + border-bottom: 1px solid @default-bg; + } + } + + > .table-row { + &:not(:last-of-type) .title .visual { + margin-bottom: ~"calc(-.5em - 1px)"; + } + + .col { + padding: .5em 0; + } + + .col:not(:last-child) { + padding-right: 1em; + } + } +} + +.content.full-width ul.item-table.table-layout { + // Again, since the li has no box model, it cannot have padding. So the first + // and last child need to get the left and right padding respectively. + // But we don't want to have a border that spans to the very right or left, + // so pseudo elements are required. We could add empty cells instead, but + // that would require hard coding the width here, which I'd like to avoid. + + grid-template-columns: ~"auto 1fr repeat(calc(var(--columns) + 1), auto)"; + + > li.table-row { + &::before, &::after { + display: inline-block; + content: '\00a0'; + margin-bottom: 1px; + } + + &::before { + padding-left: inherit; + } + + &::after { + padding-right: inherit; + } + } +} diff --git a/public/css/widget/tag-list.less b/public/css/widget/tag-list.less new file mode 100644 index 0000000..fe96691 --- /dev/null +++ b/public/css/widget/tag-list.less @@ -0,0 +1,31 @@ +.tag-list { + line-height: 1.5; + list-style-type: none; + margin: -.25em 0 0 0; // TODO: This is wrong here, if at all this must be part of wherever this widget is placed + padding: 0; + + > li { + display: inline-block; + + &:not(:last-child) { + margin-right: .417em; + margin-bottom: .25em; // TODO: Really? It's an inline ul, bottom margin is outer layout.. + } + + i { + opacity: 0.8; + } + } + + > li > a { + .rounded-corners(); + background-color: @gray-lighter; + display: block; + padding: .25em .5em; + + &:hover { + background-color: @gray-light; + text-decoration: none; + } + } +} diff --git a/public/css/widget/view-mode-switcher.less b/public/css/widget/view-mode-switcher.less new file mode 100644 index 0000000..7eda2a8 --- /dev/null +++ b/public/css/widget/view-mode-switcher.less @@ -0,0 +1,45 @@ +.view-mode-switcher { + list-style-type: none; + margin: 0 0 0.25em 0; + padding: 0; + display: flex; + + input { + display: none; + } + + label { + color: @control-color; + line-height: 1; + background: @low-sat-blue; + padding: 14/16*.25em 14/16*.5em; + font-size: 16/12em; + height: 24/16em; // desired pixel height / font-size + cursor: pointer; + + &:first-of-type { + border-top-left-radius: 0.25em; + border-bottom-left-radius: 0.25em; + } + + &:last-of-type { + border-top-right-radius: 0.25em; + border-bottom-right-radius: 0.25em; + } + + &:not(:last-of-type) { + border-right: 1px solid @low-sat-blue-dark; + } + + i { + // fix height for Chrome + display: block; + } + } + + input[checked] + label { + background-color: @control-color; + color: @text-color-on-icinga-blue; + cursor: default; + } +} diff --git a/public/js/action-list.js b/public/js/action-list.js new file mode 100644 index 0000000..69cab05 --- /dev/null +++ b/public/js/action-list.js @@ -0,0 +1,788 @@ +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +;(function (Icinga) { + + "use strict"; + + try { + var notjQuery = require('icinga/icinga-php-library/notjQuery'); + } catch (e) { + console.warn('Unable to provide input enrichments. Libraries not available:', e); + return; + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + class ActionList extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.on('click', '.action-list [data-action-item]:not(.page-separator), .action-list [data-action-item] a[href]', this.onClick, this); + this.on('close-column', '#main > #col2', this.onColumnClose, this); + this.on('column-moved', this.onColumnMoved, this); + + this.on('rendered', '#main .container', this.onRendered, this); + this.on('keydown', '#body', this.onKeyDown, this); + + this.on('click', '.load-more[data-no-icinga-ajax] a', this.onLoadMoreClick, this); + this.on('keypress', '.load-more[data-no-icinga-ajax] a', this.onKeyPress, this); + + this.lastActivatedItemUrl = null; + this.lastTimeoutId = null; + this.isProcessingLoadMore = false; + this.activeRequests = {}; + } + + /** + * Parse the filter query contained in the given URL query string + * + * @param {string} queryString + * + * @returns {array} + */ + parseSelectionQuery(queryString) { + return queryString.split('|'); + } + + /** + * Suspend auto refresh for the given item's container + * + * @param {Element} item + * + * @return {string} The container's id + */ + suspendAutoRefresh(item) { + const container = item.closest('.container'); + container.dataset.suspendAutorefresh = ''; + + return container.id; + } + + /** + * Enable auto refresh on the given container + * + * @param {string} containerId + */ + enableAutoRefresh(containerId) { + delete document.getElementById(containerId).dataset.suspendAutorefresh; + } + + onClick(event) { + let _this = event.data.self; + let target = event.currentTarget; + + if (target.matches('a') && (! target.matches('.subject') || event.ctrlKey || event.metaKey)) { + return true; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + + let item = target.closest('[data-action-item]'); + let list = target.closest('.action-list'); + let activeItems = _this.getActiveItems(list); + let toActiveItems = [], + toDeactivateItems = []; + + const isBeingMultiSelected = list.matches('[data-icinga-multiselect-url]') + && (event.ctrlKey || event.metaKey || event.shiftKey); + + if (isBeingMultiSelected) { + if (event.ctrlKey || event.metaKey) { + if (item.classList.contains('active')) { + toDeactivateItems.push(item); + } else { + toActiveItems.push(item); + } + } else { + document.getSelection().removeAllRanges(); + + let allItems = _this.getAllItems(list); + + let startIndex = allItems.indexOf(item); + if(startIndex < 0) { + startIndex = 0; + } + + let endIndex = activeItems.length ? allItems.indexOf(activeItems[0]) : 0; + if (startIndex > endIndex) { + toActiveItems = allItems.slice(endIndex, startIndex + 1); + } else { + endIndex = activeItems.length ? allItems.indexOf(activeItems[activeItems.length - 1]) : 0; + toActiveItems = allItems.slice(startIndex, endIndex + 1); + } + + toDeactivateItems = activeItems.filter(item => ! toActiveItems.includes(item)); + toActiveItems = toActiveItems.filter(item => ! activeItems.includes(item)); + } + } else { + toDeactivateItems = activeItems; + toActiveItems.push(item); + } + + if (activeItems.length === 1 + && toActiveItems.length === 0 + && _this.icinga.loader.getLinkTargetFor($(target)).attr('id') === 'col2' + ) { + _this.icinga.ui.layout1col(); + _this.enableAutoRefresh('col1'); + return; + } + + let dashboard = list.closest('.dashboard'); + if (dashboard) { + dashboard.querySelectorAll('.action-list').forEach(otherList => { + if (otherList !== list) { + toDeactivateItems.push(..._this.getAllItems(otherList)); + } + }) + } + + let lastActivatedUrl = null; + if (toActiveItems.includes(item)) { + lastActivatedUrl = item.dataset.icingaDetailFilter; + } else if (activeItems.length > 1) { + lastActivatedUrl = activeItems[activeItems.length - 1] === item + ? activeItems[activeItems.length - 2].dataset.icingaDetailFilter + : activeItems[activeItems.length - 1].dataset.icingaDetailFilter; + } + + _this.clearSelection(toDeactivateItems); + _this.setActive(toActiveItems); + + if (! dashboard) { + _this.addSelectionCountToFooter(list); + } + + _this.setLastActivatedItemUrl(lastActivatedUrl); + _this.loadDetailUrl(list, target.matches('a') ? target.getAttribute('href') : null); + } + + /** + * Add the selection count to footer if list allow multi selection + * + * @param list + */ + addSelectionCountToFooter(list) { + if (! list.matches('[data-icinga-multiselect-url]')) { + return; + } + + let activeItemCount = this.getActiveItems(list).length; + let footer = list.closest('.container').querySelector('.footer'); + + // For items that do not have a bottom status bar like Downtimes, Comments... + if (footer === null) { + footer = notjQuery.render( + '<div class="footer" data-action-list-automatically-added>' + + '<div class="selection-count"><span class="selected-items"></span></div>' + + '</div>' + ) + + list.closest('.container').appendChild(footer); + } + + let selectionCount = footer.querySelector('.selection-count'); + if (selectionCount === null) { + selectionCount = notjQuery.render( + '<div class="selection-count"><span class="selected-items"></span></div>' + ); + + footer.prepend(selectionCount); + } + + let selectedItems = selectionCount.querySelector('.selected-items'); + selectedItems.innerText = activeItemCount + ? list.dataset.icingaMultiselectCountLabel.replace('%d', activeItemCount) + : list.dataset.icingaMultiselectHintLabel; + + if (activeItemCount === 0) { + selectedItems.classList.add('hint'); + } else { + selectedItems.classList.remove('hint'); + } + } + + /** + * Key navigation for .action-list + * + * - `Shift + ArrowUp|ArrowDown` = Multiselect + * - `ArrowUp|ArrowDown` = Select next/previous + * - `Ctrl|cmd + A` = Select all on currect page + * + * @param event + */ + onKeyDown(event) { + let _this = event.data.self; + let list = null; + let pressedArrowDownKey = event.key === 'ArrowDown'; + let pressedArrowUpKey = event.key === 'ArrowUp'; + let focusedElement = document.activeElement; + + if ( + _this.isProcessingLoadMore + || ! event.key // input auto-completion is triggered + || (event.key.toLowerCase() !== 'a' && ! pressedArrowDownKey && ! pressedArrowUpKey) + ) { + return; + } + + if (focusedElement && ( + focusedElement.matches('#main > :scope') + || focusedElement.matches('#body')) + ) { + let activeItem = document.querySelector( + '#main > .container > .content > .action-list [data-action-item].active' + ); + if (activeItem) { + list = activeItem.closest('.action-list'); + } else { + list = focusedElement.querySelector('#main > .container > .content > .action-list'); + } + } else if (focusedElement) { + list = focusedElement.closest('.content > .action-list'); + } + + if (! list) { + return; + } + + let isMultiSelectableList = list.matches('[data-icinga-multiselect-url]'); + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + if (! isMultiSelectableList) { + return; + } + + event.preventDefault(); + _this.selectAll(list); + return; + } + + event.preventDefault(); + + let allItems = _this.getAllItems(list); + let firstListItem = allItems[0]; + let lastListItem = allItems[allItems.length -1]; + let activeItems = _this.getActiveItems(list); + let markAsLastActive = null; // initialized only if it is different from toActiveItem + let toActiveItem = null; + let wasAllSelected = activeItems.length === allItems.length; + let lastActivatedItem = list.querySelector( + `[data-icinga-detail-filter="${ _this.lastActivatedItemUrl }"]` + ); + + if (! lastActivatedItem && activeItems.length) { + lastActivatedItem = activeItems[activeItems.length - 1]; + } + + let directionalNextItem = _this.getDirectionalNext(lastActivatedItem, event.key); + + if (activeItems.length === 0) { + toActiveItem = pressedArrowDownKey ? firstListItem : lastListItem; + // reset all on manual page refresh + _this.clearSelection(activeItems); + if (toActiveItem.classList.contains('load-more')) { + toActiveItem = toActiveItem.previousElementSibling; + } + } else if (isMultiSelectableList && event.shiftKey) { + if (activeItems.length === 1) { + toActiveItem = directionalNextItem; + } else if (wasAllSelected && ( + (lastActivatedItem !== firstListItem && pressedArrowDownKey) + || (lastActivatedItem !== lastListItem && pressedArrowUpKey) + )) { + if (pressedArrowDownKey) { + toActiveItem = lastActivatedItem === lastListItem ? null : lastListItem; + } else { + toActiveItem = lastActivatedItem === firstListItem ? null : lastListItem; + } + + } else if (directionalNextItem && directionalNextItem.classList.contains('active')) { + // deactivate last activated by down to up select + _this.clearSelection([lastActivatedItem]); + if (wasAllSelected) { + _this.scrollItemIntoView(lastActivatedItem, event.key); + } + + toActiveItem = directionalNextItem; + } else { + [toActiveItem, markAsLastActive] = _this.findToActiveItem(lastActivatedItem, event.key); + } + } else { + toActiveItem = directionalNextItem ?? lastActivatedItem; + + if (toActiveItem) { + if (toActiveItem.classList.contains('load-more')) { + clearTimeout(_this.lastTimeoutId); + _this.handleLoadMoreNavigate(toActiveItem, lastActivatedItem, event.key); + return; + } + + _this.clearSelection(activeItems); + if (toActiveItem.classList.contains('page-separator')) { + toActiveItem = _this.getDirectionalNext(toActiveItem, event.key); + } + } + } + + if (! toActiveItem) { + return; + } + + _this.setActive(toActiveItem); + _this.setLastActivatedItemUrl( + markAsLastActive ? markAsLastActive.dataset.icingaDetailFilter : toActiveItem.dataset.icingaDetailFilter + ); + _this.scrollItemIntoView(toActiveItem, event.key); + _this.addSelectionCountToFooter(list); + _this.loadDetailUrl(list); + } + + /** + * Get the next list item according to the pressed key (`ArrowUp` or `ArrowDown`) + * + * @param item The list item from which we want the next item + * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`) + * + * @returns {Element|null} + */ + getDirectionalNext(item, eventKey) { + if (! item) { + return null; + } + + return eventKey === 'ArrowUp' ? item.previousElementSibling : item.nextElementSibling; + } + + /** + * Find the list item that should be activated next + * + * @param lastActivatedItem + * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`) + * + * @returns {Element[]} + */ + findToActiveItem(lastActivatedItem, eventKey) { + let toActiveItem; + let markAsLastActive; + + toActiveItem = this.getDirectionalNext(lastActivatedItem, eventKey); + + while (toActiveItem) { + if (! toActiveItem.classList.contains('active')) { + break; + } + + toActiveItem = this.getDirectionalNext(toActiveItem, eventKey); + } + + markAsLastActive = toActiveItem; + // if the next/previous sibling element is already active, + // mark the last/first active element in list as last active + while (markAsLastActive && this.getDirectionalNext(markAsLastActive, eventKey)) { + if (! this.getDirectionalNext(markAsLastActive, eventKey).classList.contains('active')) { + break; + } + + markAsLastActive = this.getDirectionalNext(markAsLastActive, eventKey); + } + + return [toActiveItem, markAsLastActive]; + } + + /** + * Select All list items + * + * @param list The action list + */ + selectAll(list) { + let allItems = this.getAllItems(list); + let activeItems = this.getActiveItems(list); + this.setActive(allItems.filter(item => ! activeItems.includes(item))); + this.setLastActivatedItemUrl(allItems[allItems.length -1].dataset.icingaDetailFilter); + this.addSelectionCountToFooter(list); + this.loadDetailUrl(list); + } + + /** + * Clear the selection by removing .active class + * + * @param selectedItems The items with class active + */ + clearSelection(selectedItems) { + selectedItems.forEach(item => item.classList.remove('active')); + } + + /** + * Set the last activated item Url + * + * @param url + */ + setLastActivatedItemUrl (url) { + this.lastActivatedItemUrl = url; + } + + /** + * Scroll the given item into view + * + * @param item Item to scroll into view + * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`) + */ + scrollItemIntoView(item, pressedKey) { + item.scrollIntoView({block: "nearest"}); + let directionalNext = this.getDirectionalNext(item, pressedKey); + + if (directionalNext) { + directionalNext.scrollIntoView({block: "nearest"}); + } + } + + /** + * Load the detail url with selected items + * + * @param list The action list + * @param anchorUrl If any anchor is clicked (e.g. host in service list) + */ + loadDetailUrl(list, anchorUrl = null) { + let url = anchorUrl; + let activeItems = this.getActiveItems(list); + + if (url === null) { + if (activeItems.length > 1) { + url = this.createMultiSelectUrl(activeItems); + } else { + let anchor = activeItems[0].querySelector('[href]'); + url = anchor ? anchor.getAttribute('href') : null; + } + } + + if (url === null) { + return; + } + + const suspendedContainer = this.suspendAutoRefresh(list); + + clearTimeout(this.lastTimeoutId); + this.lastTimeoutId = setTimeout(() => { + const requestNo = this.lastTimeoutId; + this.activeRequests[requestNo] = suspendedContainer; + this.lastTimeoutId = null; + + let req = this.icinga.loader.loadUrl( + url, + this.icinga.loader.getLinkTargetFor($(activeItems[0])) + ); + + req.always((_, __, errorThrown) => { + if (errorThrown !== 'abort') { + this.enableAutoRefresh(this.activeRequests[requestNo]); + } + + delete this.activeRequests[requestNo]; + }); + }, 250); + } + + /** + * Add .active class to given list item + * + * @param toActiveItem The list item(s) + */ + setActive(toActiveItem) { + if (toActiveItem instanceof HTMLElement) { + toActiveItem.classList.add('active'); + } else { + toActiveItem.forEach(item => item.classList.add('active')); + } + } + + /** + * Get the active items from given list + * + * @param list The action list + * + * @return array + */ + getActiveItems(list) + { + let items; + if (list.tagName.toLowerCase() === 'table') { + items = list.querySelectorAll(':scope > tbody > [data-action-item].active'); + } else { + items = list.querySelectorAll(':scope > [data-action-item].active'); + } + + return Array.from(items); + } + + /** + * Get all available items from given list + * + * @param list The action list + * + * @return array + */ + getAllItems(list) + { + let items; + if (list.tagName.toLowerCase() === 'table') { + items = list.querySelectorAll(':scope > tbody > [data-action-item]'); + } else { + items = list.querySelectorAll(':scope > [data-action-item]'); + } + + return Array.from(items); + } + + /** + * Handle the navigation on load-more button + * + * @param loadMoreElement + * @param lastActivatedItem + * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`) + */ + handleLoadMoreNavigate(loadMoreElement, lastActivatedItem, pressedKey) { + let req = this.loadMore(loadMoreElement.firstChild); + this.isProcessingLoadMore = true; + req.done(() => { + this.isProcessingLoadMore = false; + // list has now new items, so select the lastActivatedItem and then move forward + let toActiveItem = lastActivatedItem.nextElementSibling; + while (toActiveItem) { + if (toActiveItem.hasAttribute('data-action-item')) { + this.clearSelection([lastActivatedItem]); + this.setActive(toActiveItem); + this.setLastActivatedItemUrl(toActiveItem.dataset.icingaDetailFilter); + this.scrollItemIntoView(toActiveItem, pressedKey); + this.addSelectionCountToFooter(toActiveItem.parentElement); + this.loadDetailUrl(toActiveItem.parentElement); + return; + } + + toActiveItem = toActiveItem.nextElementSibling; + } + }); + } + + /** + * Click on load-more button + * + * @param event + * + * @returns {boolean} + */ + onLoadMoreClick(event) { + event.stopPropagation(); + event.preventDefault(); + + event.data.self.loadMore(event.target); + + return false; + } + + onKeyPress(event) { + if (event.key === ' ') { // space + event.data.self.onLoadMoreClick(event); + } + } + + /** + * Load more list items based on the given anchor + * + * @param anchor + * + * @returns {*|{getAllResponseHeaders: function(): *|null, abort: function(*): this, setRequestHeader: function(*, *): this, readyState: number, getResponseHeader: function(*): null|*, overrideMimeType: function(*): this, statusCode: function(*): this}|jQuery|boolean} + */ + loadMore(anchor) { + let showMore = anchor.parentElement; + var progressTimer = this.icinga.timer.register(function () { + var label = anchor.innerText; + + var dots = label.substr(-3); + if (dots.slice(0, 1) !== '.') { + dots = '. '; + } else { + label = label.slice(0, -3); + if (dots === '...') { + dots = '. '; + } else if (dots === '.. ') { + dots = '...'; + } else if (dots === '. ') { + dots = '.. '; + } + } + + anchor.innerText = label + dots; + }, null, 250); + + let url = anchor.getAttribute('href'); + let req = this.icinga.loader.loadUrl( + // Add showCompact, we don't want controls in paged results + this.icinga.utils.addUrlFlag(url, 'showCompact'), + $(showMore.parentElement), + undefined, + undefined, + 'append', + false, + progressTimer + ); + req.addToHistory = false; + req.done(function () { + showMore.remove(); + + // Set data-icinga-url to make it available for Icinga.History.getCurrentState() + req.$target.closest('.container').data('icingaUrl', url); + + this.icinga.history.replaceCurrentState(); + }); + + return req; + } + + /** + * Create the detail url for multi selectable list + * + * @param items List items + * @param withBaseUrl Default to true + * + * @returns {string} The url + */ + createMultiSelectUrl(items, withBaseUrl = true) { + let filters = []; + items.forEach(item => { + filters.push(item.getAttribute('data-icinga-multiselect-filter')); + }); + + let url = '?' + filters.join('|'); + + if (withBaseUrl) { + return items[0].closest('.action-list').getAttribute('data-icinga-multiselect-url') + url; + } + + return url; + } + + onColumnClose(event) { + let _this = event.data.self; + let list = _this.findDetailUrlActionList(document.getElementById('col1')); + if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) { + _this.clearSelection(_this.getActiveItems(list)); + _this.addSelectionCountToFooter(list); + } + } + + /** + * Find the action list using the detail url + * + * @param {Element} container + * + * @return Element|null + */ + findDetailUrlActionList(container) { + let detailUrl = this.icinga.utils.parseUrl( + this.icinga.history.getCol2State().replace(/^#!/, '') + ); + + let detailItem = container.querySelector( + '[data-icinga-detail-filter="' + + detailUrl.query.replace('?', '') + '"],' + + '[data-icinga-multiselect-filter="' + + detailUrl.query.split('|', 1).toString().replace('?', '') + '"]' + ); + + return detailItem ? detailItem.parentElement : null; + } + + /** + * Triggers when column is moved to left or right + * + * @param event + * @param sourceId The content is moved from + */ + onColumnMoved(event, sourceId) { + let _this = event.data.self; + + if (event.target.id === 'col2' && sourceId === 'col1') { // only for browser-back (col1 shifted to col2) + _this.clearSelection(event.target.querySelectorAll('.action-list .active')); + } else if (event.target.id === 'col1' && sourceId === 'col2') { + for (const requestNo of Object.keys(_this.activeRequests)) { + if (_this.activeRequests[requestNo] === sourceId) { + _this.enableAutoRefresh(_this.activeRequests[requestNo]); + _this.activeRequests[requestNo] = _this.suspendAutoRefresh(event.target); + } + } + } + } + + onRendered(event, isAutoRefresh) { + let _this = event.data.self; + let container = event.target; + let isTopLevelContainer = container.matches('#main > :scope'); + + let list; + if (event.currentTarget !== container || Object.keys(_this.activeRequests).length) { + // Nested containers are not processed multiple times || still processing selection/navigation request + return; + } else if (isTopLevelContainer && container.id !== 'col1') { + if (isAutoRefresh) { + return; + } + + // only for browser back/forward navigation + list = _this.findDetailUrlActionList(document.getElementById('col1')); + } else { + list = _this.findDetailUrlActionList(container); + } + + if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) { + let detailUrl = _this.icinga.utils.parseUrl( + _this.icinga.history.getCol2State().replace(/^#!/, '') + ); + let toActiveItems = []; + if (list.dataset.icingaMultiselectUrl === detailUrl.path) { + for (const filter of _this.parseSelectionQuery(detailUrl.query.slice(1))) { + let item = list.querySelector( + '[data-icinga-multiselect-filter="' + filter + '"]' + ); + + if (item) { + toActiveItems.push(item); + } + } + } else if (_this.matchesDetailUrl(list.dataset.icingaDetailUrl, detailUrl.path)) { + let item = list.querySelector( + '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]' + ); + + if (item) { + toActiveItems.push(item); + } + } + + _this.clearSelection(_this.getAllItems(list).filter(item => !toActiveItems.includes(item))); + _this.setActive(toActiveItems); + } + + if (isTopLevelContainer) { + let footerList = list ?? container.querySelector('.content > .action-list'); + if (footerList) { + _this.addSelectionCountToFooter(footerList); + } + } + } + + matchesDetailUrl(itemUrl, detailUrl) { + if (itemUrl === detailUrl) { + return true; + } + + // The slash is used to avoid false positives (e.g. icingadb/hostgroup and icingadb/host) + return detailUrl.startsWith(itemUrl + '/'); + } + } + + Icinga.Behaviors.ActionList = ActionList; + +}(Icinga)); diff --git a/public/js/migrate.js b/public/js/migrate.js new file mode 100644 index 0000000..dddbaed --- /dev/null +++ b/public/js/migrate.js @@ -0,0 +1,654 @@ +/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ + +;(function(Icinga, $) { + + 'use strict'; + + const ANIMATION_LENGTH = 350; + + const POPUP_HTML = '<div class="icinga-module module-icingadb">\n' + + ' <div id="migrate-popup">\n' + + ' <div class="suggestion-area">\n' + + ' <button type="button" class="close">Don\'t show this again</button>\n' + + ' <ul class="search-migration-suggestions"></ul>\n' + + ' <p class="search-migration-hint">Miss some results? Try the link(s) below</p>\n' + + ' <ul class="monitoring-migration-suggestions"></ul>\n' + + ' <p class="monitoring-migration-hint">Preview this in Icinga DB</p>\n' + + ' </div>\n' + + ' <div class="minimizer"><i class="icon-"></i></div>\n' + + ' </div>\n' + + '</div>'; + + const SUGGESTION_HTML = '<li>\n' + + ' <button type="button" value="1"></button>\n' + + ' <button type="button" value="0"><i class="icon-"></i></button>\n' + + '</li>'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * Icinga DB Migration behavior. + * + * @param icinga {Icinga} The current Icinga Object + */ + class Migrate extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.knownMigrations = {}; + this.knownBackendSupport = {}; + this.urlMigrationReadyState = null; + this.backendSupportReadyState = null; + this.searchMigrationReadyState = null; + this.backendSupportRelated = {}; + this.$popup = null; + + // Some persistence, we don't want to annoy our users too much + this.storage = Icinga.Storage.BehaviorStorage('icingadb.migrate'); + this.tempStorage = Icinga.Storage.BehaviorStorage('icingadb.migrate'); + this.tempStorage.setBackend(window.sessionStorage); + this.previousMigrations = {}; + + // We don't want to ask the server to migrate non-monitoring urls + this.isMonitoringUrl = new RegExp('^' + icinga.config.baseUrl + '/monitoring/'); + + this.on('rendered', this.onRendered, this); + this.on('close-column', this.onColumnClose, this); + this.on('click', '#migrate-popup button.close', this.onClose, this); + this.on('click', '#migrate-popup li button', this.onDecision, this); + this.on('click', '#migrate-popup .minimizer', this.onHandleClicked, this); + this.storage.onChange('minimized', this.onMinimized, this); + } + + update(data) { + if (data !== 'bogus') { + return; + } + + $.each(this.backendSupportRelated, (id, _) => { + let $container = $('#' + id); + let req = this.icinga.loader.loadUrl($container.data('icingaUrl'), $container); + req.addToHistory = false; + req.scripted = true; + }); + } + + onRendered(event) { + var _this = event.data.self; + var $target = $(event.target); + + if (_this.tempStorage.get('closed') || $('#layout.fullscreen-layout').length) { + // Don't bother in case the user closed the popup or we're in fullscreen + return; + } + + if (!$target.is('#main > .container')) { + if ($target.is('#main .container')) { + var attrUrl = $target.attr('data-icinga-url'); + var dataUrl = $target.data('icingaUrl'); + if (!! attrUrl && attrUrl !== dataUrl) { + // Search urls are redirected, update any migration suggestions + _this.prepareMigration($target); + return; + } + } + + // We are else really only interested in top-level containers + return; + } + + var $dashboard = $target.children('.dashboard'); + if ($dashboard.length) { + // After a page load dashlets have no id as `renderContentToContainer()` didn't ran yet + _this.icinga.ui.assignUniqueContainerIds(); + + $target = $dashboard.children('.container'); + } + + _this.prepareMigration($target); + } + + prepareMigration($target) { + let monitoringUrls = {}; + let searchUrls = {}; + let modules = {} + + $target.each((_, container) => { + let $container = $(container); + let href = decodeURIComponent($container.data('icingaUrl')); + let containerId = $container.attr('id'); + + if (!!href) { + if ( + typeof this.previousMigrations[containerId] !== 'undefined' + && this.previousMigrations[containerId] === href + ) { + delete this.previousMigrations[containerId]; + } else { + if (href.match(this.isMonitoringUrl)) { + monitoringUrls[containerId] = href; + } else if ($container.find('[data-enrichment-type="search-bar"]').length) { + searchUrls[containerId] = href; + } + } + } + + let moduleName = $container.data('icingaModule'); + if (!! moduleName && moduleName !== 'default' && moduleName !== 'monitoring' && moduleName !== 'icingadb') { + modules[containerId] = moduleName; + } + }); + + if (Object.keys(monitoringUrls).length) { + this.setUrlMigrationReadyState(false); + this.migrateUrls(monitoringUrls, 'monitoring'); + } else { + this.setUrlMigrationReadyState(null); + } + + if (Object.keys(searchUrls).length) { + this.setSearchMigrationReadyState(false); + this.migrateUrls(searchUrls, 'search'); + } else { + this.setSearchMigrationReadyState(null); + } + + if (Object.keys(modules).length) { + this.setBackendSupportReadyState(false); + this.prepareBackendCheckboxForm(modules); + } else { + this.setBackendSupportReadyState(null); + } + + if ( + this.urlMigrationReadyState === null + && this.backendSupportReadyState === null + && this.searchMigrationReadyState === null + ) { + this.cleanupPopup(); + } + } + + onColumnClose(event) { + var _this = event.data.self; + _this.Popup().find('.suggestion-area > ul li').each(function () { + var $suggestion = $(this); + var suggestionUrl = $suggestion.data('containerUrl'); + var $container = $('#' + $suggestion.data('containerId')); + + var containerUrl = ''; + if ($container.length) { + containerUrl = decodeURIComponent($container.data('icingaUrl')); + } + + if (suggestionUrl !== containerUrl) { + var $newContainer = $('#main > .container').filter(function () { + return decodeURIComponent($(this).data('icingaUrl')) === suggestionUrl; + }); + if ($newContainer.length) { + // Container moved + $suggestion.attr('id', 'suggest-' + $newContainer.attr('id')); + $suggestion.data('containerId', $newContainer.attr('id')); + } + } + }); + + let backendSupportRelated = { ..._this.backendSupportRelated }; + $.each(backendSupportRelated, (id, module) => { + let $container = $('#' + id); + if (! $container.length || $container.data('icingaModule') !== module) { + let $newContainer = $('#main > .container').filter(function () { + return $(this).data('icingaModule') === module; + }); + if ($newContainer.length) { + _this.backendSupportRelated[$newContainer.attr('id')] = module; + } + + delete _this.backendSupportRelated[id]; + } + }); + + _this.cleanupPopup(); + } + + onClose(event) { + var _this = event.data.self; + _this.tempStorage.set('closed', true); + _this.hidePopup(); + } + + onDecision(event) { + var _this = event.data.self; + var $button = $(event.target).closest('button'); + var $suggestion = $button.parent(); + var $container = $('#' + $suggestion.data('containerId')); + var containerUrl = decodeURIComponent($container.data('icingaUrl')); + + if ($button.attr('value') === '1') { + // Yes + var newHref = _this.knownMigrations[containerUrl]; + _this.icinga.loader.loadUrl(newHref, $container); + + _this.previousMigrations[$suggestion.data('containerId')] = containerUrl; + + if ($container.parent().is('.dashboard')) { + $container.find('h1 a').attr('href', _this.icinga.utils.removeUrlParams(newHref, ['showCompact'])); + } + } else { + // No + _this.knownMigrations[containerUrl] = false; + } + + if (_this.Popup().find('li').length === 1 && ! _this.Popup().find('#setAsBackendForm').length) { + _this.hidePopup(function () { + // Let the transition finish first, looks cleaner + $suggestion.remove(); + }); + } else { + $suggestion.remove(); + } + } + + onHandleClicked(event) { + var _this = event.data.self; + if (_this.togglePopup()) { + _this.storage.set('minimized', true); + } else { + _this.storage.remove('minimized'); + } + } + + onMinimized(isMinimized, oldValue) { + if (isMinimized && isMinimized !== oldValue && this.isShown()) { + this.minimizePopup(); + } + } + + migrateUrls(urls, type) { + var _this = this, + containerIds = [], + containerUrls = []; + + $.each(urls, function (containerId, containerUrl) { + if (typeof _this.knownMigrations[containerUrl] === 'undefined') { + containerUrls.push(containerUrl); + containerIds.push(containerId); + } + }); + + let endpoint, changeCallback; + if (type === 'monitoring') { + endpoint = 'monitoring-url'; + changeCallback = this.changeUrlMigrationReadyState.bind(this); + } else { + endpoint = 'search-url'; + changeCallback = this.changeSearchMigrationReadyState.bind(this); + } + + if (containerUrls.length) { + var req = $.ajax({ + context: this, + type: 'post', + url: this.icinga.config.baseUrl + '/icingadb/migrate/' + endpoint, + headers: {'Accept': 'application/json'}, + contentType: 'application/json', + data: JSON.stringify(containerUrls) + }); + + req.urls = urls; + req.suggestionType = type; + req.urlIndexToContainerId = containerIds; + req.done(this.processUrlMigrationResults); + req.always(() => changeCallback(true)); + } else { + // All urls have already been migrated once, show popup immediately + this.addSuggestions(urls, type); + changeCallback(true); + } + } + + processUrlMigrationResults(data, textStatus, req) { + var _this = this; + var result, containerId; + + if (data.status === 'success') { + result = data.data; + } else { // if (data.status === 'fail') + result = data.data.result; + + $.each(data.data.errors, function (k, error) { + _this.icinga.logger.error('[Migrate] Erroneous url "' + k + '": ' + error[0] + '\n' + error[1]); + }); + } + + $.each(result, function (i, migratedUrl) { + containerId = req.urlIndexToContainerId[i]; + _this.knownMigrations[req.urls[containerId]] = migratedUrl; + }); + + this.addSuggestions(req.urls, req.suggestionType); + } + + prepareBackendCheckboxForm(modules) { + let containerIds = []; + let moduleNames = []; + + $.each(modules, (id, module) => { + if (typeof this.knownBackendSupport[module] === 'undefined') { + containerIds.push(id); + moduleNames.push(module); + } + }); + + if (moduleNames.length) { + let req = $.ajax({ + context : this, + type : 'post', + url : this.icinga.config.baseUrl + '/icingadb/migrate/backend-support', + headers : { 'Accept': 'application/json' }, + contentType : 'application/json', + data : JSON.stringify(moduleNames) + }); + + req.modules = modules; + req.moduleIndexToContainerId = containerIds; + req.done(this.processBackendSupportResults); + req.always(() => this.changeBackendSupportReadyState(true)); + } else { + // All modules have already been checked once, show popup immediately + this.setupBackendCheckboxForm(modules); + this.changeBackendSupportReadyState(true); + } + } + + processBackendSupportResults(data, textStatus, req) { + let result = data.data; + + $.each(result, (i, state) => { + let containerId = req.moduleIndexToContainerId[i]; + this.knownBackendSupport[req.modules[containerId]] = state; + }); + + this.setupBackendCheckboxForm(req.modules); + } + + setupBackendCheckboxForm(modules) { + let supportedModules = {}; + + $.each(modules, (id, module) => { + if (this.knownBackendSupport[module]) { + supportedModules[id] = module; + } + }); + + if (Object.keys(supportedModules).length) { + this.backendSupportRelated = { ...this.backendSupportRelated, ...supportedModules }; + + let req = $.ajax({ + context : this, + type : 'get', + url : this.icinga.config.baseUrl + '/icingadb/migrate/checkbox-state?showCompact' + }); + + req.done(this.setCheckboxState); + } + } + + setCheckboxState(html, textStatus, req) { + let $form = this.Popup().find('.suggestion-area > #setAsBackendForm'); + if (! $form.length) { + $form = $(html); + $form.attr('data-base-target', 'migrate-popup-backend-submit-blackhole'); + $form.append('<div id="migrate-popup-backend-submit-blackhole"></div>'); + + this.Popup().find('.monitoring-migration-suggestions').before($form); + } else { + let $newForm = $(html); + $form.find('[name=backend]').prop('checked', $newForm.find('[name=backend]').is(':checked')); + } + + this.showPopup(); + } + + addSuggestions(urls, type) { + var where; + if (type === 'monitoring') { + where = '.monitoring-migration-suggestions'; + } else { + where = '.search-migration-suggestions'; + } + + var _this = this, + hasSuggestions = false, + $ul = this.Popup().find('.suggestion-area > ul' + where); + $.each(urls, function (containerId, containerUrl) { + // No urls for which the user clicked "No" or an error occurred and only migrated urls please + if (_this.knownMigrations[containerUrl] !== false && _this.knownMigrations[containerUrl] !== containerUrl) { + var $container = $('#' + containerId); + + var $suggestion = $ul.find('li#suggest-' + containerId); + if ($suggestion.length) { + if ($suggestion.data('containerUrl') === containerUrl) { + // There's already a suggestion for this exact container and url + hasSuggestions = true; + return; + } + + $suggestion.data('containerUrl', containerUrl); + } else { + $suggestion = $(SUGGESTION_HTML); + $suggestion.attr('id', 'suggest-' + containerId); + $suggestion.data('containerId', containerId); + $suggestion.data('containerUrl', containerUrl); + $ul.append($suggestion); + } + + hasSuggestions = true; + + var title; + if ($container.data('icingaTitle')) { + title = $container.data('icingaTitle').split(' :: ').slice(0, -1).join(' :: '); + } else if ($container.parent().is('.dashboard')) { + title = $container.find('h1 a').text(); + } else { + title = $container.find('.tabs li.active a').text(); + } + + $suggestion.find('button:first-of-type').text(title); + } + }); + + if (hasSuggestions) { + this.showPopup(); + if (type === 'search') { + this.maximizePopup(); + } + } + } + + cleanupSuggestions() { + var _this = this, + toBeRemoved = []; + this.Popup().find('li').each(function () { + var $suggestion = $(this); + var $container = $('#' + $suggestion.data('containerId')); + var containerUrl = decodeURIComponent($container.data('icingaUrl')); + if ( + // Unknown url, yet + typeof _this.knownMigrations[containerUrl] === 'undefined' + // User doesn't want to migrate + || _this.knownMigrations[containerUrl] === false + // Already migrated or no migration necessary + || containerUrl === _this.knownMigrations[containerUrl] + // The container URL changed + || containerUrl !== $suggestion.data('containerUrl') + ) { + toBeRemoved.push($suggestion); + } + }); + + return toBeRemoved; + } + + cleanupBackendForm() { + let $form = this.Popup().find('#setAsBackendForm'); + if (! $form.length) { + return false; + } + + let stillRelated = {}; + $.each(this.backendSupportRelated, (id, module) => { + let $container = $('#' + id); + if ($container.length && $container.data('icingaModule') === module) { + stillRelated[id] = module; + } + }); + + this.backendSupportRelated = stillRelated; + + if (Object.keys(stillRelated).length) { + return true; + } + + return $form; + } + + cleanupPopup() { + let toBeRemoved = this.cleanupSuggestions(); + let hasBackendForm = this.cleanupBackendForm(); + + if (hasBackendForm !== true && this.Popup().find('li').length === toBeRemoved.length) { + this.hidePopup(() => { + // Let the transition finish first, looks cleaner + $.each(toBeRemoved, function (_, $suggestion) { + $suggestion.remove(); + }); + + if (typeof hasBackendForm === 'object') { + hasBackendForm.remove(); + } + }); + } else { + $.each(toBeRemoved, function (_, $suggestion) { + $suggestion.remove(); + }); + + if (typeof hasBackendForm === 'object') { + hasBackendForm.remove(); + } + + // Let showPopup() handle the automatic minimization in case all search suggestions have been removed + this.showPopup(); + } + } + + showPopup() { + var $popup = this.Popup(); + if (this.storage.get('minimized') && ! this.forceFullyMaximized()) { + if (this.isShown()) { + this.minimizePopup(); + } else { + $popup.addClass('active minimized hidden'); + } + } else { + $popup.addClass('active'); + } + } + + hidePopup(after) { + this.Popup().removeClass('active minimized hidden'); + + if (typeof after === 'function') { + setTimeout(after, ANIMATION_LENGTH); + } + } + + isShown() { + return this.Popup().is('.active'); + } + + minimizePopup() { + var $popup = this.Popup(); + $popup.addClass('minimized'); + setTimeout(function () { + $popup.addClass('hidden'); + }, ANIMATION_LENGTH); + } + + maximizePopup() { + this.Popup().removeClass('minimized hidden'); + } + + forceFullyMaximized() { + return this.Popup().find('.search-migration-suggestions:not(:empty)').length > 0; + } + + togglePopup() { + if (this.Popup().is('.minimized')) { + this.maximizePopup(); + return false; + } else { + this.minimizePopup(); + return true; + } + } + + setUrlMigrationReadyState(state) { + this.urlMigrationReadyState = state; + } + + changeUrlMigrationReadyState(state) { + this.setUrlMigrationReadyState(state); + + if (this.backendSupportReadyState !== false && this.searchMigrationReadyState !== false) { + this.searchMigrationReadyState = null; + this.backendSupportReadyState = null; + this.urlMigrationReadyState = null; + this.cleanupPopup(); + } + } + + setSearchMigrationReadyState(state) { + this.searchMigrationReadyState = state; + } + + changeSearchMigrationReadyState(state) { + this.setSearchMigrationReadyState(state); + + if (this.backendSupportReadyState !== false && this.urlMigrationReadyState !== false) { + this.searchMigrationReadyState = null; + this.backendSupportReadyState = null; + this.urlMigrationReadyState = null; + this.cleanupPopup(); + } + } + + setBackendSupportReadyState(state) { + this.backendSupportReadyState = state; + } + + changeBackendSupportReadyState(state) { + this.setBackendSupportReadyState(state); + + if (this.urlMigrationReadyState !== false && this.searchMigrationReadyState !== false) { + this.searchMigrationReadyState = null; + this.backendSupportReadyState = null; + this.urlMigrationReadyState = null; + this.cleanupPopup(); + } + } + + Popup() { + // Node.contains() is used due to `?renderLayout` + if (this.$popup === null || ! document.body.contains(this.$popup[0])) { + $('#layout').append($(POPUP_HTML)); + this.$popup = $('#migrate-popup'); + } + + return this.$popup; + } + } + + Icinga.Behaviors.Migrate = Migrate; + +})(Icinga, jQuery); diff --git a/public/js/progress-bar.js b/public/js/progress-bar.js new file mode 100644 index 0000000..be24f1c --- /dev/null +++ b/public/js/progress-bar.js @@ -0,0 +1,110 @@ +(function (Icinga) { + + "use strict"; + + class ProgressBar extends Icinga.EventListener { + constructor(icinga) + { + super(icinga); + + /** + * Frame update threshold. If it reaches zero, the view is updated + * + * Currently, only every third frame is updated. + * + * @type {number} + */ + this.frameUpdateThreshold = 3; + + /** + * Threshold at which animations get smoothed out (in milliseconds) + * + * @type {number} + */ + this.smoothUpdateThreshold = 250; + + this.on('rendered', '#main > .container', this.onRendered, this); + } + + onRendered(event) + { + const _this = event.data.self; + const container = event.target; + + container.querySelectorAll('[data-animate-progress]').forEach(progress => { + const frequency = ( + (Number(progress.dataset.endTime) - Number(progress.dataset.startTime) + ) * 1000) / progress.parentElement.offsetWidth; + + _this.updateProgress( + now => _this.animateProgress(progress, now), frequency); + }); + } + + animateProgress(progress, now) + { + if (! progress.isConnected) { + return false; // Exit early if the node is removed from the DOM + } + + const durationScale = 100; + + const startTime = Number(progress.dataset.startTime); + const endTime = Number(progress.dataset.endTime); + const duration = endTime - startTime; + const end = new Date(endTime * 1000.0); + + let leftNow = durationScale * (1 - (end - now) / (duration * 1000.0)); + if (leftNow > durationScale) { + leftNow = durationScale; + } else if (leftNow < 0) { + leftNow = 0; + } + + const switchAfter = Number(progress.dataset.switchAfter); + if (! isNaN(switchAfter)) { + const switchClass = progress.dataset.switchClass; + const switchAt = new Date((startTime * 1000.0) + (switchAfter * 1000.0)); + if (now < switchAt) { + progress.classList.add(switchClass); + } else if (progress.classList.contains(switchClass)) { + progress.classList.remove(switchClass); + } + } + + const bar = progress.querySelector(':scope > .bar'); + bar.style.width = leftNow + '%'; + + return leftNow !== durationScale; + } + + updateProgress(callback, frequency, now = null) + { + if (now === null) { + now = new Date(); + } + + if (! callback(now)) { + return; + } + + if (frequency < this.smoothUpdateThreshold) { + let counter = this.frameUpdateThreshold; + const onNextFrame = timeSinceOrigin => { + if (--counter === 0) { + this.updateProgress(callback, frequency, new Date(performance.timeOrigin + timeSinceOrigin)); + } else { + requestAnimationFrame(onNextFrame); + } + }; + requestAnimationFrame(onNextFrame); + } else { + setTimeout(() => this.updateProgress(callback, frequency), frequency); + } + } + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + Icinga.Behaviors.ProgressBar = ProgressBar; +})(Icinga); |