web: add dark mode and theme selection

This adds a theme selection in the preferences in the
config modal and adds a new dark theme.

Removes the line.png image and instead uses CSS
linear-gradient that is available in all browsers
since around 2018, also fixes the 15 pixels spacing
issue that is there today.

You can select between three different themes.

Auto will use your system preference to choose either the
light or dark theme, changes dynamically based on your
system preference.

Light is the current theme.

Dark is the theme added by this patch series.

The UX this changes is that if somebody has their system
preferences set to dark, for example in Mac OS X that is
in System Settings -> Appearance -> Dark the user will
get the Zuul web UI in dark by default and same for the
opposite.

This uses a poor man's dark mode for swagger-ui
as per the comment in [1].

[1] https://github.com/swagger-api/swagger-ui/issues/5327#issuecomment-742375520

Change-Id: I01cf32f3decdb885307a76eb79d644667bbbf9a3
This commit is contained in:
Tobias Urdin 2023-03-15 23:36:45 +00:00
parent de9dfa2bc4
commit 59cd5de78b
33 changed files with 443 additions and 113 deletions

View File

@ -0,0 +1,9 @@
---
features:
- |
Added a new dark theme for the Zuul web interface.
- |
Added theme selection for the Zuul web interface. The default theme is set
to Auto which means your system/browsers preference determines if the Light
or Dark theme should be used. Either can be explicitly set in the settings
for the web interface by clicking the cogs in the top right.

View File

@ -120,4 +120,30 @@ IconProperty.propTypes = {
const ConditionalWrapper = ({ condition, wrapper, children }) =>
condition ? wrapper(children) : children
export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper }
function resolveDarkMode(theme) {
let darkMode = false
if (theme === 'Auto') {
let matchMedia = window.matchMedia || function () {
return {
matches: false,
}
}
darkMode = matchMedia('(prefers-color-scheme: dark)').matches
} else if (theme === 'Dark') {
darkMode = true
}
return darkMode
}
function setDarkMode(darkMode) {
if (darkMode) {
document.documentElement.classList.add('pf-theme-dark')
} else {
document.documentElement.classList.remove('pf-theme-dark')
}
}
export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper, resolveDarkMode, setDarkMode }

View File

