|
1 | 1 | import * as React from 'react';
|
2 | 2 | import { Alert } from '@patternfly/react-core';
|
| 3 | +import { LogViewer } from '@patternfly/react-log-viewer'; |
3 | 4 | import { Base64 } from 'js-base64';
|
4 |
| -import { throttle } from 'lodash'; |
5 | 5 | import { useTranslation } from 'react-i18next';
|
6 | 6 | import { consoleFetchText } from '@openshift-console/dynamic-plugin-sdk';
|
7 | 7 | import { LOG_SOURCE_TERMINATED } from '../../consts';
|
8 | 8 | import { WSFactory } from '@openshift-console/dynamic-plugin-sdk/lib/utils/k8s/ws-factory';
|
9 |
| -import { ContainerSpec, PodKind } from '../../types'; |
| 9 | +import { ContainerSpec, ContainerStatus, PodKind } from '../../types'; |
10 | 10 | import { PodModel } from '../../models';
|
11 | 11 | import { resourceURL } from '../utils/k8s-utils';
|
12 |
| -import './Logs.scss'; |
| 12 | +import { containerToLogSourceStatus } from '../utils/pipeline-utils'; |
| 13 | +import './MultiStreamLogs.scss'; |
13 | 14 |
|
14 |
| -consoleFetchText; |
15 | 15 | type LogsProps = {
|
16 | 16 | resource: PodKind;
|
17 |
| - resourceStatus: string; |
18 |
| - container: ContainerSpec; |
19 |
| - render: boolean; |
20 |
| - autoScroll?: boolean; |
21 |
| - onComplete: (containerName: string) => void; |
| 17 | + containers: ContainerSpec[]; |
| 18 | + setCurrentLogsGetter?: (getter: () => string) => void; |
| 19 | +}; |
| 20 | + |
| 21 | +type LogData = { |
| 22 | + [containerName: string]: { |
| 23 | + logs: string[]; |
| 24 | + status: string; |
| 25 | + }; |
| 26 | +}; |
| 27 | + |
| 28 | +const processLogData = ( |
| 29 | + logData: LogData, |
| 30 | + containers: ContainerSpec[], |
| 31 | +): string => { |
| 32 | + let result = ''; |
| 33 | + |
| 34 | + containers.map(({ name: containerName }) => { |
| 35 | + if (logData[containerName]) { |
| 36 | + const { logs } = logData[containerName]; |
| 37 | + const uniqueLogs = Array.from(new Set(logs)); |
| 38 | + const filteredLogs = uniqueLogs.filter((log) => log.trim() !== ''); |
| 39 | + const formattedContainerName = `${containerName.toUpperCase()}`; |
| 40 | + |
| 41 | + if (filteredLogs.length === 0) { |
| 42 | + result += `${formattedContainerName}\n\n`; |
| 43 | + } else { |
| 44 | + const joinedLogs = filteredLogs.join('\n'); |
| 45 | + result += `${formattedContainerName}\n${joinedLogs}\n\n`; |
| 46 | + } |
| 47 | + } |
| 48 | + }); |
| 49 | + return result; |
22 | 50 | };
|
23 | 51 |
|
24 | 52 | const Logs: React.FC<LogsProps> = ({
|
25 | 53 | resource,
|
26 |
| - resourceStatus, |
27 |
| - container, |
28 |
| - onComplete, |
29 |
| - render, |
30 |
| - autoScroll = true, |
| 54 | + containers, |
| 55 | + setCurrentLogsGetter, |
31 | 56 | }) => {
|
| 57 | + if (!resource) return null; |
32 | 58 | const { t } = useTranslation('plugin__pipelines-console-plugin');
|
33 |
| - const { name } = container; |
34 |
| - const { kind, metadata = {} } = resource; |
| 59 | + const { metadata = {} } = resource; |
35 | 60 | const { name: resName, namespace: resNamespace } = metadata;
|
36 |
| - const scrollToRef = React.useRef<HTMLDivElement>(null); |
37 |
| - const contentRef = React.useRef<HTMLDivElement>(null); |
38 | 61 | const [error, setError] = React.useState<boolean>(false);
|
39 |
| - const resourceStatusRef = React.useRef<string>(resourceStatus); |
40 |
| - const onCompleteRef = React.useRef<(name) => void>(); |
41 |
| - const blockContentRef = React.useRef<string>(''); |
42 |
| - onCompleteRef.current = onComplete; |
43 |
| - |
44 |
| - const addContentAndScroll = React.useCallback( |
45 |
| - throttle(() => { |
46 |
| - if (contentRef.current) { |
47 |
| - contentRef.current.innerText += blockContentRef.current; |
48 |
| - } |
49 |
| - if (scrollToRef.current) { |
50 |
| - scrollToRef.current.scrollIntoView({ |
51 |
| - behavior: 'smooth', |
52 |
| - block: 'end', |
53 |
| - }); |
54 |
| - } |
55 |
| - blockContentRef.current = ''; |
56 |
| - }, 1000), |
57 |
| - [], |
| 62 | + const [logData, setLogData] = React.useState<LogData>({}); |
| 63 | + const [formattedLogString, setFormattedLogString] = React.useState(''); |
| 64 | + const [scrollToRow, setScrollToRow] = React.useState<number>(0); |
| 65 | + const [activeContainers, setActiveContainers] = React.useState<Set<string>>( |
| 66 | + new Set(), |
58 | 67 | );
|
59 | 68 |
|
60 |
| - const appendMessage = React.useRef<(blockContent) => void>(); |
| 69 | + React.useEffect(() => { |
| 70 | + setCurrentLogsGetter(() => { |
| 71 | + return formattedLogString; |
| 72 | + }); |
| 73 | + }, [setCurrentLogsGetter, formattedLogString]); |
| 74 | + |
| 75 | + const appendMessage = React.useCallback( |
| 76 | + (containerName: string, blockContent: string, resourceStatus: string) => { |
| 77 | + if (blockContent) { |
| 78 | + setLogData((prevLogData) => { |
| 79 | + if (resourceStatus === 'terminated') { |
| 80 | + // Replace the entire content with blockContent |
| 81 | + return { |
| 82 | + ...prevLogData, |
| 83 | + [containerName]: { |
| 84 | + logs: [blockContent], |
| 85 | + status: resourceStatus, |
| 86 | + }, |
| 87 | + }; |
| 88 | + } else { |
| 89 | + // Otherwise, append the blockContent to the existing logs |
| 90 | + const existingLogs = prevLogData[containerName]?.logs || []; |
| 91 | + const updatedLogs = [...existingLogs, blockContent.trimEnd()]; |
61 | 92 |
|
62 |
| - appendMessage.current = React.useCallback( |
63 |
| - (blockContent: string) => { |
64 |
| - blockContentRef.current += blockContent; |
65 |
| - if (scrollToRef.current && blockContent && render && autoScroll) { |
66 |
| - addContentAndScroll(); |
| 93 | + return { |
| 94 | + ...prevLogData, |
| 95 | + [containerName]: { |
| 96 | + logs: updatedLogs, |
| 97 | + status: resourceStatus, |
| 98 | + }, |
| 99 | + }; |
| 100 | + } |
| 101 | + }); |
67 | 102 | }
|
68 | 103 | },
|
69 |
| - [autoScroll, render, addContentAndScroll], |
| 104 | + [], |
70 | 105 | );
|
71 | 106 |
|
72 |
| - if (resourceStatusRef.current !== resourceStatus) { |
73 |
| - resourceStatusRef.current = resourceStatus; |
74 |
| - } |
| 107 | + const retryWebSocket = ( |
| 108 | + watchURL: string, |
| 109 | + wsOpts: any, |
| 110 | + onMessage: (message: string) => void, |
| 111 | + onError: () => void, |
| 112 | + retryCount = 0, |
| 113 | + ) => { |
| 114 | + let ws = new WSFactory(watchURL, wsOpts); |
| 115 | + const handleError = () => { |
| 116 | + if (retryCount < 5) { |
| 117 | + setTimeout(() => { |
| 118 | + retryWebSocket(watchURL, wsOpts, onMessage, onError, retryCount + 1); |
| 119 | + }, 3000); // Retry after 3 seconds |
| 120 | + } else { |
| 121 | + onError(); |
| 122 | + } |
| 123 | + }; |
| 124 | + |
| 125 | + ws.onmessage((msg) => { |
| 126 | + const message = Base64.decode(msg); |
| 127 | + onMessage(message); |
| 128 | + }).onerror(() => { |
| 129 | + handleError(); |
| 130 | + }); |
| 131 | + |
| 132 | + return ws; |
| 133 | + }; |
75 | 134 |
|
76 | 135 | React.useEffect(() => {
|
77 |
| - let loaded = false; |
78 |
| - let ws: WSFactory; |
79 |
| - const urlOpts = { |
80 |
| - ns: resNamespace, |
81 |
| - name: resName, |
82 |
| - path: 'log', |
83 |
| - queryParams: { |
84 |
| - container: name, |
85 |
| - follow: 'true', |
86 |
| - timestamps: 'true', |
87 |
| - }, |
88 |
| - }; |
89 |
| - const watchURL = resourceURL(PodModel, urlOpts); |
90 |
| - if (resourceStatusRef.current === LOG_SOURCE_TERMINATED) { |
91 |
| - consoleFetchText(watchURL) |
92 |
| - .then((res) => { |
93 |
| - if (loaded) return; |
94 |
| - appendMessage.current(res); |
95 |
| - onCompleteRef.current(name); |
96 |
| - }) |
97 |
| - .catch(() => { |
98 |
| - if (loaded) return; |
99 |
| - setError(true); |
100 |
| - onCompleteRef.current(name); |
101 |
| - }); |
102 |
| - } else { |
103 |
| - const wsOpts = { |
104 |
| - host: 'auto', |
105 |
| - path: watchURL, |
106 |
| - subprotocols: ['base64.binary.k8s.io'], |
| 136 | + containers.forEach((container) => { |
| 137 | + if (activeContainers.has(container.name)) return; |
| 138 | + setActiveContainers((prev) => new Set(prev).add(container.name)); |
| 139 | + let loaded = false; |
| 140 | + let ws: WSFactory; |
| 141 | + const { name } = container; |
| 142 | + const urlOpts = { |
| 143 | + ns: resNamespace, |
| 144 | + name: resName, |
| 145 | + path: 'log', |
| 146 | + queryParams: { |
| 147 | + container: name, |
| 148 | + follow: 'true', |
| 149 | + timestamps: 'true', |
| 150 | + }, |
107 | 151 | };
|
108 |
| - ws = new WSFactory(watchURL, wsOpts); |
109 |
| - ws.onmessage((msg) => { |
110 |
| - if (loaded) return; |
111 |
| - const message = Base64.decode(msg); |
112 |
| - appendMessage.current(message); |
113 |
| - }) |
114 |
| - .onclose(() => { |
115 |
| - onCompleteRef.current(name); |
116 |
| - }) |
117 |
| - .onerror(() => { |
118 |
| - if (loaded) return; |
119 |
| - setError(true); |
120 |
| - onCompleteRef.current(name); |
121 |
| - }); |
122 |
| - } |
123 |
| - return () => { |
124 |
| - loaded = true; |
125 |
| - ws && ws.destroy(); |
126 |
| - }; |
127 |
| - }, [kind, name, resName, resNamespace]); |
| 152 | + const watchURL = resourceURL(PodModel, urlOpts); |
| 153 | + |
| 154 | + const containerStatus: ContainerStatus[] = |
| 155 | + resource?.status?.containerStatuses ?? []; |
| 156 | + const statusIndex = containerStatus.findIndex( |
| 157 | + (c) => c.name === container.name, |
| 158 | + ); |
| 159 | + const resourceStatus = containerToLogSourceStatus( |
| 160 | + containerStatus[statusIndex], |
| 161 | + ); |
| 162 | + |
| 163 | + if (resourceStatus === LOG_SOURCE_TERMINATED) { |
| 164 | + consoleFetchText(watchURL) |
| 165 | + .then((res) => { |
| 166 | + if (loaded) return; |
| 167 | + appendMessage(name, res, resourceStatus); |
| 168 | + }) |
| 169 | + .catch(() => { |
| 170 | + if (loaded) return; |
| 171 | + setError(true); |
| 172 | + }); |
| 173 | + } else { |
| 174 | + const wsOpts = { |
| 175 | + host: 'auto', |
| 176 | + path: watchURL, |
| 177 | + subprotocols: ['base64.binary.k8s.io'], |
| 178 | + }; |
| 179 | + ws = retryWebSocket( |
| 180 | + watchURL, |
| 181 | + wsOpts, |
| 182 | + (message) => { |
| 183 | + if (loaded) return; |
| 184 | + setError(false); |
| 185 | + appendMessage(name, message, resourceStatus); |
| 186 | + }, |
| 187 | + () => { |
| 188 | + if (loaded) return; |
| 189 | + setError(true); |
| 190 | + }, |
| 191 | + ); |
| 192 | + } |
| 193 | + return () => { |
| 194 | + loaded = true; |
| 195 | + if (ws) { |
| 196 | + ws.destroy(); |
| 197 | + } |
| 198 | + }; |
| 199 | + }); |
| 200 | + }, [ |
| 201 | + resName, |
| 202 | + resNamespace, |
| 203 | + resource?.status?.containerStatuses, |
| 204 | + activeContainers, |
| 205 | + ]); |
128 | 206 |
|
129 | 207 | React.useEffect(() => {
|
130 |
| - if (scrollToRef.current && render && autoScroll) { |
131 |
| - addContentAndScroll(); |
132 |
| - } |
133 |
| - }, [autoScroll, render, addContentAndScroll]); |
| 208 | + const formattedString = processLogData(logData, containers); |
| 209 | + setFormattedLogString(formattedString); |
| 210 | + const totalLines = formattedString.split('\n').length; |
| 211 | + setScrollToRow(totalLines); |
| 212 | + }, [logData]); |
134 | 213 |
|
135 | 214 | return (
|
136 |
| - <div className="odc-logs" style={{ display: render ? '' : 'none' }}> |
137 |
| - <p className="odc-logs__name">{name}</p> |
| 215 | + <div className="odc-logs-logviewer"> |
138 | 216 | {error && (
|
139 | 217 | <Alert
|
140 | 218 | variant="danger"
|
141 | 219 | isInline
|
142 | 220 | title={t('An error occurred while retrieving the requested logs.')}
|
143 | 221 | />
|
144 | 222 | )}
|
145 |
| - <div> |
146 |
| - <div className="odc-logs__content" ref={contentRef} /> |
147 |
| - <div ref={scrollToRef} /> |
148 |
| - </div> |
| 223 | + <LogViewer |
| 224 | + hasLineNumbers={false} |
| 225 | + isTextWrapped={false} |
| 226 | + data={formattedLogString} |
| 227 | + theme="dark" |
| 228 | + scrollToRow={scrollToRow} |
| 229 | + height="100%" |
| 230 | + /> |
149 | 231 | </div>
|
150 | 232 | );
|
151 | 233 | };
|
|
0 commit comments