events factory/toolkit

This commit is contained in:
typescreep
2025-11-01 17:44:03 +03:00
parent a39bdd2894
commit f67c6a3a9b
18 changed files with 5 additions and 1037 deletions

8
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"@ant-design/icons": "5.6.0",
"@monaco-editor/react": "4.6.0",
"@originjs/vite-plugin-federation": "1.3.6",
"@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.151",
"@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.152",
"@readme/openapi-parser": "4.0.0",
"@reduxjs/toolkit": "2.2.5",
"@tanstack/react-query": "5.62.2",
@@ -2804,9 +2804,9 @@
}
},
"node_modules/@prorobotech/openapi-k8s-toolkit": {
"version": "0.0.1-alpha.151",
"resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.151.tgz",
"integrity": "sha512-AV+6muJNp75WLUPGuLi3JIH8j3P1sBgodHFNfebJ25CdfzJQeYUVkRs0yqWQoNFnw1lG92W4i7fIToYmk1WzJQ==",
"version": "0.0.1-alpha.152",
"resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.152.tgz",
"integrity": "sha512-bfOD3cTkfqc5C+4FR1LNvB7I7JzXdOUf9BhoEb1QD9bGH4jel89ZAScvqRhB1HichogXO21NnZNGSmco2oYOfQ==",
"license": "MIT",
"dependencies": {
"@monaco-editor/react": "4.6.0",

View File

@@ -20,7 +20,7 @@
"@ant-design/icons": "5.6.0",
"@monaco-editor/react": "4.6.0",
"@originjs/vite-plugin-federation": "1.3.6",
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.151",
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.152",
"@readme/openapi-parser": "4.0.0",
"@reduxjs/toolkit": "2.2.5",
"@tanstack/react-query": "5.62.2",

View File

@@ -27,7 +27,6 @@ import {
FactoryPage,
FactoryAdminPage,
SearchPage,
EventsPage,
} from 'pages'
import { getBasePrefix } from 'utils/getBaseprefix'
import { colorsLight, colorsDark, sizes } from 'constants/colors'
@@ -125,7 +124,6 @@ export const App: FC<TAppProps> = ({ isFederation, forcedTheme }) => {
element={<FactoryPage />}
/>
<Route path={`${prefix}/:clusterName/:namespace?/:syntheticProject?/search/*`} element={<SearchPage />} />
<Route path={`${prefix}/:clusterName/:namespace?/:syntheticProject?/events/*`} element={<EventsPage />} />
<Route path={`${prefix}/factory-admin/*`} element={<FactoryAdminPage />} />
</Route>
</Routes>

View File

@@ -1,417 +0,0 @@
/* eslint-disable max-lines-per-function */
// ------------------------------------------------------------
// Simple, self-contained React component implementing:
// - WebSocket connection to your events endpoint
// - Handling of INITIAL, PAGE, ADDED, MODIFIED, DELETED, PAGE_ERROR
// - Infinite scroll via IntersectionObserver (sends { type: "SCROLL" })
// - Lightweight CSS-in-JS styling
// - Minimal reconnection logic (bounded exponential backoff)
// - Small initials avatar (derived from a name/kind)
// ------------------------------------------------------------
import React, { FC, useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { theme as antdtheme, Flex, Tooltip, Empty } from 'antd'
import {
// TRequestError,
TKindIndex,
TKindWithVersion,
getKinds,
getSortedKindsAll,
pluralByKind,
ResumeCircleIcon,
PauseCircleIcon,
LockedIcon,
UnlockedIcon,
} from '@prorobotech/openapi-k8s-toolkit'
import { TScrollMsg, TServerFrame } from './types'
import { eventKey, compareRV, getRV, getMaxRV } from './utils'
import { reducer } from './reducer'
import { EventRow } from './molecules'
import { Styled } from './styled'
type TEventsProps = {
baseprefix?: string
cluster: string
wsUrl: string // e.g. ws://localhost:3000/api/events?namespace=default&limit=40
pageSize?: number // SCROLL page size (optional)
height?: number // optional override
title?: string
}
export const Events: FC<TEventsProps> = ({ baseprefix, cluster, wsUrl, pageSize = 50, height }) => {
const { token } = antdtheme.useToken()
// const [error, setError] = useState<TRequestError | undefined>()
// const [isLoading, setIsLoading] = useState<boolean>(false)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [kindIndex, setKindIndex] = useState<TKindIndex>()
const [kindsWithVersion, setKindWithVersion] = useState<TKindWithVersion[]>()
useEffect(() => {
// setIsLoading(true)
// setError(undefined)
getKinds({ clusterName: cluster })
.then(data => {
setKindIndex(data)
setKindWithVersion(getSortedKindsAll(data))
// setIsLoading(false)
// setError(undefined)
})
.catch(error => {
// setIsLoading(false)
// setError(error)
// eslint-disable-next-line no-console
console.error(error)
})
}, [cluster])
// pause behaviour
const [isPaused, setIsPaused] = useState(false)
const pausedRef = useRef(isPaused)
useEffect(() => {
pausedRef.current = isPaused
}, [isPaused])
// ignore REMOVE signal
const [isRemoveIgnored, setIsRemoveIgnored] = useState(true)
const removeIgnoredRef = useRef(isRemoveIgnored)
useEffect(() => {
removeIgnoredRef.current = isRemoveIgnored
}, [isRemoveIgnored])
// track latest resourceVersion we have processed
const latestRVRef = useRef<string | undefined>(undefined)
// Reducer-backed store of events
const [state, dispatch] = useReducer(reducer, { order: [], byKey: {} })
// Pagination/bookmarking state returned by server
const [contToken, setContToken] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState<boolean>(false)
// Connection state & errors for small status UI
const [connStatus, setConnStatus] = useState<'connecting' | 'open' | 'closed'>('connecting')
const [lastError, setLastError] = useState<string | undefined>(undefined)
// ------------------ Refs (mutable, do not trigger render) ------------------
const wsRef = useRef<WebSocket | null>(null) // current WebSocket instance
const listRef = useRef<HTMLDivElement | null>(null) // scrollable list element
const sentinelRef = useRef<HTMLDivElement | null>(null) // bottom sentinel for IO
const wantMoreRef = useRef(false) // whether sentinel is currently visible
const fetchingRef = useRef(false) // guard: avoid parallel PAGE requests
const backoffRef = useRef(750) // ms; increases on failures up to a cap
const urlRef = useRef(wsUrl) // latest wsUrl (stable inside callbacks)
// Guards for unmount & reconnect timer
const mountedRef = useRef(true)
const reconnectTimerRef = useRef<number | null>(null)
const onMessageRef = useRef<(ev: MessageEvent) => void>(() => {})
const startedRef = useRef(false)
const connectingRef = useRef(false)
const haveAnchorRef = useRef(false)
// Keep urlRef in sync so connect() uses the latest wsUrl
useEffect(() => {
urlRef.current = wsUrl
}, [wsUrl])
// Close current WS safely
const closeWS = useCallback(() => {
try {
wsRef.current?.close()
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
}
wsRef.current = null
}, [])
// Attempt to request the next page of older events
const sendScroll = useCallback(() => {
const token = contToken
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return
if (!token || fetchingRef.current) return
fetchingRef.current = true
const msg: TScrollMsg = { type: 'SCROLL', continue: token, limit: pageSize }
wsRef.current.send(JSON.stringify(msg))
}, [contToken, pageSize])
const maybeAutoScroll = useCallback(() => {
if (wantMoreRef.current && hasMore) sendScroll()
}, [hasMore, sendScroll])
// Handle all incoming frames from the server
useEffect(() => {
onMessageRef.current = (ev: MessageEvent) => {
let frame: TServerFrame | undefined
try {
frame = JSON.parse(String(ev.data)) as TServerFrame
} catch {
return
}
if (!frame) return
if (frame.type === 'INITIAL') {
dispatch({ type: 'RESET', items: frame.items })
setContToken(frame.continue)
setHasMore(Boolean(frame.continue))
setLastError(undefined)
fetchingRef.current = false
const snapshotRV = frame.resourceVersion || getMaxRV(frame.items)
if (snapshotRV) {
latestRVRef.current = snapshotRV
haveAnchorRef.current = true // NEW: we now have a safe anchor
}
return
}
if (frame.type === 'PAGE') {
dispatch({ type: 'APPEND_PAGE', items: frame.items })
setContToken(frame.continue)
setHasMore(Boolean(frame.continue))
fetchingRef.current = false
const batchRV = getMaxRV(frame.items)
if (batchRV && (!latestRVRef.current || compareRV(batchRV, latestRVRef.current) > 0)) {
latestRVRef.current = batchRV
}
maybeAutoScroll()
return
}
if (frame.type === 'PAGE_ERROR') {
setLastError(frame.error || 'Failed to load next page')
fetchingRef.current = false
return
}
if (frame.type === 'ADDED' || frame.type === 'MODIFIED' || frame.type === 'DELETED') {
const rv = getRV(frame.item)
if (rv && (!latestRVRef.current || compareRV(rv, latestRVRef.current) > 0)) {
latestRVRef.current = rv
}
}
if (!pausedRef.current) {
if (frame.type === 'ADDED' || frame.type === 'MODIFIED') {
dispatch({ type: 'UPSERT', item: frame.item })
return
}
if (!removeIgnoredRef.current && frame.type === 'DELETED') {
dispatch({ type: 'REMOVE', key: eventKey(frame.item) })
}
}
}
}, [maybeAutoScroll])
const buildWsUrl = useCallback((raw: string) => {
try {
const hasScheme = /^[a-z]+:/i.test(raw)
const base = window.location.origin
let u = hasScheme ? new URL(raw) : new URL(raw.startsWith('/') ? raw : `/${raw}`, base)
if (u.protocol === 'http:') u.protocol = 'ws:'
if (u.protocol === 'https:') u.protocol = 'wss:'
if (u.protocol !== 'ws:' && u.protocol !== 'wss:') {
u = new URL(u.pathname + u.search + u.hash, base)
u.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
}
if (haveAnchorRef.current && latestRVRef.current) {
u.searchParams.set('sinceRV', latestRVRef.current)
} else {
u.searchParams.delete('sinceRV')
}
return u.toString()
} catch {
const origin = window.location.origin.replace(/^http/, 'ws')
const prefix = raw.startsWith('/') ? '' : '/'
const rv = haveAnchorRef.current ? latestRVRef.current : undefined
const sep = raw.includes('?') ? '&' : '?'
return `${origin}${prefix}${raw}${rv ? `${sep}sinceRV=${encodeURIComponent(rv)}` : ''}`
}
}, [])
// Establish and maintain the WebSocket connection with bounded backoff
const connect = useCallback(() => {
if (!mountedRef.current) return
// Prevent duplicate opens
if (connectingRef.current) return
if (
wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)
) {
return
}
connectingRef.current = true
setConnStatus('connecting')
setLastError(undefined)
const url = buildWsUrl(urlRef.current)
const ws = new WebSocket(url)
wsRef.current = ws
ws.addEventListener('open', () => {
if (!mountedRef.current) return
backoffRef.current = 750
fetchingRef.current = false
setConnStatus('open')
connectingRef.current = false
})
ws.addEventListener('message', ev => onMessageRef.current(ev))
const scheduleReconnect = () => {
if (wsRef.current === ws) wsRef.current = null
setConnStatus('closed')
connectingRef.current = false
// Bounded exponential backoff with jitter to avoid herding
const base = Math.min(backoffRef.current, 8000)
const jitter = Math.random() * 0.4 + 0.8 // 0.8x1.2x
const wait = Math.floor(base * jitter)
const next = Math.min(base * 2, 12000)
backoffRef.current = next
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
reconnectTimerRef.current = window.setTimeout(() => {
if (!mountedRef.current) return
connect()
}, wait)
}
ws.addEventListener('close', scheduleReconnect)
ws.addEventListener('error', () => {
setLastError('WebSocket error')
scheduleReconnect()
})
}, [buildWsUrl])
// Kick off initial connection on mount; clean up on unmount
useEffect(() => {
if (startedRef.current) return undefined // StrictMode double-invoke guard
startedRef.current = true
mountedRef.current = true
connect()
return () => {
mountedRef.current = false
startedRef.current = false
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
closeWS()
wsRef.current = null
connectingRef.current = false
}
// INTENTIONALLY EMPTY DEPS do not reopen on state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// IntersectionObserver to trigger SCROLL when sentinel becomes visible
useEffect(() => {
// Get the current DOM element referenced by sentinelRef
const el = sentinelRef.current
// If the sentinel element is not mounted yet, exit early
if (!el) return undefined
// Create a new IntersectionObserver to watch visibility changes of the sentinel
const io = new IntersectionObserver(entries => {
// Determine if any observed element is currently visible in the viewport
const visible = entries.some(e => e.isIntersecting)
// Store the current visibility status in a ref (no re-render triggered)
wantMoreRef.current = visible
// If sentinel is visible and there are more pages available, request the next page
if (visible && hasMore) sendScroll()
})
// Start observing the sentinel element for intersection events
io.observe(el)
// Cleanup: disconnect the observer when component unmounts or dependencies change
return () => io.disconnect()
// Dependencies: re-run this effect if hasMore or sendScroll changes
}, [hasMore, sendScroll])
// Fallback: if user scrolls near bottom manually, also try to fetch
const onScroll = useCallback(() => {
if (!listRef.current) return
const nearBottom = listRef.current.scrollTop + listRef.current.clientHeight >= listRef.current.scrollHeight - 24
if (nearBottom && hasMore) sendScroll()
}, [hasMore, sendScroll])
const total = state.order.length
const getPlural = kindsWithVersion ? pluralByKind(kindsWithVersion) : undefined
return (
<Styled.Root $maxHeight={height || 640}>
<Styled.Header>
<Styled.HeaderLeftSide>
<Flex justify="start" align="center" gap={10}>
<Styled.CursorPointerDiv
onClick={() => {
if (isPaused) {
setIsPaused(false)
} else {
setIsPaused(true)
}
}}
>
{isPaused ? <ResumeCircleIcon /> : <PauseCircleIcon />}
</Styled.CursorPointerDiv>
<Styled.StatusText>
{isPaused && 'Streaming paused'}
{!isPaused && connStatus === 'connecting' && 'Connecting…'}
{!isPaused && connStatus === 'open' && 'Streaming events...'}
{!isPaused && connStatus === 'closed' && 'Reconnecting…'}
</Styled.StatusText>
</Flex>
</Styled.HeaderLeftSide>
<Styled.HeaderRightSide $colorTextDescription={token.colorTextDescription}>
{!hasMore && <div>No more events · </div>}
{typeof total === 'number' ? <div>Loaded {total} events</div> : ''}
{lastError && <span aria-live="polite"> · {lastError}</span>}
<Tooltip
title={
<div>
<div>{isRemoveIgnored ? 'Handle REMOVE signals' : 'Ignore REMOVE signals'}</div>
<Flex justify="end">Locked means ignore</Flex>
</div>
}
placement="left"
>
<Styled.CursorPointerDiv onClick={() => setIsRemoveIgnored(!isRemoveIgnored)}>
{isRemoveIgnored ? <LockedIcon size={16} /> : <UnlockedIcon size={16} />}
</Styled.CursorPointerDiv>
</Tooltip>
</Styled.HeaderRightSide>
</Styled.Header>
{/* Scrollable list of event rows */}
<Styled.List ref={listRef} onScroll={onScroll}>
{state.order.length > 0 ? (
state.order.map(k => (
<EventRow key={k} e={state.byKey[k]} baseprefix={baseprefix} cluster={cluster} getPlural={getPlural} />
))
) : (
<Empty />
)}
{/* Infinite scroll sentinel */}
<Styled.Sentinel ref={sentinelRef} />
</Styled.List>
{state.order.length > 0 && <Styled.Timeline $colorText={token.colorText} $maxHeight={height || 640} />}
</Styled.Root>
)
}

View File

@@ -1 +0,0 @@
export * from './Events'

View File

@@ -1,122 +0,0 @@
import React, { FC } from 'react'
import { useNavigate } from 'react-router-dom'
import { theme as antdtheme, Flex, Typography } from 'antd'
import { EarthIcon, getUppercase, hslFromString, Spacer } from '@prorobotech/openapi-k8s-toolkit'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { TEventsV1Event } from '../../types'
import { eventText, timeAgo, getResourceLink, getNamespaceLink, formatEventSummary } from './utils'
import { Styled } from './styled'
type TEventRowProps = {
e: TEventsV1Event
baseprefix?: string
cluster: string
getPlural?: (kind: string, apiVersion?: string) => string | undefined
}
export const EventRow: FC<TEventRowProps> = ({ e, baseprefix, cluster, getPlural }) => {
const { token } = antdtheme.useToken()
const navigate = useNavigate()
const theme = useSelector((state: RootState) => state.openapiTheme.theme)
const abbr = e.regarding?.kind ? getUppercase(e.regarding.kind) : undefined
const bgColor = e.regarding?.kind && abbr ? hslFromString(e.regarding?.kind, theme) : 'initial'
const bgColorNamespace = hslFromString('Namespace', theme)
const regardingKind: string | undefined = e.regarding?.kind
const regardingApiVersion: string = e.regarding?.apiVersion || 'v1'
const pluralName: string | undefined =
regardingKind && regardingApiVersion ? getPlural?.(regardingKind, regardingApiVersion) : undefined
const resourceLink: string | undefined = getResourceLink({
baseprefix,
cluster,
namespace: e.regarding?.namespace,
apiGroupVersion: regardingApiVersion,
pluralName,
name: e.regarding?.name,
})
const namespaceLink: string | undefined = getNamespaceLink({
baseprefix,
cluster,
apiGroupVersion: 'v1',
pluralName: 'namespaces',
namespace: e.regarding?.namespace,
})
return (
<Styled.Card
$bigBorder={e.type === 'Warning'}
$mainColor={e.type === 'Warning' ? token.colorWarningActive : token.colorText}
>
<Flex justify="space-between" align="center">
<Flex align="center" gap={16}>
<Flex align="center" gap={8}>
<Styled.Abbr $bgColor={bgColor}>{abbr}</Styled.Abbr>
{resourceLink ? (
<Typography.Link
onClick={e => {
e.preventDefault()
navigate(resourceLink)
}}
>
{e.regarding?.name}
</Typography.Link>
) : (
<Typography.Text>{e.regarding?.name}</Typography.Text>
)}
</Flex>
{e.regarding?.namespace && (
<Flex align="center" gap={8}>
<Styled.Abbr $bgColor={bgColorNamespace}>NS</Styled.Abbr>
{namespaceLink ? (
<Typography.Link
onClick={e => {
e.preventDefault()
navigate(namespaceLink)
}}
>
{e.regarding?.namespace}
</Typography.Link>
) : (
<Typography.Text>{e.regarding?.namespace}</Typography.Text>
)}
</Flex>
)}
</Flex>
{e.metadata?.creationTimestamp && (
<Flex gap={4} align="center">
<div>
<EarthIcon />
</div>
<Styled.TimeStamp>{timeAgo(e.metadata?.creationTimestamp)}</Styled.TimeStamp>
</Flex>
)}
</Flex>
<Spacer $space={16} $samespace />
<Flex justify="space-between">
<Flex gap={8} align="center" wrap>
<div>
{e.deprecatedSource?.component && (
<Flex gap={8} align="center" wrap>
<Flex gap={6} align="center" wrap>
<Typography.Text type="secondary">Generated by</Typography.Text>
<Styled.Title>{e.deprecatedSource?.component}</Styled.Title>
</Flex>
<div>
<Typography.Text type="secondary"></Typography.Text>
</div>
</Flex>
)}
</div>
<Styled.Title>{e.reason || e.action || 'Event'}</Styled.Title>
</Flex>
<Styled.TimesInPeriod>
<Typography.Text type="secondary">{formatEventSummary(e)}</Typography.Text>
</Styled.TimesInPeriod>
</Flex>
<Spacer $space={16} $samespace />
{eventText(e) && <div>{eventText(e)}</div>}
</Styled.Card>
)
}

View File

@@ -1 +0,0 @@
export { EventRow } from './EventRow'

View File

@@ -1,73 +0,0 @@
import styled from 'styled-components'
type TCardProps = {
$mainColor: string
$bigBorder?: boolean
}
const Card = styled.div<TCardProps>`
border-radius: 6px;
padding: 16px 8px;
border: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px solid ${({ $mainColor }) => $mainColor};
gap: 12px;
margin-bottom: 16px;
position: relative;
&:before {
position: absolute;
content: '';
width: 36px;
height: ${({ $bigBorder }) => ($bigBorder ? 2 : 1)}px;
background: ${({ $mainColor }) => $mainColor};
left: -37px;
top: 50%; /* halfway down parent */
transform: translateY(-50%); /* center vertically */
}
&:after {
position: absolute;
content: '';
width: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px;
height: ${({ $bigBorder }) => ($bigBorder ? 7 : 6)}px;
border-radius: 50%;
background: ${({ $mainColor }) => $mainColor};
left: ${({ $bigBorder }) => ($bigBorder ? -41 : -39)}px;
top: 50%;
transform: translateY(-50%);
}
`
type TAbbrProps = {
$bgColor: string
}
const Abbr = styled.span<TAbbrProps>`
background-color: ${({ $bgColor }) => $bgColor};
border-radius: 13px;
padding: 1px 5px;
font-size: 13px;
height: min-content;
margin-right: 4px;
`
const TimeStamp = styled.div`
font-weight: 400;
font-size: 12px;
line-height: 20px;
`
const Title = styled.div`
font-weight: 700;
`
const TimesInPeriod = styled.div`
margin-top: -16px;
`
export const Styled = {
Card,
Abbr,
TimeStamp,
Title,
TimesInPeriod,
}

View File

@@ -1,95 +0,0 @@
import {
BASE_FACTORY_NAMESPACED_API_KEY,
BASE_FACTORY_CLUSTERSCOPED_API_KEY,
BASE_FACTORY_NAMESPACED_BUILTIN_KEY,
BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY,
BASE_NAMESPACE_FACTORY_KEY,
} from 'constants/customizationApiGroupAndVersion'
import { TEventsV1Event } from '../../types'
// Prefer modern `note`, fallback to legacy `message`
export const eventText = (e: TEventsV1Event) => e.note || e.message || ''
// Friendly relative time formatter; returns locale string for >24h
export const timeAgo = (iso?: string) => {
if (!iso) {
return ''
}
const dt = new Date(iso).getTime()
const diff = Date.now() - dt
if (diff < 60_000) {
return `${Math.max(0, Math.floor(diff / 1000))}s ago`
}
if (diff < 3_600_000) {
return `${Math.floor(diff / 60_000)}m ago`
}
if (diff < 86_400_000) {
return `${Math.floor(diff / 3_600_000)}h ago`
}
return new Date(iso).toLocaleString()
}
export const getResourceLink = ({
baseprefix,
cluster,
namespace,
apiGroupVersion,
pluralName,
name,
}: {
baseprefix?: string
cluster: string
namespace?: string
apiGroupVersion: string
pluralName?: string
name?: string
}): string | undefined => {
if (!pluralName || !name) {
return undefined
}
if (apiGroupVersion === 'v1') {
return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${
namespace ? BASE_FACTORY_NAMESPACED_BUILTIN_KEY : BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY
}/${apiGroupVersion}/${pluralName}/${name}`
}
return `${baseprefix}/${cluster}${namespace ? `/${namespace}` : ''}/factory/${
namespace ? BASE_FACTORY_NAMESPACED_API_KEY : BASE_FACTORY_CLUSTERSCOPED_API_KEY
}/${apiGroupVersion}/${pluralName}/${name}`
}
export const getNamespaceLink = ({
baseprefix,
cluster,
apiGroupVersion,
pluralName,
namespace,
}: {
baseprefix?: string
cluster: string
pluralName: string
apiGroupVersion: string
namespace?: string
}): string | undefined => {
if (!namespace) {
return undefined
}
return `${baseprefix}/${cluster}/factory/${BASE_NAMESPACE_FACTORY_KEY}/${apiGroupVersion}/${pluralName}/${namespace}`
}
export const formatEventSummary = (event: TEventsV1Event): string | undefined => {
if (!event.deprecatedCount || !event.deprecatedFirstTimestamp) {
return undefined
}
const now = new Date()
const first = new Date(event.deprecatedFirstTimestamp)
const days = Math.floor((now.getTime() - first.getTime()) / (1000 * 60 * 60 * 24))
return `${event.deprecatedCount} times ${days === 0 ? 'today' : `in the last ${days} days`}`
}

View File

@@ -1 +0,0 @@
export { EventRow } from './EventRow'

View File

@@ -1,60 +0,0 @@
// ------------------------------------------------------------
// Reducer to maintain a keyed list of events, supporting ADDED/MODIFIED/DELETED
// ------------------------------------------------------------
// We keep an `order` array for display order (newest first) and a `byKey` map
// for O(1) updates/reads. Pages append to the END (older items), while live
// UPSERTs (ADDED/MODIFIED) unshift to the START if new.
import { TEventsV1Event } from './types'
import { eventKey } from './utils'
type TState = {
order: string[] // list of keys (newest first)
byKey: Record<string, TEventsV1Event>
}
type TAction =
| { type: 'RESET'; items: TEventsV1Event[] }
| { type: 'APPEND_PAGE'; items: TEventsV1Event[] } // for older pages (append to end)
| { type: 'UPSERT'; item: TEventsV1Event } // ADDED/MODIFIED
| { type: 'REMOVE'; key: string } // DELETED
export const reducer = (state: TState, action: TAction): TState => {
switch (action.type) {
case 'RESET': {
// Replace everything with the initial payload (usually newest N)
const order = action.items.map(eventKey)
const byKey: TState['byKey'] = {}
// eslint-disable-next-line no-return-assign
action.items.forEach(it => (byKey[eventKey(it)] = it))
return { order, byKey }
}
case 'APPEND_PAGE': {
// Append only truly new keys to the end; update any items that already exist
const next = { ...state.byKey }
const addKeys: string[] = []
action.items.forEach(it => {
const k = eventKey(it)
if (!next[k]) addKeys.push(k)
next[k] = it
})
return { order: [...state.order, ...addKeys], byKey: next }
}
case 'UPSERT': {
// Insert new items at the front; replace existing in-place
const k = eventKey(action.item)
const exists = Boolean(state.byKey[k])
const byKey = { ...state.byKey, [k]: action.item }
const order = exists ? state.order : [k, ...state.order]
return { order, byKey }
}
case 'REMOVE': {
// Remove from map and order if present
if (!state.byKey[action.key]) return state
const byKey = { ...state.byKey }
delete byKey[action.key]
return { order: state.order.filter(k => k !== action.key), byKey }
}
default:
return state
}
}

View File

@@ -1,100 +0,0 @@
import styled from 'styled-components'
type TRootProps = {
$maxHeight: number
}
const Root = styled.div<TRootProps>`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: ${({ $maxHeight }) => $maxHeight}px;
border-radius: 12px;
overflow: hidden;
position: relative;
`
const Header = styled.div`
display: flex;
align-items: center;
gap: 16px;
align-self: stretch;
margin-bottom: 16px;
padding-left: 19px;
`
const HeaderLeftSide = styled.div`
display: flex;
align-items: center;
gap: 10px;
flex: 1 0 0;
`
const CursorPointerDiv = styled.div`
cursor: pointer;
user-select: none;
`
const StatusText = styled.div`
font-size: 16px;
line-height: 24px; /* 150% */
`
type THeaderRightSideProps = {
$colorTextDescription: string
}
const HeaderRightSide = styled.div<THeaderRightSideProps>`
display: flex;
gap: 4px;
text-align: right;
color: ${({ $colorTextDescription }) => $colorTextDescription};
`
const List = styled.div`
flex: 1;
overflow-y: auto;
padding: 8px 8px 8px 72px;
z-index: 2;
`
type TTimelineProps = {
$colorText: string
$maxHeight: number
}
const Timeline = styled.div<TTimelineProps>`
width: 100%;
height: ${({ $maxHeight }) => $maxHeight}px;
position: absolute;
top: 40px;
left: 36px;
z-index: 1;
&:before {
content: '';
position: absolute;
top: -2px;
width: 1px;
background: ${({ $colorText }) => $colorText};
pointer-events: none;
height: 100%;
}
`
const Sentinel = styled.div`
height: 1px;
`
export const Styled = {
Root,
Header,
HeaderLeftSide,
CursorPointerDiv,
StatusText,
HeaderRightSide,
Timeline,
List,
Sentinel,
}

View File

@@ -1,80 +0,0 @@
// ========================= Types ============================
// Messages are intentionally permissive (no k8s deps). Adjust to your API as needed.
type TWatchPhase = 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK'
// Shape of an events.k8s.io/v1 Event (subset)
// Note: Both modern `note` and legacy `message` are supported for text.
// Only the fields we render / key on are listed here.
export type TEventsV1Event = {
metadata?: {
name?: string
namespace?: string
resourceVersion?: string
creationTimestamp?: string
}
type?: string // Normal | Warning
reason?: string
note?: string // message text in events.k8s.io/v1
message?: string // legacy fallback
reportingController?: string
reportingInstance?: string
deprecatedCount?: number
deprecatedFirstTimestamp?: Date
action?: string
eventTime?: string
regarding?: {
apiVersion?: string
kind?: string
name?: string
namespace?: string
}
deprecatedSource?: {
component?: string
host?: string
}
}
// ====================== Server Frames =======================
// Incoming frames from the server. Your backend should emit one of these.
// INITIAL: first page (newest events) + a `continue` token
// PAGE: older page fetched via SCROLL
// PAGE_ERROR: pagination failed (keep live stream running)
// ADDED/MODIFIED/DELETED: watch-style deltas for live updates
type TInitialFrame = {
type: 'INITIAL'
items: TEventsV1Event[]
continue?: string
remainingItemCount?: number
resourceVersion?: string
}
type TPageFrame = {
type: 'PAGE'
items: TEventsV1Event[]
continue?: string
remainingItemCount?: number
}
type TPageErrorFrame = {
type: 'PAGE_ERROR'
error: string
}
type TDeltaFrame = {
type: TWatchPhase // ADDED | MODIFIED | DELETED
item: TEventsV1Event
}
export type TServerFrame = TInitialFrame | TPageFrame | TPageErrorFrame | TDeltaFrame
// Outgoing scroll request to server
// Sent when the bottom sentinel intersects view and `continue` exists.
export type TScrollMsg = {
type: 'SCROLL'
continue: string
limit?: number
}

View File

@@ -1,28 +0,0 @@
import { TEventsV1Event } from './types'
// Unique key per event for stable list rendering and updates
export const eventKey = (e: TEventsV1Event) => {
const n = e.metadata?.name ?? ''
const ns = e.metadata?.namespace ?? ''
return `${ns}/${n}`
}
// Compare resourceVersions safely (string-based)
export const compareRV = (a: string, b: string): number => {
if (a.length !== b.length) return a.length > b.length ? 1 : -1
// eslint-disable-next-line no-nested-ternary
return a > b ? 1 : a < b ? -1 : 0
}
type WithRV = { metadata?: { resourceVersion?: string } }
export const getRV = (item: WithRV): string | undefined => item?.metadata?.resourceVersion
// ✅ Pure functional + no restricted syntax
export const getMaxRV = <T extends WithRV>(items: ReadonlyArray<T>): string | undefined => {
const rvs = items
.map(getRV)
.filter((v): v is string => Boolean(v))
.sort(compareRV)
return rvs.length ? rvs[rvs.length - 1] : undefined
}

View File

@@ -10,4 +10,3 @@ export * from './HeaderSecond'
export * from './Sidebar'
export * from './Footer'
export * from './Search'
export * from './Events'

View File

@@ -1,48 +0,0 @@
import React, { FC } from 'react'
import { useParams } from 'react-router-dom'
import { ManageableBreadcrumbs, ManageableSidebar, Events, NavigationContainer } from 'components'
import { getBreadcrumbsIdPrefix } from 'utils/getBreadcrumbsIdPrefix'
import { getSidebarIdPrefix } from 'utils/getSidebarIdPrefix'
import { BaseTemplate } from 'templates'
export const EventsPage: FC = () => {
const { clusterName, namespace, syntheticProject, key } = useParams()
const possibleProject = syntheticProject && namespace ? syntheticProject : namespace
const possibleInstance = syntheticProject && namespace ? namespace : undefined
const breadcrumbsId = `${getBreadcrumbsIdPrefix({
instance: !!syntheticProject,
project: !!namespace,
})}factory-${key}`
const sidebarId = `${getSidebarIdPrefix({
instance: !!syntheticProject,
project: !!namespace,
})}factory-${key}`
return (
<BaseTemplate
sidebar={
<ManageableSidebar
instanceName={possibleInstance}
projectName={possibleProject}
idToCompare={sidebarId}
currentTags={['events']}
/>
}
// withNoCluster
>
<NavigationContainer>
<ManageableBreadcrumbs idToCompare={breadcrumbsId} />
</NavigationContainer>
{clusterName && (
<Events
baseprefix="/openapi-ui"
cluster={clusterName}
wsUrl={`/api/clusters/${clusterName}/openapi-bff-ws/events/eventsWs?limit=40`}
/>
)}
</BaseTemplate>
)
}

View File

@@ -1 +0,0 @@
export { EventsPage } from './EventsPage'

View File

@@ -19,5 +19,3 @@ export { FactoryPage } from './FactoryPage'
export { FactoryAdminPage } from './FactoryAdminPage'
/* search */
export { SearchPage } from './SearchPage'
/* events */
export { EventsPage } from './EventsPage'