diff --git a/package.json b/package.json index 90782da..6598a3f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@patternfly/react-component-groups": "^5.1.0", "@patternfly/react-core": "^5.2.1", "@patternfly/react-icons": "5.2.1", + "@patternfly/react-log-viewer": "5.3.0", "@patternfly/react-table": "5.2.1", "@patternfly/react-tokens": "5.2.1", "@patternfly/react-topology": "5.2.1", diff --git a/src/components/logs/Logs.scss b/src/components/logs/Logs.scss deleted file mode 100644 index 6ff9aef..0000000 --- a/src/components/logs/Logs.scss +++ /dev/null @@ -1,13 +0,0 @@ -.odc-logs { - padding: var(--pf-v5-global--spacer--sm) var(--pf-v5-global--spacer--md); - - &__name { - font-weight: var(--pf-v5-global--FontWeight--bold); - text-transform: uppercase; - } - - &__content { - padding-left: var(--pf-v5-global--spacer--md); - white-space: pre; - } -} diff --git a/src/components/logs/Logs.tsx b/src/components/logs/Logs.tsx index 5653544..1d829d3 100644 --- a/src/components/logs/Logs.tsx +++ b/src/components/logs/Logs.tsx @@ -1,140 +1,218 @@ import * as React from 'react'; import { Alert } from '@patternfly/react-core'; +import { LogViewer } from '@patternfly/react-log-viewer'; import { Base64 } from 'js-base64'; -import { throttle } from 'lodash'; import { useTranslation } from 'react-i18next'; import { consoleFetchText } from '@openshift-console/dynamic-plugin-sdk'; import { LOG_SOURCE_TERMINATED } from '../../consts'; import { WSFactory } from '@openshift-console/dynamic-plugin-sdk/lib/utils/k8s/ws-factory'; -import { ContainerSpec, PodKind } from '../../types'; +import { ContainerSpec, ContainerStatus, PodKind } from '../../types'; import { PodModel } from '../../models'; import { resourceURL } from '../utils/k8s-utils'; -import './Logs.scss'; +import { containerToLogSourceStatus } from '../utils/pipeline-utils'; +import './MultiStreamLogs.scss'; -consoleFetchText; type LogsProps = { resource: PodKind; - resourceStatus: string; - container: ContainerSpec; - render: boolean; - autoScroll?: boolean; - onComplete: (containerName: string) => void; + containers: ContainerSpec[]; + setCurrentLogsGetter?: (getter: () => string) => void; +}; + +type LogData = { + [containerName: string]: { + logs: string[]; + status: string; + }; +}; + +const processLogData = ( + logData: LogData, + containers: ContainerSpec[], +): string => { + let result = ''; + + containers.map(({ name: containerName }) => { + if (logData[containerName]) { + const { logs } = logData[containerName]; + const uniqueLogs = Array.from(new Set(logs)); + const filteredLogs = uniqueLogs.filter((log) => log.trim() !== ''); + const formattedContainerName = `${containerName.toUpperCase()}`; + + if (filteredLogs.length === 0) { + result += `${formattedContainerName}\n\n`; + } else { + const joinedLogs = filteredLogs.join('\n'); + result += `${formattedContainerName}\n${joinedLogs}\n\n`; + } + } + }); + return result; }; const Logs: React.FC = ({ resource, - resourceStatus, - container, - onComplete, - render, - autoScroll = true, + containers, + setCurrentLogsGetter, }) => { + if (!resource) return null; const { t } = useTranslation('plugin__pipelines-console-plugin'); - const { name } = container; - const { kind, metadata = {} } = resource; + const { metadata = {} } = resource; const { name: resName, namespace: resNamespace } = metadata; - const scrollToRef = React.useRef(null); - const contentRef = React.useRef(null); const [error, setError] = React.useState(false); - const resourceStatusRef = React.useRef(resourceStatus); - const onCompleteRef = React.useRef<(name) => void>(); - const blockContentRef = React.useRef(''); - onCompleteRef.current = onComplete; - - const addContentAndScroll = React.useCallback( - throttle(() => { - if (contentRef.current) { - contentRef.current.innerText += blockContentRef.current; - } - if (scrollToRef.current) { - scrollToRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }); - } - blockContentRef.current = ''; - }, 1000), - [], + const [logData, setLogData] = React.useState({}); + const [formattedLogString, setFormattedLogString] = React.useState(''); + const [scrollToRow, setScrollToRow] = React.useState(0); + const [activeContainers, setActiveContainers] = React.useState>( + new Set(), ); - const appendMessage = React.useRef<(blockContent) => void>(); + React.useEffect(() => { + setCurrentLogsGetter(() => { + return formattedLogString; + }); + }, [setCurrentLogsGetter, formattedLogString]); + + const appendMessage = React.useCallback( + (containerName: string, blockContent: string, resourceStatus: string) => { + if (blockContent) { + setLogData((prevLogData) => { + if (resourceStatus === 'terminated') { + // Replace the entire content with blockContent + return { + ...prevLogData, + [containerName]: { + logs: [blockContent], + status: resourceStatus, + }, + }; + } else { + // Otherwise, append the blockContent to the existing logs + const existingLogs = prevLogData[containerName]?.logs || []; + const updatedLogs = [...existingLogs, blockContent.trimEnd()]; - appendMessage.current = React.useCallback( - (blockContent: string) => { - blockContentRef.current += blockContent; - if (scrollToRef.current && blockContent && render && autoScroll) { - addContentAndScroll(); + return { + ...prevLogData, + [containerName]: { + logs: updatedLogs, + status: resourceStatus, + }, + }; + } + }); } }, - [autoScroll, render, addContentAndScroll], + [], ); - if (resourceStatusRef.current !== resourceStatus) { - resourceStatusRef.current = resourceStatus; - } + const retryWebSocket = ( + watchURL: string, + wsOpts: any, + onMessage: (message: string) => void, + onError: () => void, + retryCount = 0, + ) => { + let ws = new WSFactory(watchURL, wsOpts); + const handleError = () => { + if (retryCount < 5) { + setTimeout(() => { + retryWebSocket(watchURL, wsOpts, onMessage, onError, retryCount + 1); + }, 3000); // Retry after 3 seconds + } else { + onError(); + } + }; + + ws.onmessage((msg) => { + const message = Base64.decode(msg); + onMessage(message); + }).onerror(() => { + handleError(); + }); + + return ws; + }; React.useEffect(() => { - let loaded = false; - let ws: WSFactory; - const urlOpts = { - ns: resNamespace, - name: resName, - path: 'log', - queryParams: { - container: name, - follow: 'true', - timestamps: 'true', - }, - }; - const watchURL = resourceURL(PodModel, urlOpts); - if (resourceStatusRef.current === LOG_SOURCE_TERMINATED) { - consoleFetchText(watchURL) - .then((res) => { - if (loaded) return; - appendMessage.current(res); - onCompleteRef.current(name); - }) - .catch(() => { - if (loaded) return; - setError(true); - onCompleteRef.current(name); - }); - } else { - const wsOpts = { - host: 'auto', - path: watchURL, - subprotocols: ['base64.binary.k8s.io'], + containers.forEach((container) => { + if (activeContainers.has(container.name)) return; + setActiveContainers((prev) => new Set(prev).add(container.name)); + let loaded = false; + let ws: WSFactory; + const { name } = container; + const urlOpts = { + ns: resNamespace, + name: resName, + path: 'log', + queryParams: { + container: name, + follow: 'true', + timestamps: 'true', + }, }; - ws = new WSFactory(watchURL, wsOpts); - ws.onmessage((msg) => { - if (loaded) return; - const message = Base64.decode(msg); - appendMessage.current(message); - }) - .onclose(() => { - onCompleteRef.current(name); - }) - .onerror(() => { - if (loaded) return; - setError(true); - onCompleteRef.current(name); - }); - } - return () => { - loaded = true; - ws && ws.destroy(); - }; - }, [kind, name, resName, resNamespace]); + const watchURL = resourceURL(PodModel, urlOpts); + + const containerStatus: ContainerStatus[] = + resource?.status?.containerStatuses ?? []; + const statusIndex = containerStatus.findIndex( + (c) => c.name === container.name, + ); + const resourceStatus = containerToLogSourceStatus( + containerStatus[statusIndex], + ); + + if (resourceStatus === LOG_SOURCE_TERMINATED) { + consoleFetchText(watchURL) + .then((res) => { + if (loaded) return; + appendMessage(name, res, resourceStatus); + }) + .catch(() => { + if (loaded) return; + setError(true); + }); + } else { + const wsOpts = { + host: 'auto', + path: watchURL, + subprotocols: ['base64.binary.k8s.io'], + }; + ws = retryWebSocket( + watchURL, + wsOpts, + (message) => { + if (loaded) return; + setError(false); + appendMessage(name, message, resourceStatus); + }, + () => { + if (loaded) return; + setError(true); + }, + ); + } + return () => { + loaded = true; + if (ws) { + ws.destroy(); + } + }; + }); + }, [ + resName, + resNamespace, + resource?.status?.containerStatuses, + activeContainers, + ]); React.useEffect(() => { - if (scrollToRef.current && render && autoScroll) { - addContentAndScroll(); - } - }, [autoScroll, render, addContentAndScroll]); + const formattedString = processLogData(logData, containers); + setFormattedLogString(formattedString); + const totalLines = formattedString.split('\n').length; + setScrollToRow(totalLines); + }, [logData]); return ( -
-

