mirror of
https://github.com/outbackdingo/openapi-ui.git
synced 2026-01-27 10:19:49 +00:00
events factory/toolkit
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.8x–1.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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Events'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { EventRow } from './EventRow'
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`}`
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { EventRow } from './EventRow'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,4 +10,3 @@ export * from './HeaderSecond'
|
||||
export * from './Sidebar'
|
||||
export * from './Footer'
|
||||
export * from './Search'
|
||||
export * from './Events'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { EventsPage } from './EventsPage'
|
||||
@@ -19,5 +19,3 @@ export { FactoryPage } from './FactoryPage'
|
||||
export { FactoryAdminPage } from './FactoryAdminPage'
|
||||
/* search */
|
||||
export { SearchPage } from './SearchPage'
|
||||
/* events */
|
||||
export { EventsPage } from './EventsPage'
|
||||
|
||||
Reference in New Issue
Block a user