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

View File

@ -132,3 +132,77 @@
.noShrink {
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
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
export declare const headerLayout: string
export declare const invert: string
export declare const line: string
export declare const logViewer: string
export declare const main: string
export declare const markdown: string
export declare const markdownContainer: string
export declare const name: string
export declare const noShrink: string
export declare const pipelineSteps: string
export declare const scrollDownBtn: string
export declare const selected: string
export declare const status: string
export declare const stepContainer: string
export declare const stepHeader: string
export declare const stepLogContainer: string
export declare const terminal: string

View File

@ -14,20 +14,22 @@
* 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 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 { isEmpty } from 'lodash-es'
import { Color, FontVariation } from '@harnessio/design-system'
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 { ButtonRoleProps, timeDistance } from 'utils/Utils'
import { useScheduleJob } from 'hooks/useScheduleJob'
import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import css from './LogViewer.module.scss'
export interface LogViewerProps {
search?: string
content?: string
@ -53,6 +55,11 @@ enum StepTypes {
INITIALIZE = 'Initialize'
}
enum StepStatus {
RUNNING = 'running',
SUCCESS = 'success'
}
export type EnumCheckPayloadKindExtended = EnumCheckPayloadKind | 'harness_stage'
const LogTerminal: React.FC<LogViewerProps> = ({
@ -63,9 +70,10 @@ const LogTerminal: React.FC<LogViewerProps> = ({
selectedItemData
}) => {
const { hooks } = useAppContext()
const { getString } = useStrings()
const ref = useRef<HTMLDivElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const containerKey = selectedItemData ? selectedItemData.id : 'default-key'
useEffect(() => {
// Clear the container first
@ -82,50 +90,94 @@ const LogTerminal: React.FC<LogViewerProps> = ({
}
}, [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])
// 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
}
// State to manage expanded states of all containers
const [expandedStates, setExpandedStates] = useState(new Map<string, boolean>())
// UseEffect to initialize the states
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])
const [isBottom, setIsBottom] = useState(false)
const [expandedStates, setExpandedStates] = useState<Map<string, { expanded: boolean; streaming: boolean }>>(
new Map()
)
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
const toggleExpandedState = (key: string) => {
const toggleExpandedState = useCallback((key: string) => {
setExpandedStates(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
})
}, [])
const [steps, setSteps] = useState(new Map())
useEffect(() => {
if (stepNameLogKeyMap) {
const newStatuses = new Map()
stepNameLogKeyMap.forEach((value, key) => {
newStatuses.set(key, value)
})
setSteps(newStatuses)
}
if (
stepNameLogKeyMap &&
(selectedItemData?.payload?.kind as EnumCheckPayloadKindExtended) === 'harness_stage' &&
selectedItemData?.status !== 'running'
) {
const renderedSteps = Array.from(stepNameLogKeyMap?.entries() || []).map(([key, data], idx) => {
}, [stepNameLogKeyMap, selectedItemData?.status, toggleExpandedState])
const renderedSteps = useMemo(() => {
return Array.from(steps?.entries() || []).map(([key, data], idx) => {
if (key === undefined || idx === 0) {
return
}
const expanded =
expandedStates.get(data.logBaseKey)?.expanded || data.status === 'AsyncWaiting' || data.status === 'Queued'
return (
<Container key={data.logBaseKey} className={cx(css.pipelineSteps)}>
<Container ref={containerRef} key={data.logBaseKey} className={cx(css.pipelineSteps)}>
<Container className={css.stepContainer}>
<Layout.Horizontal
spacing="small"
className={cx(css.stepHeader, {
[css.expanded]: expandedStates.get(key),
[css.selected]: expandedStates.get(key)
[css.expanded]: expanded,
[css.selected]: expanded
})}
{...ButtonRoleProps}
onClick={() => {
toggleExpandedState(key)
toggleExpandedState(data.logBaseKey)
}}>
<NavArrowRight color={Utils.getRealCSSColor(Color.GREY_500)} className={cx(css.noShrink, css.chevron)} />
<ExecutionStatus
@ -146,28 +198,58 @@ const LogTerminal: React.FC<LogViewerProps> = ({
</Text>
</Render>
</Layout.Horizontal>
<Render when={expandedStates.get(key)}>
<Render when={expanded}>
<LogStageContainer
value={key}
status={data.status}
getLogData={getLogData}
logKey={data.logBaseKey}
expanded={expandedStates.get(key)}
expanded={expanded}
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>
</Container>
</Container>
)
})
return <>{renderedSteps}</>
}
return <Container key={`nolog_${containerKey}`} ref={ref} className={cx(css.main, className)} />
}) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, selectedItemData, toggleExpandedState, expandedStates])
return (
<>
{steps && (selectedItemData?.payload?.kind as EnumCheckPayloadKindExtended) === 'harness_stage' ? (
renderedSteps
) : (
<Container key={`nolog_${containerKey}`} ref={ref} className={cx(css.main, className)} />
)}
</>
)
}
const lineElement = (line = '') => {
const element = document.createElement('pre')
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
}
@ -175,9 +257,11 @@ export const LogViewer = React.memo(LogTerminal)
export interface LogStageContainerProps {
stepNameLogKeyMap?: Map<string, string>
expanded?: boolean // eslint-disable-next-line @typescript-eslint/no-explicit-any
getLogData: (logKey: string) => any
expanded?: boolean
getLogData: (logKey: string, status: string, onMessageStreaming: (e: any) => void) => any
logKey: string
status: string
value: string
setSelectedStage: React.Dispatch<React.SetStateAction<TypesStage | null>>
}
@ -187,12 +271,50 @@ export const LogStageContainer: React.FC<LogStageContainerProps> = ({
expanded,
logKey,
value,
status,
setSelectedStage
}) => {
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(() => {
if (expanded) {
if (expanded && status !== StepStatus.RUNNING) {
const pipelineArr = parseLogString(data)
const fragment = new DocumentFragment()
// Clear the container first
@ -212,7 +334,9 @@ export const LogStageContainer: React.FC<LogStageContainerProps> = ({
const logContainer = localRef.current as HTMLDivElement
logContainer.appendChild(fragment)
}
}
} else if (status === StepStatus.RUNNING) {
sendStreamLogToRenderer(data || '')
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded, data, setSelectedStage])
return <Container key={`harnesslog_${value}`} ref={localRef} className={cx(css.main, css.stepLogContainer)} />
}

View File

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

View File

@ -967,3 +967,10 @@ clear: Clear
searchExamples: Search Examples
nMoreMatches: This file contains {{ n }} more matches not shown.
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 & {
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) {
processExecutionData(queue)
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, selectedStageId])
if (!prChecksDecisionResult) {
return null
}
}, [queue, selectedStageId, hookData])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enqueue = (item: any) => {
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 newQueue = [...curQueue]

View File

@ -21,6 +21,7 @@ import { get, sortBy } from 'lodash-es'
import cx from 'classnames'
import { useHistory } from 'react-router-dom'
import { Container, Layout, Text, FlexExpander, Utils } from '@harnessio/uicore'
import ReactTimeago from 'react-timeago'
import { Color, FontVariation } from '@harnessio/design-system'
import {
ButtonRoleProps,
@ -30,6 +31,7 @@ import {
timeDistance
} from 'utils/Utils'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { useQueryParams } from 'hooks/useQueryParams'
import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code'
import { ExecutionState, ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
@ -44,7 +46,7 @@ interface ChecksMenuProps extends ChecksProps {
type TypesCheckPayloadExtended = EnumCheckPayloadKind | 'harness_stage'
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 {
HARNESS_STAGE = 'harness_stage'
@ -56,6 +58,7 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
onDataItemChanged,
setSelectedStage: setSelectedStageFromProps
}) => {
const { getString } = useStrings()
const { routes, standalone } = useAppContext()
const history = useHistory()
const { uid } = useQueryParams<{ uid: string }>()
@ -103,7 +106,9 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
selectedStage
])
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[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -159,7 +164,11 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
})
const res = findStatus()
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.includes(key)) {
@ -173,6 +182,24 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
})
setStatusTimeStates(initialMap)
}, [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 (
<Layout.Vertical padding={{ top: 'large' }} spacing={'small'} className={cx(css.menu, css.leftPaneContent)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@ -209,7 +236,14 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
padding={{ right: 'small' }}
font={{ variation: FontVariation.SMALL }}
className={css.noShrink}>
{statusTimeStates[pipelineId]?.time}
{statusTimeStates[pipelineId]?.status === 'running' ? (
<ReactTimeago
date={new Date(statusTimeStates[pipelineId]?.started || 0)}
formatter={customFormatter}
/>
) : (
statusTimeStates[pipelineId]?.time
)}
</Text>
</Render>
<NavArrowRight
@ -227,6 +261,7 @@ export const ChecksMenu: React.FC<ChecksMenuProps> = ({
prChecksDecisionResult={prChecksDecisionResult}
key={itemData.uid}
itemData={itemData}
customFormatter={customFormatter}
isPipeline={itemData.payload?.kind === PullRequestCheckType.PIPELINE}
isSelected={itemData.uid === selectedUID}
onClick={stage => {
@ -261,6 +296,7 @@ interface CheckMenuItemProps extends ChecksProps {
itemData: TypesCheck
onClick: (stage?: TypesStage) => void
setSelectedStage: (stage: TypesStage | null) => void
customFormatter: (_value: number, _unit: string, _suffix: string, date: Date | string | number) => string
}
const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
@ -270,7 +306,8 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
onClick,
repoMetadata,
pullReqMetadata,
setSelectedStage
setSelectedStage,
customFormatter
}) => {
const [expanded, setExpanded] = useState(isSelected)
@ -317,7 +354,11 @@ const CheckMenuItem: React.FC<CheckMenuItemProps> = ({
<FlexExpander />
<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>
<Render when={isPipeline}>

View File

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