Pipeline Checks refactoring and bug fixes (#515)

This commit is contained in:
Tan Nhu 2023-09-18 20:52:38 +00:00 committed by Harness
parent b29d2765b0
commit 1572618093
13 changed files with 489 additions and 368 deletions

View File

@ -50,7 +50,7 @@
"@uiw/codemirror-extensions-hyper-link": "^4.19.9", "@uiw/codemirror-extensions-hyper-link": "^4.19.9",
"@uiw/codemirror-themes-all": "^4.19.9", "@uiw/codemirror-themes-all": "^4.19.9",
"@uiw/react-markdown-preview": "^4.1.12", "@uiw/react-markdown-preview": "^4.1.12",
"anser": "2.0.1", "anser": "^2.1.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"clipboard-copy": "^3.1.0", "clipboard-copy": "^3.1.0",
"diff2html": "3.4.22", "diff2html": "3.4.22",
@ -85,11 +85,10 @@
"rehype-video": "^1.2.2", "rehype-video": "^1.2.2",
"restful-react": "15.6.0", "restful-react": "15.6.0",
"webpack-retry-chunk-load-plugin": "^3.1.0", "webpack-retry-chunk-load-plugin": "^3.1.0",
"xterm": "^5.1.0", "xterm": "^5.3.0",
"xterm-addon-canvas": "^0.3.0", "xterm-addon-fit": "^0.8.0",
"xterm-addon-fit": "^0.7.0",
"xterm-addon-search": "^0.11.0", "xterm-addon-search": "^0.11.0",
"xterm-addon-web-links": "^0.8.0", "xterm-addon-web-links": "^0.9.0",
"yaml": "^1.10.0", "yaml": "^1.10.0",
"yup": "^0.29.1" "yup": "^0.29.1"
}, },

View File

@ -4,6 +4,9 @@
@include vars; @include vars;
&.fullPage { &.fullPage {
--font-family: Inter, sans-serif;
--font-family-mono: Roboto Mono, monospace;
height: var(--page-height); height: var(--page-height);
} }

View File

@ -1,65 +1,48 @@
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { Container } from '@harnessio/uicore' import { Container } from '@harnessio/uicore'
import { Terminal } from 'xterm' import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit' import { FitAddon } from 'xterm-addon-fit'
import { CanvasAddon } from 'xterm-addon-canvas'
import { SearchAddon } from 'xterm-addon-search' import { SearchAddon } from 'xterm-addon-search'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
import { useEventListener } from 'hooks/useEventListener' import { useEventListener } from 'hooks/useEventListener'
const DEFAULT_SCROLLBACK_LINES = 100000 export type TermRefs = { term: Terminal; fitAddon: FitAddon }
export type TermRefs = { term: Terminal; fitAddon: FitAddon } | undefined
export interface LogViewerProps { export interface LogViewerProps {
/** Search text */ search?: string
searchText?: string
/** Number of scrollback lines */
scrollbackLines?: number
/** Log content as string */
content: string content: string
termRefs?: React.MutableRefObject<TermRefs | undefined>
termRefs?: React.MutableRefObject<TermRefs>
autoHeight?: boolean autoHeight?: boolean
} }
export const LogViewer: React.FC<LogViewerProps> = ({ scrollbackLines, content, termRefs, autoHeight }) => { const LogTerminal: React.FC<LogViewerProps> = ({ content, termRefs, autoHeight }) => {
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const lines = useMemo(() => content.split(/\r?\n/), [content]) const term = useRef<TermRefs>()
const term = useRef<{ term: Terminal; fitAddon: FitAddon }>()
useEffect(() => { useEffect(() => {
if (!term.current) { if (!term.current) {
const _term = new Terminal({ const _term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
allowTransparency: true, allowTransparency: true,
disableStdin: true, disableStdin: true,
scrollback: scrollbackLines || DEFAULT_SCROLLBACK_LINES, tabStopWidth: 2,
theme: { scrollOnUserInput: false,
background: 'transparent' smoothScrollDuration: 0,
} scrollback: 10000
}) })
const searchAddon = new SearchAddon() const searchAddon = new SearchAddon()
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
_term.loadAddon(searchAddon) _term.loadAddon(searchAddon)
_term.loadAddon(fitAddon) _term.loadAddon(fitAddon)
_term.loadAddon(webLinksAddon)
_term.loadAddon(new CanvasAddon())
_term.open(ref?.current as HTMLDivElement) _term.open(ref?.current as HTMLDivElement)
fitAddon.fit() fitAddon.fit()
searchAddon.activate(_term) searchAddon.activate(_term)
_term.write('\x1b[?25l') // disable cursor // disable cursor
_term.write('\x1b[?25l')
term.current = { term: _term, fitAddon } term.current = { term: _term, fitAddon }
@ -67,24 +50,38 @@ export const LogViewer: React.FC<LogViewerProps> = ({ scrollbackLines, content,
termRefs.current = term.current termRefs.current = term.current
} }
} }
return () => {
if (term.current) {
if (termRefs) {
termRefs.current = undefined
}
setTimeout(() => term.current?.term.dispose(), 1000)
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
term.current?.term?.clear() const lines = content.split(/\r?\n/)
lines.forEach(line => term.current?.term?.writeln(line)) lines.forEach(line => term.current?.term?.writeln(line))
if (autoHeight) { if (autoHeight) {
term.current?.term?.resize(term.current?.term?.cols, lines.length + 1) term.current?.term?.resize(term.current?.term?.cols, lines.length + 1)
} }
setTimeout(() => {
term.current?.term.scrollToTop()
}, 0)
return () => { return () => {
term.current?.term?.clear() term.current?.term?.clear()
} }
}, [lines, autoHeight]) }, [content, autoHeight])
useEventListener('resize', () => { useEventListener('resize', () => term.current?.fitAddon?.fit())
term.current?.fitAddon?.fit()
})
return <Container ref={ref} width="100%" height={autoHeight ? 'auto' : '100%'} /> return <Container ref={ref} width="100%" height={autoHeight ? 'auto' : '100%'} />
} }
export const LogViewer = React.memo(LogTerminal)

