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-themes-all": "^4.19.9",
"@uiw/react-markdown-preview": "^4.1.12",
"anser": "2.0.1",
"anser": "^2.1.1",
"classnames": "^2.2.6",
"clipboard-copy": "^3.1.0",
"diff2html": "3.4.22",
@ -85,11 +85,10 @@
"rehype-video": "^1.2.2",
"restful-react": "15.6.0",
"webpack-retry-chunk-load-plugin": "^3.1.0",
"xterm": "^5.1.0",
"xterm-addon-canvas": "^0.3.0",
"xterm-addon-fit": "^0.7.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.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",
"yup": "^0.29.1"
},

View File

@ -4,6 +4,9 @@
@include vars;
&.fullPage {
--font-family: Inter, sans-serif;
--font-family-mono: Roboto Mono, monospace;
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 { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { CanvasAddon } from 'xterm-addon-canvas'
import { SearchAddon } from 'xterm-addon-search'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { useEventListener } from 'hooks/useEventListener'
const DEFAULT_SCROLLBACK_LINES = 100000
export type TermRefs = { term: Terminal; fitAddon: FitAddon } | undefined
export type TermRefs = { term: Terminal; fitAddon: FitAddon }
export interface LogViewerProps {
/** Search text */
searchText?: string
/** Number of scrollback lines */
scrollbackLines?: number
/** Log content as string */
search?: string
content: string
termRefs?: React.MutableRefObject<TermRefs>
termRefs?: React.MutableRefObject<TermRefs | undefined>
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 lines = useMemo(() => content.split(/\r?\n/), [content])
const term = useRef<{ term: Terminal; fitAddon: FitAddon }>()
const term = useRef<TermRefs>()
useEffect(() => {
if (!term.current) {
const _term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
allowTransparency: true,
disableStdin: true,
scrollback: scrollbackLines || DEFAULT_SCROLLBACK_LINES,
theme: {
background: 'transparent'
}
tabStopWidth: 2,
scrollOnUserInput: false,
smoothScrollDuration: 0,
scrollback: 10000
})
const searchAddon = new SearchAddon()
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
_term.loadAddon(searchAddon)
_term.loadAddon(fitAddon)
_term.loadAddon(webLinksAddon)
_term.loadAddon(new CanvasAddon())
_term.open(ref?.current as HTMLDivElement)
fitAddon.fit()
searchAddon.activate(_term)
_term.write('\x1b[?25l') // disable cursor
// disable cursor
_term.write('\x1b[?25l')
term.current = { term: _term, fitAddon }
@ -67,24 +50,38 @@ export const LogViewer: React.FC<LogViewerProps> = ({ scrollbackLines, content,
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
useEffect(() => {
term.current?.term?.clear()
const lines = content.split(/\r?\n/)
lines.forEach(line => term.current?.term?.writeln(line))
if (autoHeight) {
term.current?.term?.resize(term.current?.term?.cols, lines.length + 1)
}
setTimeout(() => {
term.current?.term.scrollToTop()
}, 0)
return () => {
term.current?.term?.clear()
}
}, [lines, autoHeight])
}, [content, autoHeight])
useEventListener('resize', () => {
term.current?.fitAddon?.fit()
})
useEventListener('resize', () => term.current?.fitAddon?.fit())
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 { getErrorMessage } from 'utils/Utils'
export function useShowRequestError(error: GetDataError<Unknown> | null) {
export function useShowRequestError(error: GetDataError<Unknown> | null, timeout?: number) {
const { showError } = useToaster()
useEffect(() => {
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 {
--stage-title-height: 54px;
--stage-detail-section-height: 48px;
@ -194,6 +196,9 @@
height: 100%;
overflow: auto;
padding: 10px 20px 0 !important;
display: flex;
flex-direction: column;
gap: 5px;
&::before {
content: '';
@ -248,6 +253,19 @@
}
.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
export declare const active: string
export declare const chevron: string
export declare const consoleLine: string
export declare const content: string
export declare const expanded: string
export declare const header: string

View File

@ -1,25 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Falsy, Match, Render, Truthy } from 'react-jsx-match'
import { CheckCircle, NavArrowRight } from 'iconoir-react'
import { get } from 'lodash-es'
import { get, sortBy } from 'lodash-es'
import cx from 'classnames'
import { useGet } from 'restful-react'
import { useHistory } from 'react-router-dom'
import {
Container,
Layout,
Text,
FlexExpander,
Utils,
Button,
ButtonVariation,
ButtonSize,
useToaster
} from '@harnessio/uicore'
import { Container, Layout, Text, FlexExpander, Utils, Button, ButtonVariation, ButtonSize } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
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 { useAppContext } from 'AppContext'
import { useQueryParams } from 'hooks/useQueryParams'
@ -27,17 +16,18 @@ import { useStrings } from 'framework/strings'
import { Split } from 'components/Split/Split'
import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer'
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 { useShowRequestError } from 'hooks/useShowRequestError'
// import drone from './drone.svg'
import { CheckPipelineStages } from './CheckPipelineStages'
import { findDefaultExecution } from './ChecksUtils'
import { CheckPipelineSteps } from './CheckPipelineSteps'
import css from './Checks.module.scss'
interface ChecksProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> {
prChecksDecisionResult?: PRChecksDecisionResult
}
export const Checks: React.FC<ChecksProps> = props => {
export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullRequestMetadata, prChecksDecisionResult }) => {
const { getString } = useStrings()
const history = useHistory()
const { routes } = useAppContext()
@ -56,32 +46,34 @@ export const Checks: React.FC<ChecksProps> = props => {
const executionLink = useMemo(() => {
if (selectedStage) {
return routes.toCODEExecution({
repoPath: props.repoMetadata?.path as string,
repoPath: repoMetadata?.path as string,
pipeline: selectedItemData?.uid as string,
execution: get(selectedItemData, 'payload.data.execution_number', '')
})
} else {
return selectedItemData?.link
}
}, [props.repoMetadata?.path, routes, selectedItemData, selectedStage])
}, [repoMetadata?.path, routes, selectedItemData, selectedStage])
if (!props.prChecksDecisionResult) {
if (!prChecksDecisionResult) {
return null
}
return (
<Container className={css.main}>
<Match expr={props.prChecksDecisionResult?.overallStatus}>
<Match expr={prChecksDecisionResult?.overallStatus}>
<Truthy>
<Split
split="vertical"
size="calc(100% - 400px)"
minSize={800}
maxSize="calc(100% - 900px)"
size={'calc(100% - 400px)'}
minSize={'calc(100% - 300px)'}
maxSize={'calc(100% - 600px)'}
onDragFinished={onSplitPaneResized}
primary="second">
<ChecksMenu
{...props}
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
prChecksDecisionResult={prChecksDecisionResult}
onDataItemChanged={data => {
setTimeout(() => setSelectedItemData(data), 0)
}}
@ -138,10 +130,12 @@ export const Checks: React.FC<ChecksProps> = props => {
<Container className={css.terminalContainer}>
<Match expr={selectedStage}>
<Truthy>
<PipelineSteps
itemData={selectedItemData as TypesCheck}
repoMetadata={props.repoMetadata}
<CheckPipelineSteps
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
pipelineName={selectedItemData?.uid as string}
stage={selectedStage as TypesStage}
executionNumber={get(selectedItemData, 'payload.data.execution_number', '')}
/>
</Truthy>
<Falsy>
@ -181,6 +175,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
const { uid } = useQueryParams<{ uid: string }>()
const [selectedUID, setSelectedUID] = React.useState<string | undefined>()
const [selectedStage, setSelectedStage] = useState<TypesStage | null>(null)
useMemo(() => {
if (selectedUID) {
const selectedDataItem = prChecksDecisionResult?.data?.find(item => item.uid === selectedUID)
@ -196,14 +191,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
setSelectedUID(uid)
}
} else {
// Find and set a default selected item. Order: Error, Failure, Running, Success, Pending
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]
const defaultSelectedItem = findDefaultExecution(prChecksDecisionResult?.data)
if (defaultSelectedItem) {
onDataItemChanged(defaultSelectedItem)
@ -231,7 +219,7 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
return (
<Container className={css.menu}>
{prChecksDecisionResult?.data?.map(itemData => (
{sortBy(prChecksDecisionResult?.data || [], ['uid'])?.map(itemData => (
<CheckMenuItem
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
@ -253,6 +241,10 @@ const ChecksMenu: React.FC<ChecksMenuProps> = ({
}) + `?uid=${itemData.uid}${stage ? `&stageId=${stage.name}` : ''}`
)
}}
setSelectedStage={stage => {
setSelectedStage(stage)
setSelectedStageFromProps(stage)
}}
/>
))}
</Container>
@ -264,6 +256,7 @@ interface CheckMenuItemProps extends ChecksProps {
isSelected?: boolean
itemData: TypesCheck
onClick: (stage?: TypesStage) => void
setSelectedStage: (stage: TypesStage | null) => void
}
const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
@ -271,7 +264,9 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
isSelected = false,
itemData,
onClick,
repoMetadata
repoMetadata,
pullRequestMetadata,
setSelectedStage
}) => {
const [expanded, setExpanded] = useState(isSelected)
@ -297,7 +292,6 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
<Match expr={isPipeline}>
<Truthy>
<Icon name="pipeline" size={20} />
{/* <img src={drone} width={20} height={20} /> */}
</Truthy>
<Falsy>
<CheckCircle color={Utils.getRealCSSColor(Color.GREY_500)} className={css.noShrink} />
@ -332,255 +326,15 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
</Layout.Horizontal>
<Render when={isPipeline}>
<PipelineStages itemData={itemData} expanded={expanded} repoMetadata={repoMetadata} onClick={onClick} />
</Render>
</Container>
)
}
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}
<CheckPipelineStages
pipelineName={itemData.uid as string}
executionNumber={get(itemData, 'payload.data.execution_number', '')}
expanded={expanded}
repoMetadata={repoMetadata}
stage={stage}
step={step}
pullRequestMetadata={pullRequestMetadata}
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>
</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 {
font-family: Menlo, Monaco, 'Courier New', monospace var(--font-family-mono) !important;
font-weight: normal !important;
font-family: var(--font-family-mono) !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-variation-settings: normal;
line-height: 18px;
letter-spacing: 0px;
}
@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"
uri-js "^4.2.2"
anser@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/anser/-/anser-2.0.1.tgz#8d9069291fee18306ffaf2e364a690dcc8ed78ad"
integrity sha512-4g5Np4CVD3c5c/36Mj0jllEA5bQcuXF0dqakZcuHGeubBzw93EAhwRuQCzgFm4/ZwvyBMzFdtn9BcihOjnxIdQ==
anser@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz#8afae28d345424c82de89cc0e4d1348eb0c5af7c"
integrity sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==
ansi-align@^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"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xterm-addon-canvas@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/xterm-addon-canvas/-/xterm-addon-canvas-0.3.0.tgz#8cfb5a13297f4a31a12870c1119af2c139392b50"
integrity sha512-2deF4ev6T+NjgSM56H+jcAWz4k5viEoaBtuDEyfo5Qdh1r7HOvNzLC45HSeegdH38qmEcL9XIt0KXyOINpSFRA==
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-fit@^0.8.0:
version "0.8.0"
resolved "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz#48ca99015385141918f955ca7819e85f3691d35f"
integrity sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==
xterm-addon-search@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.11.0.tgz#2a00ff7f9848f6140e7c4d1782486b0b18b06e0d"
integrity sha512-6U4uHXcQ7G5igsdaGqrJ9ehm7vep24bXqWxuy3AnIosXF2Z5uy2MvmYRyTGNembIqPV/x1YhBQ7uShtuqBHhOQ==
xterm-addon-web-links@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.8.0.tgz#2cb1d57129271022569208578b0bf4774e7e6ea9"
integrity sha512-J4tKngmIu20ytX9SEJjAP3UGksah7iALqBtfTwT9ZnmFHVplCumYQsUJfKuS+JwMhjsjH61YXfndenLNvjRrEw==
xterm-addon-web-links@^0.9.0:
version "0.9.0"
resolved "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz#c65b18588d1f613e703eb6feb7f129e7ff1c63e7"
integrity sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==
xterm@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.1.0.tgz#3e160d60e6801c864b55adf19171c49d2ff2b4fc"
integrity sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==
xterm@^5.3.0:
version "5.3.0"
resolved "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz#867daf9cc826f3d45b5377320aabd996cb0fce46"
integrity sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.3"