@ -62,7 +62,6 @@ class HeldBuildList extends React.Component {
to={`${tenant.linkPrefix}/build/${node.build}`}
style={{
textDecoration: 'none',
color: 'var(--pf-global--disabled-color--100)',
}}
>
<DataListItemRow>

View File

@ -18,15 +18,16 @@ import {
TreeView,
} from 'patternfly-react'
import ReactJson from 'react-json-view'
import { connect } from 'react-redux'
class Artifact extends React.Component {
static propTypes = {
artifact: PropTypes.object.isRequired
artifact: PropTypes.object.isRequired,
preferences: PropTypes.object,
}
render() {
const { artifact } = this.props
const { artifact, preferences } = this.props
return (
<table className="table table-striped table-bordered" style={{width:'50%'}}>
<tbody>
@ -41,7 +42,8 @@ class Artifact extends React.Component {
collapsed={true}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
displayDataTypes={false}
theme={preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
:artifact.metadata[key].toString()}
</td>
</tr>
@ -54,17 +56,18 @@ class Artifact extends React.Component {
class ArtifactList extends React.Component {
static propTypes = {
artifacts: PropTypes.array.isRequired
artifacts: PropTypes.array.isRequired,
preferences: PropTypes.object,
}
render() {
const { artifacts } = this.props
const { artifacts, preferences } = this.props
const nodes = artifacts.map((artifact, index) => {
const node = {text: <a href={artifact.url}>{artifact.name}</a>,
icon: null}
if (artifact.metadata) {
node['nodes']= [{text: <Artifact key={index} artifact={artifact}/>,
node['nodes']= [{text: <Artifact key={index} artifact={artifact} preferences={preferences}/>,
icon: ''}]
}
return node
@ -83,4 +86,10 @@ class ArtifactList extends React.Component {
}
}
export default ArtifactList
function mapStateToProps(state) {
return {
preferences: state.preferences,
}
}
export default connect(mapStateToProps)(ArtifactList)

View File

@ -13,6 +13,7 @@
// under the License.
import * as React from 'react'
import { connect } from 'react-redux'
import { Fragment } from 'react'
import ReAnsi from '@softwarefactory-project/re-ansi'
import PropTypes from 'prop-types'
@ -73,6 +74,7 @@ class BuildOutputLabel extends React.Component {
class BuildOutput extends React.Component {
static propTypes = {
output: PropTypes.object,
preferences: PropTypes.object,
}
renderHosts (hosts) {
@ -109,8 +111,12 @@ class BuildOutput extends React.Component {
renderFailedTask (host, task) {
const max_lines = 42
let zuulOutputClass = 'zuul-build-output'
if (this.props.preferences.darkMode) {
zuulOutputClass = 'zuul-build-output-dark'
}
return (
<Card key={host + task.zuul_log_id} className="zuul-task-summary-failed">
<Card key={host + task.zuul_log_id} className="zuul-task-summary-failed" style={this.props.preferences.darkMode ? {background: 'var(--pf-global--BackgroundColor--300)'} : {}}>
<CardHeader>
<TimesIcon style={{ color: 'var(--pf-global--danger-color--100)' }}/>
&nbsp;Task&nbsp;<strong>{task.name}</strong>&nbsp;
@ -119,25 +125,25 @@ class BuildOutput extends React.Component {
<CardBody>
{task.invocation && task.invocation.module_args &&
task.invocation.module_args._raw_params && (
<pre key="cmd" title="cmd" className={`${'cmd'}`}>
<pre key="cmd" title="cmd" className={'cmd ' + zuulOutputClass}>
{task.invocation.module_args._raw_params}
</pre>
)}
{task.msg && (
<pre key="msg" title="msg">{task.msg}</pre>
<pre key="msg" title="msg" className={zuulOutputClass}>{task.msg}</pre>
)}
{task.exception && (
<pre key="exc" style={{ color: 'red' }} title="exc">{task.exception}</pre>
<pre key="exc" style={{ color: 'red' }} title="exc" className={zuulOutputClass}>{task.exception}</pre>
)}
{task.stdout_lines && task.stdout_lines.length > 0 && (
<Fragment>
{task.stdout_lines.length > max_lines && (
<details className={`${'foldable'} ${'stdout'}`}><summary></summary>
<pre key="stdout" title="stdout">
<pre key="stdout" title="stdout" className={zuulOutputClass}>
<ReAnsi log={task.stdout_lines.slice(0, -max_lines).join('\n')} />
</pre>
</details>)}
<pre key="stdout" title="stdout">
<pre key="stdout" title="stdout" className={zuulOutputClass}>
<ReAnsi log={task.stdout_lines.slice(-max_lines).join('\n')} />
</pre>
</Fragment>
@ -146,12 +152,12 @@ class BuildOutput extends React.Component {
<Fragment>
{task.stderr_lines.length > max_lines && (
<details className={`${'foldable'} ${'stderr'}`}><summary></summary>
<pre key="stderr" title="stderr">
<pre key="stderr" title="stderr" className={zuulOutputClass}>
<ReAnsi log={task.stderr_lines.slice(0, -max_lines).join('\n')} />
</pre>
</details>
)}
<pre key="stderr" title="stderr">
<pre key="stderr" title="stderr" className={zuulOutputClass}>
<ReAnsi log={task.stderr_lines.slice(-max_lines).join('\n')} />
</pre>
</Fragment>
@ -177,4 +183,10 @@ class BuildOutput extends React.Component {
}
export default BuildOutput
function mapStateToProps(state) {
return {
preferences: state.preferences,
}
}
export default connect(mapStateToProps)(BuildOutput)

View File

@ -14,6 +14,8 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import configureStore from '../../store'
import BuildOutput from './BuildOutput'
const fakeOutput = (width, height) => ({
@ -31,7 +33,11 @@ it('BuildOutput renders big task', () => {
const div = document.createElement('div')
const output = fakeOutput(512, 1024)
const begin = performance.now()
ReactDOM.render(<BuildOutput output={output} />, div, () => {
const store = configureStore()
ReactDOM.render(
<Provider store={store}>
<BuildOutput output={output} />
</Provider>, div, () => {
const end = performance.now()
console.log('Render took ' + (end - begin) + ' milliseconds.')
})

View File

@ -47,7 +47,7 @@ import { addNotification, addApiError } from '../../actions/notifications'
import { ChartModal } from '../charts/ChartModal'
import BuildsetGanttChart from '../charts/GanttChart'
function Buildset({ buildset, timezone, tenant, user }) {
function Buildset({ buildset, timezone, tenant, user, preferences }) {
const buildset_link = buildExternalLink(buildset)
const [isGanttChartModalOpen, setIsGanttChartModalOpen] = useState(false)
@ -319,7 +319,9 @@ function Buildset({ buildset, timezone, tenant, user }) {
value={
<>
<strong>Message:</strong>
<pre>{buildset.message}</pre>
<div className={preferences.darkMode ? 'zuul-console-dark' : ''}>
<pre>{buildset.message}</pre>
</div>
</>
}
/>
@ -349,10 +351,12 @@ Buildset.propTypes = {
tenant: PropTypes.object,
timezone: PropTypes.string,
user: PropTypes.object,
preferences: PropTypes.object,
}
export default connect((state) => ({
tenant: state.tenant,
timezone: state.timezone,
user: state.user,
preferences: state.preferences,
}))(Buildset)

View File

@ -18,6 +18,7 @@ import * as React from 'react'
import ReAnsi from '@softwarefactory-project/re-ansi'
import PropTypes from 'prop-types'
import ReactJson from 'react-json-view'
import { connect } from 'react-redux'
import {
Button,
@ -60,6 +61,7 @@ class TaskOutput extends React.Component {
static propTypes = {
data: PropTypes.object,
include: PropTypes.array,
preferences: PropTypes.object,
}
renderResults(value) {
@ -130,7 +132,8 @@ class TaskOutput extends React.Component {
name={null}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
displayDataTypes={false}
theme={this.props.preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
</pre>
)
} else {
@ -142,7 +145,7 @@ class TaskOutput extends React.Component {
}
return (
<div key={key}>
<div className={this.props.preferences.darkMode ? 'zuul-console-dark' : 'zuul-console-light'} key={key}>
{ret && <h5>{key}</h5>}
{ret && ret}
</div>
@ -170,6 +173,7 @@ class HostTask extends React.Component {
errorIds: PropTypes.object,
taskPath: PropTypes.array,
displayPath: PropTypes.array,
preferences: PropTypes.object,
}
state = {
@ -290,7 +294,7 @@ class HostTask extends React.Component {
</DataListCell>
)
const content = <TaskOutput data={this.props.host} include={INTERESTING_KEYS}/>
const content = <TaskOutput data={this.props.host} include={INTERESTING_KEYS} preferences={this.props.preferences}/>
let item = null
if (interestingKeys) {
@ -354,7 +358,7 @@ class HostTask extends React.Component {
isOpen={this.state.showModal}
onClose={this.close}
description={modalDescription}>
<TaskOutput data={host}/>
<TaskOutput data={host} preferences={this.props.preferences}/>
</Modal>
</>
)
@ -367,6 +371,7 @@ class PlayBook extends React.Component {
errorIds: PropTypes.object,
taskPath: PropTypes.array,
displayPath: PropTypes.array,
preferences: PropTypes.object,
}
constructor(props) {
@ -404,8 +409,8 @@ class PlayBook extends React.Component {
dataListCells.push(
<DataListCell key='name' width={1}>
<strong>
{playbook.phase[0].toUpperCase() + playbook.phase.slice(1)} playbook<
/strong>
{playbook.phase[0].toUpperCase() + playbook.phase.slice(1)} playbook
</strong>
</DataListCell>)
dataListCells.push(
<DataListCell key='path' width={5}>
@ -463,7 +468,8 @@ class PlayBook extends React.Component {
taskPath={taskPath.concat([
idx.toString(), idx2.toString(), hostname])}
displayPath={displayPath} task={task} host={host}
errorIds={errorIds}/>
errorIds={errorIds}
preferences={this.props.preferences}/>
))))}
</DataList>
@ -484,6 +490,7 @@ class Console extends React.Component {
errorIds: PropTypes.object,
output: PropTypes.array,
displayPath: PropTypes.array,
preferences: PropTypes.object,
}
render () {
@ -492,7 +499,7 @@ class Console extends React.Component {
return (
<React.Fragment>
<br />
<span className="zuul-console">
<span className={`zuul-console ${this.props.preferences.darkMode ? 'zuul-console-dark' : 'zuul-console-light'}`}>
<DataList isCompact={true}
style={{ fontSize: 'var(--pf-global--FontSize--md)' }}>
{
@ -500,6 +507,7 @@ class Console extends React.Component {
<PlayBook
key={idx} playbook={playbook} taskPath={[idx.toString()]}
displayPath={displayPath} errorIds={errorIds}
preferences={this.props.preferences}
/>))
}
</DataList>
@ -509,5 +517,11 @@ class Console extends React.Component {
}
}
function mapStateToProps(state) {
return {
preferences: state.preferences,
}
}
export default Console
export default connect(mapStateToProps)(Console)

View File

@ -26,7 +26,7 @@ import { buildResultLegendData, buildsBarStyle } from './Misc'
function BuildsetGanttChart(props) {
const { builds, timezone } = props
const { builds, timezone, preferences } = props
const sortedByStartTime = builds.sort((a, b) => {
if (a.start_time > b.start_time) {
return -1
@ -64,6 +64,10 @@ function BuildsetGanttChart(props) {
const chartLegend = buildResultLegendData.filter((legend) => { return uniqueResults.indexOf(legend.name) > -1 })
let horizontalLegendTextColor = '#000'
if (preferences.darkMode) {
horizontalLegendTextColor = '#ccc'
}
return (
<div style={{ height: Math.max(400, 20 * builds.length) + 'px', width: '900px' }}>
@ -81,10 +85,9 @@ function BuildsetGanttChart(props) {
legendOrientation='horizontal'
legendPosition='top'
legendData={legendData}
legendComponent={<ChartLegend data={chartLegend} itemsPerRow={4} />}
legendComponent={<ChartLegend data={chartLegend} itemsPerRow={4} style={{labels: {fill: horizontalLegendTextColor}}} />}
>
<ChartAxis />
<ChartAxis style={{tickLabels: {fill:horizontalLegendTextColor}}} />
<ChartAxis
dependentAxis
showGrid
@ -103,15 +106,16 @@ function BuildsetGanttChart(props) {
return moment.duration(t, 'seconds').format(format)
}}
fixLabelOverlap={true}
style={{ tickLabels: { angle: -25, padding: 1, verticalAnchor: 'middle', textAnchor: 'end' } }} />
style={{ tickLabels: { angle: -25, padding: 1, verticalAnchor: 'middle', textAnchor: 'end', fill: horizontalLegendTextColor } }}
/>
<ChartBar
data={data}
style={buildsBarStyle}
style={ buildsBarStyle }
labelComponent={
<ChartTooltip constrainToVisibleArea />}
<ChartTooltip constrainToVisibleArea/>}
labels={({ datum }) => `${datum.result}\nStarted ${datum.started}\nEnded ${datum.ended}`}
/>
</ Chart>
</Chart>
</div>
)
@ -120,8 +124,10 @@ function BuildsetGanttChart(props) {
BuildsetGanttChart.propTypes = {
builds: PropTypes.array.isRequired,
timezone: PropTypes.string,
preferences: PropTypes.object,
}
export default connect((state) => ({
timezone: state.timezone,
}))(BuildsetGanttChart)
preferences: state.preferences,
}))(BuildsetGanttChart)

View File

@ -18,10 +18,14 @@ import {
ButtonVariant,
Modal,
ModalVariant,
Switch
Switch,
Select,
SelectOption,
SelectVariant
} from '@patternfly/react-core'
import { CogIcon } from '@patternfly/react-icons'
import { setPreference } from '../../actions/preferences'
import { resolveDarkMode, setDarkMode } from '../../Misc'
class ConfigModal extends React.Component {
@ -39,6 +43,8 @@ class ConfigModal extends React.Component {
this.state = {
isModalOpen: false,
autoReload: false,
theme: 'Auto',
isThemeOpen: false,
}
this.handleModalToggle = () => {
this.setState(({ isModalOpen }) => ({
@ -47,9 +53,39 @@ class ConfigModal extends React.Component {
this.resetState()
}
this.handleEscape = () => {
if (this.state.isThemeOpen) {
this.setState(({ isThemeOpen }) => ({
isThemeOpen: !isThemeOpen,
}))
} else {
this.handleModalToggle()
}
}
this.handleThemeToggle = () => {
this.setState(({ isThemeOpen }) => ({
isThemeOpen: !isThemeOpen,
}))
}
this.handleThemeSelect = (event, selection) => {
this.setState({
theme: selection,
isThemeOpen: false
})
}
this.handleTheme = () => {
let darkMode = resolveDarkMode(this.state.theme)
setDarkMode(darkMode)
}
this.handleSave = () => {
this.handleModalToggle()
this.props.dispatch(setPreference('autoReload', this.state.autoReload))
this.props.dispatch(setPreference('theme', this.state.theme))
this.handleTheme()
}
this.handleAutoReload = () => {
@ -62,11 +98,12 @@ class ConfigModal extends React.Component {
resetState() {
this.setState({
autoReload: this.props.preferences.autoReload,
theme: this.props.preferences.theme,
})
}
render() {
const { isModalOpen, autoReload } = this.state
const { isModalOpen, autoReload, theme, isThemeOpen } = this.state
return (
<React.Fragment>
<Button
@ -80,6 +117,7 @@ class ConfigModal extends React.Component {
title="Preferences"
isOpen={isModalOpen}
onClose={this.handleModalToggle}
onEscapePress={this.handleEscape}
actions={[
<Button key="confirm" variant="primary" onClick={this.handleSave}>
Confirm
@ -91,6 +129,8 @@ class ConfigModal extends React.Component {
>
<div>
<p key="info">Application settings are saved in browser local storage only. They are applied whether authenticated or not.</p>
</div>
<div>
<Switch
key="autoreload"
id="autoreload"
@ -99,6 +139,24 @@ class ConfigModal extends React.Component {
onChange={this.handleAutoReload}
/>
</div>
<div style={{'paddingTop': '25px'}}>
<p key="theme-info">Select your preferred theme, auto will base it on your system preference.</p>
</div>
<div>
<Select
variant={SelectVariant.single}
label="Select Input"
onToggle={this.handleThemeToggle}
onSelect={this.handleThemeSelect}
selections={theme}
isOpen={isThemeOpen}
menuAppendTo="parent"
>
<SelectOption key="auto" value="Auto"/>
<SelectOption key="light" value="Light"/>
<SelectOption key="dark" value="Dark"/>
</Select>
</div>
</Modal>
</React.Fragment>
)

View File

@ -58,7 +58,8 @@ class JobVariant extends React.Component {
static propTypes = {
parent: PropTypes.object,
tenant: PropTypes.object,
variant: PropTypes.object.isRequired
variant: PropTypes.object.isRequired,
preferences: PropTypes.object,
}
renderStatus (variant) {
@ -161,7 +162,8 @@ class JobVariant extends React.Component {
collapsed={true}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
displayDataTypes={false}
theme={this.props.preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
</span>
)
}
@ -200,7 +202,8 @@ class JobVariant extends React.Component {
collapsed={true}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
displayDataTypes={false}
theme={this.props.preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
</span>
)
nice_label = (<span><CodeIcon /> Job variables</span>)
@ -287,4 +290,7 @@ class JobVariant extends React.Component {
}
}
export default connect(state => ({tenant: state.tenant}))(JobVariant)
export default connect(state => ({
tenant: state.tenant,
preferences: state.preferences,
}))(JobVariant)

View File

@ -21,10 +21,15 @@ import { useHistory } from 'react-router-dom'
import { makeJobGraphKey, fetchJobGraphIfNeeded } from '../../actions/jobgraph'
import { graphviz } from 'd3-graphviz'
function makeDot(tenant, pipeline, project, branch, jobGraph) {
function makeDot(tenant, pipeline, project, branch, jobGraph, dark) {
let ret = 'digraph job_graph {\n'
ret += ' bgcolor="transparent"\n'
ret += ' rankdir=LR;\n'
ret += ' node [shape=box];\n'
if (dark) {
ret += ' node [shape=box color="white" fontcolor="white"];\n'
} else {
ret += ' node [shape=box];\n'
}
jobGraph.forEach((job) => {
const searchParams = new URLSearchParams('')
searchParams.append('pipeline', pipeline)
@ -43,8 +48,15 @@ function makeDot(tenant, pipeline, project, branch, jobGraph) {
if (job.dependencies.length) {
job.dependencies.forEach((dep) => {
let soft = ' [dir=back]'
if (dark) {
soft = ' [dir=back color="white" fontcolor="white"]'
}
if (dep.soft) {
soft = ' [style=dashed dir=back]'
if (dark) {
soft = ' [style=dashed dir=back color="white" fontcolor="white"]'
} else {
soft = ' [style=dashed dir=back]'
}
}
ret += ' "' + dep.name + '" -> "' + job.name + '"' + soft + ';\n'
})
@ -99,7 +111,7 @@ GraphViz.propTypes = {
function JobGraphDisplay(props) {
const [dot, setDot] = useState()
const {fetchJobGraphIfNeeded, tenant, project, pipeline, branch} = props
const {fetchJobGraphIfNeeded, tenant, project, pipeline, branch, preferences } = props
useEffect(() => {
fetchJobGraphIfNeeded(tenant, project.name, pipeline, branch)
@ -112,9 +124,9 @@ function JobGraphDisplay(props) {
const jobGraph = tenantJobGraph ? tenantJobGraph[jobGraphKey] : undefined
useEffect(() => {
if (jobGraph) {
setDot(makeDot(tenant, pipeline, project, branch, jobGraph))
setDot(makeDot(tenant, pipeline, project, branch, jobGraph, preferences.darkMode))
}
}, [tenant, pipeline, project, branch, jobGraph])
}, [tenant, pipeline, project, branch, jobGraph, preferences])
return (
<>
{dot && <GraphViz dot={dot}/>}
@ -131,11 +143,13 @@ JobGraphDisplay.propTypes = {
jobgraph: PropTypes.object,
dispatch: PropTypes.func,
state: PropTypes.object,
preferences: PropTypes.object,
}
function mapStateToProps(state) {
return {
tenant: state.tenant,
jobgraph: state.jobgraph,
preferences: state.preferences,
state: state,
}
}

View File

@ -163,6 +163,7 @@ class JobsList extends React.Component {
<FormGroup controlId='jobs'>
<FormControl
type='text'
className="pf-c-form-control"
placeholder='job name'
defaultValue={filter}
inputRef={i => this.filter = i}

View File

@ -59,7 +59,7 @@ function ProjectVariant(props) {
return (
<div>
<table className='table table-striped table-bordered'>
<table className={`table ${props.preferences.darkMode ? 'zuul-table-dark' : 'table-striped table-bordered'}`}>
<tbody>
{rows.map(item => (
<tr key={item.label}>
@ -75,12 +75,14 @@ function ProjectVariant(props) {
ProjectVariant.propTypes = {
tenant: PropTypes.object,
variant: PropTypes.object.isRequired
variant: PropTypes.object.isRequired,
preferences: PropTypes.object,
}
function mapStateToProps(state) {
return {
tenant: state.tenant,
preferences: state.preferences,
}
}

View File

@ -48,7 +48,8 @@ class Change extends React.Component {
pipeline: PropTypes.object,
tenant: PropTypes.object,
user: PropTypes.object,
dispatch: PropTypes.func
dispatch: PropTypes.func,
preferences: PropTypes.object
}
state = {
@ -268,7 +269,11 @@ class Change extends React.Component {
for (i = 0; i < queue._tree_columns; i++) {
let className = ''
if (i < change._tree.length && change._tree[i] !== null) {
className = ' zuul-change-row-line'
if (this.props.preferences.darkMode) {
className = ' zuul-change-row-line-dark'
} else {
className = ' zuul-change-row-line'
}
}
row.push(
<td key={i} className={'zuul-change-row' + className}>
@ -313,4 +318,5 @@ class Change extends React.Component {
export default connect(state => ({
tenant: state.tenant,
user: state.user,
preferences: state.preferences,
}))(Change)

View File

@ -25,7 +25,8 @@ class ChangePanel extends React.Component {
static propTypes = {
globalExpanded: PropTypes.bool.isRequired,
change: PropTypes.object.isRequired,
tenant: PropTypes.object
tenant: PropTypes.object,
preferences: PropTypes.object
}
constructor () {
@ -126,7 +127,7 @@ class ChangePanel extends React.Component {
const interesting_jobs = change.jobs.filter(j => this.jobStrResult(j) !== 'skipped')
let jobPercent = (100 / interesting_jobs.length).toFixed(2)
return (
<div className='progress zuul-change-total-result'>
<div className={`progress zuul-change-total-result${this.props.preferences.darkMode ? ' progress-dark' : ''}`}>
{change.jobs.map((job, idx) => {
let result = this.jobStrResult(job)
if (['queued', 'waiting', 'skipped'].includes(result)) {
@ -204,7 +205,7 @@ class ChangePanel extends React.Component {
}
return (
<div className='progress zuul-job-result'
<div className={`progress zuul-job-result${this.props.preferences.darkMode ? ' progress-dark' : ''}`}
title={title}>
<div className={'progress-bar ' + className}
role='progressbar'
@ -321,9 +322,9 @@ class ChangePanel extends React.Component {
return (
<>
<ul className='list-group zuul-patchset-body'>
<ul className={`list-group ${this.props.preferences.darkMode ? 'zuul-patchset-body-dark' : 'zuul-patchset-body'}`}>
{interestingJobs.map((job, idx) => (
<li key={idx} className='list-group-item zuul-change-job'>
<li key={idx} className={`list-group-item ${this.props.preferences.darkMode ? 'zuul-change-job-dark' : 'zuul-change-job'}`}>
{this.renderJob(job, times.jobs[job.name])}
</li>
))}
@ -389,8 +390,8 @@ class ChangePanel extends React.Component {
}
const times = this.calculateTimes(change)
const header = (
<div className='panel panel-default zuul-change'>
<div className='panel-heading zuul-patchset-header'
<div className={`panel panel-default ${this.props.preferences.darkMode ? 'zuul-change-dark' : 'zuul-change'}`}>
<div className={`panel-heading ${this.props.preferences.darkMode ? 'zuul-patchset-header-dark' : 'zuul-patchset-header'}`}
onClick={this.onClick}>
<div className='row'>
<div className='col-xs-8'>
@ -422,4 +423,7 @@ class ChangePanel extends React.Component {
}
}
export default connect(state => ({tenant: state.tenant}))(ChangePanel)
export default connect(state => ({
tenant: state.tenant,
preferences: state.preferences,
}))(ChangePanel)

View File

@ -111,6 +111,7 @@ class SelectTz extends React.Component {
<OutlinedClockIcon/>
<Select
className="zuul-select-tz"
classNamePrefix="zuul-select-tz"
styles={customStyles}
components={{ DropdownIndicator }}
value={this.state.currentValue}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 B

View File

@ -40,8 +40,18 @@ a.refresh {
}
.zuul-select-tz {
/* That's the color PF4 uses for the dropdown items in the navbar */
color: var(--pf-global--Color--dark-100);
/* Always use black because when using dark mode the theme will default
to another dark color which is hard to see on a white background */
color: #000;
}
.pf-theme-dark .zuul-select-tz .zuul-select-tz__option {
background: #222;
color: #fff;
}
.pf-theme-dark .zuul-select-tz .zuul-select-tz__option:hover {
background: #000;
}
/* Config error modal */
@ -53,6 +63,15 @@ a.refresh {
margin-left: var(--pf-global--spacer--md);
}
.pf-theme-dark .zuul-config-errors-title, .pf-theme-dark .zuul-config-errors-count {
color: #fff !important;
}
.pf-theme-dark .pf-c-notification-drawer pre {
background: #000;
color: #fff;
}
/*
* Build Lists and Tables
*/
@ -66,6 +85,10 @@ a.refresh {
font-weight: bold;
}
.zuul-menu-dropdown-toggle {
background: transparent !important;
}
.zuul-menu-dropdown-toggle:before {
content: none !important;
}
@ -167,6 +190,11 @@ a.refresh {
margin-bottom: 10px;
}
.zuul-change-dark {
margin-bottom: 10px;
border-color: #222;
}
.zuul-change-id {
float: right;
}
@ -210,6 +238,13 @@ a.refresh {
padding: 2px 8px;
}
.zuul-change-job-dark {
padding: 2px 8px;
background: #000;
color: #ccc;
border: 1px solid #222;
}
/* Force_break_very_long_non_hyphenated_repo_names */
.change_project {
word-break: break-all;
@ -233,6 +268,21 @@ a.refresh {
padding: 8px 12px;
}
.zuul-patchset-header-dark {
font-size: small;
padding: 8px 12px;
background: #000 !important;
color: #ccc !important;
border-color: #222 !important;
}
.zuul-patchset-body {
}
.zuul-patchset-body-dark {
border-top: 1px solid #000;
}
.zuul-log-output {
color: black;
}
@ -283,7 +333,7 @@ a.refresh {
}
.zuul-build-status {
background: white;
background: transparent;
font-size: 16px;
}
@ -292,14 +342,23 @@ a.refresh {
}
.zuul-change-row-line {
background-image: url('images/line.png');
background-repeat: 'repeat-y';
background: linear-gradient(#000, #000) no-repeat center/2px 100%;
background-position-y: 15px;
}
.zuul-change-row-line-dark {
background: linear-gradient(#fff, #fff) no-repeat center/2px 100%;
background-position-y: 15px;
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
.progress-dark {
background: #333 !important;
}
/* Job Tree View group gap */
div.tree-view-container ul.list-group {
margin: 0px 0px;
@ -325,6 +384,10 @@ pre.version {
background-color: var(--pf-global--palette--red-50) !important;
}
.pf-theme-dark .zuul-console-task-failed {
background-color: var(--pf-global--palette--red-300) !important;
}
.zuul-console .pf-c-data-list__expandable-content {
border: none;
}
@ -344,11 +407,21 @@ pre.version {
border-radius: 5px;
}
.zuul-console .pf-c-data-list__item:hover
.zuul-console-light .pf-c-data-list__item:hover
{
background: var(--pf-global--palette--blue-50);
}
.zuul-console-dark .pf-c-data-list__item:hover
{
background: var(--pf-global--BackgroundColor--200);
}
.zuul-console-dark pre {
background: #000;
color: #fff;
}
.zuul-console .pf-c-data-list__item:hover::before
{
background: var(--pf-global--active-color--400);
@ -451,3 +524,42 @@ details.foldable[open] summary::before {
.zuul-task-summary-failed.pf-c-card {
background: var(--pf-global--palette--red-50);
}
.pf-theme-dark .pf-c-nav__link {
color: #fff !important;
}
.pf-theme-dark .pf-c-modal-box__title-text, .pf-theme-dark .pf-c-modal-box__body {
color: #fff !important;
}
.pf-theme-dark .swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.pf-theme-dark .swagger-ui .highlight-code {
filter: invert(100%) hue-rotate(180deg);
}
.zuul-table-dark .list-group-item {
background-color: #333 !important;
}
.zuul-build-output {
}
.zuul-build-output-dark {
background-color: #000 !important;
color: #fff;
}
.pf-theme-dark .zuul-log-sev-0 {
color: #ccc !important;
}
.pf-theme-dark .zuul-log-sev-1 {
color: #ccc !important;
}
.pf-theme-dark .pf-c-empty-state {
color: #fff !important;
}

View File

@ -59,6 +59,7 @@ class AutoholdPage extends React.Component {
autohold: PropTypes.object,
isFetching: PropTypes.bool.isRequired,
fetchAutohold: PropTypes.func.isRequired,
preferences: PropTypes.object,
}
updateData = () => {
@ -147,7 +148,7 @@ class AutoholdPage extends React.Component {
return (
<>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Title headingLevel="h2">Autohold Request {autohold.id}</Title>
<Flex className="zuul-autohold-attributes">
@ -211,7 +212,9 @@ class AutoholdPage extends React.Component {
value={
<>
<strong>Reason:</strong>
<pre>{autohold.reason}</pre>
<div className={this.props.preferences.darkMode ? 'zuul-console-dark' : ''}>
<pre>{autohold.reason}</pre>
</div>
</>
}
/>
@ -221,7 +224,7 @@ class AutoholdPage extends React.Component {
</Flex>
</Flex>
</PageSection>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Title headingLevel="h3">
<BuildIcon
style={{
@ -243,6 +246,7 @@ function mapStateToProps(state) {
autohold: state.autoholds.autohold,
tenant: state.tenant,
isFetching: state.autoholds.isFetching,
preferences: state.preferences,
}
}

View File

@ -65,6 +65,7 @@ class BuildPage extends React.Component {
activeTab: PropTypes.string.isRequired,
location: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
preferences: PropTypes.object,
}
state = {
@ -250,10 +251,10 @@ class BuildPage extends React.Component {
return (
<>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Build build={build} active={activeTab} hash={hash} />
</PageSection>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Tabs
isFilled
activeKey={activeTab}
@ -314,7 +315,7 @@ class BuildPage extends React.Component {
</Tabs>
</PageSection>
{!this.state.topOfPageVisible && (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Button onClick={scrollToTop} variant="primary" style={{position: 'fixed', bottom: 20, right: 20, zIndex: 1}}>
Go to top of page <ArrowUpIcon/>
</Button>
@ -362,6 +363,7 @@ function mapStateToProps(state, ownProps) {
isFetchingManifest: state.build.isFetchingManifest,
isFetchingOutput: state.build.isFetchingOutput,
isFetchingLogfile: state.logfile.isFetching,
preferences: state.preferences,
}
}

View File

@ -38,6 +38,7 @@ class BuildsetPage extends React.Component {
buildset: PropTypes.object,
isFetching: PropTypes.bool.isRequired,
fetchBuildset: PropTypes.func.isRequired,
preferences: PropTypes.object,
}
updateData = () => {
@ -105,10 +106,10 @@ class BuildsetPage extends React.Component {
return (
<>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Buildset buildset={buildset} />
</PageSection>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Title headingLevel="h3">
<BuildIcon
style={{
@ -134,6 +135,7 @@ function mapStateToProps(state, ownProps) {
buildset,
tenant: state.tenant,
isFetching: state.build.isFetching,
preferences: state.preferences,
}
}

View File

@ -32,6 +32,7 @@ class BuildsetsPage extends React.Component {
tenant: PropTypes.object,
location: PropTypes.object,
history: PropTypes.object,
preferences: PropTypes.object,
}
constructor(props) {
@ -230,7 +231,7 @@ class BuildsetsPage extends React.Component {
const { buildsets, fetching, filters, resultsPerPage, currentPage, itemCount } = this.state
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<FilterToolbar
filterCategories={this.filterCategories}
onFilterChange={this.handleFilterChange}
@ -268,4 +269,7 @@ class BuildsetsPage extends React.Component {
}
}
export default connect((state) => ({ tenant: state.tenant }))(BuildsetsPage)
export default connect((state) => ({
tenant: state.tenant,
preferences: state.preferences,
}))(BuildsetsPage)

View File

@ -18,7 +18,12 @@ import { connect } from 'react-redux'
import {
Icon
} from 'patternfly-react'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import {
PageSection,
PageSectionVariants,
List,
ListItem,
} from '@patternfly/react-core'
import { fetchConfigErrorsAction } from '../actions/configErrors'
@ -26,7 +31,8 @@ class ConfigErrorsPage extends React.Component {
static propTypes = {
configErrors: PropTypes.object,
tenant: PropTypes.object,
dispatch: PropTypes.func
dispatch: PropTypes.func,
preferences: PropTypes.object,
}
updateData = () => {
@ -36,7 +42,7 @@ class ConfigErrorsPage extends React.Component {
render () {
const { configErrors } = this.props
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<div className="pull-right">
{/* Lint warning jsx-a11y/anchor-is-valid */}
{/* eslint-disable-next-line */}
@ -45,22 +51,22 @@ class ConfigErrorsPage extends React.Component {
</a>
</div>
<div className="pull-left">
<ul className="list-group">
<List isPlain isBordered>
{configErrors.map((item, idx) => {
let ctxPath = item.source_context.path
if (item.source_context.branch !== 'master') {
ctxPath += ' (' + item.source_context.branch + ')'
}
return (
<li className="list-group-item" key={idx}>
<ListItem key={idx}>
<h3>{item.source_context.project} - {ctxPath}</h3>
<p style={{whiteSpace: 'pre-wrap'}}>
{item.error}
</p>
</li>
</ListItem>
)
})}
</ul>
</List>
</div>
</PageSection>
)
@ -69,5 +75,6 @@ class ConfigErrorsPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
configErrors: state.configErrors.errors
configErrors: state.configErrors.errors,
preferences: state.preferences,
}))(ConfigErrorsPage)

View File

@ -97,7 +97,8 @@ function FreezeJobPage(props) {
collapsed={false}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
displayDataTypes={false}
theme={props.preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
</span>
)
}
@ -133,12 +134,14 @@ FreezeJobPage.propTypes = {
fetchFreezeJobIfNeeded: PropTypes.func,
tenant: PropTypes.object,
freezejob: PropTypes.object,
preferences: PropTypes.object,
}
function mapStateToProps(state) {
return {
tenant: state.tenant,
freezejob: state.freezejob,
preferences: state.preferences,
}
}

View File

@ -26,7 +26,8 @@ class JobPage extends React.Component {
match: PropTypes.object.isRequired,
tenant: PropTypes.object,
remoteData: PropTypes.object,
dispatch: PropTypes.func
dispatch: PropTypes.func,
preferences: PropTypes.object,
}
updateData = (force) => {
@ -53,7 +54,7 @@ class JobPage extends React.Component {
const tenantJobs = remoteData.jobs[this.props.tenant.name]
const jobName = this.props.match.params.jobName
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode? PageSectionVariants.dark : PageSectionVariants.light}>
{tenantJobs && tenantJobs[jobName] && <Job job={tenantJobs[jobName]} />}
</PageSection>
)
@ -63,4 +64,5 @@ class JobPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
remoteData: state.job,
preferences: state.preferences,
}))(JobPage)

View File

@ -26,7 +26,8 @@ class JobsPage extends React.Component {
static propTypes = {
tenant: PropTypes.object,
remoteData: PropTypes.object,
dispatch: PropTypes.func
dispatch: PropTypes.func,
preferences: PropTypes.object,
}
updateData = (force) => {
@ -51,8 +52,8 @@ class JobsPage extends React.Component {
const jobs = remoteData.jobs[this.props.tenant.name]
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection style={{paddingRight: '5px'}}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<PageSection variant={PageSectionVariants.light} style={{paddingRight: '5px'}}>
<Fetchable
isFetching={remoteData.isFetching}
fetchCallback={this.updateData}
@ -70,4 +71,5 @@ class JobsPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
remoteData: state.jobs,
preferences: state.preferences,
}))(JobsPage)

View File

@ -26,7 +26,8 @@ class OpenApiPage extends React.Component {
static propTypes = {
tenant: PropTypes.object,
remoteData: PropTypes.object,
dispatch: PropTypes.func
dispatch: PropTypes.func,
preferences: PropTypes.object,
}
updateData = (force) => {
@ -51,7 +52,7 @@ class OpenApiPage extends React.Component {
render() {
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<div id="swaggerContainer" />
</PageSection>
)
@ -61,4 +62,5 @@ class OpenApiPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
remoteData: state.openapi,
preferences: state.preferences,
}))(OpenApiPage)

View File

@ -46,7 +46,7 @@ function ProjectPage(props) {
return (
<>
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<TextContent>
<Text component="h2">Project {projectName}</Text>
<Fetchable
@ -70,12 +70,14 @@ ProjectPage.propTypes = {
tenant: PropTypes.object,
remoteData: PropTypes.object,
fetchProjectIfNeeded: PropTypes.func,
preferences: PropTypes.object,
}
function mapStateToProps(state) {
return {
tenant: state.tenant,
remoteData: state.project,
preferences: state.preferences,
}
}

View File

@ -25,7 +25,7 @@ import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import { fetchSemaphoresIfNeeded } from '../actions/semaphores'
import Semaphore from '../containers/semaphore/Semaphore'
function SemaphorePage({ match, semaphores, tenant, fetchSemaphoresIfNeeded, isFetching }) {
function SemaphorePage({ match, semaphores, tenant, fetchSemaphoresIfNeeded, isFetching, preferences }) {
const semaphoreName = match.params.semaphoreName
@ -38,7 +38,7 @@ function SemaphorePage({ match, semaphores, tenant, fetchSemaphoresIfNeeded, isF
e => e.name === semaphoreName) : undefined
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Title headingLevel="h2">
Details for Semaphore <span style={{color: 'var(--pf-global--primary-color--100)'}}>{semaphoreName}</span>
</Title>
@ -55,6 +55,7 @@ SemaphorePage.propTypes = {
tenant: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
fetchSemaphoresIfNeeded: PropTypes.func.isRequired,
preferences: PropTypes.object,
}
const mapDispatchToProps = { fetchSemaphoresIfNeeded }
@ -63,6 +64,7 @@ function mapStateToProps(state) {
tenant: state.tenant,
semaphores: state.semaphores.semaphores,
isFetching: state.semaphores.isFetching,
preferences: state.preferences,
}
}

View File

@ -197,6 +197,7 @@ class StatusPage extends React.Component {
<FormGroup controlId='status'>
<FormControl
type='text'
className="pf-c-form-control"
placeholder='change or project name'
defaultValue={filter}
inputRef={i => this.filter = i}
@ -222,7 +223,7 @@ class StatusPage extends React.Component {
</Form>
)
return (
<PageSection variant={PageSectionVariants.light}>
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<div style={{display: 'flex', float: 'right'}}>
<Fetchable
isFetching={remoteData.isFetching}

View File

@ -31,7 +31,8 @@ class StreamPage extends React.Component {
static propTypes = {
match: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
tenant: PropTypes.object
tenant: PropTypes.object,
preferences: PropTypes.object,
}
state = {
@ -167,10 +168,11 @@ class StreamPage extends React.Component {
render () {
return (
<PageSection variant={PageSectionVariants.light} >
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Form inline>
<FormGroup controlId='stream'>
<FormControl
className="pf-c-form-control"
type='text'
placeholder='search'
onKeyPress={this.handleKeyPress}
@ -201,4 +203,7 @@ class StreamPage extends React.Component {
}
export default connect(state => ({tenant: state.tenant}))(StreamPage)
export default connect(state => ({
tenant: state.tenant,
preferences: state.preferences,
}))(StreamPage)

View File

@ -15,13 +15,14 @@
import {
PREFERENCE_SET,
} from '../actions/preferences'
import { resolveDarkMode, setDarkMode } from '../Misc'
const stored_prefs = localStorage.getItem('preferences')
let default_prefs
if (stored_prefs === null) {
default_prefs = {
autoReload: true
autoReload: true,
theme: 'Auto'
}
} else {
default_prefs = JSON.parse(stored_prefs)
@ -30,13 +31,15 @@ if (stored_prefs === null) {
export default (state = {
...default_prefs
}, action) => {
let newstate
switch (action.type) {
case PREFERENCE_SET:
newstate = { ...state, [action.key]: action.value }
localStorage.setItem('preferences', JSON.stringify(newstate))
return newstate
default:
return state
if (action.type === PREFERENCE_SET) {
let newstate = { ...state, [action.key]: action.value }
delete newstate.darkMode
localStorage.setItem('preferences', JSON.stringify(newstate))
let darkMode = resolveDarkMode(newstate.theme)
setDarkMode(darkMode)
return { ...newstate, darkMode: darkMode }
}
let darkMode = resolveDarkMode(state.theme)
setDarkMode(darkMode)
return { ...state, darkMode: darkMode }
}