feat: [code-1195]: integrate status check logs in harness mode (#922)

This commit is contained in:
Calvin Lee 2023-12-18 23:08:52 +00:00 committed by Harness
parent b873fb95ed
commit 35c815beb7
6 changed files with 399 additions and 12 deletions

View File

@ -33,3 +33,94 @@
white-space: pre-wrap !important;
}
}
.pipelineSteps {
padding: 10px 20px 0 !important;
display: flex;
flex-direction: column;
gap: 5px;
&::before {
content: '';
height: 10px;
width: 100%;
background-color: var(--black);
position: absolute;
top: 64px;
z-index: 1;
}
.stepContainer {
display: flex;
flex-direction: column;
word-break: break-all;
}
.stepHeader {
display: flex;
align-items: center;
min-height: 34px;
border-radius: 6px;
padding: 0 10px 0 6px;
position: sticky;
top: 74px;
z-index: 2;
background-color: var(--black);
&.expanded {
.chevron {
transform: rotate(90deg);
}
}
.chevron {
transition: transform 0.2s ease;
}
&:hover {
background-color: #22222aa9;
}
&.selected {
background-color: #22222a;
}
&.selected .name {
color: var(--primary-7) !important;
font-weight: 600 !important;
}
.name {
color: #b0b1c3 !important;
font-weight: 400 !important;
font-size: 14px !important;
font-family: var(--font-family-mono);
}
}
.stepLogContainer {
padding: 15px 10px 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;
&:empty {
display: inline-block;
min-height: 20px;
}
}
}
}
.noShrink {
flex-shrink: inherit;
}

View File

@ -16,5 +16,15 @@
/* eslint-disable */
// This is an auto-generated file
export declare const chevron: string
export declare const consoleLine: string
export declare const expanded: string
export declare const line: string
export declare const main: string
export declare const name: string
export declare const noShrink: string
export declare const pipelineSteps: string
export declare const selected: string
export declare const stepContainer: string
export declare const stepHeader: string
export declare const stepLogContainer: string

View File

