Skip to content

Commit 519c44f

Browse files
authored
Merge pull request #74 from OkeyAmy/implement-virtual-scroll-for-activity-log
feat: virtualize facility activity log
2 parents cc2c16f + dbf44ae commit 519c44f

6 files changed

Lines changed: 299 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client'
2+
3+
import { memo, useMemo } from 'react'
4+
import { ActivityLogRow } from '@/src/components/activity/ActivityLogRow'
5+
import type { ActivityLogEvent } from '@/src/hooks/useActivityLogSubscription'
6+
import { useVirtualList } from '@/src/lib/virtualScroll/useVirtualList'
7+
8+
interface ActivityLogListProps {
9+
events: ActivityLogEvent[]
10+
}
11+
12+
const VisibleRows = memo(function VisibleRows({
13+
events,
14+
start,
15+
measureElement,
16+
}: {
17+
events: readonly ActivityLogEvent[]
18+
start: number
19+
measureElement: (index: number, element: HTMLElement | null) => void
20+
}) {
21+
return events.map((event, offset) => (
22+
<ActivityLogRow key={event.id} event={event} index={start + offset} measureElement={measureElement} />
23+
))
24+
})
25+
26+
export function ActivityLogList({ events }: ActivityLogListProps) {
27+
const orderedEvents = useMemo(() => events, [events])
28+
const virtualList = useVirtualList(orderedEvents, {
29+
totalCount: orderedEvents.length,
30+
estimatedRowHeight: 32,
31+
overscanScreens: 3,
32+
maxRenderedItems: 100,
33+
preserveKey: 'facility-activity-log',
34+
})
35+
36+
return (
37+
<section className="rounded-lg border border-[#d8d0c1] bg-white" aria-labelledby="activity-log-heading">
38+
<div className="flex items-center justify-between border-b border-[#d8d0c1] px-4 py-3">
39+
<h2 id="activity-log-heading" className="text-sm font-semibold text-[#171512]">Node Activity Log</h2>
40+
<span className="text-xs text-[#6f5f48]">{events.length.toLocaleString()} events</span>
41+
</div>
42+
<div ref={virtualList.containerRef} className="h-[420px] overflow-y-auto" data-testid="activity-log-scrollport">
43+
<div ref={virtualList.topSentinelRef} aria-hidden="true" />
44+
<ul style={{ paddingTop: virtualList.paddingTop, paddingBottom: virtualList.paddingBottom }}>
45+
<VisibleRows events={virtualList.visibleItems} start={virtualList.start} measureElement={virtualList.measureElement} />
46+
</ul>
47+
<div ref={virtualList.bottomSentinelRef} aria-hidden="true" />
48+
</div>
49+
</section>
50+
)
51+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { memo } from 'react'
2+
import type { ActivityLogEvent } from '@/src/hooks/useActivityLogSubscription'
3+
4+
interface ActivityLogRowProps {
5+
event: ActivityLogEvent
6+
index: number
7+
measureElement: (index: number, element: HTMLElement | null) => void
8+
}
9+
10+
export const ActivityLogRow = memo(function ActivityLogRow({ event, index, measureElement }: ActivityLogRowProps) {
11+
return (
12+
<li
13+
ref={(element) => measureElement(index, element)}
14+
className="grid grid-cols-[5.5rem_5rem_1fr] gap-3 border-b border-[#ece5d8] px-3 py-1 text-xs leading-5 text-[#3e3830]"
15+
data-testid="activity-log-row"
16+
>
17+
<time className="font-mono text-[#6f5f48]" dateTime={event.timestamp}>
18+
{new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
19+
</time>
20+
<span className="font-semibold text-[#171512]">{event.nodeId}</span>
21+
<span className={event.level === 'error' ? 'text-[#9a3412]' : event.level === 'warning' ? 'text-[#d97706]' : ''}>
22+
{event.message}
23+
</span>
24+
</li>
25+
)
26+
})

src/components/dashboard/FacilityDashboard.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react'
44
import { create } from 'zustand'
55
import { NodeList } from '@/src/components/network/NodeList'
66
import { AlertFeed } from '@/src/components/dashboard/AlertFeed'
7+
import { ActivityLogList } from '@/src/components/activity/ActivityLogList'
78
import { SkeletonCard } from '@/src/components/skeleton/SkeletonCard'
89
import { SkeletonChart } from '@/src/components/skeleton/SkeletonChart'
910
import { useSkeletonTiming } from '@/src/hooks/useSkeletonTiming'
11+
import { useActivityLogSubscription } from '@/src/hooks/useActivityLogSubscription'
1012
import type { NodePosition } from '@/src/types/network'
1113

1214
interface DashboardStore {
@@ -80,6 +82,7 @@ function AlertSectionSkeleton() {
8082
}
8183

8284
export function FacilityDashboard() {
85+
const { events: activityEvents } = useActivityLogSubscription(10_000)
8386
const [nodesData, setNodesData] = useState<NodePosition[] | null>(null)
8487
const setNodesReady = useDashboardStore((s) => s.setNodesReady)
8588
const setAlertsReady = useDashboardStore((s) => s.setAlertsReady)
@@ -198,6 +201,10 @@ export function FacilityDashboard() {
198201
))}
199202
</div>
200203

204+
<div className="mt-6">
205+
<ActivityLogList events={activityEvents} />
206+
</div>
207+
201208
<div className="mt-6">
202209
{metricsSkeleton.showSkeleton ? (
203210
<SkeletonChart bars={16} height={220} />
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client'
2+
3+
import { useEffect, useMemo, useState } from 'react'
4+
5+
export interface ActivityLogEvent {
6+
id: string
7+
timestamp: string
8+
nodeId: string
9+
level: 'info' | 'warning' | 'error'
10+
message: string
11+
}
12+
13+
function createMockEvent(index: number): ActivityLogEvent {
14+
const level = index % 19 === 0 ? 'error' : index % 7 === 0 ? 'warning' : 'info'
15+
return {
16+
id: `activity-${index}`,
17+
timestamp: new Date(Date.now() - index * 60_000).toISOString(),
18+
nodeId: `node-${(index % 50) + 1}`,
19+
level,
20+
message: level === 'error'
21+
? `Node ${(index % 50) + 1} reported a multiline synchronization error while reconciling the latest ledger checkpoint. Retry ${index}.`
22+
: `Node ${(index % 50) + 1} ${level === 'warning' ? 'latency warning' : 'heartbeat accepted'} at checkpoint ${index}.`,
23+
}
24+
}
25+
26+
export function createActivityLogEvents(count: number): ActivityLogEvent[] {
27+
return Array.from({ length: count }, (_, index) => createMockEvent(index))
28+
}
29+
30+
export function useActivityLogSubscription(seedCount = 750) {
31+
const initialEvents = useMemo(() => createActivityLogEvents(seedCount), [seedCount])
32+
const [events, setEvents] = useState<ActivityLogEvent[]>(initialEvents)
33+
34+
useEffect(() => {
35+
setEvents(initialEvents)
36+
}, [initialEvents])
37+
38+
return { events, prependEvent: (event: ActivityLogEvent) => setEvents((current) => [event, ...current]) }
39+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
'use client'
2+
3+
import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4+
5+
interface UseVirtualListOptions {
6+
totalCount: number
7+
estimatedRowHeight: number
8+
overscanScreens?: number
9+
maxRenderedItems?: number
10+
preserveKey?: string
11+
}
12+
13+
interface VirtualItem {
14+
index: number
15+
offsetTop: number
16+
height: number
17+
}
18+
19+
interface VirtualRange {
20+
start: number
21+
end: number
22+
paddingTop: number
23+
paddingBottom: number
24+
totalHeight: number
25+
virtualItems: VirtualItem[]
26+
}
27+
28+
const savedScrollPositions = new Map<string, number>()
29+
30+
export function useVirtualList<T>(
31+
items: readonly T[],
32+
{
33+
totalCount,
34+
estimatedRowHeight,
35+
overscanScreens = 3,
36+
maxRenderedItems = 100,
37+
preserveKey,
38+
}: UseVirtualListOptions,
39+
): VirtualRange & {
40+
containerRef: RefObject<HTMLDivElement | null>
41+
topSentinelRef: RefObject<HTMLDivElement | null>
42+
bottomSentinelRef: RefObject<HTMLDivElement | null>
43+
visibleItems: readonly T[]
44+
measureElement: (index: number, element: HTMLElement | null) => void
45+
} {
46+
const containerRef = useRef<HTMLDivElement | null>(null)
47+
const topSentinelRef = useRef<HTMLDivElement | null>(null)
48+
const bottomSentinelRef = useRef<HTMLDivElement | null>(null)
49+
const heightsRef = useRef(new Map<number, number>())
50+
const [scrollState, setScrollState] = useState({ scrollTop: 0, viewportHeight: 0 })
51+
52+
const getHeight = useCallback(
53+
(index: number) => heightsRef.current.get(index) ?? estimatedRowHeight,
54+
[estimatedRowHeight],
55+
)
56+
57+
const getOffsetTop = useCallback(
58+
(index: number) => {
59+
let offset = 0
60+
for (let i = 0; i < index; i += 1) offset += getHeight(i)
61+
return offset
62+
},
63+
[getHeight],
64+
)
65+
66+
const totalHeight = useMemo(() => {
67+
let measuredDelta = 0
68+
heightsRef.current.forEach((height) => {
69+
measuredDelta += height - estimatedRowHeight
70+
})
71+
return Math.max(0, totalCount * estimatedRowHeight + measuredDelta)
72+
}, [estimatedRowHeight, totalCount, scrollState])
73+
74+
const range = useMemo(() => {
75+
const { scrollTop, viewportHeight } = scrollState
76+
const overscanPixels = viewportHeight * overscanScreens
77+
const requestedStart = Math.max(0, Math.floor((scrollTop - overscanPixels) / estimatedRowHeight))
78+
const requestedEnd = Math.min(
79+
totalCount,
80+
Math.ceil((scrollTop + viewportHeight + overscanPixels) / estimatedRowHeight),
81+
)
82+
const visibleCenter = Math.max(0, Math.floor((scrollTop + viewportHeight / 2) / estimatedRowHeight))
83+
const requestedCount = Math.max(1, requestedEnd - requestedStart)
84+
const cappedCount = Math.min(maxRenderedItems, requestedCount, totalCount)
85+
const start = requestedCount > maxRenderedItems
86+
? Math.max(0, Math.min(totalCount - cappedCount, visibleCenter - Math.floor(cappedCount / 2)))
87+
: requestedStart
88+
const end = Math.min(totalCount, start + cappedCount)
89+
const paddingTop = getOffsetTop(start)
90+
const virtualItems = Array.from({ length: end - start }, (_, position) => {
91+
const index = start + position
92+
return { index, offsetTop: getOffsetTop(index), height: getHeight(index) }
93+
})
94+
const renderedHeight = virtualItems.reduce((sum, item) => sum + item.height, 0)
95+
96+
return {
97+
start,
98+
end,
99+
paddingTop,
100+
paddingBottom: Math.max(0, totalHeight - paddingTop - renderedHeight),
101+
totalHeight,
102+
virtualItems,
103+
}
104+
}, [estimatedRowHeight, getHeight, getOffsetTop, maxRenderedItems, overscanScreens, scrollState, totalCount, totalHeight])
105+
106+
const updateScrollState = useCallback(() => {
107+
const container = containerRef.current
108+
if (!container) return
109+
const next = { scrollTop: container.scrollTop, viewportHeight: container.clientHeight }
110+
if (preserveKey) savedScrollPositions.set(preserveKey, next.scrollTop)
111+
setScrollState((previous) => (
112+
previous.scrollTop === next.scrollTop && previous.viewportHeight === next.viewportHeight ? previous : next
113+
))
114+
}, [preserveKey])
115+
116+
useLayoutEffect(() => {
117+
const container = containerRef.current
118+
if (!container) return
119+
if (preserveKey) container.scrollTop = savedScrollPositions.get(preserveKey) ?? container.scrollTop
120+
updateScrollState()
121+
}, [preserveKey, updateScrollState])
122+
123+
useEffect(() => {
124+
const container = containerRef.current
125+
if (!container) return
126+
const observer = new IntersectionObserver(updateScrollState, { root: container })
127+
if (topSentinelRef.current) observer.observe(topSentinelRef.current)
128+
if (bottomSentinelRef.current) observer.observe(bottomSentinelRef.current)
129+
container.addEventListener('scroll', updateScrollState, { passive: true })
130+
const resizeObserver = new ResizeObserver(updateScrollState)
131+
resizeObserver.observe(container)
132+
return () => {
133+
container.removeEventListener('scroll', updateScrollState)
134+
observer.disconnect()
135+
resizeObserver.disconnect()
136+
}
137+
}, [updateScrollState])
138+
139+
const measureElement = useCallback((index: number, element: HTMLElement | null) => {
140+
if (!element) return
141+
const nextHeight = element.getBoundingClientRect().height
142+
if (nextHeight > 0 && heightsRef.current.get(index) !== nextHeight) {
143+
heightsRef.current.set(index, nextHeight)
144+
updateScrollState()
145+
}
146+
}, [updateScrollState])
147+
148+
return {
149+
...range,
150+
containerRef,
151+
topSentinelRef,
152+
bottomSentinelRef,
153+
visibleItems: items.slice(range.start, range.end),
154+
measureElement,
155+
}
156+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test.describe('Activity log virtualization', () => {
4+
test('keeps rendered rows capped while scrolling a large log', async ({ page }) => {
5+
await page.goto('/dashboard/facility')
6+
7+
const scrollport = page.getByTestId('activity-log-scrollport')
8+
await expect(scrollport).toBeVisible()
9+
10+
for (const position of [0, 8_000, 80_000, 160_000, 240_000]) {
11+
await scrollport.evaluate((element, top) => {
12+
element.scrollTop = top
13+
element.dispatchEvent(new Event('scroll'))
14+
}, position)
15+
await page.waitForTimeout(50)
16+
const renderedRows = await page.getByTestId('activity-log-row').count()
17+
expect(renderedRows).toBeLessThanOrEqual(100)
18+
}
19+
})
20+
})

0 commit comments

Comments
 (0)