View File

@ -3,12 +3,12 @@ import { useEffect } from 'react'
import type { GetDataError } from 'restful-react' import type { GetDataError } from 'restful-react'
import { getErrorMessage } from 'utils/Utils' import { getErrorMessage } from 'utils/Utils'
export function useShowRequestError(error: GetDataError<Unknown> | null) { export function useShowRequestError(error: GetDataError<Unknown> | null, timeout?: number) {
const { showError } = useToaster() const { showError } = useToaster()
useEffect(() => { useEffect(() => {
if (error) { if (error) {
showError(getErrorMessage(error)) showError(getErrorMessage(error), timeout)
} }
}, [error, showError]) }, [error, showError, timeout])
} }

View File

@ -0,0 +1,154 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGet } from 'restful-react'
import { useHistory } from 'react-router-dom'
import { Color } from '@harnessio/design-system'
import { Icon } from '@harnessio/icons'
import { Container, Layout, Text } from '@harnessio/uicore'
import cx from 'classnames'
import { Falsy, Match, Truthy } from 'react-jsx-match'
import { useAppContext } from 'AppContext'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import { useQueryParams } from 'hooks/useQueryParams'
import { useShowRequestError } from 'hooks/useShowRequestError'
import type { TypesExecution, TypesStage } from 'services/code'
import type { GitInfoProps } from 'utils/GitUtils'
import { ButtonRoleProps, PullRequestSection } from 'utils/Utils'
import { findDefaultExecution } from './ChecksUtils'
import css from './Checks.module.scss'
interface CheckPipelineStagesProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> {
pipelineName: string
executionNumber: string
expanded?: boolean
onSelectStage: (stage: TypesStage) => void
}
export const CheckPipelineStages: React.FC<CheckPipelineStagesProps> = ({
pipelineName,
executionNumber,
expanded,
repoMetadata,
pullRequestMetadata,
onSelectStage
}) => {
const { data, error, loading, refetch } = useGet<TypesExecution>({
path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipelineName}/executions/${executionNumber}`,
lazy: true
})
const [execution, setExecution] = useState<TypesExecution>()
const { uid, stageId } = useQueryParams<{ pullRequestId: string; uid: string; stageId: string }>()
const stages = useMemo(() => execution?.stages || [], [execution])
const history = useHistory()
const { routes } = useAppContext()
useShowRequestError(error, 0)
useEffect(() => {
let timeoutId = 0
if (repoMetadata && expanded) {
if (!execution && !error) {
refetch()
} else {
if (
!error &&
stages.find(({ status }) => status === ExecutionState.PENDING || status === ExecutionState.RUNNING)
) {
timeoutId = window.setTimeout(refetch, POLLING_INTERVAL)
}
}
}
return () => {
window.clearTimeout(timeoutId)
}
}, [repoMetadata, expanded, execution, refetch, error, stages])
const selectStage = useCallback(
(stage: TypesStage) => {
history.replace(
routes.toCODEPullRequest({
repoPath: repoMetadata.path as string,
pullRequestId: String(pullRequestMetadata.number),
pullRequestSection: PullRequestSection.CHECKS
}) + `?uid=${pipelineName}${`&stageId=${stage.name}`}`
)
onSelectStage(stage)
},
[history, onSelectStage, pipelineName, pullRequestMetadata.number, repoMetadata.path, routes]
)
const stageRef = useRef<TypesStage>()
useEffect(() => {
if (data) {
setExecution(data)
}
}, [data])
useEffect(() => {
if (!expanded) {
setExecution(undefined)
}
}, [expanded])
useEffect(() => {
if (stages.length) {
if (uid === pipelineName) {
// Pre-select stage if no stage is selected in the url
if (!stageId) {
selectStage(findDefaultExecution(stages) as TypesStage)
} else {
// If a stage is selected in url, find if it's matched
// with a stage from polling data and update selected
// stage accordingly to make sure parents has the latest
// stage data (status, time, etc...)
const _stage = stages.find(stg => stg.name === stageId && stageRef.current !== stg)
if (_stage) {
stageRef.current = _stage
selectStage(_stage)
}
}
}
}
}, [selectStage, pipelineName, stageId, stages, uid])
return (
<Container className={cx(css.pipelineStages, { [css.hidden]: !expanded || error })}>
<Match expr={loading && !execution}>
<Truthy>
<Container className={css.spinner}>
<Icon name="steps-spinner" size={16} />
</Container>
</Truthy>
<Falsy>
<>
{stages.map(stage => (
<Layout.Horizontal
spacing="small"
key={stage.name}
className={cx(css.subMenu, { [css.selected]: pipelineName === uid && stage.name === stageId })}
{...ButtonRoleProps}
onClick={() => {
// ALways send back the latest stage
selectStage(stages.find(stg => stg === stage.name) || stage)
}}>
<ExecutionStatus
className={cx(css.status, css.noShrink)}
status={stage.status as ExecutionState}
iconSize={16}
noBackground
iconOnly
/>
<Text color={Color.GREY_800} className={css.text}>
{stage.name}
</Text>
</Layout.Horizontal>
))}
</>
</Falsy>
</Match>
</Container>
)
}
const POLLING_INTERVAL = 2000

View File

@ -0,0 +1,198 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Render } from 'react-jsx-match'
import { NavArrowRight } from 'iconoir-react'
import cx from 'classnames'
import { useGet } from 'restful-react'
import Anser from 'anser'
import { Container, Layout, Text, FlexExpander, Utils, useToaster } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
import { ButtonRoleProps, getErrorMessage, timeDistance } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils'
import type { LivelogLine, TypesStage, TypesStep } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import { useShowRequestError } from 'hooks/useShowRequestError'
import css from './Checks.module.scss'
interface CheckPipelineStepsProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> {
pipelineName: string
executionNumber: string
stage: TypesStage
}
export const CheckPipelineSteps: React.FC<CheckPipelineStepsProps> = ({
repoMetadata,
pullRequestMetadata,
pipelineName,
stage,
executionNumber
}) => {
return (
<Container className={cx(css.pipelineSteps)}>
{stage.steps?.map(step => (
<CheckPipelineStep
key={pipelineName + stage.name + executionNumber + step.name + step.started}
pipelineName={pipelineName}
executionNumber={executionNumber}
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
stage={stage}
step={step}
/>
))}
</Container>
)
}
const CheckPipelineStep: React.FC<CheckPipelineStepsProps & { step: TypesStep }> = ({
pipelineName,
executionNumber,
stage,
repoMetadata,
step
}) => {
const { showError } = useToaster()
const eventSourceRef = useRef<EventSource | null>(null)
const isRunning = useMemo(() => step.status === ExecutionState.RUNNING, [step])
const [expanded, setExpanded] = useState(
isRunning || step.status === ExecutionState.ERROR || step.status === ExecutionState.FAILURE
)
const stepLogPath = useMemo(
() =>
`/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipelineName}/executions/${executionNumber}/logs/${stage.number}/${step.number}`,
[executionNumber, pipelineName, repoMetadata?.path, stage.number, step.number]
)
const lazy =
!expanded || isRunning || step.status === ExecutionState.PENDING || step.status === ExecutionState.SKIPPED
const {
data: logs,
error,
loading,
refetch
} = useGet<LivelogLine[]>({
path: stepLogPath,
lazy: true
})
const [isStreamingDone, setIsStreamingDone] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
const [autoCollapse, setAutoCollapse] = useState(false)
const closeEventStream = useCallback(() => {
eventSourceRef.current?.close()
eventSourceRef.current = null
}, [])
useEffect(() => {
if (expanded && isRunning) {
setAutoCollapse(false)
if (containerRef.current) {
containerRef.current.textContent = ''
}
eventSourceRef.current = new EventSource(`${stepLogPath}/stream`)
eventSourceRef.current.onmessage = event => {
try {
const scrollParent = containerRef.current?.closest(`.${css.pipelineSteps}`) as HTMLDivElement
const autoScroll =
scrollParent && scrollParent.scrollTop === scrollParent.scrollHeight - scrollParent.offsetHeight
const element = createLogLineElement((JSON.parse(event.data) as LivelogLine).out)
containerRef.current?.appendChild(element)
if (autoScroll) {
scrollParent.scrollTop = scrollParent.scrollHeight
}
} catch (exception) {
showError(getErrorMessage(exception))
closeEventStream()
}
}
eventSourceRef.current.onerror = () => {
setIsStreamingDone(true)
setAutoCollapse(true)
closeEventStream()
}
} else {
closeEventStream()
}
return closeEventStream
}, [expanded, isRunning, showError, stepLogPath, step.status, closeEventStream])
useEffect(() => {
if (!lazy && !error && (!isStreamingDone || !isRunning) && expanded) {
refetch()
}
}, [lazy, error, refetch, isStreamingDone, expanded, isRunning])
useEffect(() => {
if (autoCollapse && expanded && step.status === ExecutionState.SUCCESS) {
setAutoCollapse(false)
setExpanded(false)
}
}, [autoCollapse, expanded, step.status])
useEffect(() => {
if (!isRunning && logs?.length) {
logs.forEach(_log => {
const element = createLogLineElement(_log.out)
containerRef.current?.appendChild(element)
})
}
}, [isRunning, logs])
useShowRequestError(error, 0)
return (
<Container key={step.number}>
<Layout.Horizontal
spacing="small"
className={cx(css.stepHeader, { [css.expanded]: expanded, [css.selected]: expanded })}
{...ButtonRoleProps}
onClick={() => {
setExpanded(!expanded)
}}>
<NavArrowRight
color={Utils.getRealCSSColor(Color.GREY_500)}
className={cx(css.noShrink, css.chevron)}
strokeWidth="1.5"
/>
<ExecutionStatus
className={cx(css.status, css.noShrink)}
status={step.status as ExecutionState}
iconSize={16}
noBackground
iconOnly
/>
<Text className={css.name} lineClamp={1}>
{step.name}
</Text>
<FlexExpander />
<Render when={loading}>
<Icon name="steps-spinner" size={16} />
</Render>
<Render when={step.started && step.stopped}>
<Text color={Color.GREY_300} font={{ variation: FontVariation.SMALL }} className={css.noShrink}>
{timeDistance(step.started, step.stopped)}
</Text>
</Render>
</Layout.Horizontal>
<Render when={expanded}>
<Container className={css.stepLogViewer} ref={containerRef}></Container>
</Render>
</Container>
)
}
const createLogLineElement = (line = '') => {
const element = document.createElement('pre')
element.className = css.consoleLine
element.innerHTML = Anser.ansiToHtml(line.replace(/\r?\n$/, ''))
return element
}

View File

@ -1,3 +1,5 @@
@import 'src/utils/utils';
.main { .main {
--stage-title-height: 54px; --stage-title-height: 54px;
--stage-detail-section-height: 48px; --stage-detail-section-height: 48px;
@ -194,6 +196,9 @@
height: 100%; height: 100%;
overflow: auto; overflow: auto;
padding: 10px 20px 0 !important; padding: 10px 20px 0 !important;
display: flex;
flex-direction: column;
gap: 5px;
&::before { &::before {
content: ''; content: '';
@ -248,6 +253,19 @@
} }
.stepLogViewer { .stepLogViewer {
padding-left: 20px; padding: 15px 36px;
flex-shrink: 0;
.consoleLine {
color: var(--white);
@include mono-font;
word-wrap: break-word !important;
white-space: pre-wrap !important;
cursor: text;
margin: 0;
padding: 0;
}
} }
} }

View File

@ -2,6 +2,7 @@
// This is an auto-generated file // This is an auto-generated file
export declare const active: string export declare const active: string
export declare const chevron: string export declare const chevron: string
export declare const consoleLine: string
export declare const content: string export declare const content: string
export declare const expanded: string export declare const expanded: string
export declare const header: string export declare const header: string

View File

@ -1,25 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Falsy, Match, Render, Truthy } from 'react-jsx-match' import { Falsy, Match, Render, Truthy } from 'react-jsx-match'
import { CheckCircle, NavArrowRight } from 'iconoir-react' import { CheckCircle, NavArrowRight } from 'iconoir-react'
import { get } from 'lodash-es' import { get, sortBy } from 'lodash-es'
import cx from 'classnames' import cx from 'classnames'
import { useGet } from 'restful-react'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { import { Container, Layout, Text, FlexExpander, Utils, Button, ButtonVariation, ButtonSize } from '@harnessio/uicore'
Container,
Layout,
Text,
FlexExpander,
Utils,
Button,
ButtonVariation,
ButtonSize,
useToaster
} from '@harnessio/uicore'
import { Icon } from '@harnessio/icons' import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system' import { Color, FontVariation } from '@harnessio/design-system'
import { LogViewer, TermRefs } from 'components/LogViewer/LogViewer' import { LogViewer, TermRefs } from 'components/LogViewer/LogViewer'
import { ButtonRoleProps, getErrorMessage, PullRequestCheckType, PullRequestSection, timeDistance } from 'utils/Utils' import { ButtonRoleProps, PullRequestCheckType, PullRequestSection, timeDistance } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils' import type { GitInfoProps } from 'utils/GitUtils'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useQueryParams } from 'hooks/useQueryParams' import { useQueryParams } from 'hooks/useQueryParams'
@ -27,17 +16,18 @@ import { useStrings } from 'framework/strings'
import { Split } from 'components/Split/Split' import { Split } from 'components/Split/Split'
import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer' import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer'
import type { PRChecksDecisionResult } from 'hooks/usePRChecksDecision' import type { PRChecksDecisionResult } from 'hooks/usePRChecksDecision'
import type { LivelogLine, TypesCheck, TypesExecution, TypesStage, TypesStep } from 'services/code' import type { TypesCheck, TypesStage } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus' import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import { useShowRequestError } from 'hooks/useShowRequestError' import { CheckPipelineStages } from './CheckPipelineStages'
// import drone from './drone.svg' import { findDefaultExecution } from './ChecksUtils'
import { CheckPipelineSteps } from './CheckPipelineSteps'
import css from './Checks.module.scss' import css from './Checks.module.scss'
interface ChecksProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> { interface ChecksProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> {
prChecksDecisionResult?: PRChecksDecisionResult prChecksDecisionResult?: PRChecksDecisionResult
} }
export const Checks: React.FC<ChecksProps> = props => { export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullRequestMetadata, prChecksDecisionResult }) => {
const { getString } = useStrings() const { getString } = useStrings()
const history = useHistory() const history = useHistory()
const { routes } = useAppContext() const { routes } = useAppContext()
@ -56,32 +46,34 @@ export const Checks: React.FC<ChecksProps> = props => {
const executionLink = useMemo(() => { const executionLink = useMemo(() => {
if (selectedStage) { if (selectedStage) {
return routes.toCODEExecution({ return routes.toCODEExecution({
repoPath: props.repoMetadata?.path as string, repoPath: repoMetadata?.path as string,
pipeline: selectedItemData?.uid as string, pipeline: selectedItemData?.uid as string,
execution: get(selectedItemData, 'payload.data.execution_number', '') execution: get(selectedItemData, 'payload.data.execution_number', '')
}) })
} else { } else {
return selectedItemData?.link return selectedItemData?.link
} }
}, [props.repoMetadata?.path, routes, selectedItemData, selectedStage]) }, [repoMetadata?.path, routes, selectedItemData, selectedStage])
if (!props.prChecksDecisionResult) { if (!prChecksDecisionResult) {
return null return null
} }
return ( return (
<Container className={css.main}> <Container className={css.main}>
<Match expr={props.prChecksDecisionResult?.overallStatus}> <Match expr={prChecksDecisionResult?.overallStatus}>
<Truthy> <Truthy>
<Split <Split
split="vertical" split="vertical"
size="calc(100% - 400px)" size={'calc(100% - 400px)'}
minSize={800} minSize={'calc(100% - 300px)'}
maxSize="calc(100% - 900px)" maxSize={'calc(100% - 600px)'}
onDragFinished={onSplitPaneResized} onDragFinished={onSplitPaneResized}
primary="second"> primary="second">
<ChecksMenu <ChecksMenu
{...props} repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
prChecksDecisionResult={prChecksDecisionResult}
onDataItemChanged={data => { onDataItemChanged={data => {
setTimeout(() => setSelectedItemData(data), 0) setTimeout(() => setSelectedItemData(data), 0)
}} }}
@ -138,10 +130,12 @@ export const Checks: React.FC<ChecksProps> = props => {
<Container className={css.terminalContainer}> <Container className={css.terminalContainer}>
<Match expr={selectedStage}> <Match expr={selectedStage}>
<Truthy> <Truthy>
<PipelineSteps <CheckPipelineSteps
itemData={selectedItemData as TypesCheck} repoMetadata={repoMetadata}
repoMetadata={props.repoMetadata} pullRequestMetadata={pullRequestMetadata}
pipelineName={selectedItemData?.uid as string}
stage={selectedStage as TypesStage} stage={selectedStage as TypesStage}
executionNumber={get(selectedItemData, 'payload.data.execution_number', '')}
/> />
</Truthy> </Truthy>
<Falsy> <Falsy>
@ -181,6 +175,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
const { uid } = useQueryParams<{ uid: string }>() const { uid } = useQueryParams<{ uid: string }>()
const [selectedUID, setSelectedUID] = React.useState<string | undefined>() const [selectedUID, setSelectedUID] = React.useState<string | undefined>()
const [selectedStage, setSelectedStage] = useState<TypesStage | null>(null) const [selectedStage, setSelectedStage] = useState<TypesStage | null>(null)
useMemo(() => { useMemo(() => {
if (selectedUID) { if (selectedUID) {
const selectedDataItem = prChecksDecisionResult?.data?.find(item => item.uid === selectedUID) const selectedDataItem = prChecksDecisionResult?.data?.find(item => item.uid === selectedUID)
@ -196,14 +191,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
setSelectedUID(uid) setSelectedUID(uid)
} }
} else { } else {
// Find and set a default selected item. Order: Error, Failure, Running, Success, Pending const defaultSelectedItem = findDefaultExecution(prChecksDecisionResult?.data)
const defaultSelectedItem =
prChecksDecisionResult?.data?.find(({ status }) => status === ExecutionState.ERROR) ||
prChecksDecisionResult?.data?.find(({ status }) => status === ExecutionState.FAILURE) ||
prChecksDecisionResult?.data?.find(({ status }) => status === ExecutionState.RUNNING) ||
prChecksDecisionResult?.data?.find(({ status }) => status === ExecutionState.SUCCESS) ||
prChecksDecisionResult?.data?.find(({ status }) => status === ExecutionState.PENDING) ||
prChecksDecisionResult?.data?.[0]
if (defaultSelectedItem) { if (defaultSelectedItem) {
onDataItemChanged(defaultSelectedItem) onDataItemChanged(defaultSelectedItem)
@ -231,7 +219,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
return ( return (
<Container className={css.menu}> <Container className={css.menu}>
{prChecksDecisionResult?.data?.map(itemData => ( {sortBy(prChecksDecisionResult?.data || [], ['uid'])?.map(itemData => (
<CheckMenuItem <CheckMenuItem
repoMetadata={repoMetadata} repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata} pullRequestMetadata={pullRequestMetadata}
@ -253,6 +241,10 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
}) + `?uid=${itemData.uid}${stage ? `&stageId=${stage.name}` : ''}` }) + `?uid=${itemData.uid}${stage ? `&stageId=${stage.name}` : ''}`
) )
}} }}
setSelectedStage={stage => {
setSelectedStage(stage)
setSelectedStageFromProps(stage)
}}
/> />
))} ))}
</Container> </Container>
@ -264,6 +256,7 @@ interface CheckMenuItemProps extends ChecksProps {
isSelected?: boolean isSelected?: boolean
itemData: TypesCheck itemData: TypesCheck
onClick: (stage?: TypesStage) => void onClick: (stage?: TypesStage) => void
setSelectedStage: (stage: TypesStage | null) => void
} }
const CheckMenuItem: React.FC<CheckMenuItemProps> = ({ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
@ -271,7 +264,9 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
isSelected = false, isSelected = false,
itemData, itemData,
onClick, onClick,
repoMetadata repoMetadata,
pullRequestMetadata,
setSelectedStage
}) => { }) => {
const [expanded, setExpanded] = useState(isSelected) const [expanded, setExpanded] = useState(isSelected)
@ -297,7 +292,6 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
<Match expr={isPipeline}> <Match expr={isPipeline}>
<Truthy> <Truthy>
<Icon name="pipeline" size={20} /> <Icon name="pipeline" size={20} />
{/* <img src={drone} width={20} height={20} /> */}
</Truthy> </Truthy>
<Falsy> <Falsy>
<CheckCircle color={Utils.getRealCSSColor(Color.GREY_500)} className={css.noShrink} /> <CheckCircle color={Utils.getRealCSSColor(Color.GREY_500)} className={css.noShrink} />
@ -332,255 +326,15 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
</Layout.Horizontal> </Layout.Horizontal>
<Render when={isPipeline}> <Render when={isPipeline}>
<PipelineStages itemData={itemData} expanded={expanded} repoMetadata={repoMetadata} onClick={onClick} /> <CheckPipelineStages
</Render> pipelineName={itemData.uid as string}
</Container> executionNumber={get(itemData, 'payload.data.execution_number', '')}
) expanded={expanded}
}
const PipelineStages: React.FC<
Pick<CheckMenuItemProps, 'repoMetadata' | 'itemData' | 'onClick'> & { expanded: boolean }
> = ({ itemData, expanded, repoMetadata, onClick }) => {
const {
data: execution,
error,
loading,
refetch
} = useGet<TypesExecution>({
path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${itemData.uid}/executions/${get(
itemData,
'payload.data.execution_number'
)}`,
lazy: true
})
const { uid, stageId } = useQueryParams<{ uid: string; stageId: string }>()
const stages = useMemo(() => execution?.stages, [execution])
const [selectedStageName, setSelectedStageName] = useState<string>('')
useShowRequestError(error)
useEffect(() => {
let timeoutId = 0
if (repoMetadata && expanded) {
if (!execution) {
refetch()
} else {
if (
!error &&
stages?.find(({ status }) => status === ExecutionState.PENDING || status === ExecutionState.RUNNING)
) {
timeoutId = window.setTimeout(refetch, PIPELINE_STAGE_POLLING_INTERVAL)
}
}
}
return () => {
window.clearTimeout(timeoutId)
}
}, [repoMetadata, expanded, execution, refetch, error, stages])
useEffect(() => {
if (stages?.length) {
if (uid === itemData.uid && !selectedStageName) {
if (!stageId) {
setSelectedStageName(stages[0].name as string)
onClick(stages[0])
} else {
const _stage = stages.find(({ name }) => name === stageId)
if (_stage) {
setSelectedStageName(_stage.name as string)
onClick(_stage)
}
}
} else if (uid !== itemData.uid && selectedStageName) {
setSelectedStageName('')
}
}
}, [stages, selectedStageName, setSelectedStageName, onClick, stageId, uid, itemData.uid])
useEffect(() => {
if (stageId && selectedStageName && selectedStageName !== stageId) {
setSelectedStageName('')
}
}, [stageId, selectedStageName])
return (
<Container className={cx(css.pipelineStages, { [css.hidden]: !expanded })}>
<Match expr={loading && !execution}>
<Truthy>
<Container className={css.spinner}>
<Icon name="steps-spinner" size={16} />
</Container>
</Truthy>
<Falsy>
<>
{stages?.map(stage => (
<Layout.Horizontal
spacing="small"
key={stage.name}
className={cx(css.subMenu, { [css.selected]: stage.name === selectedStageName })}
{...ButtonRoleProps}
onClick={() => {
onClick(stage)
setSelectedStageName(stage.name as string)
}}>
<ExecutionStatus
className={cx(css.status, css.noShrink)}
status={stage.status as ExecutionState}
iconSize={16}
noBackground
iconOnly
/>
<Text color={Color.GREY_800} className={css.text}>
{stage.name}
</Text>
</Layout.Horizontal>
))}
</>
</Falsy>
</Match>
</Container>
)
}
const PipelineSteps: React.FC<Pick<CheckMenuItemProps, 'repoMetadata' | 'itemData'> & { stage: TypesStage }> = ({
itemData,
stage,
repoMetadata
}) => {
return (
<Container className={cx(css.pipelineSteps)}>
{stage.steps?.map(step => (
<PipelineStep
key={(itemData.uid + ((stage.name as string) + step.name)) as string}
itemData={itemData}
repoMetadata={repoMetadata} repoMetadata={repoMetadata}
stage={stage} pullRequestMetadata={pullRequestMetadata}
step={step} onSelectStage={setSelectedStage}
/> />
))}
</Container>
)
}
const PipelineStep: React.FC<
Pick<CheckMenuItemProps, 'repoMetadata' | 'itemData'> & { stage: TypesStage; step: TypesStep }
> = ({ itemData, stage, repoMetadata, step }) => {
const { showError } = useToaster()
const eventSourceRef = useRef<EventSource | null>(null)
const [streamingLogs, setStreamingLogs] = useState<LivelogLine[]>([])
const isRunning = useMemo(() => step.status === ExecutionState.RUNNING, [step])
const [expanded, setExpanded] = useState(
isRunning || step.status === ExecutionState.ERROR || step.status === ExecutionState.FAILURE
)
const stepLogPath = useMemo(
() =>
`/api/v1/repos/${repoMetadata?.path}/+/pipelines/${itemData.uid}/executions/${get(
itemData,
'payload.data.execution_number'
)}/logs/${stage.number}/${step.number}`,
[itemData, repoMetadata?.path, stage.number, step.number]
)
const lazy =
!expanded || isRunning || step.status === ExecutionState.PENDING || step.status === ExecutionState.SKIPPED
const {
data: logs,
error,
loading
} = useGet<LivelogLine[]>({
path: stepLogPath,
lazy
})
const logContent = useMemo(
() => ((isRunning ? streamingLogs : logs) || []).map(log => log.out || '').join(''),
[streamingLogs, logs, isRunning]
)
useEffect(() => {
if (isRunning) {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
setStreamingLogs([])
eventSourceRef.current = new EventSource(`${stepLogPath}/stream`)
eventSourceRef.current.onmessage = event => {
try {
setStreamingLogs(existingLogs => [...existingLogs, JSON.parse(event.data)])
} catch (exception) {
showError(getErrorMessage(exception))
eventSourceRef.current?.close()
eventSourceRef.current = null
}
}
}
return () => {
setStreamingLogs([])
eventSourceRef.current?.close()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useShowRequestError(error)
return (
<Container key={step.number}>
<Layout.Horizontal
spacing="small"
className={cx(css.stepHeader, { [css.expanded]: expanded, [css.selected]: expanded })}
{...ButtonRoleProps}
onClick={() => {
setExpanded(!expanded)
}}>
<NavArrowRight
color={Utils.getRealCSSColor(Color.GREY_500)}
className={cx(css.noShrink, css.chevron)}
strokeWidth="1.5"
/>
<ExecutionStatus
className={cx(css.status, css.noShrink)}
status={step.status as ExecutionState}
iconSize={16}
noBackground
iconOnly
/>
<Text className={css.name} lineClamp={1}>
{step.name}
</Text>
<FlexExpander />
<Render when={loading}>
<Icon name="steps-spinner" size={16} />
</Render>
<Render when={step.started && step.stopped}>
<Text color={Color.GREY_300} font={{ variation: FontVariation.SMALL }} className={css.noShrink}>
{timeDistance(step.started, step.stopped)}
</Text>
</Render>
</Layout.Horizontal>
<Render when={expanded}>
<Container className={css.stepLogViewer}>
<Match expr={isRunning}>
<Truthy>
{/* Streaming puts too much pressure on xtermjs and cause incorrect row calculation. Using key to force React to create new instance every time there is new data */}
{[streamingLogs.length].map(len => (
<LogViewer key={len} content={logContent} autoHeight />
))}
</Truthy>
<Falsy>
<LogViewer content={logContent} autoHeight />
</Falsy>
</Match>
</Container>
</Render> </Render>
</Container> </Container>
) )
} }
const PIPELINE_STAGE_POLLING_INTERVAL = 5000

View File

@ -0,0 +1,15 @@
import { ExecutionState } from 'components/ExecutionStatus/ExecutionStatus'
import type { EnumCheckStatus } from 'services/code'
type CheckType = { status: EnumCheckStatus }[]
export function findDefaultExecution<T>(collection: Iterable<T> | null | undefined) {
return (collection as CheckType)?.length
? (((collection as CheckType).find(({ status }) => status === ExecutionState.ERROR) ||
(collection as CheckType).find(({ status }) => status === ExecutionState.FAILURE) ||
(collection as CheckType).find(({ status }) => status === ExecutionState.RUNNING) ||
(collection as CheckType).find(({ status }) => status === ExecutionState.SUCCESS) ||
(collection as CheckType).find(({ status }) => status === ExecutionState.PENDING) ||
(collection as CheckType)[0]) as T)
: null
}

View File

@ -1,11 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.9">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7141 7.77147C13.7141 8.50462 13.5719 9.21745 13.3235 9.87395C13.2779 9.9945 13.124 10.0248 13.0323 9.93419L11.2087 8.13261C11.1326 8.05744 11.1199 7.94005 11.1695 7.84529C11.3672 7.46734 11.4661 7.03501 11.4661 6.5829C11.492 5.1101 10.2776 3.89569 8.77893 3.89569C8.30313 3.89569 7.86706 4.0149 7.48816 4.21847C7.39489 4.26857 7.27878 4.25661 7.20342 4.18223L5.58012 2.58001C5.46572 2.4671 5.50071 2.27474 5.65076 2.21712C6.31081 1.96367 7.01208 1.82861 7.77123 1.82861C11.0527 1.82861 13.7141 4.48998 13.7141 7.77147ZM4.33148 2.93518C4.43742 2.86053 4.58114 2.87604 4.67279 2.96769L6.51749 4.81239C6.599 4.8939 6.60614 5.02281 6.54217 5.1187C6.25383 5.55093 6.09172 6.05957 6.09172 6.60874C6.09172 8.10737 7.30613 9.32178 8.80477 9.32178C9.35394 9.32178 9.86258 9.15967 10.2948 8.87134C10.3907 8.80737 10.5196 8.8145 10.6011 8.89601L12.5979 10.8928C12.6908 10.9857 12.7053 11.1321 12.6276 11.2382C11.5311 12.7361 9.78238 13.7143 7.77123 13.7143C4.48974 13.7143 1.82837 11.053 1.82837 7.77147C1.82837 5.75853 2.80833 4.0085 4.33148 2.93518ZM8.80477 8.28824C9.81247 8.28824 10.6135 7.48725 10.6135 6.47955C10.6135 5.47184 9.81247 4.67085 8.80477 4.67085C7.79706 4.67085 6.99607 5.47184 6.99607 6.47955C6.99607 7.48725 7.79706 8.28824 8.80477 8.28824Z" fill="url(#paint0_linear_4015_405182)"/>
</g>
<defs>
<linearGradient id="paint0_linear_4015_405182" x1="-2.01082" y1="5.6678" x2="5.66756" y2="17.5535" gradientUnits="userSpaceOnUse">
<stop stop-color="#73DFE7"/>
<stop offset="1" stop-color="#0095F7"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,14 +1,12 @@
$code-editor-font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
@mixin mono-font { @mixin mono-font {
font-family: Menlo, Monaco, 'Courier New', monospace var(--font-family-mono) !important; font-family: var(--font-family-mono) !important;
font-weight: normal !important;
font-size: 13px !important; font-size: 13px !important;
font-weight: normal !important;
font-kerning: none;
letter-spacing: 0;
line-height: 20px;
font-feature-settings: 'liga' 0, 'calt' 0; font-feature-settings: 'liga' 0, 'calt' 0;
font-variation-settings: normal; font-variation-settings: normal;
line-height: 18px;
letter-spacing: 0px;
} }
@mixin markdown-font { @mixin markdown-font {

View File

@ -2273,10 +2273,10 @@ ajv@^8.0.0, ajv@^8.0.1, ajv@^8.9.0:
require-from-string "^2.0.2" require-from-string "^2.0.2"
uri-js "^4.2.2" uri-js "^4.2.2"
anser@2.0.1: anser@^2.1.1:
version "2.0.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/anser/-/anser-2.0.1.tgz#8d9069291fee18306ffaf2e364a690dcc8ed78ad" resolved "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz#8afae28d345424c82de89cc0e4d1348eb0c5af7c"
integrity sha512-4g5Np4CVD3c5c/36Mj0jllEA5bQcuXF0dqakZcuHGeubBzw93EAhwRuQCzgFm4/ZwvyBMzFdtn9BcihOjnxIdQ== integrity sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==
ansi-align@^2.0.0: ansi-align@^2.0.0:
version "2.0.0" version "2.0.0"
@ -11410,30 +11410,25 @@ xmlchars@^2.2.0:
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xterm-addon-canvas@^0.3.0: xterm-addon-fit@^0.8.0:
version "0.3.0" version "0.8.0"
resolved "https://registry.yarnpkg.com/xterm-addon-canvas/-/xterm-addon-canvas-0.3.0.tgz#8cfb5a13297f4a31a12870c1119af2c139392b50" resolved "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz#48ca99015385141918f955ca7819e85f3691d35f"
integrity sha512-2deF4ev6T+NjgSM56H+jcAWz4k5viEoaBtuDEyfo5Qdh1r7HOvNzLC45HSeegdH38qmEcL9XIt0KXyOINpSFRA== integrity sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==
xterm-addon-fit@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.7.0.tgz#b8ade6d96e63b47443862088f6670b49fb752c6a"
integrity sha512-tQgHGoHqRTgeROPnvmtEJywLKoC/V9eNs4bLLz7iyJr1aW/QFzRwfd3MGiJ6odJd9xEfxcW36/xRU47JkD5NKQ==
xterm-addon-search@^0.11.0: xterm-addon-search@^0.11.0:
version "0.11.0" version "0.11.0"
resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.11.0.tgz#2a00ff7f9848f6140e7c4d1782486b0b18b06e0d" resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.11.0.tgz#2a00ff7f9848f6140e7c4d1782486b0b18b06e0d"
integrity sha512-6U4uHXcQ7G5igsdaGqrJ9ehm7vep24bXqWxuy3AnIosXF2Z5uy2MvmYRyTGNembIqPV/x1YhBQ7uShtuqBHhOQ== integrity sha512-6U4uHXcQ7G5igsdaGqrJ9ehm7vep24bXqWxuy3AnIosXF2Z5uy2MvmYRyTGNembIqPV/x1YhBQ7uShtuqBHhOQ==
xterm-addon-web-links@^0.8.0: xterm-addon-web-links@^0.9.0:
version "0.8.0" version "0.9.0"
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.8.0.tgz#2cb1d57129271022569208578b0bf4774e7e6ea9" resolved "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz#c65b18588d1f613e703eb6feb7f129e7ff1c63e7"
integrity sha512-J4tKngmIu20ytX9SEJjAP3UGksah7iALqBtfTwT9ZnmFHVplCumYQsUJfKuS+JwMhjsjH61YXfndenLNvjRrEw== integrity sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==
xterm@^5.1.0: xterm@^5.3.0:
version "5.1.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.1.0.tgz#3e160d60e6801c864b55adf19171c49d2ff2b4fc" resolved "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz#867daf9cc826f3d45b5377320aabd996cb0fce46"
integrity sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ== integrity sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.3" version "4.0.3"