From 9f953c78c28a68203394020a9906699a199d6515 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 30 Apr 2026 16:34:35 +0530 Subject: [PATCH 1/3] feat: add LogStageHeader component and implement virtualized logs rendering --- package-lock.json | 26 ++ package.json | 1 + src/Common/Helper.tsx | 6 +- .../CICDHistory/LogStageAccordion.tsx | 171 ---------- .../Components/CICDHistory/LogStageHeader.tsx | 104 ++++++ .../Components/CICDHistory/LogsRenderer.tsx | 297 ++++++++++++++++-- src/Shared/Components/CICDHistory/types.tsx | 15 +- src/Shared/Components/CICDHistory/utils.tsx | 12 + 8 files changed, 426 insertions(+), 206 deletions(-) delete mode 100644 src/Shared/Components/CICDHistory/LogStageAccordion.tsx create mode 100644 src/Shared/Components/CICDHistory/LogStageHeader.tsx diff --git a/package-lock.json b/package-lock.json index ca9cf2331..da96bf4bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-vscode-keymap": "6.0.2", "@tanstack/react-query": "<5", + "@tanstack/react-virtual": "^3.13.24", "@uiw/codemirror-extensions-hyper-link": "4.23.10", "@uiw/codemirror-theme-github": "4.23.7", "@uiw/react-codemirror": "4.23.7", @@ -3769,6 +3770,31 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", diff --git a/package.json b/package.json index 37e20de6a..b3263defa 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-vscode-keymap": "6.0.2", "@tanstack/react-query": "<5", + "@tanstack/react-virtual": "^3.13.24", "@uiw/codemirror-extensions-hyper-link": "4.23.10", "@uiw/codemirror-theme-github": "4.23.7", "@uiw/react-codemirror": "4.23.7", diff --git a/src/Common/Helper.tsx b/src/Common/Helper.tsx index 7406c4d19..8fb832d9d 100644 --- a/src/Common/Helper.tsx +++ b/src/Common/Helper.tsx @@ -891,11 +891,7 @@ export function useScrollable(options: scrollableInterface) { ) function scrollToTop(e) { - targetRef.current.scrollBy({ - top: -1 * scrollTop, - left: 0, - behavior: 'smooth', - }) + targetRef.current.scrollTop = 0 if (options.autoBottomScroll) { toggleAutoBottom(false) } diff --git a/src/Shared/Components/CICDHistory/LogStageAccordion.tsx b/src/Shared/Components/CICDHistory/LogStageAccordion.tsx deleted file mode 100644 index cfb9577d9..000000000 --- a/src/Shared/Components/CICDHistory/LogStageAccordion.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { RefCallback } from 'react' -import DOMPurify from 'dompurify' - -import { ReactComponent as ICCaretDown } from '@Icons/ic-caret-down.svg' -import { ReactComponent as ICStack } from '@Icons/ic-stack.svg' -import { getTimeDifference } from '@Shared/Helpers' - -import { TargetPlatformListTooltip } from '../TargetPlatforms' -import { LogStageAccordionProps } from './types' -import { getLogSearchIndex, getStageStatusIcon } from './utils' - -const LogsItemContainer = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) - -const LogStageAccordion = ({ - stage, - isOpen, - logs, - endTime, - startTime, - status, - handleStageClose, - handleStageOpen, - stageIndex, - isLoading, - fullScreenView, - searchIndex, - targetPlatforms, - logsRendererRef, -}: LogStageAccordionProps) => { - const handleAccordionToggle = () => { - if (isOpen) { - handleStageClose(stageIndex) - } else { - handleStageOpen(stageIndex) - } - } - - const getFormattedTimeDifference = (): string => { - const timeDifference = getTimeDifference({ startTime, endTime }) - if (timeDifference === '0s') { - return '< 1s' - } - return timeDifference - } - - const scrollIntoView: RefCallback = (node) => { - if (!node) { - return - } - - if (node.dataset.containsMatch === 'true' && node.dataset.triggered !== 'true') { - // eslint-disable-next-line no-param-reassign - node.dataset.triggered = 'true' - // TODO: this will additionally scroll the top most scrollbar. Need to check into that - node.scrollIntoView({ block: 'center', behavior: 'smooth' }) - } - - if (node.dataset.containsMatch === 'false') { - // eslint-disable-next-line no-param-reassign - node.dataset.triggered = 'false' - } - } - - const getLogsRendererReference = () => logsRendererRef.current - - return ( -
- - - {isOpen && ( -
- {logs.map((log: string, logsIndex: number) => { - const doesLineContainSearchMatch = - getLogSearchIndex({ stageIndex, lineNumberInsideStage: logsIndex }) === searchIndex - - return ( - - - {logsIndex + 1} - -
-                            
-                        )
-                    })}
-
-                    {isLoading && (
-                        
-                            
-                            
- - )} -
- )} -
- ) -} - -export default LogStageAccordion diff --git a/src/Shared/Components/CICDHistory/LogStageHeader.tsx b/src/Shared/Components/CICDHistory/LogStageHeader.tsx new file mode 100644 index 000000000..bd46e6908 --- /dev/null +++ b/src/Shared/Components/CICDHistory/LogStageHeader.tsx @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReactComponent as ICCaretDown } from '@Icons/ic-caret-down.svg' +import { ReactComponent as ICStack } from '@Icons/ic-stack.svg' +import { getTimeDifference } from '@Shared/Helpers' + +import { TargetPlatformListTooltip } from '../TargetPlatforms' +import { LogStageHeaderProps } from './types' +import { getStageStatusIcon } from './utils' + +const LogStageHeader = ({ + stage, + isOpen, + status, + startTime, + endTime, + targetPlatforms, + stageIndex, + fullScreenView, + handleStageClose, + handleStageOpen, + logsRendererRef, + applySticky = true, +}: LogStageHeaderProps) => { + const handleAccordionToggle = () => { + if (isOpen) { + handleStageClose(stageIndex) + } else { + handleStageOpen(stageIndex) + } + } + + const getFormattedTimeDifference = (): string => { + const timeDifference = getTimeDifference({ startTime, endTime }) + if (timeDifference === '0s') { + return '< 1s' + } + return timeDifference + } + + const getLogsRendererReference = () => logsRendererRef.current + + return ( + + ) +} + +export default LogStageHeader diff --git a/src/Shared/Components/CICDHistory/LogsRenderer.tsx b/src/Shared/Components/CICDHistory/LogsRenderer.tsx index 19057c01e..930ed47a2 100644 --- a/src/Shared/Components/CICDHistory/LogsRenderer.tsx +++ b/src/Shared/Components/CICDHistory/LogsRenderer.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import AnsiUp from 'ansi_up' import DOMPurify from 'dompurify' @@ -47,18 +48,19 @@ import { LOGS_STAGE_STREAM_SEPARATOR, POD_STATUS, } from './constants' -import LogStageAccordion from './LogStageAccordion' +import LogStageHeader from './LogStageHeader' import { CreateMarkupPropsType, CreateMarkupReturnType, DeploymentHistoryBaseParamsType, HistoryComponentType, LogsRendererType, + LogVirtualItem, StageDetailType, StageInfoDTO, StageStatusType, } from './types' -import { getLogSearchIndex } from './utils' +import { findScrollableAncestor, getLogSearchIndex } from './utils' import './LogsRenderer.scss' @@ -171,9 +173,31 @@ const useCIEventSource = (url: string, maxLength?: number): [string[], EventSour return [dataVal, eventSourceRef.current, logsNotAvailableError] } +const STAGE_OVERHEAD_HEIGHT = 36 +// lh-20(20) + paddingBottom(4) +const LOG_HEIGHT = 24 +const OVERSCAN_COUNT = 50 + +const LogLine = ({ log, logIndex }: { log: string; logIndex: number }) => ( +
+ + {logIndex + 1} + +
+    
+) + const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, fullScreenView }: LogsRendererType) => { const { pipelineId, envId, appId } = useParams() const logsRendererRef = useRef(null) + const listContainerRef = useRef(null) + const scrollElementRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + const [scrollTrigger, setScrollTrigger] = useState(false) const logsURL = parentType === HistoryComponentType.CI @@ -443,6 +467,7 @@ const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, ful currentIndex = currentSearchIndex > 0 ? currentSearchIndex - 1 : searchResults.length - 1 } setCurrentSearchIndex(currentIndex) + setScrollTrigger((prev) => !prev) setStageList(getStageListFromStreamData(currentIndex, searchText)) } } @@ -476,6 +501,237 @@ const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, ful setStageList(newLogs) } + /** + * Flattens stageList into a single ordered array for the virtualizer. + * Each stage contributes one 'header' item, followed by one 'log' item per line (only when open). + * + * headerFlatIndexSet — Set of flat-array positions that hold a header. + * Used by rangeExtractor to always keep the active sticky header rendered. + * + * stageIndexToHeaderFlatIndex — Maps stageIndex → that stage's position in flatItems. + * Used to scroll to the correct log line when navigating search results. + */ + const { flatItems, headerFlatIndexSet, stageIndexToHeaderFlatIndex } = useMemo(() => { + const items: LogVirtualItem[] = [] + const headerIndexSet = new Set() + const headerMap = new Map() + + stageList.forEach((stage, stageIndex) => { + const headerFlatIdx = items.length + items.push({ type: 'header', stageIndex }) + headerIndexSet.add(headerFlatIdx) + headerMap.set(stageIndex, headerFlatIdx) + + if (stage.isOpen) { + stage.logs.forEach((_, logIndex) => { + items.push({ type: 'log', stageIndex, logIndex }) + }) + } + }) + + return { flatItems: items, headerFlatIndexSet: headerIndexSet, stageIndexToHeaderFlatIndex: headerMap } + }, [stageList]) + + // Find the scrollable ancestor once and measure the list container's offset within it. + // This is needed because the scroll container is a div (not window), so we use useVirtualizer + // with a custom getScrollElement, and scrollMargin = distance from scroll container top to list top. + useLayoutEffect(() => { + if (!listContainerRef.current) { + return + } + const scrollEl = findScrollableAncestor(listContainerRef.current) + scrollElementRef.current = scrollEl + if (scrollEl) { + const listRect = listContainerRef.current.getBoundingClientRect() + const containerRect = scrollEl.getBoundingClientRect() + setScrollMargin(listRect.top - containerRect.top + scrollEl.scrollTop) + } + }, [areStagesAvailable, fullScreenView]) + + const estimateSize = useCallback( + (index: number) => { + const item = flatItems[index] + if (!item) { + return LOG_HEIGHT + } + return item.type === 'header' ? STAGE_OVERHEAD_HEIGHT : LOG_HEIGHT + }, + [flatItems], + ) + + const getItemKey = useCallback( + (index: number) => { + const item = flatItems[index] + if (!item) { + return `item-${index}` + } + return item.type === 'header' ? `header-${item.stageIndex}` : `log-${item.stageIndex}-${item.logIndex}` + }, + [flatItems], + ) + + /** + * Extends TanStack's default visible range to always include the active sticky header — + * the last header whose flat index is <= the first visible row. Without this, the virtualizer + * would unmount that header as the user scrolls past it, breaking the sticky effect. + */ + const rangeExtractor = useCallback( + (range: { startIndex: number; endIndex: number; overscan: number; count: number }) => { + const base = defaultRangeExtractor(range) + // NOTE: This can also be a binary search since headerFlatIndexSet is sorted, but in practice the number of stages (headers) is small so it doesn't matter + const activeStickyIdx = Array.from(headerFlatIndexSet).reduce( + (last, idx) => (idx <= range.startIndex ? idx : last), + -1, + ) + if (activeStickyIdx === -1) { + return base + } + const next = new Set(base) + next.add(activeStickyIdx) + return Array.from(next).sort((a, b) => a - b) + }, + [headerFlatIndexSet], + ) + + const virtualizer = useVirtualizer({ + count: flatItems.length, + estimateSize, + overscan: OVERSCAN_COUNT, + scrollMargin, + getScrollElement: () => scrollElementRef.current, + getItemKey, + rangeExtractor, + }) + + /** + * Scrolls to the active search result whenever the user navigates matches. + * scrollTrigger toggles on every navigation so cycling back to the same + * result index still re-triggers the effect. + * + * targetSearchIdx encodes the match as "-". + * We look up the stage's header flat index, then offset by 1 (skip the header) + * plus the log's position within the stage to get its absolute flat index. + */ + useEffect(() => { + const targetSearchIdx = searchResults[currentSearchIndex] + if (!targetSearchIdx || !areStagesAvailable) { + return + } + + const [stageIdxStr, logIdxStr] = targetSearchIdx.split('-') + const headerFlatIdx = stageIndexToHeaderFlatIndex.get(Number(stageIdxStr)) + + if (headerFlatIdx === undefined) { + return + } + + virtualizer.scrollToIndex(headerFlatIdx + 1 + Number(logIdxStr), { align: 'center', behavior: 'smooth' }) + }, [currentSearchIndex, scrollTrigger]) + + const renderVirtualLogs = () => { + const stickyTop = fullScreenView ? 44 : 80 + const scrollTop = scrollElementRef.current?.scrollTop ?? 0 + const virtualItems = virtualizer.getVirtualItems() + + // vItem.start already includes scrollMargin (TanStack adds it), so + // vItem.start - scrollTop = item's viewport position from the scroll container top. + // + // Active sticky = last header whose viewport position has gone above stickyTop. + // Next sticky = first header below stickyTop after the active one. + // + // We track nextStickyFlatIdx so the active sticky can be pushed upward as the next + // header slides in — preventing both headers from being visible at the top simultaneously. + let activeStickyFlatIdx = -1 + let nextStickyFlatIdx = -1 + const vItemByIndex = new Map() + + virtualItems.forEach((vItem) => { + vItemByIndex.set(vItem.index, vItem) + if (flatItems[vItem.index]?.type !== 'header') { + return + } + const viewportTop = vItem.start - scrollTop + if (viewportTop < stickyTop) { + activeStickyFlatIdx = vItem.index + nextStickyFlatIdx = -1 // reset each time a newer active is found + } else if (nextStickyFlatIdx === -1) { + nextStickyFlatIdx = vItem.index + } + }) + + return virtualItems.map((virtualItem) => { + const item = flatItems[virtualItem.index] + if (!item) { + return null + } + + const isActiveSticky = virtualItem.index === activeStickyFlatIdx + + // Push-up: reduce the sticky top offset as the next header enters the sticky zone, + // so the active header slides off the top at the same rate the next one arrives. + let computedStickyTop = stickyTop + if (isActiveSticky && nextStickyFlatIdx !== -1) { + const activeHeight = vItemByIndex.get(activeStickyFlatIdx)?.size ?? STAGE_OVERHEAD_HEIGHT + const nextViewportTop = (vItemByIndex.get(nextStickyFlatIdx)?.start ?? Infinity) - scrollTop + computedStickyTop = stickyTop - Math.max(0, stickyTop + activeHeight - nextViewportTop) + } + + const baseStyle: React.CSSProperties = isActiveSticky + ? { + position: 'sticky', + top: computedStickyTop, + zIndex: 1, + width: '100%', + paddingLeft: 12, + paddingRight: 12, + } + : { + position: 'absolute', + top: 0, + width: '100%', + // item.start includes scrollMargin; subtract to get container-relative position + transform: `translateY(${virtualItem.start - scrollMargin}px)`, + paddingLeft: 12, + paddingRight: 12, + } + + if (item.type === 'header') { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) + }) + } + const renderLogs = () => { if (areStagesAvailable) { return ( @@ -557,29 +813,20 @@ const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, ful
-
- {stageList.map( - ({ stage, isOpen, logs, endTime, startTime, status, targetPlatforms }, index) => ( - - ), - )} +
+ {renderVirtualLogs()}
+ {areEventsProgressing && ( +
+
+ +
+
+
+ )}
) } diff --git a/src/Shared/Components/CICDHistory/types.tsx b/src/Shared/Components/CICDHistory/types.tsx index 58a299b95..ecfb81735 100644 --- a/src/Shared/Components/CICDHistory/types.tsx +++ b/src/Shared/Components/CICDHistory/types.tsx @@ -837,16 +837,21 @@ export interface StageDetailType extends Pick { +export type LogVirtualItem = + | { type: 'header'; stageIndex: number } + | { type: 'log'; stageIndex: number; logIndex: number } + +export interface LogStageHeaderProps extends Omit, Pick { handleStageClose: (index: number) => void handleStageOpen: (index: number) => void stageIndex: number + logsRendererRef: MutableRefObject /** - * A stage is loading if it is last in current stage list and event is not closed + * When false, the sticky CSS is not applied to the button. + * The virtual list wrapper div controls positioning instead. + * @default true */ - isLoading: boolean - searchIndex: string - logsRendererRef: MutableRefObject + applySticky?: boolean } export interface CreateMarkupReturnType { diff --git a/src/Shared/Components/CICDHistory/utils.tsx b/src/Shared/Components/CICDHistory/utils.tsx index 430d4622f..b856b7705 100644 --- a/src/Shared/Components/CICDHistory/utils.tsx +++ b/src/Shared/Components/CICDHistory/utils.tsx @@ -463,3 +463,15 @@ export const getTriggerOutputTabs = ( export const getSortedTriggerHistory = (triggerHistory: Map) => Array.from(triggerHistory).sort(([a], [b]) => b - a) + +export const findScrollableAncestor = (el: HTMLElement | null): HTMLElement | null => { + let current = el?.parentElement ?? null + while (current) { + const { overflowY } = window.getComputedStyle(current) + if (overflowY === 'auto' || overflowY === 'scroll') { + return current + } + current = current.parentElement + } + return null +} From 2bb4d64551e1fd0e256a2c9baee40529276fd73d Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 30 Apr 2026 16:35:21 +0530 Subject: [PATCH 2/3] feat: update version to 1.22.2-beta-0 in package.json and package-lock.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index da96bf4bc..a56ec65d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-pre-1", + "version": "1.22.2-beta-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-pre-1", + "version": "1.22.2-beta-0", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index b3263defa..a8a2e3916 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-pre-1", + "version": "1.22.2-beta-0", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 386ef5fe5d65f0b5be324f2166ed8f2609c89fb0 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 5 May 2026 12:22:12 +0530 Subject: [PATCH 3/3] feat: update version to 1.22.2-pre-2 in package.json and package-lock.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a56ec65d9..1f4abad3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-beta-0", + "version": "1.22.2-pre-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-beta-0", + "version": "1.22.2-pre-2", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index a8a2e3916..de5f602b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.22.2-beta-0", + "version": "1.22.2-pre-2", "description": "Supporting common component library", "type": "module", "main": "dist/index.js",