From 15726180939392527482f25725aea587c3bbcd45 Mon Sep 17 00:00:00 2001 From: Tan Nhu Date: Mon, 18 Sep 2023 20:52:38 +0000 Subject: [PATCH] Pipeline Checks refactoring and bug fixes (#515) --- web/package.json | 9 +- web/src/App.module.scss | 3 + web/src/components/LogViewer/LogViewer.tsx | 65 ++-- web/src/hooks/useShowRequestError.ts | 6 +- .../Checks/CheckPipelineStages.tsx | 154 +++++++++ .../PullRequest/Checks/CheckPipelineSteps.tsx | 198 +++++++++++ .../PullRequest/Checks/Checks.module.scss | 20 +- .../Checks/Checks.module.scss.d.ts | 1 + web/src/pages/PullRequest/Checks/Checks.tsx | 326 +++--------------- .../pages/PullRequest/Checks/ChecksUtils.ts | 15 + web/src/pages/PullRequest/Checks/drone.svg | 11 - web/src/utils/utils.scss | 12 +- web/yarn.lock | 37 +- 13 files changed, 489 insertions(+), 368 deletions(-) create mode 100644 web/src/pages/PullRequest/Checks/CheckPipelineStages.tsx create mode 100644 web/src/pages/PullRequest/Checks/CheckPipelineSteps.tsx create mode 100644 web/src/pages/PullRequest/Checks/ChecksUtils.ts delete mode 100644 web/src/pages/PullRequest/Checks/drone.svg diff --git a/web/package.json b/web/package.json index adab8327c..d89de9567 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/src/App.module.scss b/web/src/App.module.scss index 0cedaa961..83b7c0e52 100644 --- a/web/src/App.module.scss +++ b/web/src/App.module.scss @@ -4,6 +4,9 @@ @include vars; &.fullPage { + --font-family: Inter, sans-serif; + --font-family-mono: Roboto Mono, monospace; + height: var(--page-height); } diff --git a/web/src/components/LogViewer/LogViewer.tsx b/web/src/components/LogViewer/LogViewer.tsx index b17429cb6..567b59bf1 100644 --- a/web/src/components/LogViewer/LogViewer.tsx +++ b/web/src/components/LogViewer/LogViewer.tsx @@ -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?: React.MutableRefObject autoHeight?: boolean } -export const LogViewer: React.FC = ({ scrollbackLines, content, termRefs, autoHeight }) => { +const LogTerminal: React.FC = ({ content, termRefs, autoHeight }) => { const ref = useRef(null) - const lines = useMemo(() => content.split(/\r?\n/), [content]) - const term = useRef<{ term: Terminal; fitAddon: FitAddon }>() + const term = useRef() 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 = ({ 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 } + +export const LogViewer = React.memo(LogTerminal) diff --git a/web/src/hooks/useShowRequestError.ts b/web/src/hooks/useShowRequestError.ts index 0e63574f7..bd61b1893 100644 --- a/web/src/hooks/useShowRequestError.ts +++ b/web/src/hooks/useShowRequestError.ts @@ -3,12 +3,12 @@ import { useEffect } from 'react' import type { GetDataError } from 'restful-react' import { getErrorMessage } from 'utils/Utils' -export function useShowRequestError(error: GetDataError | null) { +export function useShowRequestError(error: GetDataError | null, timeout?: number) { const { showError } = useToaster() useEffect(() => { if (error) { - showError(getErrorMessage(error)) + showError(getErrorMessage(error), timeout) } - }, [error, showError]) + }, [error, showError, timeout]) } diff --git a/web/src/pages/PullRequest/Checks/CheckPipelineStages.tsx b/web/src/pages/PullRequest/Checks/CheckPipelineStages.tsx new file mode 100644 index 000000000..3796556ba --- /dev/null +++ b/web/src/pages/PullRequest/Checks/CheckPipelineStages.tsx @@ -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 { + pipelineName: string + executionNumber: string + expanded?: boolean + onSelectStage: (stage: TypesStage) => void +} + +export const CheckPipelineStages: React.FC = ({ + pipelineName, + executionNumber, + expanded, + repoMetadata, + pullRequestMetadata, + onSelectStage +}) => { + const { data, error, loading, refetch } = useGet({ + path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipelineName}/executions/${executionNumber}`, + lazy: true + }) + const [execution, setExecution] = useState() + 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() + + 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 ( + + + + + + + + + <> + {stages.map(stage => ( + { + // ALways send back the latest stage + selectStage(stages.find(stg => stg === stage.name) || stage) + }}> + + + {stage.name} + + + ))} + + + + + ) +} + +const POLLING_INTERVAL = 2000 diff --git a/web/src/pages/PullRequest/Checks/CheckPipelineSteps.tsx b/web/src/pages/PullRequest/Checks/CheckPipelineSteps.tsx new file mode 100644 index 000000000..248b24ccb --- /dev/null +++ b/web/src/pages/PullRequest/Checks/CheckPipelineSteps.tsx @@ -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 { + pipelineName: string + executionNumber: string + stage: TypesStage +} + +export const CheckPipelineSteps: React.FC = ({ + repoMetadata, + pullRequestMetadata, + pipelineName, + stage, + executionNumber +}) => { + return ( + + {stage.steps?.map(step => ( + + ))} + + ) +} + +const CheckPipelineStep: React.FC = ({ + pipelineName, + executionNumber, + stage, + repoMetadata, + step +}) => { + const { showError } = useToaster() + const eventSourceRef = useRef(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({ + path: stepLogPath, + lazy: true + }) + const [isStreamingDone, setIsStreamingDone] = useState(false) + const containerRef = useRef(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 ( + + { + setExpanded(!expanded) + }}> + + + + + + {step.name} + + + + + + + + + + + {timeDistance(step.started, step.stopped)} + + + + + + + + ) +} + +const createLogLineElement = (line = '') => { + const element = document.createElement('pre') + element.className = css.consoleLine + element.innerHTML = Anser.ansiToHtml(line.replace(/\r?\n$/, '')) + return element +} diff --git a/web/src/pages/PullRequest/Checks/Checks.module.scss b/web/src/pages/PullRequest/Checks/Checks.module.scss index eac5beb91..cc21dd121 100644 --- a/web/src/pages/PullRequest/Checks/Checks.module.scss +++ b/web/src/pages/PullRequest/Checks/Checks.module.scss @@ -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; + } } } diff --git a/web/src/pages/PullRequest/Checks/Checks.module.scss.d.ts b/web/src/pages/PullRequest/Checks/Checks.module.scss.d.ts index 46a160996..026b433a8 100644 --- a/web/src/pages/PullRequest/Checks/Checks.module.scss.d.ts +++ b/web/src/pages/PullRequest/Checks/Checks.module.scss.d.ts @@ -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 diff --git a/web/src/pages/PullRequest/Checks/Checks.tsx b/web/src/pages/PullRequest/Checks/Checks.tsx index d6309c6a2..3abd43a77 100644 --- a/web/src/pages/PullRequest/Checks/Checks.tsx +++ b/web/src/pages/PullRequest/Checks/Checks.tsx @@ -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 { prChecksDecisionResult?: PRChecksDecisionResult } -export const Checks: React.FC = props => { +export const Checks: React.FC = ({ repoMetadata, pullRequestMetadata, prChecksDecisionResult }) => { const { getString } = useStrings() const history = useHistory() const { routes } = useAppContext() @@ -56,32 +46,34 @@ export const Checks: React.FC = 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 ( - + { setTimeout(() => setSelectedItemData(data), 0) }} @@ -138,10 +130,12 @@ export const Checks: React.FC = props => { - @@ -181,6 +175,7 @@ const ChecksMenu: React.FC = ({ const { uid } = useQueryParams<{ uid: string }>() const [selectedUID, setSelectedUID] = React.useState() const [selectedStage, setSelectedStage] = useState(null) + useMemo(() => { if (selectedUID) { const selectedDataItem = prChecksDecisionResult?.data?.find(item => item.uid === selectedUID) @@ -196,14 +191,7 @@ const ChecksMenu: React.FC = ({ 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 = ({ return ( - {prChecksDecisionResult?.data?.map(itemData => ( + {sortBy(prChecksDecisionResult?.data || [], ['uid'])?.map(itemData => ( = ({ }) + `?uid=${itemData.uid}${stage ? `&stageId=${stage.name}` : ''}` ) }} + setSelectedStage={stage => { + setSelectedStage(stage) + setSelectedStageFromProps(stage) + }} /> ))} @@ -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 = ({ @@ -271,7 +264,9 @@ const CheckMenuItem: React.FC = ({ isSelected = false, itemData, onClick, - repoMetadata + repoMetadata, + pullRequestMetadata, + setSelectedStage }) => { const [expanded, setExpanded] = useState(isSelected) @@ -297,7 +292,6 @@ const CheckMenuItem: React.FC = ({ - {/* */} @@ -332,255 +326,15 @@ const CheckMenuItem: React.FC = ({ - - - - ) -} - -const PipelineStages: React.FC< - Pick & { expanded: boolean } -> = ({ itemData, expanded, repoMetadata, onClick }) => { - const { - data: execution, - error, - loading, - refetch - } = useGet({ - 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('') - - 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 ( - - - - - - - - - <> - {stages?.map(stage => ( - { - onClick(stage) - setSelectedStageName(stage.name as string) - }}> - - - {stage.name} - - - ))} - - - - - ) -} - -const PipelineSteps: React.FC & { stage: TypesStage }> = ({ - itemData, - stage, - repoMetadata -}) => { - return ( - - {stage.steps?.map(step => ( - - ))} - - ) -} - -const PipelineStep: React.FC< - Pick & { stage: TypesStage; step: TypesStep } -> = ({ itemData, stage, repoMetadata, step }) => { - const { showError } = useToaster() - const eventSourceRef = useRef(null) - const [streamingLogs, setStreamingLogs] = useState([]) - 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({ - 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 ( - - { - setExpanded(!expanded) - }}> - - - - - - {step.name} - - - - - - - - - - - {timeDistance(step.started, step.stopped)} - - - - - - - - {/* 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 => ( - - ))} - - - - - - ) } - -const PIPELINE_STAGE_POLLING_INTERVAL = 5000 diff --git a/web/src/pages/PullRequest/Checks/ChecksUtils.ts b/web/src/pages/PullRequest/Checks/ChecksUtils.ts new file mode 100644 index 000000000..9a686bb77 --- /dev/null +++ b/web/src/pages/PullRequest/Checks/ChecksUtils.ts @@ -0,0 +1,15 @@ +import { ExecutionState } from 'components/ExecutionStatus/ExecutionStatus' +import type { EnumCheckStatus } from 'services/code' + +type CheckType = { status: EnumCheckStatus }[] + +export function findDefaultExecution(collection: Iterable | 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 +} diff --git a/web/src/pages/PullRequest/Checks/drone.svg b/web/src/pages/PullRequest/Checks/drone.svg deleted file mode 100644 index b03ec9e4e..000000000 --- a/web/src/pages/PullRequest/Checks/drone.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/web/src/utils/utils.scss b/web/src/utils/utils.scss index 07ce2d680..d788e58da 100644 --- a/web/src/utils/utils.scss +++ b/web/src/utils/utils.scss @@ -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 { diff --git a/web/yarn.lock b/web/yarn.lock index df9318466..0b75a0241 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -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"