Implement new status page

The current status page shows a lot of information about the different
pipelines, queues and even the individual queue items and their jobs.
This information can become quite overwhelming - especially for larger
tenants with a lot of pipelines and longer (gate) queues.

For operators, this makes it hard to get a clear picture of what's going
on and for the individual developers it's hard to find their individual
changes (or queues).

The new status page tries to provide a more "operator-oriented" view by
showing the most imporant information about a tenant in a very concised
manner. The idea is to focus only on the relevant information to get an
answer to the following questions:

  1. What is the current state of the tenant?
  2. Which items are failing?
  3. Which pipelines/queues are succeeding (or failing)?
  4. Which queues are piling up items?

To achieve this, the new status page focuses on higher-level information
like pipelines and queues, but doesn't show more detailed ones like the
individual queue items and their jobs. Also, the queues are much more
prominent in this new status view.

To compensate the missing information, the next change implements an
additional "pipeline details view".

The general idea behind this rework is to provide both a
"operator-oriented" view and a "developer-oriented" view without mixing
both into a single view (which is kind of what the current status view
tries to do). Instead of showing all information on a single page, the
new approach splits that information into different scopes:

  1. status / pipeline overview
  2. pipeline and queue details
  3. individual change

Based on those scopes, the pages that handle those focus only on the
relevant information, but don't show too many details about the other
scopes. The changes in this stack currently cover 1. and 2., whereby 3.
is already existing in form of the "single change panel/view".

The change doesn't touch the current StatusPage, but creates a new one
for easier review. The old status page will be cleaned up in a follow-up
commit.

Change-Id: I255e84ba562bb87da6bdbd44b51adbcc73136c47
This commit is contained in:
Felix Edel 2024-02-22 10:18:49 +01:00
parent 01e9472306
commit 719ba50787
6 changed files with 471 additions and 59 deletions

View File

@ -0,0 +1,150 @@
// Copyright 2020 BMW Group
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import PropTypes from 'prop-types'
import { Tooltip } from '@patternfly/react-core'
import {
BundleIcon,
CheckIcon,
CodeBranchIcon,
ExclamationIcon,
FlaskIcon,
OutlinedClockIcon,
SortAmountDownIcon,
StreamIcon,
TimesIcon,
} from '@patternfly/react-icons'
const QUEUE_ITEM_ICON_CONFIGS = {
SUCCESS: {
icon: CheckIcon,
color: 'var(--pf-global--success-color--100)',
variant: 'success',
},
FAILURE: {
icon: TimesIcon,
color: 'var(--pf-global--danger-color--100)',
variant: 'danger',
},
MERGE_CONFLICT: {
icon: ExclamationIcon,
color: 'var(--pf-global--warning-color--100)',
variant: 'warning',
},
QUEUED: {
icon: OutlinedClockIcon,
color: 'var(--pf-global--info-color--100)',
variant: 'info',
},
WAITING: {
icon: OutlinedClockIcon,
color: 'var(--pf-global--disabled-color--100)',
variant: 'pending',
},
}
/*
Note: the documentation links are unused at the moment, but kept for
convenience. We might figure a way to use these at some point.
*/
const PIPELINE_ICON_CONFIGS = {
dependent: {
icon: CodeBranchIcon,
help_title: 'Dependent Pipeline',
help: 'A dependent pipeline ensures that every change is tested exactly in the order it is going to be merged into the repository.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.dependent',
},
independent: {
icon: FlaskIcon,
help_title: 'Independent Pipeline',
help: 'An independent pipeline treats every change as independent of other changes in it.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.independent',
},
serial: {
icon: SortAmountDownIcon,
help_title: 'Serial Pipeline',
help: 'A serial pipeline supports shared queues, but only one item in each shared queue is processed at a time.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.serial',
},
supercedent: {
icon: BundleIcon,
help_title: 'Supercedent Pipeline',
help: 'A supercedent pipeline groups items by project and ref, and processes only one item per grouping at a time. Only two items (currently processing and latest) can be queued per grouping.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.supercedent',
},
unknown: {
icon: StreamIcon,
help_title: '?',
help: 'Unknown pipeline type',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html'
},
}
const DEFAULT_PIPELINE_ICON_CONFIG = PIPELINE_ICON_CONFIGS['unknown']
const getQueueItemIconConfig = (item) => {
if (item.failing_reasons && item.failing_reasons.length > 0) {
let reasons = item.failing_reasons.join(', ')
if (reasons.match(/merge conflict/)) {
return QUEUE_ITEM_ICON_CONFIGS['MERGE_CONFLICT']
}
return QUEUE_ITEM_ICON_CONFIGS['FAILURE']
}
if (item.active !== true) {
return QUEUE_ITEM_ICON_CONFIGS['QUEUED']
}
if (item.live !== true) {
return QUEUE_ITEM_ICON_CONFIGS['WAITING']
}
return QUEUE_ITEM_ICON_CONFIGS['SUCCESS']
}
function PipelineIcon({ pipelineType, size = 'sm' }) {
const iconConfig = PIPELINE_ICON_CONFIGS[pipelineType] || DEFAULT_PIPELINE_ICON_CONFIG
const Icon = iconConfig.icon
// Define the verticalAlign based on the size
let verticalAlign = '-0.2em'
if (size === 'md') {
verticalAlign = '-0.35em'
}
return (
<Tooltip
position="bottom"
content={<div><strong>{iconConfig.help_title}</strong><p>{iconConfig.help}</p></div>}
>
<Icon
size={size}
style={{
marginRight: 'var(--pf-global--spacer--sm)',
verticalAlign: verticalAlign,
}}
/>
</Tooltip>
)
}
PipelineIcon.propTypes = {
pipelineType: PropTypes.string,
size: PropTypes.string,
}
export { getQueueItemIconConfig, PipelineIcon }