@ -14,26 +14,142 @@
* limitations under the License.
*/
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Anser from 'anser'
import cx from 'classnames'
import { Container } from '@harnessio/uicore'
import { Container, FlexExpander, Layout, Text, Utils } from '@harnessio/uicore'
import { NavArrowRight } from 'iconoir-react'
import { Color } from '@harnessio/design-system'
import { Render } from 'react-jsx-match'
import { parseLogString } from 'pages/PullRequest/Checks/ChecksUtils'
import { useAppContext } from 'AppContext'
import { ButtonRoleProps } from 'utils/Utils'
import type { EnumCheckPayloadKind, TypesCheck, TypesStage } from 'services/code'
import css from './LogViewer.module.scss'
export interface LogViewerProps {
search?: string
content?: string
className?: string
stepNameLogKeyMap?: Map<string, string>
setSelectedStage: React.Dispatch<React.SetStateAction<TypesStage | null>>
selectedItemData: TypesCheck | undefined
}
const LogTerminal: React.FC<LogViewerProps> = ({ content, className }) => {
export interface LogLine {
time: string
message: string
out: string
level: string
details: {
[key: string]: string
}
}
enum StepTypes {
LITEENGINETASK = 'liteEngineTask',
INITIALIZE = 'initialize'
}
export type EnumCheckPayloadKindExtended = EnumCheckPayloadKind | 'harness_stage'
const LogTerminal: React.FC<LogViewerProps> = ({
content,
className,
stepNameLogKeyMap,
setSelectedStage,
selectedItemData
}) => {
const { hooks } = useAppContext()
const ref = useRef<HTMLDivElement | null>(null)
const containerKey = selectedItemData ? selectedItemData.id : 'default-key'
useEffect(() => {
content?.split(/\r?\n/).forEach(line => ref.current?.appendChild(lineElement(line)))
}, [content])
// Clear the container first
if (ref.current) {
ref.current.innerHTML = ''
}
return <Container ref={ref} className={cx(css.main, className)} />
if (stepNameLogKeyMap && content) {
content.split(/\r?\n/).forEach(line => {
if (ref.current) {
ref.current.appendChild(lineElement(line))
}
})
}
}, [content, stepNameLogKeyMap, selectedItemData])
const getLogData = (logBaseKey: string) => {
const logContent = hooks?.useLogsContentHook([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, false)
})
setExpandedStates(states) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Function to toggle expanded state of a container
const toggleExpandedState = (key: string) => {
setExpandedStates(prevStates => {
const newStates = new Map(prevStates)
newStates.set(key, !newStates.get(key))
return newStates
})
}
if (
stepNameLogKeyMap &&
(selectedItemData?.payload?.kind as EnumCheckPayloadKindExtended) === 'harness_stage' &&
selectedItemData?.status !== 'running'
) {
const renderedSteps = Array.from(stepNameLogKeyMap?.entries() || []).map(([key, value], idx) => {
// const data = getLogData(value)
if (key === undefined || idx === 0) {
return
}
return (
<Container key={value} 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)
})}
{...ButtonRoleProps}
onClick={() => {
toggleExpandedState(key)
}}>
<NavArrowRight color={Utils.getRealCSSColor(Color.GREY_500)} className={cx(css.noShrink, css.chevron)} />
<Text className={css.name} lineClamp={1}>
{key === StepTypes.LITEENGINETASK ? StepTypes.INITIALIZE : key}
</Text>
<FlexExpander />
</Layout.Horizontal>
<Render when={expandedStates.get(key)}>
<LogStageContainer
value={key}
getLogData={getLogData}
logKey={value}
expanded={expandedStates.get(key)}
setSelectedStage={setSelectedStage}
/>
</Render>
</Container>
</Container>
)
})
return <>{renderedSteps}</>
}
return <Container key={`nolog_${containerKey}`} ref={ref} className={cx(css.main, className)} />
}
const lineElement = (line = '') => {
@ -44,3 +160,41 @@ const lineElement = (line = '') => {
}
export const LogViewer = React.memo(LogTerminal)
export interface LogStageContainerProps {
stepNameLogKeyMap?: Map<string, string>
expanded?: boolean
getLogData: (logKey: string) => any // eslint-disable-next-line @typescript-eslint/no-explicit-any
logKey: string
value: string
setSelectedStage: React.Dispatch<React.SetStateAction<TypesStage | null>>
}
export const LogStageContainer: React.FC<LogStageContainerProps> = ({
getLogData,
stepNameLogKeyMap,
expanded,
logKey,
value,
setSelectedStage
}) => {
const localRef = useRef<HTMLDivElement | null>(null) // Create a unique ref for each LogStageContainer instance
const data = getLogData(logKey)
useEffect(() => {
if (expanded) {
const pipelineArr = parseLogString(data)
const fragment = new DocumentFragment()
// Clear the container first
if (localRef.current) {
localRef.current.innerHTML = ''
}
if (pipelineArr) {
pipelineArr?.forEach((line: LogLine) => fragment.appendChild(lineElement(line.message)))
const logContainer = localRef.current as HTMLDivElement
logContainer.appendChild(fragment)
}
}
}, [expanded, data, setSelectedStage, stepNameLogKeyMap])
return <Container key={`harnesslog_${value}`} ref={localRef} className={cx(css.main, css.stepLogContainer)} />
}

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useMemo, useState } from 'react'
import React, { useEffect, useMemo, useReducer, useState } from 'react'
import { Falsy, Match, Render, Truthy } from 'react-jsx-match'
import { get } from 'lodash-es'
import cx from 'classnames'
@ -34,12 +34,32 @@ import { CheckPipelineSteps } from './CheckPipelineSteps'
import { ChecksMenu } from './ChecksMenu'
import css from './Checks.module.scss'
// Define the reducer outside your component
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapReducer = (state: any, action: { type: any; newEntries: any }) => {
switch (action.type) {
case 'UPDATE_MAP':
// Return a new updated map
return new Map([...state, ...action.newEntries])
default:
return state
}
}
interface SelectedItemDataInterface {
execution_id: string
stage_id: string
}
export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullRequestMetadata, prChecksDecisionResult }) => {
const { getString } = useStrings()
const history = useHistory()
const { routes } = useAppContext()
const { routes, standalone } = useAppContext()
const [selectedItemData, setSelectedItemData] = useState<TypesCheck>()
const [selectedStage, setSelectedStage] = useState<TypesStage | null>(null)
const [queue, setQueue] = useState<string[]>([])
const [stepNameLogKeyMap, dispatch] = useReducer(mapReducer, new Map())
const { hooks } = useAppContext()
const isCheckDataMarkdown = useMemo(
() => selectedItemData?.payload?.kind === PullRequestCheckType.MARKDOWN,
[selectedItemData?.payload?.kind]
@ -59,11 +79,78 @@ export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullRequestMetadat
return selectedItemData?.link
}
}, [repoMetadata?.path, routes, selectedItemData, selectedStage])
const executionId =
standalone && selectedItemData ? null : (selectedItemData?.payload?.data as SelectedItemDataInterface)?.execution_id
const selectedStageId =
standalone && selectedItemData ? null : (selectedItemData?.payload?.data as SelectedItemDataInterface)?.stage_id
const hookData = hooks?.useExecutionDataHook?.(executionId, selectedStageId)
const executionApiCallData = hookData?.data
const rootNodeId = executionApiCallData?.data?.executionGraph?.rootNodeId
useEffect(() => {
if (rootNodeId) {
enqueue(rootNodeId)
}
}, [rootNodeId])
useEffect(() => {
if (queue.length !== 0) {
processExecutionData(queue)
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue])
if (!prChecksDecisionResult) {
return null
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enqueue = (item: any) => {
setQueue(prevQueue => [...prevQueue, item])
}
const processExecutionData = (curQueue: string[]) => {
const newQueue = [...curQueue]
const newEntries = []
while (newQueue.length !== 0) {
const item = newQueue.shift()
if (item) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeArray = (executionApiCallData.data.executionGraph.nodeAdjacencyListMap as any)[item]
//add node to queue
if (nodeArray) {
nodeArray.children.map((node: string) => {
newQueue.push(node)
})
}
if (nodeArray) {
nodeArray.nextIds.map((node: string) => {
newQueue.push(node)
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeMapItem = (executionApiCallData.data.executionGraph.nodeMap as any)[item]
if (nodeMapItem) {
// Assume that you generate a key-yarn value pair for the map here
const key = nodeMapItem.stepParameters.name
const value = nodeMapItem.logBaseKey
if (item !== rootNodeId) {
newEntries.push([key, value])
}
} else {
continue
}
}
}
// Update the map using the reducer
if (newEntries.length > 0) {
dispatch({ type: 'UPDATE_MAP', newEntries })
}
setQueue(newQueue) // Update the queue state
}
return (
<Container className={css.main}>
<Match expr={prChecksDecisionResult?.overallStatus}>
@ -141,7 +228,13 @@ export const Checks: React.FC<ChecksProps> = ({ repoMetadata, pullRequestMetadat
/>
</Truthy>
<Falsy>
<LogViewer content={logContent} className={css.logViewer} />
<LogViewer
content={logContent}
stepNameLogKeyMap={stepNameLogKeyMap}
className={css.logViewer}
setSelectedStage={setSelectedStage}
selectedItemData={selectedItemData}
/>
</Falsy>
</Match>
</Falsy>

View File

@ -35,3 +35,40 @@ export function findDefaultExecution<T>(collection: Iterable<T> | null | undefin
(collection as CheckType)[0]) as T)
: null
}
export interface DetailDict {
[key: string]: string
}
export function parseLogString(logString: string) {
if (!logString) {
return ''
}
const logEntries = logString.trim().split('\n')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedLogs: any = []
logEntries.forEach(entry => {
// Parse the entry as JSON
const jsonEntry = JSON.parse(entry)
// Apply the regex to the 'out' field
const parts = jsonEntry.out.match(/time="([^"]+)" level=([^ ]+) msg="([^"]+)"(.*)/)
if (parts) {
const [, time, level, message, details, out] = parts
const detailParts = details.trim().split(' ')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detailDict: any = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detailParts.forEach((part: any) => {
if (part.includes('=')) {
const [key, value] = part.split('=')
detailDict[key.trim()] = value.trim()
}
})
parsedLogs.push({ time, level, message, out, details: detailDict })
} else {
parsedLogs.push({ time: jsonEntry.time, level: jsonEntry.level, message: jsonEntry.out })
}
})
return parsedLogs
}

View File

@ -63,6 +63,7 @@ import css from './PullRequestActionsBox.module.scss'
const codeOwnersNotFoundMessage = 'CODEOWNERS file not found'
const codeOwnersNotFoundMessage2 = `path "CODEOWNERS" not found`
const codeOwnersNotFoundMessage3 = `failed to find node 'CODEOWNERS' in 'main': failed to get tree node: failed to ls file: path "CODEOWNERS" not found`
const POLLING_INTERVAL = 60000
export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
@ -134,7 +135,8 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
setAllowedStrats(err.allowed_methods)
} else if (
getErrorMessage(err) === codeOwnersNotFoundMessage ||
getErrorMessage(err) === codeOwnersNotFoundMessage2
getErrorMessage(err) === codeOwnersNotFoundMessage2 ||
getErrorMessage(err) === codeOwnersNotFoundMessage3
) {
return
} else {
@ -147,8 +149,8 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
useEffect(() => {
// recheck PR in case source SHA changed or PR was marked as unchecked
// TODO: optimize call to handle all causes and avoid double calls by keeping track of SHA
dryMerge()
}, [unchecked, pullRequestMetadata?.source_sha]) // eslint-disable-next-line react-hooks/exhaustive-deps
dryMerge() // eslint-disable-next-line react-hooks/exhaustive-deps
}, [unchecked, pullRequestMetadata?.source_sha])
useEffect(() => {
// dryMerge()