diff --git a/package-lock.json b/package-lock.json
index 44bff63c8..96559fedb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@devtron-labs/devtron-fe-common-lib",
- "version": "4.0.3",
+ "version": "4.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@devtron-labs/devtron-fe-common-lib",
- "version": "4.0.3",
+ "version": "4.0.4",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
@@ -26,6 +26,7 @@
"@rjsf/utils": "^6.2.4",
"@rjsf/validator-ajv8": "^6.2.4",
"@tanstack/react-query": "^5.90.21",
+ "@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",
@@ -3844,6 +3845,31 @@
"react": "^18 || ^19"
}
},
+ "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/@tippyjs/react": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz",
diff --git a/package.json b/package.json
index 580e6053b..62f595ad2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@devtron-labs/devtron-fe-common-lib",
- "version": "4.0.3",
+ "version": "4.0.4",
"description": "Supporting common component library",
"type": "module",
"main": "dist/index.js",
@@ -107,6 +107,7 @@
"@rjsf/utils": "^6.2.4",
"@rjsf/validator-ajv8": "^6.2.4",
"@tanstack/react-query": "^5.90.21",
+ "@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 9daf9c526..bbc80f4d5 100644
--- a/src/Common/Helper.tsx
+++ b/src/Common/Helper.tsx
@@ -889,11 +889,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 f171770ef..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 ICCaretDown from '@Icons/ic-caret-down.svg?react'
-import ICStack from '@Icons/ic-stack.svg?react'
-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..b4fbf64f4
--- /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 ICCaretDown from '@Icons/ic-caret-down.svg?react'
+import ICStack from '@Icons/ic-stack.svg?react'
+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 0423ba7a2..a84dbff9f 100644
--- a/src/Shared/Components/CICDHistory/LogsRenderer.tsx
+++ b/src/Shared/Components/CICDHistory/LogsRenderer.tsx
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { type JSX, 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'
@@ -173,9 +175,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 }) => (
+
+)
+
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
@@ -445,6 +469,7 @@ const LogsRenderer = ({ triggerDetails, isBlobStorageConfigured, parentType, ful
currentIndex = currentSearchIndex > 0 ? currentSearchIndex - 1 : searchResults.length - 1
}
setCurrentSearchIndex(currentIndex)
+ setScrollTrigger((prev) => !prev)
setStageList(getStageListFromStreamData(currentIndex, searchText))
}
}
@@ -478,6 +503,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 (
@@ -559,29 +815,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 e327df6f2..4cf8d4f08 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 fac5363ae..6719c641f 100644
--- a/src/Shared/Components/CICDHistory/utils.tsx
+++ b/src/Shared/Components/CICDHistory/utils.tsx
@@ -464,3 +464,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
+}