feat: [code-1362]: add streaming and other fixes in status checks (#1020)

This commit is contained in:
Calvin Lee 2024-02-06 04:24:47 +00:00 committed by Harness
parent e0f8248ead
commit 3a3d11f589
10 changed files with 339 additions and 55 deletions

View File

@ -43,7 +43,8 @@ interface ExecutionStatusProps {
export enum ExecutionStateExtended { export enum ExecutionStateExtended {
FAILED = 'failed', FAILED = 'failed',
ABORTED = 'aborted' ABORTED = 'aborted',
ASYNCWAITING = 'asyncwaiting'
} }
export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({ export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({
@ -102,6 +103,11 @@ export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({
icon: 'execution-stopped', icon: 'execution-stopped',
css: null, css: null,
title: getString('killed').toLocaleUpperCase() title: getString('killed').toLocaleUpperCase()
},
[ExecutionStateExtended.ASYNCWAITING]: {
icon: 'running-filled',
css: css.running,
title: getString('running').toLocaleUpperCase()
} }
}), }),
[getString, inExecution, isCi] [getString, inExecution, isCi]

View File

@ -132,3 +132,77 @@
.noShrink { .noShrink {
flex-shrink: inherit; flex-shrink: inherit;
} }
.content {
background-color: var(--black);
overflow: auto;
&.markdown {
:global {
.wmde-markdown {
background-color: transparent !important;
}
}
padding: 0 var(--spacing-large) var(--spacing-medium);
}
&.terminal {
.header {
padding: var(--spacing-medium) var(--spacing-large) 0;
}
span[data-icon='execution-success'] svg {
circle {
color: transparent !important;
}
}
}
.header {
padding-top: var(--spacing-medium);
position: sticky;
top: 0;
background-color: var(--black);
height: var(--log-content-header-height);
z-index: 3;
.headerLayout {
border-bottom: 1px solid var(--grey-800);
padding-bottom: var(--spacing-medium);
align-items: center;
}
}
.markdownContainer {
padding-top: var(--spacing-medium);
padding-left: var(--spacing-small);
}
.logViewer {
padding: var(--spacing-medium) var(--spacing-medium) var(--spacing-medium) var(--spacing-xxlarge);
}
}
.scrollDownBtn {
position: absolute;
padding: 8px !important;
bottom: 7px;
right: 30px;
& > :global(.bp3-icon) {
padding: 0 !important;
}
& > :global(.bp3-button-text) {
width: 0;
padding-left: 0;
overflow: hidden;
display: inline-block;
}
&:hover > :global(.bp3-button-text) {
width: auto;
padding-left: 4px;
}
}

View File

@ -18,15 +18,23 @@
// This is an auto-generated file // This is an auto-generated file
export declare const chevron: string export declare const chevron: string
export declare const consoleLine: string export declare const consoleLine: string
export declare const content: string
export declare const expanded: string export declare const expanded: string
export declare const header: string
export declare const headerLayout: string
export declare const invert: string export declare const invert: string
export declare const line: string export declare const line: string
export declare const logViewer: string
export declare const main: string export declare const main: string
export declare const markdown: string
export declare const markdownContainer: string
export declare const name: string export declare const name: string
export declare const noShrink: string export declare const noShrink: string
export declare const pipelineSteps: string export declare const pipelineSteps: string
export declare const scrollDownBtn: string
export declare const selected: string export declare const selected: string
export declare const status: string export declare const status: string
export declare const stepContainer: string export declare const stepContainer: string
export declare const stepHeader: string export declare const stepHeader: string
export declare const stepLogContainer: string export declare const stepLogContainer: string
export declare const terminal: string

View File

@ -14,20 +14,22 @@
* limitations under the License. * limitations under the License.
*/ */
import React, { useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Anser from 'anser' import Anser from 'anser'
import cx from 'classnames' import cx from 'classnames'
import { Container, FlexExpander, Layout, Text, Utils } from '@harnessio/uicore' import { Button, ButtonSize, ButtonVariation, Container, FlexExpander, Layout, Text, Utils } from '@harnessio/uicore'
import { NavArrowRight } from 'iconoir-react' import { NavArrowRight } from 'iconoir-react'
import { isEmpty } from 'lodash-es'
import { Color, FontVariation } from '@harnessio/design-system' import { Color, FontVariation } from '@harnessio/design-system'
import { Render } from 'react-jsx-match' import { Render } from 'react-jsx-match'
import { parseLogString } from 'pages/PullRequest/Checks/ChecksUtils' import { useStrings } from 'framework/strings'
import { capitalizeFirstLetter, parseLogString } from 'pages/PullRequest/Checks/ChecksUtils'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { ButtonRoleProps, timeDistance } from 'utils/Utils' import { ButtonRoleProps, timeDistance } from 'utils/Utils'
import { useScheduleJob } from 'hooks/useScheduleJob'
import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code' import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus' import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import css from './LogViewer.module.scss' import css from './LogViewer.module.scss'
export interface LogViewerProps { export interface LogViewerProps {
search?: string search?: string
content?: string content?: string
@ -53,6 +55,11 @@ enum StepTypes {
INITIALIZE = 'Initialize' INITIALIZE = 'Initialize'
} }
enum StepStatus {
RUNNING = 'running',
SUCCESS = 'success'
}
export type EnumCheckPayloadKindExtended = EnumCheckPayloadKind | 'harness_stage' export type EnumCheckPayloadKindExtended = EnumCheckPayloadKind | 'harness_stage'
const LogTerminal: React.FC<LogViewerProps> = ({ const LogTerminal: React.FC<LogViewerProps> = ({
@ -63,9 +70,10 @@ const LogTerminal: React.FC<LogViewerProps> = ({
selectedItemData selectedItemData
}) => { }) => {
const { hooks } = useAppContext() const { hooks } = useAppContext()
const { getString } = useStrings()
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const containerKey = selectedItemData ? selectedItemData.id : 'default-key' const containerKey = selectedItemData ? selectedItemData.id : 'default-key'
useEffect(() => { useEffect(() => {
// Clear the container first // Clear the container first
@ -82,50 +90,94 @@ const LogTerminal: React.FC<LogViewerProps> = ({
} }
}, [content, stepNameLogKeyMap, selectedItemData]) }, [content, stepNameLogKeyMap, selectedItemData])
const getLogData = (logBaseKey: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const getLogData = (logBaseKey: string, status: string, onMessageStreaming: (e: any) => void) => {
const logContent = hooks?.useLogsContent([logBaseKey]) const logContent = hooks?.useLogsContent([logBaseKey])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onError = (e: any) => {
if (e.type === 'error') {
streamingData?.closeStream()
}
}
const streamingData = hooks?.useLogsStreaming([logBaseKey], onMessageStreaming, onError)
if (
selectedItemData?.status === StepStatus.RUNNING &&
status !== capitalizeFirstLetter(StepStatus.SUCCESS) &&
!isEmpty(logContent.streamData)
) {
return streamingData.streamData[logBaseKey]
}
return logContent.blobDataCur return logContent.blobDataCur
} }
// State to manage expanded states of all containers const [isBottom, setIsBottom] = useState(false)
const [expandedStates, setExpandedStates] = useState(new Map<string, boolean>()) const [expandedStates, setExpandedStates] = useState<Map<string, { expanded: boolean; streaming: boolean }>>(
// UseEffect to initialize the states new Map()
useEffect(() => { )
const states = new Map<string, boolean>()
stepNameLogKeyMap?.forEach(value => {
states.set(value.logBaseKey, false)
})
setExpandedStates(states) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedItemData])
useEffect(() => {
const states = new Map<string, { expanded: boolean; streaming: boolean }>()
if (expandedStates.size === 0) {
stepNameLogKeyMap?.forEach(_ => {
states.set(_.logBaseKey, { expanded: false, streaming: false })
})
setExpandedStates(states)
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepNameLogKeyMap])
const handleClick = () => {
const logContainer = containerRef.current as HTMLDivElement
const scrollParent = logContainer?.parentElement as HTMLDivElement
if (!isBottom) {
scrollParent.scrollTop = scrollParent.scrollHeight
setIsBottom(true)
} else if (isBottom) {
scrollParent.scrollTop = 0
setIsBottom(false)
}
}
// Function to toggle expanded state of a container // Function to toggle expanded state of a container
const toggleExpandedState = (key: string) => { const toggleExpandedState = useCallback((key: string) => {
setExpandedStates(prevStates => { setExpandedStates(prevStates => {
const newStates = new Map(prevStates) const newStates = new Map(prevStates)
newStates.set(key, !newStates.get(key)) const currentState = newStates.get(key) || { expanded: false, streaming: false }
newStates.set(key, { ...currentState, expanded: !currentState.expanded })
return newStates return newStates
}) })
} }, [])
if (
stepNameLogKeyMap && const [steps, setSteps] = useState(new Map())
(selectedItemData?.payload?.kind as EnumCheckPayloadKindExtended) === 'harness_stage' &&
selectedItemData?.status !== 'running' useEffect(() => {
) { if (stepNameLogKeyMap) {
const renderedSteps = Array.from(stepNameLogKeyMap?.entries() || []).map(([key, data], idx) => { const newStatuses = new Map()
stepNameLogKeyMap.forEach((value, key) => {
newStatuses.set(key, value)
})
setSteps(newStatuses)
}
}, [stepNameLogKeyMap, selectedItemData?.status, toggleExpandedState])
const renderedSteps = useMemo(() => {
return Array.from(steps?.entries() || []).map(([key, data], idx) => {
if (key === undefined || idx === 0) { if (key === undefined || idx === 0) {
return return
} }
const expanded =
expandedStates.get(data.logBaseKey)?.expanded || data.status === 'AsyncWaiting' || data.status === 'Queued'
return ( return (
<Container key={data.logBaseKey} className={cx(css.pipelineSteps)}> <Container ref={containerRef} key={data.logBaseKey} className={cx(css.pipelineSteps)}>
<Container className={css.stepContainer}> <Container className={css.stepContainer}>
<Layout.Horizontal <Layout.Horizontal
spacing="small" spacing="small"
className={cx(css.stepHeader, { className={cx(css.stepHeader, {
[css.expanded]: expandedStates.get(key), [css.expanded]: expanded,
[css.selected]: expandedStates.get(key) [css.selected]: expanded
})} })}
{...ButtonRoleProps} {...ButtonRoleProps}
onClick={() => { onClick={() => {
toggleExpandedState(key) toggleExpandedState(data.logBaseKey)
}}> }}>
<NavArrowRight color={Utils.getRealCSSColor(Color.GREY_500)} className={cx(css.noShrink, css.chevron)} /> <NavArrowRight color={Utils.getRealCSSColor(Color.GREY_500)} className={cx(css.noShrink, css.chevron)} />
<ExecutionStatus <ExecutionStatus
@ -146,28 +198,58 @@ const LogTerminal: React.FC<LogViewerProps> = ({
</Text> </Text>
</Render> </Render>
</Layout.Horizontal> </Layout.Horizontal>
<Render when={expandedStates.get(key)}> <Render when={expanded}>
<LogStageContainer <LogStageContainer
value={key} value={key}
status={data.status}
getLogData={getLogData} getLogData={getLogData}
logKey={data.logBaseKey} logKey={data.logBaseKey}
expanded={expandedStates.get(key)} expanded={expanded}
setSelectedStage={setSelectedStage} setSelectedStage={setSelectedStage}
/> />
<Button
size={ButtonSize.SMALL}
variation={ButtonVariation.PRIMARY}
text={isBottom ? getString('top') : getString('bottom')}
icon={isBottom ? 'arrow-up' : 'arrow-down'}
iconProps={{ size: 10 }}
onClick={handleClick}
className={css.scrollDownBtn}
/>
</Render> </Render>
</Container> </Container>
</Container> </Container>
) )
}) }) // eslint-disable-next-line react-hooks/exhaustive-deps
return <>{renderedSteps}</> }, [steps, selectedItemData, toggleExpandedState, expandedStates])
} return (
return <Container key={`nolog_${containerKey}`} ref={ref} className={cx(css.main, className)} /> <>
{steps && (selectedItemData?.payload?.kind as EnumCheckPayloadKindExtended) === 'harness_stage' ? (
renderedSteps
) : (
<Container key={`nolog_${containerKey}`} ref={ref} className={cx(css.main, className)} />
)}
</>
)
} }
const lineElement = (line = '') => { const lineElement = (line = '') => {
const element = document.createElement('pre') const element = document.createElement('pre')
element.className = css.line element.className = css.line
element.innerHTML = Anser.ansiToHtml(line.replace(/\r?\n$/, ''))
// Function to escape special HTML characters
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Escaping HTML special characters in the line
const escapedLine = escapeHtml(line.replace(/\r?\n$/, ''))
element.innerHTML = Anser.ansiToHtml(escapedLine)
return element return element
} }
@ -175,9 +257,11 @@ export const LogViewer = React.memo(LogTerminal)
export interface LogStageContainerProps { export interface LogStageContainerProps {
stepNameLogKeyMap?: Map<string, string> stepNameLogKeyMap?: Map<string, string>
expanded?: boolean // eslint-disable-next-line @typescript-eslint/no-explicit-any expanded?: boolean
getLogData: (logKey: string) => any
getLogData: (logKey: string, status: string, onMessageStreaming: (e: any) => void) => any
logKey: string logKey: string
status: string
value: string value: string
setSelectedStage: React.Dispatch<React.SetStateAction<TypesStage | null>> setSelectedStage: React.Dispatch<React.SetStateAction<TypesStage | null>>
} }
@ -187,12 +271,50 @@ export const LogStageContainer: React.FC<LogStageContainerProps> = ({
expanded, expanded,
logKey, logKey,
value, value,
status,
setSelectedStage setSelectedStage
}) => { }) => {
const localRef = useRef<HTMLDivElement | null>(null) // Create a unique ref for each LogStageContainer instance const localRef = useRef<HTMLDivElement | null>(null) // Create a unique ref for each LogStageContainer instance
const data = getLogData(logKey) // eslint-disable-next-line @typescript-eslint/no-explicit-any
const onMessageStreaming = (e: any) => {
if (e.data) {
sendStreamLogToRenderer(e.data || '')
}
}
const data = getLogData(logKey, status, onMessageStreaming)
const sendStreamLogToRenderer = useScheduleJob({
handler: useCallback((blocks: string[]) => {
const logContainer = localRef.current as HTMLDivElement
if (logContainer) {
const fragment = new DocumentFragment()
blocks.forEach((block: string) => {
const blockData = JSON.parse(block)
const linePos = blockData.pos + 1
const localDate = new Date(blockData.time)
const formattedDate = localDate.toLocaleString()
fragment.appendChild(
lineElement(`${linePos} ${blockData.level} ${formattedDate.replace(',', '')} ${blockData.out}`)
)
})
const scrollParent = logContainer.parentElement?.parentElement?.parentElement as HTMLDivElement
const autoScroll =
scrollParent && scrollParent.scrollTop === scrollParent.scrollHeight - scrollParent.offsetHeight
logContainer?.appendChild(fragment)
if (autoScroll || scrollParent.scrollTop === 0) {
scrollParent.scrollTop = scrollParent.scrollHeight
}
}
}, []),
isStreaming: true
})
useEffect(() => { useEffect(() => {
if (expanded) { if (expanded && status !== StepStatus.RUNNING) {
const pipelineArr = parseLogString(data) const pipelineArr = parseLogString(data)
const fragment = new DocumentFragment() const fragment = new DocumentFragment()
// Clear the container first // Clear the container first
@ -212,7 +334,9 @@ export const LogStageContainer: React.FC<LogStageContainerProps> = ({
const logContainer = localRef.current as HTMLDivElement const logContainer = localRef.current as HTMLDivElement
logContainer.appendChild(fragment) logContainer.appendChild(fragment)
} }
} } else if (status === StepStatus.RUNNING) {
sendStreamLogToRenderer(data || '')
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded, data, setSelectedStage]) }, [expanded, data, setSelectedStage])
return <Container key={`harnesslog_${value}`} ref={localRef} className={cx(css.main, css.stepLogContainer)} /> return <Container key={`harnesslog_${value}`} ref={localRef} className={cx(css.main, css.stepLogContainer)} />
} }

View File

@ -38,6 +38,7 @@ export interface StringsMap {
blameCommitLine: string blameCommitLine: string
blameEmpty: string blameEmpty: string
botAlerts: string botAlerts: string
bottom: string
branch: string branch: string
branchCreated: string branchCreated: string
branchDeleted: string branchDeleted: string
@ -205,6 +206,11 @@ export interface StringsMap {
createWebhook: string createWebhook: string
created: string created: string
creationDate: string creationDate: string
customDay: string
customHour: string
customMin: string
customSecond: string
customTime: string
dangerDeleteRepo: string dangerDeleteRepo: string
defaultBranch: string defaultBranch: string
defaultBranchTitle: string defaultBranchTitle: string
@ -798,6 +804,7 @@ export interface StringsMap {
title: string title: string
token: string token: string
tooltipRepoEdit: string tooltipRepoEdit: string
top: string
'triggers.actions': string 'triggers.actions': string
'triggers.createSuccess': string 'triggers.createSuccess': string
'triggers.createTrigger': string 'triggers.createTrigger': string

View File

@ -967,3 +967,10 @@ clear: Clear
searchExamples: Search Examples searchExamples: Search Examples
nMoreMatches: This file contains {{ n }} more matches not shown. nMoreMatches: This file contains {{ n }} more matches not shown.
seeNMoreMatches: See all {{ n }} matches in the full file seeNMoreMatches: See all {{ n }} matches in the full file
bottom: Bottom
top: Top
customTime: '{{days}} {{hours}} {{minutes}} {{seconds}}'
customDay: '{{days}}d'
customHour: '{{hours}}h'
customMin: '{{minutes}}m'
customSecond: '{{seconds}}s'

View File

@ -237,7 +237,7 @@ const CheckPipelineStep: React.FC<CheckPipelineStepsProps & { step: TypesStep }>
) )
} }
const createLogLineElement = (line = '') => { export const createLogLineElement = (line = '') => {
const element = document.createElement('pre') as HTMLPreElement & { const element = document.createElement('pre') as HTMLPreElement & {
setHTML: (html: string, options: Record<string, unknown>) => void setHTML: (html: string, options: Record<string, unknown>) => void
} }

View File

@ -106,16 +106,29 @@ export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullReqMetadata, p
if (queue.length !== 0) { if (queue.length !== 0) {
processExecutionData(queue) processExecutionData(queue)
} // eslint-disable-next-line react-hooks/exhaustive-deps } // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, selectedStageId]) }, [queue, selectedStageId, hookData])
if (!prChecksDecisionResult) {
return null
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const enqueue = (item: any) => { const enqueue = (item: any) => {
setQueue(prevQueue => [...prevQueue, item]) setQueue(prevQueue => [...prevQueue, item])
} }
useEffect(() => {
const pollingInterval = 4000
const fetchAndProcessData = () => {
hookData?.refetch()
if (hookData && rootNodeId) {
enqueue(rootNodeId) // Use your existing enqueue logic here
}
}
// Set up the polling with setInterval
const intervalId = setInterval(fetchAndProcessData, pollingInterval)
// Clean up the interval on component unmount
return () => clearInterval(intervalId)
}, [hookData, enqueue, dispatch])
if (!prChecksDecisionResult) {
return null
}
const processExecutionData = (curQueue: string[]) => { const processExecutionData = (curQueue: string[]) => {
const newQueue = [...curQueue] const newQueue = [...curQueue]

View File

@ -21,6 +21,7 @@ import { get, sortBy } from 'lodash-es'
import cx from 'classnames' import cx from 'classnames'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { Container, Layout, Text, FlexExpander, Utils } from '@harnessio/uicore' import { Container, Layout, Text, FlexExpander, Utils } from '@harnessio/uicore'
import ReactTimeago from 'react-timeago'
import { Color, FontVariation } from '@harnessio/design-system' import { Color, FontVariation } from '@harnessio/design-system'
import { import {
ButtonRoleProps, ButtonRoleProps,
@ -30,6 +31,7 @@ import {
timeDistance timeDistance
} from 'utils/Utils' } from 'utils/Utils'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { useQueryParams } from 'hooks/useQueryParams' import { useQueryParams } from 'hooks/useQueryParams'
import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code' import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus' import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
@ -44,7 +46,7 @@ interface ChecksMenuProps extends ChecksProps {
type TypesCheckPayloadExtended = EnumCheckPayloadKind | 'harness_stage' type TypesCheckPayloadExtended = EnumCheckPayloadKind | 'harness_stage'
type ExpandedStates = { [key: string]: boolean } type ExpandedStates = { [key: string]: boolean }
type ElapsedTimeStatusMap = { [key: string]: { status: 'string'; time: string } } type ElapsedTimeStatusMap = { [key: string]: { status: 'string'; time: string; started: string } }
enum CheckKindPayload { enum CheckKindPayload {
HARNESS_STAGE = 'harness_stage' HARNESS_STAGE = 'harness_stage'
@ -56,6 +58,7 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
onDataItemChanged, onDataItemChanged,
setSelectedStage: setSelectedStageFromProps setSelectedStage: setSelectedStageFromProps
}) => { }) => {
const { getString } = useStrings()
const { routes, standalone } = useAppContext() const { routes, standalone } = useAppContext()
const history = useHistory() const history = useHistory()
const { uid } = useQueryParams<{ uid: string }>() const { uid } = useQueryParams<{ uid: string }>()
@ -103,7 +106,9 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
selectedStage selectedStage
]) ])
const [expandedPipelineId, setExpandedPipelineId] = useState<string | null>(null) const [expandedPipelineId, setExpandedPipelineId] = useState<string | null>(null)
const [statusTimeStates, setStatusTimeStates] = useState<{ [key: string]: { status: string; time: string } }>({}) const [statusTimeStates, setStatusTimeStates] = useState<{
[key: string]: { status: string; time: string; started: string }
}>({})
const groupByPipeline = (data: TypesCheck[]) => { const groupByPipeline = (data: TypesCheck[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -159,7 +164,11 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
}) })
const res = findStatus() const res = findStatus()
const statusVal = res ? res.status : '' const statusVal = res ? res.status : ''
initialMap[key] = { status: statusVal, time: timeDistance(startTime, endTime) } initialMap[key] = {
status: statusVal,
time: timeDistance(startTime, endTime),
started: groupedData[key][0].started
}
} }
if (uid) { if (uid) {
if (uid.includes(key)) { if (uid.includes(key)) {
@ -173,6 +182,24 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
}) })
setStatusTimeStates(initialMap) setStatusTimeStates(initialMap)
}, [groupedData, uid]) }, [groupedData, uid])
const customFormatter = (_value: number, _unit: string, _suffix: string, date: Date | string | number) => {
const now = new Date()
const then = new Date(date)
const secondsPast = (now.getTime() - then.getTime()) / 1000
const days = Math.round(secondsPast / 86400)
const remainder = secondsPast % 86400
const hours = Math.floor(remainder / 3600)
const minutes = Math.floor((remainder % 3600) / 60)
const seconds = Math.floor(remainder % 60)
return getString('customTime', {
days: days ? getString('customDay', { days }) : '',
hours: hours ? getString('customHour', { hours }) : '',
minutes: minutes ? getString('customMin', { minutes }) : '',
seconds: seconds ? getString('customSecond', { seconds }) : ''
})
}
return ( return (
<Layout.Vertical padding={{ top: 'large' }} spacing={'small'} className={cx(css.menu, css.leftPaneContent)}> <Layout.Vertical padding={{ top: 'large' }} spacing={'small'} className={cx(css.menu, css.leftPaneContent)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@ -209,7 +236,14 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
padding={{ right: 'small' }} padding={{ right: 'small' }}
font={{ variation: FontVariation.SMALL }} font={{ variation: FontVariation.SMALL }}
className={css.noShrink}> className={css.noShrink}>
{statusTimeStates[pipelineId]?.time} {statusTimeStates[pipelineId]?.status === 'running' ? (
<ReactTimeago
date={new Date(statusTimeStates[pipelineId]?.started || 0)}
formatter={customFormatter}
/>
) : (
statusTimeStates[pipelineId]?.time
)}
</Text> </Text>
</Render> </Render>
<NavArrowRight <NavArrowRight
@ -227,6 +261,7 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
prChecksDecisionResult={prChecksDecisionResult} prChecksDecisionResult={prChecksDecisionResult}
key={itemData.uid} key={itemData.uid}
itemData={itemData} itemData={itemData}
customFormatter={customFormatter}
isPipeline={itemData.payload?.kind === PullRequestCheckType.PIPELINE} isPipeline={itemData.payload?.kind === PullRequestCheckType.PIPELINE}
isSelected={itemData.uid === selectedUID} isSelected={itemData.uid === selectedUID}
onClick={stage => { onClick={stage => {
@ -261,6 +296,7 @@ interface CheckMenuItemProps extends ChecksProps {
itemData: TypesCheck itemData: TypesCheck
onClick: (stage?: TypesStage) => void onClick: (stage?: TypesStage) => void
setSelectedStage: (stage: TypesStage | null) => void setSelectedStage: (stage: TypesStage | null) => void
customFormatter: (_value: number, _unit: string, _suffix: string, date: Date | string | number) => string
} }
const CheckMenuItem: React.FC<CheckMenuItemProps> = ({ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
@ -270,7 +306,8 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
onClick, onClick,
repoMetadata, repoMetadata,
pullReqMetadata, pullReqMetadata,
setSelectedStage setSelectedStage,
customFormatter
}) => { }) => {
const [expanded, setExpanded] = useState(isSelected) const [expanded, setExpanded] = useState(isSelected)
@ -317,7 +354,11 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
<FlexExpander /> <FlexExpander />
<Text color={Color.GREY_300} font={{ variation: FontVariation.SMALL }} className={css.noShrink}> <Text color={Color.GREY_300} font={{ variation: FontVariation.SMALL }} className={css.noShrink}>
{timeDistance(itemData.updated, itemData.created)} {itemData?.ended && itemData?.started ? (
timeDistance(itemData.updated, itemData.created)
) : (
<ReactTimeago date={new Date(itemData?.started || 0)} formatter={customFormatter} />
)}
</Text> </Text>
<Render when={isPipeline}> <Render when={isPipeline}>

View File

@ -91,3 +91,7 @@ export function parseLogString(logString: string) {
return parsedLogs return parsedLogs
} }
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}