Skip to content

Commit 1f0d83b

Browse files
authored
Merge pull request #154 from PRO-Robotech/feature/dev
events wip
2 parents 15d0d04 + 864d0e7 commit 1f0d83b

File tree

18 files changed

+657
-5
lines changed

18 files changed

+657
-5
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"@ant-design/icons": "5.6.0",
2121
"@monaco-editor/react": "4.6.0",
2222
"@originjs/vite-plugin-federation": "1.3.6",
23-
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.148",
23+
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.149",
2424
"@readme/openapi-parser": "4.0.0",
2525
"@reduxjs/toolkit": "2.2.5",
2626
"@tanstack/react-query": "5.62.2",

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
FactoryPage,
2828
FactoryAdminPage,
2929
SearchPage,
30+
EventsPage,
3031
} from 'pages'
3132
import { getBasePrefix } from 'utils/getBaseprefix'
3233
import { colorsLight, colorsDark, sizes } from 'constants/colors'
@@ -124,6 +125,7 @@ export const App: FC<TAppProps> = ({ isFederation, forcedTheme }) => {
124125
element={<FactoryPage />}
125126
/>
126127
<Route path={`${prefix}/:clusterName/:namespace?/:syntheticProject?/search/*`} element={<SearchPage />} />
128+
<Route path={`${prefix}/:clusterName/:namespace?/:syntheticProject?/events/*`} element={<EventsPage />} />
127129
<Route path={`${prefix}/factory-admin/*`} element={<FactoryAdminPage />} />
128130
</Route>
129131
</Routes>
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// ------------------------------------------------------------
2+
// Simple, self-contained React component implementing:
3+
// - WebSocket connection to your events endpoint
4+
// - Handling of INITIAL, PAGE, ADDED, MODIFIED, DELETED, PAGE_ERROR
5+
// - Infinite scroll via IntersectionObserver (sends { type: "SCROLL" })
6+
// - Lightweight CSS-in-JS styling
7+
// - Minimal reconnection logic (bounded exponential backoff)
8+
// - Small initials avatar (derived from a name/kind)
9+
// ------------------------------------------------------------
10+
11+
import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react'
12+
import { theme as antdtheme } from 'antd'
13+
import { TScrollMsg, TServerFrame } from './types'
14+
import { eventKey } from './utils'
15+
import { reducer } from './reducer'
16+
import { EventRow } from './molecules'
17+
import { Styled } from './styled'
18+
19+
type TEventsProps = {
20+
wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40
21+
pageSize?: number // SCROLL page size (optional)
22+
height?: number // optional override
23+
title?: string
24+
}
25+
26+
export const Events: FC<TEventsProps> = ({ wsUrl, pageSize = 50, height }) => {
27+
const { token } = antdtheme.useToken()
28+
// Reducer-backed store of events
29+
const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} })
30+
31+
// Pagination/bookmarking state returned by server
32+
const [contToken, setContToken] = useState<string | undefined>(undefined)
33+
const [hasMore, setHasMore] = useState<boolean>(false)
34+
35+
// Connection state & errors for small status UI
36+
const [connStatus, setConnStatus] = useState<'connecting' | 'open' | 'closed'>('connecting')
37+
const [lastError, setLastError] = useState<string | undefined>(undefined)
38+
39+
// ------------------ Refs (mutable, do not trigger render) ------------------
40+
const wsRef = useRef<WebSocket | null>(null) // current WebSocket instance
41+
const listRef = useRef<HTMLDivElement | null>(null) // scrollable list element
42+
const sentinelRef = useRef<HTMLDivElement | null>(null) // bottom sentinel for IO
43+
const wantMoreRef = useRef(false) // whether sentinel is currently visible
44+
const fetchingRef = useRef(false) // guard: avoid parallel PAGE requests
45+
const backoffRef = useRef(750) // ms; increases on failures up to a cap
46+
const urlRef = useRef(wsUrl) // latest wsUrl (stable inside callbacks)
47+
48+
// Keep urlRef in sync so connect() uses the latest wsUrl
49+
useEffect(() => {
50+
urlRef.current = wsUrl
51+
}, [wsUrl])
52+
53+
// Close current WS safely
54+
const closeWS = useCallback(() => {
55+
try {
56+
wsRef.current?.close()
57+
} catch (e) {
58+
// eslint-disable-next-line no-console
59+
console.error(e)
60+
}
61+
wsRef.current = null
62+
}, [])
63+
64+
// Attempt to request the next page of older events
65+
const sendScroll = useCallback(() => {
66+
const token = contToken
67+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return
68+
if (!token || fetchingRef.current) return
69+
fetchingRef.current = true
70+
const msg: TScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize }
71+
wsRef.current.send(JSON.stringify(msg))
72+
}, [contToken, pageSize])
73+
74+
// Handle all incoming frames from the server
75+
const onMessage = useCallback((ev: MessageEvent) => {
76+
let frame: TServerFrame | undefined
77+
try {
78+
frame = JSON.parse(String(ev.data))
79+
} catch {
80+
// Ignore malformed frames; you could surface these in UI if desired
81+
return
82+
}
83+
if (!frame) return
84+
85+
if (frame.type === 'INITIAL') {
86+
// Replace current list with newest set; set pagination token
87+
dispatch({ type: 'RESET', items: frame.items })
88+
setContToken(frame.continue)
89+
setHasMore(Boolean(frame.continue))
90+
setLastError(undefined)
91+
return
92+
}
93+
94+
if (frame.type === 'PAGE') {
95+
// Append older items to the end; clear fetching guard
96+
dispatch({ type: 'APPEND_PAGE', items: frame.items })
97+
setContToken(frame.continue)
98+
setHasMore(Boolean(frame.continue))
99+
fetchingRef.current = false
100+
return
101+
}
102+
103+
if (frame.type === 'PAGE_ERROR') {
104+
// Keep live stream but surface pagination error
105+
setLastError(frame.error || 'Failed to load next page')
106+
fetchingRef.current = false
107+
return
108+
}
109+
110+
if (frame.type === 'ADDED' || frame.type === 'MODIFIED') {
111+
// Live update: insert or replace
112+
dispatch({ type: 'UPSERT', item: frame.item })
113+
return
114+
}
115+
116+
if (frame.type === 'DELETED') {
117+
// Live delete
118+
dispatch({ type: 'REMOVE', key: eventKey(frame.item) })
119+
}
120+
}, [])
121+
122+
// Establish and maintain the WebSocket connection with bounded backoff
123+
const connect = useCallback(() => {
124+
setConnStatus('connecting')
125+
setLastError(undefined)
126+
127+
// Accept absolute ws(s) URLs; otherwise resolve relative to current origin
128+
const buildWsUrl = (raw: string) => {
129+
if (/^wss?:/i.test(raw)) return raw // already absolute ws(s)
130+
const origin = window.location.origin.replace(/^http/i, 'ws')
131+
if (raw.startsWith('/')) return `${origin}${raw}`
132+
return `${origin}/${raw}`
133+
}
134+
135+
const ws = new WebSocket(buildWsUrl(urlRef.current))
136+
wsRef.current = ws
137+
138+
ws.addEventListener('open', () => {
139+
setConnStatus('open')
140+
backoffRef.current = 750 // reset backoff on success
141+
})
142+
143+
ws.addEventListener('message', onMessage)
144+
145+
const scheduleReconnect = () => {
146+
// Only clear if we're still looking at this instance
147+
if (wsRef.current === ws) wsRef.current = null
148+
setConnStatus('closed')
149+
const wait = Math.min(backoffRef.current, 8000)
150+
const next = Math.min(wait * 2, 12000)
151+
backoffRef.current = next
152+
// Reconnect after a short delay; preserves component mount semantics
153+
setTimeout(() => {
154+
connect()
155+
}, wait)
156+
}
157+
158+
ws.addEventListener('close', scheduleReconnect)
159+
ws.addEventListener('error', () => {
160+
setLastError('WebSocket error')
161+
scheduleReconnect()
162+
})
163+
}, [onMessage])
164+
165+
// Kick off initial connection on mount; clean up on unmount
166+
useEffect(() => {
167+
connect()
168+
return () => closeWS()
169+
}, [connect, closeWS])
170+
171+
// IntersectionObserver to trigger SCROLL when sentinel becomes visible
172+
useEffect(() => {
173+
// Get the current DOM element referenced by sentinelRef
174+
const el = sentinelRef.current
175+
176+
// If the sentinel element is not mounted yet, exit early
177+
if (!el) return undefined
178+
179+
// Create a new IntersectionObserver to watch visibility changes of the sentinel
180+
const io = new IntersectionObserver(entries => {
181+
// Determine if any observed element is currently visible in the viewport
182+
const visible = entries.some(e => e.isIntersecting)
183+
184+
// Store the current visibility status in a ref (no re-render triggered)
185+
wantMoreRef.current = visible
186+
187+
// If sentinel is visible and there are more pages available, request the next page
188+
if (visible && hasMore) sendScroll()
189+
})
190+
191+
// Start observing the sentinel element for intersection events
192+
io.observe(el)
193+
194+
// Cleanup: disconnect the observer when component unmounts or dependencies change
195+
return () => io.disconnect()
196+
197+
// Dependencies: re-run this effect if hasMore or sendScroll changes
198+
}, [hasMore, sendScroll])
199+
200+
// Fallback: if user scrolls near bottom manually, also try to fetch
201+
const onScroll = useCallback(() => {
202+
if (!listRef.current) return
203+
const nearBottom = listRef.current.scrollTop + listRef.current.clientHeight >= listRef.current.scrollHeight - 24
204+
if (nearBottom && hasMore) sendScroll()
205+
}, [hasMore, sendScroll])
206+
207+
const total = state.order.length
208+
209+
return (
210+
<Styled.Root $maxHeight={height || 640}>
211+
<Styled.Header>
212+
<Styled.Status>
213+
{connStatus === 'connecting' && 'Connecting…'}
214+
{connStatus === 'open' && 'Live'}
215+
{connStatus === 'closed' && 'Reconnecting…'}
216+
{typeof total === 'number' ? ` · ${total} items` : ''}
217+
</Styled.Status>
218+
{hasMore ? <span>Scroll to load older events…</span> : <span>No more events.</span>}
219+
{lastError && <span aria-live="polite">· {lastError}</span>}
220+
</Styled.Header>
221+
222+
{/* Scrollable list of event rows */}
223+
<Styled.List ref={listRef} onScroll={onScroll}>
224+
{state.order.map(k => (
225+
<EventRow key={k} e={state.byKey[k]} />
226+
))}
227+
{/* Infinite scroll sentinel */}
228+
<Styled.Sentinel ref={sentinelRef} />
229+
</Styled.List>
230+
231+
<Styled.Timeline $colorText={token.colorText} $maxHeight={height || 640} />
232+
</Styled.Root>
233+
)
234+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Events'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { FC } from 'react'
2+
import { theme as antdtheme, Flex } from 'antd'
3+
import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit'
4+
import { useSelector } from 'react-redux'
5+
import { RootState } from 'store/store'
6+
import { TEventsV1Event } from '../../types'
7+
import { eventText, timeAgo } from './utils'
8+
import { Styled } from './styled'
9+
10+
type TEventRowProps = {
11+
e: TEventsV1Event
12+
}
13+
14+
export const EventRow: FC<TEventRowProps> = ({ e }) => {
15+
const { token } = antdtheme.useToken()
16+
const theme = useSelector((state: RootState) => state.openapiTheme.theme)
17+
18+
const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined
19+
const bgColor = e.regarding?.kind && abbr ? hslFromString(abbr, theme) : 'initial'
20+
const bgColorNamespace = hslFromString('NS', theme)
21+
22+
return (
23+
<Styled.Card $colorText={token.colorText}>
24+
<Flex justify="space-between" align="center">
25+
<Flex align="center" gap={16}>
26+
<Flex align="center" gap={8}>
27+
<Styled.Abbr $bgColor={bgColor}>{abbr}</Styled.Abbr>
28+
{e.regarding?.name}
29+
</Flex>
30+
{e.metadata?.namespace && (
31+
<Flex align="center" gap={8}>
32+
<Styled.Abbr $bgColor={bgColorNamespace}>NS</Styled.Abbr>
33+
{e.metadata?.namespace}
34+
</Flex>
35+
)}
36+
</Flex>
37+
{e.metadata?.creationTimestamp && (
38+
<Flex gap={4} align="center">
39+
<div>
40+
<EarthIcon />
41+
</div>
42+
<Styled.TimeStamp>{timeAgo(e.metadata?.creationTimestamp)}</Styled.TimeStamp>
43+
</Flex>
44+
)}
45+
</Flex>
46+
<Spacer $space={16} $samespace />
47+
<Flex gap={8} wrap>
48+
<Styled.Title>{e.reason || e.action || 'Event'}</Styled.Title>
49+
<Styled.Title></Styled.Title>
50+
<Styled.Title>{e.type || 'Normal'}</Styled.Title>
51+
</Flex>
52+
<Spacer $space={16} $samespace />
53+
{eventText(e) && <div>{eventText(e)}</div>}
54+
</Styled.Card>
55+
)
56+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { EventRow } from './EventRow'

0 commit comments

Comments
 (0)