From 35c815beb7b27ba0986ef0c81aae33ef982fb853 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Mon, 18 Dec 2023 23:08:52 +0000 Subject: [PATCH] feat: [code-1195]: integrate status check logs in harness mode (#922) --- .../LogViewer/LogViewer.module.scss | 91 ++++++++++ .../LogViewer/LogViewer.module.scss.d.ts | 10 ++ web/src/components/LogViewer/LogViewer.tsx | 166 +++++++++++++++++- web/src/pages/PullRequest/Checks/Checks.tsx | 99 ++++++++++- .../pages/PullRequest/Checks/ChecksUtils.ts | 37 ++++ .../PullRequestActionsBox.tsx | 8 +- 6 files changed, 399 insertions(+), 12 deletions(-) diff --git a/web/src/components/LogViewer/LogViewer.module.scss b/web/src/components/LogViewer/LogViewer.module.scss index eb2d29486..b588b7af7 100644 --- a/web/src/components/LogViewer/LogViewer.module.scss +++ b/web/src/components/LogViewer/LogViewer.module.scss @@ -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; +} diff --git a/web/src/components/LogViewer/LogViewer.module.scss.d.ts b/web/src/components/LogViewer/LogViewer.module.scss.d.ts index 4725cc71f..6f636fa34 100644 --- a/web/src/components/LogViewer/LogViewer.module.scss.d.ts +++ b/web/src/components/LogViewer/LogViewer.module.scss.d.ts @@ -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 diff --git a/web/src/components/LogViewer/LogViewer.tsx b/web/src/components/LogViewer/LogViewer.tsx index b798415a3..4f59c32d9 100644 --- a/web/src/components/LogViewer/LogViewer.tsx +++ b/web/src/components/LogViewer/LogViewer.tsx @@ -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 + setSelectedStage: React.Dispatch> + selectedItemData: TypesCheck | undefined } -const LogTerminal: React.FC = ({ 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 = ({ + content, + className, + stepNameLogKeyMap, + setSelectedStage, + selectedItemData +}) => { + const { hooks } = useAppContext() const ref = useRef(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 + 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()) + // UseEffect to initialize the states + useEffect(() => { + const states = new Map() + 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 ( + + + { + toggleExpandedState(key) + }}> + + + + {key === StepTypes.LITEENGINETASK ? StepTypes.INITIALIZE : key} + + + + + + + + + + ) + }) + return <>{renderedSteps} + } + return } const lineElement = (line = '') => { @@ -44,3 +160,41 @@ const lineElement = (line = '') => { } export const LogViewer = React.memo(LogTerminal) + +export interface LogStageContainerProps { + stepNameLogKeyMap?: Map + expanded?: boolean + getLogData: (logKey: string) => any // eslint-disable-next-line @typescript-eslint/no-explicit-any + logKey: string + value: string + setSelectedStage: React.Dispatch> +} + +export const LogStageContainer: React.FC = ({ + getLogData, + stepNameLogKeyMap, + expanded, + logKey, + value, + setSelectedStage +}) => { + const localRef = useRef(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 +} diff --git a/web/src/pages/PullRequest/Checks/Checks.tsx b/web/src/pages/PullRequest/Checks/Checks.tsx index 3b0233ab7..b34c3b02c 100644 --- a/web/src/pages/PullRequest/Checks/Checks.tsx +++ b/web/src/pages/PullRequest/Checks/Checks.tsx @@ -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 = ({ repoMetadata, pullRequestMetadata, prChecksDecisionResult }) => { const { getString } = useStrings() const history = useHistory() - const { routes } = useAppContext() + const { routes, standalone } = useAppContext() const [selectedItemData, setSelectedItemData] = useState() const [selectedStage, setSelectedStage] = useState(null) + const [queue, setQueue] = useState([]) + 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 = ({ 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 ( @@ -141,7 +228,13 @@ export const Checks: React.FC = ({ repoMetadata, pullRequestMetadat /> - + diff --git a/web/src/pages/PullRequest/Checks/ChecksUtils.ts b/web/src/pages/PullRequest/Checks/ChecksUtils.ts index 1743a5cd2..c65beb526 100644 --- a/web/src/pages/PullRequest/Checks/ChecksUtils.ts +++ b/web/src/pages/PullRequest/Checks/ChecksUtils.ts @@ -35,3 +35,40 @@ export function findDefaultExecution(collection: Iterable | 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 +} diff --git a/web/src/pages/PullRequest/Conversation/PullRequestActionsBox/PullRequestActionsBox.tsx b/web/src/pages/PullRequest/Conversation/PullRequestActionsBox/PullRequestActionsBox.tsx index 1e70717e4..9913756c7 100644 --- a/web/src/pages/PullRequest/Conversation/PullRequestActionsBox/PullRequestActionsBox.tsx +++ b/web/src/pages/PullRequest/Conversation/PullRequestActionsBox/PullRequestActionsBox.tsx @@ -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 = ({ @@ -134,7 +135,8 @@ export const PullRequestActionsBox: React.FC = ({ 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 = ({ 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()