{name}

+
{error && ( = ({ title={t('An error occurred while retrieving the requested logs.')} /> )} -
-
-
-
+
); }; diff --git a/src/components/logs/MultiStreamLogs.scss b/src/components/logs/MultiStreamLogs.scss index 615dae7..1d053bf 100644 --- a/src/components/logs/MultiStreamLogs.scss +++ b/src/components/logs/MultiStreamLogs.scss @@ -36,6 +36,12 @@ width: 100%; } } + &__logviewer { + background-color: var(--pf-v5-global--palette--black-1000); + color: var(--pf-v5-global--Color--light-100); + font-family: Menlo, Monaco, 'Courier New', monospace; + height: 100%; + } &__taskName { background-color: var(--pf-v5-global--BackgroundColor--dark-300); padding: var(--pf-v5-global--spacer--sm) var(--pf-v5-global--spacer--md); @@ -45,3 +51,8 @@ margin-left: var(--pf-v5-global--spacer--sm); } } + +.odc-logs-logviewer { + background-color: var(--pf-v5-global--palette--black-1000); + height: 100%; +} diff --git a/src/components/logs/MultiStreamLogs.tsx b/src/components/logs/MultiStreamLogs.tsx index 1829a98..dde5a8c 100644 --- a/src/components/logs/MultiStreamLogs.tsx +++ b/src/components/logs/MultiStreamLogs.tsx @@ -1,11 +1,8 @@ import * as React from 'react'; import Logs from './Logs'; import { getRenderContainers } from './logs-utils'; -import { ContainerSpec, ContainerStatus, PodKind } from '../../types'; +import { PodKind } from '../../types'; import { LoadingInline } from '../Loading'; -import { containerToLogSourceStatus } from '../utils/pipeline-utils'; -import { LOG_SOURCE_WAITING } from '../../consts'; -import { ScrollDirection, useScrollDirection } from './scroll'; import './MultiStreamLogs.scss'; type MultiStreamLogsProps = { @@ -19,41 +16,8 @@ export const MultiStreamLogs: React.FC = ({ taskName, setCurrentLogsGetter, }) => { - const scrollPane = React.useRef(); - const completedRef = React.useRef([]); - const [renderToCount, setRenderToCount] = React.useState(0); - const [scrollDirection, handleScrollCallback] = useScrollDirection(); const { containers, stillFetching } = getRenderContainers(resource); - const dataRef = React.useRef(null); - dataRef.current = containers; - React.useEffect(() => { - setCurrentLogsGetter(() => { - return scrollPane.current?.innerText; - }); - }, [setCurrentLogsGetter]); - - const handleComplete = React.useCallback((containerName) => { - const index = dataRef.current.findIndex( - ({ name }) => name === containerName, - ); - completedRef.current[index] = true; - const newRenderTo = dataRef.current.findIndex( - (c, i) => completedRef.current[i] !== true, - ); - if (newRenderTo === -1) { - setRenderToCount(dataRef.current.length); - } else { - setRenderToCount(newRenderTo); - } - }, []); - - const autoScroll = - scrollDirection == null || - scrollDirection !== ScrollDirection.scrolledToBottom; - - const containerStatus: ContainerStatus[] = - resource?.status?.containerStatuses ?? []; return ( <>
= ({ )}
-
- {containers.map((container, idx) => { - const statusIndex = containerStatus.findIndex( - (c) => c.name === container.name, - ); - const resourceStatus = containerToLogSourceStatus( - containerStatus[statusIndex], - ); - return ( - resourceStatus !== LOG_SOURCE_WAITING && ( - = idx} - autoScroll={autoScroll} - /> - ) - ); - })} -
+
); diff --git a/src/components/logs/TektonTaskRunLog.tsx b/src/components/logs/TektonTaskRunLog.tsx index 54b68ba..bfd978d 100644 --- a/src/components/logs/TektonTaskRunLog.tsx +++ b/src/components/logs/TektonTaskRunLog.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; +import { LogViewer } from '@patternfly/react-log-viewer'; import { HttpError } from '@openshift-console/dynamic-plugin-sdk/lib/utils/error/http-error'; import { TaskRunKind } from '../../types'; import { TektonResourceLabel } from '../../consts'; -import './Logs.scss'; -import './MultiStreamLogs.scss'; import { LoadingInline } from '../Loading'; import { useTRTaskRunLog } from '../hooks/useTektonResult'; +import './MultiStreamLogs.scss'; type TektonTaskRunLogProps = { taskRun?: TaskRunKind; @@ -16,7 +16,6 @@ export const TektonTaskRunLog: React.FC = ({ taskRun, setCurrentLogsGetter, }) => { - const scrollPane = React.useRef(); const taskName = taskRun?.metadata?.labels?.[TektonResourceLabel.pipelineTask] || '-'; const [trResults, trLoaded, trError] = useTRTaskRunLog( @@ -26,19 +25,23 @@ export const TektonTaskRunLog: React.FC = ({ ); React.useEffect(() => { - setCurrentLogsGetter(() => scrollPane.current?.innerText); - }, [setCurrentLogsGetter]); - - React.useEffect(() => { - if (!trError && trLoaded && scrollPane.current && trResults) { - scrollPane.current.scrollTop = scrollPane.current.scrollHeight; - } - }, [trError, trLoaded, trResults]); + setCurrentLogsGetter(() => formattedResults); + }, [setCurrentLogsGetter, trResults]); const errorMessage = (trError as HttpError)?.code === 404 ? `Logs are no longer accessible for ${taskName} task` : null; + + // Format trResults to include taskName + const formattedResults = React.useMemo(() => { + if (!trResults) return ''; + const formattedTaskName = `${taskName.toUpperCase()}`; + + return `${formattedTaskName}\n${trResults}\n\n`; + }, [trResults, taskName]); + const lastRowIndex = trResults ? formattedResults.split('\n').length : 0; + return ( <>
= ({ )}
-
- {errorMessage && ( -
- {errorMessage} -
- )} - {!errorMessage && trLoaded ? ( -
-

{taskName}

-
-
- {trResults} -
-
-
- ) : null} -
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {!errorMessage && trLoaded ? ( + + ) : null}
); diff --git a/src/components/pipelineRuns-details/PipelineRunLogs.scss b/src/components/pipelineRuns-details/PipelineRunLogs.scss index 70468f0..9cf8355 100644 --- a/src/components/pipelineRuns-details/PipelineRunLogs.scss +++ b/src/components/pipelineRuns-details/PipelineRunLogs.scss @@ -2,7 +2,9 @@ display: flex; flex: 1; width: 100%; - padding: var(--pf-v5-global--spacer--xl) 0; + position: absolute; + top: 0; + bottom: 0; &__nav { padding-top: 0; list-style-type: none; @@ -23,7 +25,9 @@ } &__logtext { position: relative; - background: var(--pf-v5-global--palette--black-1000); // pod log background color + background: var( + --pf-v5-global--palette--black-1000 + ); // pod log background color color: var(--pf-v5-global--palette--black-100); height: 100%; padding: var(--pf-v5-global--spacer--sm) var(--pf-v5-global--spacer--md); @@ -35,12 +39,17 @@ max-width: 30%; min-width: 208px; margin-top: var(--pf-v5-global--spacer--lg); + overflow-y: auto; } &__container { flex: 1; - padding-right: var(--pf-v5-global--spacer--xl); } &__container .co-m-pane__body { padding: 0px; } } + +.odc-pipeline-run-logs-main-div { + position: relative; + height: 100%; +} diff --git a/src/components/pipelineRuns-details/PipelineRunLogs.tsx b/src/components/pipelineRuns-details/PipelineRunLogs.tsx index ceda3bb..df28f66 100644 --- a/src/components/pipelineRuns-details/PipelineRunLogs.tsx +++ b/src/components/pipelineRuns-details/PipelineRunLogs.tsx @@ -170,87 +170,89 @@ class PipelineRunLogsWithTranslation extends React.Component< )}/logs`; return ( -
-
- {taskCount > 0 ? ( - + ) : ( +
+ {t('No task runs found')} +
+ )} +
+
+ {activeItem && resources ? ( + + ) : ( +
+
+ {waitingForPods && + !pipelineRunFinished && + `Waiting for ${taskName} task to start `} + {!resources && + pipelineRunFinished && + !obj.status && + t('No logs found')} + {logDetails && ( +
+ {logDetails.staticMessage} +
+ )} +
-
- )} + )} +
); diff --git a/yarn.lock b/yarn.lock index 491d267..c450ca5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2085,7 +2085,7 @@ clsx "^2.0.0" react-jss "^10.10.0" -"@patternfly/react-core@^5.1.1", "@patternfly/react-core@^5.2.1", "@patternfly/react-core@^5.3.4": +"@patternfly/react-core@^5.0.0", "@patternfly/react-core@^5.1.1", "@patternfly/react-core@^5.2.1", "@patternfly/react-core@^5.3.4": version "5.3.4" resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-5.3.4.tgz#84f85d3528655134cf0bcdb096f82777f0dd69b6" integrity sha512-zr2yeilIoFp8MFOo0vNgI8XuM+P2466zHvy4smyRNRH2/but2WObqx7Wu4ftd/eBMYdNqmTeuXe6JeqqRqnPMQ== @@ -2102,12 +2102,22 @@ resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-5.2.1.tgz#c29e9fbecd13c33e772abe6089e31bb86b1ab2a8" integrity sha512-aeJ0X+U2NDe8UmI5eQiT0iuR/wmUq97UkDtx3HoZcpRb9T6eUBfysllxjRqHS8rOOspdU8OWq+CUhQ/E2ZDibg== -"@patternfly/react-icons@^5.1.1", "@patternfly/react-icons@^5.2.1", "@patternfly/react-icons@^5.3.2": +"@patternfly/react-icons@^5.0.0", "@patternfly/react-icons@^5.1.1", "@patternfly/react-icons@^5.2.1", "@patternfly/react-icons@^5.3.2": version "5.3.2" resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-5.3.2.tgz#f594ed67b0d39f486ea0f0367de058d4bd056605" integrity sha512-GEygYbl0H4zD8nZuTQy2dayKIrV2bMMeWKSOEZ16Y3EYNgYVUOUnN+J0naAEuEGH39Xb1DE9n+XUbE1PC4CxPA== -"@patternfly/react-styles@^5.1.1", "@patternfly/react-styles@^5.2.1", "@patternfly/react-styles@^5.3.1": +"@patternfly/react-log-viewer@5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@patternfly/react-log-viewer/-/react-log-viewer-5.3.0.tgz#4df3344d0983c45a1a23ce237d4faa5d7285ece0" + integrity sha512-6jzhxwJwllLdX3jpoGdzIhvhPTfYuC6B+KuN2Laf7Iuioeig8bOMzJZFh6VXg+aBGd9j4JGv2dYryDsbDsTLvw== + dependencies: + "@patternfly/react-core" "^5.0.0" + "@patternfly/react-icons" "^5.0.0" + "@patternfly/react-styles" "^5.0.0" + memoize-one "^5.1.0" + +"@patternfly/react-styles@^5.0.0", "@patternfly/react-styles@^5.1.1", "@patternfly/react-styles@^5.2.1", "@patternfly/react-styles@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-5.3.1.tgz#4bc42f98c48e117df5d956ee3f551d0f57ef1b35" integrity sha512-H6uBoFH3bJjD6PP75qZ4k+2TtF59vxf9sIVerPpwrGJcRgBZbvbMZCniSC3+S2LQ8DgXLnDvieq78jJzHz0hiA== @@ -10261,6 +10271,11 @@ memfs@^4.6.0: tree-dump "^1.0.1" tslib "^2.0.0" +memoize-one@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + meow@^10.1.5: version "10.1.5" resolved "https://registry.yarnpkg.com/meow/-/meow-10.1.5.tgz#be52a1d87b5f5698602b0f32875ee5940904aa7f"