Implement auto-reload on new status page

Like the old status page, this will reload the status data every 5
seconds when auto-reload is enabled via the preferences (enabled by
default).

I picked up the idea to use a custom useInterval() react hook from this
article [1]. Using this approach avoids a lot of callback chaining and
takes care about clearing the interval on every re-render.

By using the Page Visibility API in a custom useDocumentVisibility()
hook, we can ensure that the data is only fetched when the page is in
focus. Thus, when the user switches to another tab in the browser or
another window/application, data fetching is skipped.

[1]: https://overreacted.io/making-setinterval-declarative-with-react-hooks/

Change-Id: I39fa6a0a5189665f184aeafcf07ce81365a41902
This commit is contained in:
Felix Edel
2024-06-12 12:48:54 +02:00
parent a096332814
commit 54e8027f06
5 changed files with 194 additions and 23 deletions

56
web/src/Hooks.jsx Normal file
View File

@@ -0,0 +1,56 @@
// 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 { useEffect, useRef, useState } from 'react'
function useDocumentVisibility() {
const [isDocumentVisible, setIsDocumentVisible] = useState(!document.hidden)
const handleVisibilityChange = () => {
setIsDocumentVisible(!document.hidden)
}
useEffect(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return isDocumentVisible
}
function useInterval(callback, delay) {
const savedCallback = useRef()
useEffect(() => {
savedCallback.current = callback
})
useEffect(() => {
function tick() {
savedCallback.current()
}
if (delay !== null) {
let id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [delay])
}
export { useDocumentVisibility, useInterval }

View File

@@ -23,7 +23,29 @@ import {
Spinner,
} from '@patternfly/react-core'
import { SyncIcon } from '@patternfly/react-icons'
import { SyncAltIcon, SyncIcon } from '@patternfly/react-icons'
function ReloadButton({ isReloading, reloadCallback }) {
return (
<span
className="zuul-reload-button"
onClick={() => reloadCallback()}
>
<Button
variant="plain"
isInline
icon={<SyncAltIcon />}
isLoading={isReloading}
/>
{isReloading ? 'Reloading' : 'Reload'}
</span>
)
}
ReloadButton.propTypes = {
isReloading: PropTypes.bool.isRequired,
reloadCallback: PropTypes.func.isRequired,
}
function Fetchable(props) {
const { isFetching, fetchCallback } = props
@@ -71,4 +93,4 @@ function Fetching() {
)
}
export { Fetchable, Fetching }
export { Fetchable, Fetching, ReloadButton }

View File

@@ -737,6 +737,28 @@ details.foldable[open] summary::before {
background: var(--pf-global--palette--red-50);
}
/*
* Reload Button
*/
.zuul-reload-button {
cursor: pointer;
display: inline-block;
width: 120px;
height: 37px;
}
.zuul-reload-button-floating {
float: right;
}
.zuul-reload-button button {
padding-right: 4px;
}
.zuul-reload-spinner.pf-c-spinner {
--pf-c-spinner--Color: #FFFFFF;
}
/*
* Dark Mode overrides
*/

View File

@@ -12,7 +12,7 @@
// License for the specific language governing permissions and limitations
// under the License.
import React, { useEffect } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
@@ -35,7 +35,8 @@ import ChangeQueue from '../containers/status/ChangeQueue'
import { PipelineIcon } from '../containers/status/Misc'
import { fetchStatusIfNeeded } from '../actions/status'
import { EmptyPage } from '../containers/Errors'
import { Fetching } from '../containers/Fetching'
import { Fetching, ReloadButton } from '../containers/Fetching'
import { useDocumentVisibility, useInterval } from '../Hooks'
function PipelineStats({ pipeline }) {
return (
@@ -52,12 +53,18 @@ PipelineStats.propTypes = {
pipeline: PropTypes.object.isRequired,
}
function PipelineDetails({ pipeline }) {
function PipelineDetails({ pipeline, isReloading, reloadCallback }) {
const pipelineType = pipeline.manager || 'unknown'
return (
<>
<span className="zuul-reload-button-floating">
<ReloadButton
isReloading={isReloading}
reloadCallback={reloadCallback}
/>
</span>
<Title headingLevel="h1">
<PipelineIcon pipelineType={pipelineType} />
{pipeline.name}
@@ -84,18 +91,43 @@ function PipelineDetails({ pipeline }) {
PipelineDetails.propTypes = {
pipeline: PropTypes.object.isRequired,
isReloading: PropTypes.bool.isRequired,
reloadCallback: PropTypes.func.isRequired,
}
function PipelineDetailsPage({ pipeline, isFetching, tenant, darkMode, fetchStatusIfNeeded }) {
function PipelineDetailsPage({
pipeline, isFetching, tenant, darkMode, autoReload, fetchStatusIfNeeded
}) {
const [isReloading, setIsReloading] = useState(false)
const isDocumentVisible = useDocumentVisibility()
const updateData = useCallback((tenant) => {
if (tenant.name) {
setIsReloading(true)
fetchStatusIfNeeded(tenant)
.then(() => {
setIsReloading(false)
})
}
}, [setIsReloading, fetchStatusIfNeeded])
useEffect(() => {
document.title = 'Zuul Pipeline Details'
if (tenant.name) {
fetchStatusIfNeeded(tenant)
}
}, [tenant, fetchStatusIfNeeded])
// Initial data fetch
updateData(tenant)
}, [updateData, tenant])
if (pipeline === undefined || isFetching) {
// Subsequent data fetches every 5 seconds if auto-reload is enabled
useInterval(() => {
if (isDocumentVisible && autoReload) {
updateData(tenant)
}
// Reset the interval on a manual refresh
}, isReloading ? null : 5000)
if (pipeline === undefined || (!isReloading && isFetching)) {
return <Fetching />
}
@@ -113,7 +145,7 @@ function PipelineDetailsPage({ pipeline, isFetching, tenant, darkMode, fetchStat
return (
<>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<PipelineDetails pipeline={pipeline} />
<PipelineDetails pipeline={pipeline} isReloading={isReloading} reloadCallback={() => updateData(tenant)} />
</PageSection>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Title headingLevel="h3">
@@ -136,7 +168,7 @@ function PipelineDetailsPage({ pipeline, isFetching, tenant, darkMode, fetchStat
queue => queue.heads.length > 0
).map((queue, idx) => (
<GalleryItem key={idx}>
<ChangeQueue queue={queue} pipeline={pipeline}/>
<ChangeQueue queue={queue} pipeline={pipeline} />
</GalleryItem>
))
}
@@ -152,6 +184,7 @@ PipelineDetailsPage.propTypes = {
isFetching: PropTypes.bool,
tenant: PropTypes.object,
darkMode: PropTypes.bool,
autoReload: PropTypes.bool.isRequired,
fetchStatusIfNeeded: PropTypes.func.isRequired,
}
@@ -171,6 +204,7 @@ function mapStateToProps(state, ownProps) {
isFetching: state.status.isFetching,
tenant: state.tenant,
darkMode: state.preferences.darkMode,
autoReload: state.preferences.autoReload,
}
}

View File

@@ -12,7 +12,8 @@
// License for the specific language governing permissions and limitations
// under the License.
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import PropTypes from 'prop-types'
@@ -35,10 +36,11 @@ import {
import PipelineSummary from '../containers/status/PipelineSummary'
import { fetchStatusIfNeeded } from '../actions/status'
import { Fetching } from '../containers/Fetching'
import { Fetching, ReloadButton } from '../containers/Fetching'
import { useDocumentVisibility, useInterval } from '../Hooks'
function TenantStats({ stats, timezone }) {
function TenantStats({ stats, timezone, isReloading, reloadCallback }) {
return (
<Level>
<LevelItem>
@@ -62,6 +64,10 @@ function TenantStats({ stats, timezone }) {
{moment_tz.utc(stats.last_reconfigured).tz(timezone).fromNow()}
</span>
</Tooltip>
<ReloadButton
isReloading={isReloading}
reloadCallback={reloadCallback}
/>
</LevelItem>
</Level>
)
@@ -70,6 +76,8 @@ function TenantStats({ stats, timezone }) {
TenantStats.propTypes = {
stats: PropTypes.object,
timezone: PropTypes.string,
isReloading: PropTypes.bool.isRequired,
reloadCallback: PropTypes.func.isRequired,
}
function PipelineGallery({ pipelines, tenant, showAllPipelines }) {
@@ -101,29 +109,56 @@ PipelineGallery.propTypes = {
}
function PipelineOverviewPage({
pipelines, stats, isFetching, tenant, darkMode, timezone, fetchStatusIfNeeded
pipelines, stats, isFetching, tenant, darkMode, autoReload, timezone, fetchStatusIfNeeded
}) {
const [showAllPipelines, setShowAllPipelines] = useState(false)
const [isReloading, setIsReloading] = useState(false)
const isDocumentVisible = useDocumentVisibility()
const onShowAllPipelinesToggle = (isChecked) => {
setShowAllPipelines(isChecked)
}
const updateData = useCallback((tenant) => {
if (tenant.name) {
setIsReloading(true)
fetchStatusIfNeeded(tenant)
.then(() => {
setIsReloading(false)
})
}
}, [setIsReloading, fetchStatusIfNeeded])
useEffect(() => {
document.title = 'Zuul Status'
if (tenant.name) {
fetchStatusIfNeeded(tenant)
}
}, [tenant, fetchStatusIfNeeded])
// Initial data fetch
updateData(tenant)
}, [updateData, tenant])
if (isFetching) {
// Subsequent data fetches every 5 seconds if auto-reload is enabled
useInterval(() => {
if (isDocumentVisible && autoReload) {
updateData(tenant)
}
// Reset the interval on a manual refresh
}, isReloading ? null : 5000)
// Only show the fetching component on the initial data fetch, but
// not on subsequent reloads, as this would overlay the page data.
if (!isReloading && isFetching) {
return <Fetching />
}
return (
<>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<TenantStats stats={stats} timezone={timezone} />
<TenantStats
stats={stats}
timezone={timezone}
isReloading={isReloading}
reloadCallback={() => updateData(tenant)}
/>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
@@ -158,6 +193,7 @@ PipelineOverviewPage.propTypes = {
tenant: PropTypes.object,
preferences: PropTypes.object,
darkMode: PropTypes.bool,
autoReload: PropTypes.bool.isRequired,
timezone: PropTypes.string,
fetchStatusIfNeeded: PropTypes.func,
}
@@ -199,6 +235,7 @@ function mapStateToProps(state) {
isFetching: state.status.isFetching,
tenant: state.tenant,
darkMode: state.preferences.darkMode,
autoReload: state.preferences.autoReload,
timezone: state.timezone,
}
}