View File

@ -18,53 +18,7 @@ import { Badge } from 'patternfly-react'
import { Title, Tooltip } from '@patternfly/react-core'
import ChangeQueue from './ChangeQueue'
import {
CodeBranchIcon,
FlaskIcon,
SortAmountDownIcon,
BundleIcon,
StreamIcon,
} from '@patternfly/react-icons'
/*
Note: the documentation links are unused at the moment, but kept for convenience. We might figure a way to
use these at some point.
*/
const PIPELINE_ICONS = {
dependent: {
icon: CodeBranchIcon,
help_title: 'Dependent Pipeline',
help: 'A dependent pipeline ensures that every change is tested exactly in the order it is going to be merged into the repository.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.dependent',
},
independent: {
icon: FlaskIcon,
help_title: 'Independent Pipeline',
help: 'An independent pipeline treats every change as independent of other changes in it.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.independent',
},
serial: {
icon: SortAmountDownIcon,
help_title: 'Serial Pipeline',
help: 'A serial pipeline supports shared queues, but only one item in each shared queue is processed at a time.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.serial',
},
supercedent: {
icon: BundleIcon,
help_title: 'Supercedent Pipeline',
help: 'A supercedent pipeline groups items by project and ref, and processes only one item per grouping at a time. Only two items (currently processing and latest) can be queued per grouping.',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html#value-pipeline.manager.supercedent',
},
unknown: {
icon: StreamIcon,
help_title: '?',
help: 'Unknown pipeline type',
doc_url: 'https://zuul-ci.org/docs/zuul/reference/pipeline_def.html'
},
}
const DEFAULT_PIPELINE_ICON = PIPELINE_ICONS['unknown']
import { PipelineIcon } from './Misc'
function getRefs(item) {
// Backwards compat
@ -170,18 +124,9 @@ class Pipeline extends React.Component {
const { pipeline } = this.props
let pipeline_type = pipeline.manager || 'unknown'
const pl_config = PIPELINE_ICONS[pipeline_type] || DEFAULT_PIPELINE_ICON
const Icon = pl_config.icon
return (
<>
<Tooltip
position="bottom"
content={<div><strong>{pl_config.help_title}</strong><p>{pl_config.help}</p></div>}
>
<Icon />
</Tooltip>
&nbsp;
{pipeline.name}
<PipelineIcon pipelineType={pipeline_type} /> {pipeline.name}
</>
)
}

View File

@ -0,0 +1,151 @@
// Copyright 2024 BMW Group
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import PropTypes from 'prop-types'
import {
Badge,
Button,
Card,
CardTitle,
CardBody,
Flex,
FlexItem,
Tooltip,
} from '@patternfly/react-core'
import { SquareIcon } from '@patternfly/react-icons'
import { PipelineIcon, getQueueItemIconConfig } from './Misc'
function QueueItemSquare({ item }) {
const iconConfig = getQueueItemIconConfig(item)
return (
<Button
variant="plain"
className={`zuul-item-square zuul-item-square-${iconConfig.variant}`}
>
<SquareIcon />
</Button>
)
}
QueueItemSquare.propTypes = {
item: PropTypes.object,
}
function QueueSummary({ pipeline, pipelineType }) {
// Dependent pipelines usually come with named queues, so we will
// visualize each queue individually. For other pipeline types, we
// will consolidate all heads as a single queue to simplify the
// visualization (e.g. independent pipelines like check where each
// change/item is enqueued in it's own queue by design).
if (['dependent'].indexOf(pipelineType) > -1) {
return (
pipeline.change_queues.map((queue) => (
<Flex key={`${queue.name}${queue.branch}`}>
<FlexItem>
<Card isPlain className="zuul-compact-card">
<CardTitle>
{queue.name} ({queue.branch})
</CardTitle>
<CardBody style={{ paddingBottom: '0' }}>
{queue.heads.map((head) => (
head.map((item) => <QueueItemSquare item={item} key={item.id} />)
))}
</CardBody>
</Card>
</FlexItem>
</Flex>
))
)
} else {
return (
<Flex
display={{ default: 'inlineFlex' }}
spaceItems={{ default: 'spaceItemsNone' }}
>
{pipeline.change_queues.map((queue) => (
queue.heads.map((head) => (
head.map((item) => (
<FlexItem key={item.id}>
<QueueItemSquare item={item} />
</FlexItem>
))
))
))}
</Flex>
)
}
}
QueueSummary.propTypes = {
pipeline: PropTypes.object,
pipelineType: PropTypes.string,
}
function PipelineSummary({ pipeline }) {
const countItems = (pipeline) => {
let count = 0
pipeline.change_queues.map(queue => (
queue.heads.map(head => (
head.map(() => (
count++
))
))
))
return count
}
const pipelineType = pipeline.manager || 'unknown'
const itemCount = countItems(pipeline)
return (
<Card className="zuul-pipeline-summary zuul-compact-card">
<CardTitle
style={pipelineType !== 'dependent' ? { paddingBottom: '8px' } : {}}
>
<PipelineIcon pipelineType={pipelineType} />
{pipeline.name}
<Tooltip
content={
itemCount === 1
? <div>{itemCount} item enqueued</div>
: <div>{itemCount} items enqueued</div>
}
>
<Badge
isRead
style={{ marginLeft: 'var(--pf-global--spacer--sm)', verticalAlign: '0.1em' }}
>
{itemCount}
</Badge>
</Tooltip>
</CardTitle>
<CardBody>
<QueueSummary pipeline={pipeline} pipelineType={pipelineType} />
</CardBody>
</Card>
)
}
PipelineSummary.propTypes = {
pipeline: PropTypes.object,
}
export default PipelineSummary

View File

@ -182,6 +182,76 @@ a.refresh {
}
/* Status page */
/* Use the same hover effect like for selectable cards, but without
actually selecting them */
.zuul-pipeline-summary:hover {
box-shadow: var(--pf-c-card--m-selectable--active--BoxShadow);
cursor: default;
}
/* Override PF4 padding values on compact cards to make them even more
compact. */
.pf-c-card.zuul-compact-card .pf-c-card__header {
padding: 8px 16px 0 16px;
}
.pf-c-card.zuul-compact-card .pf-c-card__title {
padding: 16px 16px 0 16px;
}
.pf-c-card.zuul-compact-card .pf-c-card__body {
padding: 0 16px 16px 16px;
}
/* TODO (felix): Could we put every title within a compact header and
remove this css rule? */
.pf-c-card.zuul-compact-card .pf-c-card__header .pf-c-card__title {
padding: 0;
padding-bottom: 8px;
}
.zuul-item-square {
opacity: 0.7;
padding: 0 !important;
}
.zuul-item-square-success {
color: var(--pf-global--success-color--100) !important;
}
.zuul-item-square-success svg:hover {
opacity: 1;
color: var(--pf-global--palette--green-600) !important;
}
.zuul-item-square-danger {
color: var(--pf-global--danger-color--100) !important;
}
.zuul-item-square-danger svg:hover {
opacity: 1;
color: var(--pf-global--danger-color--200) !important;
}
.zuul-item-square-info {
color: var(--pf-global--info-color--100) !important;
}
.zuul-item-square-info svg:hover {
opacity: 1;
color: var(--pf-global--palette--blue-500) !important;
}
.zuul-item-square-warning {
color: var(--pf-global--warning-color--100) !important;
}
.zuul-item-square-warning svg:hover {
opacity: 1;
color: var(--pf-global--palette--orange-400) !important;
}
.zuul-item-square-pending {
color: var(--pf-global--palette--black-500) !important;
}
.zuul-item-square-pending svg:hover {
opacity: 1;
color: var(--pf-global--palette--black-700) !important;
}
.zuul-pipeline-header h3 {
font-weight: var(--pf-global--FontWeight--bold);
}

View File

@ -0,0 +1,96 @@
// Copyright 2024 BMW Group
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import PropTypes from 'prop-types'
import {
Gallery,
GalleryItem,
PageSection,
PageSectionVariants,
} from '@patternfly/react-core'
import PipelineSummary from '../containers/status/PipelineSummary'
import { fetchStatusIfNeeded } from '../actions/status'
function PipelineOverviewPage({ pipelines, tenant, darkMode, fetchStatusIfNeeded }) {
useEffect(() => {
document.title = 'Zuul Status'
if (tenant.name) {
fetchStatusIfNeeded(tenant)
}
}, [tenant, fetchStatusIfNeeded])
return (
<>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Gallery
hasGutter
minWidths={{
default: '450px',
}}
>
{pipelines.map(pipeline => (
<GalleryItem key={pipeline.name}>
<PipelineSummary pipeline={pipeline} />
</GalleryItem>
))}
</Gallery>
</PageSection>
</>
)
}
PipelineOverviewPage.propTypes = {
pipelines: PropTypes.array,
tenant: PropTypes.object,
preferences: PropTypes.object,
darkMode: PropTypes.bool,
fetchStatusIfNeeded: PropTypes.func,
}
function mapStateToProps(state) {
let pipelines = []
if (state.status.status) {
// TODO (felix): Here we could filter out the pipeline data from
// the status.json and if necessary "reformat" it to only contain
// the relevant information for this component (e.g. drop all
// job related information which isn't shown).
pipelines = state.status.status.pipelines
}
// TODO (felix): Here we could also order the pipelines by any
// criteria (e.g. the pipeline_type) in case we want that. Currently
// they are ordered in the way they are defined in the zuul config.
// The sorting could also be done via the filter toolbar.
return {
pipelines,
tenant: state.tenant,
darkMode: state.preferences.darkMode,
}
}
const mapDispatchToProps = { fetchStatusIfNeeded }
export default connect(
mapStateToProps,
mapDispatchToProps,
)(withRouter(PipelineOverviewPage))

View File

@ -14,7 +14,6 @@
import ComponentsPage from './pages/Components'
import FreezeJobPage from './pages/FreezeJob'
import StatusPage from './pages/Status'
import ChangeStatusPage from './pages/ChangeStatus'
import ProjectPage from './pages/Project'
import ProjectsPage from './pages/Projects'
@ -34,6 +33,7 @@ import ConfigErrorsPage from './pages/ConfigErrors'
import TenantsPage from './pages/Tenants'
import StreamPage from './pages/Stream'
import OpenApiPage from './pages/OpenApi'
import PipelineOverviewPage from './pages/PipelineOverview'
// The Route object are created in the App component.
// Object with a title are created in the menu.
@ -43,7 +43,7 @@ const routes = () => [
{
title: 'Status',
to: '/status',
component: StatusPage
component: PipelineOverviewPage,
},
{
title: 'Projects',