fix(catalyst): chroot cloud list views consume SSE cache (services/ingresses/deployments/statefulsets/daemonsets/namespaces/nodes) (#1080)
Two stacked bugs blocked 7 cloud list views (TC-066 services, TC-067 ingresses, TC-072 deployments, TC-073 statefulsets, TC-074 daemonsets, TC-078 namespaces, TC-079 nodes) from rendering live data even though the architecture graph view showed full counts for the same kinds: 1) The architecture-graph widget opened its OWN useK8sCacheStream subscription instead of consuming the page-level snapshot exposed on CloudPage's useCloud() context. That meant TWO concurrent EventSource connections per page — the chroot's HTTP/1.1 6-connections-per-origin budget left CloudPage's subscription stuck on "connecting" while the graph's stream populated its own private snapshot, so chip counts (read off CloudPage's snapshot) showed live data only when initialState happened to land before the budget tipped, and the K8sListPage instances always read an empty CloudPage snapshot. 2) K8sListPage's useMemo for `rows` listed only `[k8sSnapshot, kind, sortByName]` as deps. The snapshot Map is mutated IN-PLACE by useK8sCacheStream (intentional, to coalesce high-frequency bursts into one React render per tick) so its reference is stable across deltas — the memo never recomputed past the initial empty snapshot. The companion `k8sRevision` counter bumps on every applied event; it's the only signal that triggers re-derivation when the in-place Map mutates. The previous code referenced `k8sRevision` as a `void` no-op "for future memo passes" — but the future was now. Fix: * ArchitectureGraphPage now accepts optional `k8sSnapshot` + `k8sRevision` props. When provided (the production path via Architecture.tsx → useCloud()), the widget reads from the shared snapshot. When omitted (storybook / direct embed / tests), it falls back to opening its own subscription so the widget remains self-sufficient. * Architecture.tsx forwards `k8sSnapshot` + `k8sRevision` from useCloud() into the widget — collapsing the two SSE connections into one shared page-level subscription. * K8sListPage adds `k8sRevision` to the rows useMemo deps so the list re-derives on every applied delta, with an extended comment explaining why the revision is what makes the in-place-mutated Map observable. No behaviour change for the working K8s-backed kinds (configmaps, secrets, replicasets, endpointslices, persistentvolumes, pods) — those went through the same path; they only "worked" when the race happened to favour the CloudPage subscription on a given session. PVCs/Buckets/Volumes/StorageClasses/etc continue to read from the topology API and are unaffected. Closes 7 FAIL rows in the iter-3 Sovereign Console QA matrix. Co-authored-by: Hati Yildiz <hati.yildiz@openova.io>
This commit is contained in:
parent
0ce2bedd98
commit
111cd55ff7
@ -19,11 +19,19 @@
|
||||
* or the type/edge palette in widgets/architecture-graph/types.ts.
|
||||
*/
|
||||
|
||||
import { ArchitectureGraphPage } from '@/widgets/architecture-graph'
|
||||
import { ArchitectureGraphPage, type K8sSnapshot } from '@/widgets/architecture-graph'
|
||||
import { useCloud } from './CloudPage'
|
||||
|
||||
export function Architecture() {
|
||||
const { deploymentId, data, isLoading, isError, refetch } = useCloud()
|
||||
const {
|
||||
deploymentId,
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
k8sSnapshot,
|
||||
k8sRevision,
|
||||
} = useCloud()
|
||||
return (
|
||||
<ArchitectureGraphPage
|
||||
deploymentId={deploymentId}
|
||||
@ -31,6 +39,15 @@ export function Architecture() {
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRefetch={refetch}
|
||||
// Forward the shared SSE snapshot so the graph widget consumes
|
||||
// the SAME live snapshot the chip counts and every K8s list view
|
||||
// read from. Without this the widget would open a duplicate
|
||||
// EventSource and the per-page snapshot read by CloudListView /
|
||||
// K8sListPage would starve under the HTTP/1.1 connection budget,
|
||||
// leaving services / ingresses / deployments / statefulsets /
|
||||
// daemonsets / namespaces / nodes rendering "No X objects".
|
||||
k8sSnapshot={(k8sSnapshot as unknown as K8sSnapshot | null) ?? null}
|
||||
k8sRevision={k8sRevision}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -103,8 +103,17 @@ export function K8sListPage({
|
||||
// alternative (per-page subscriptions) starved under the HTTP/1.1
|
||||
// 6-connections-per-origin limit because each open SSE stream
|
||||
// holds a connection slot for its lifetime.
|
||||
//
|
||||
// The snapshot Map is mutated in-place by useK8sCacheStream so its
|
||||
// reference is STABLE across deltas — listing `k8sSnapshot` alone in
|
||||
// the useMemo deps would never recompute past the first render. The
|
||||
// `k8sRevision` counter bumps on every applied event, so adding it to
|
||||
// the deps is what actually makes the list re-derive when new objects
|
||||
// arrive over SSE. Without this, services / ingresses / deployments /
|
||||
// statefulsets / daemonsets / namespaces / nodes all rendered "No X
|
||||
// objects" while the graph view (which keeps its own revision-keyed
|
||||
// memo) showed full counts.
|
||||
const { k8sSnapshot, k8sStatus, k8sRevision } = useCloud()
|
||||
void k8sRevision // dependency hint for future memo passes
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: K8sObject[] = []
|
||||
@ -124,7 +133,8 @@ export function K8sListPage({
|
||||
})
|
||||
}
|
||||
return out
|
||||
}, [k8sSnapshot, kind, sortByName])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [k8sSnapshot, k8sRevision, kind, sortByName])
|
||||
|
||||
return (
|
||||
<div data-testid={`cloud-${kind}-list`}>
|
||||
|
||||
@ -66,7 +66,7 @@ import type {
|
||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'
|
||||
import { hierarchyToGraph } from './adapter'
|
||||
import { k8sToGraph, mergeGraphs } from './k8sAdapter'
|
||||
import { useK8sCacheStream } from './useK8sCacheStream'
|
||||
import { useK8sCacheStream, type K8sSnapshot } from './useK8sCacheStream'
|
||||
import {
|
||||
ALL_EDGE_TYPES,
|
||||
ALL_NODE_TYPES,
|
||||
@ -126,6 +126,22 @@ export interface ArchitectureGraphPageProps {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
onRefetch: () => void
|
||||
/**
|
||||
* Live K8s cache snapshot fed by the page-level useK8sCacheStream
|
||||
* subscription on CloudPage. When provided, this widget reads from
|
||||
* the shared snapshot instead of opening its own EventSource —
|
||||
* ONE SSE connection per page is the canonical pattern (see
|
||||
* useK8sCacheStream's design notes about HTTP/1.1 connection-budget
|
||||
* starvation when the same origin opens duplicate streams).
|
||||
*
|
||||
* When omitted (e.g. in standalone storybook usage or tests), the
|
||||
* widget falls back to opening its own subscription.
|
||||
*/
|
||||
k8sSnapshot?: K8sSnapshot | null
|
||||
/** Companion revision counter from useK8sCacheStream — bumps on every
|
||||
* applied delta so memoised adapters re-derive when the in-place
|
||||
* Map mutates. */
|
||||
k8sRevision?: number
|
||||
}
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────────── */
|
||||
@ -191,15 +207,30 @@ export function ArchitectureGraphPage({
|
||||
isLoading,
|
||||
isError,
|
||||
onRefetch,
|
||||
k8sSnapshot: k8sSnapshotProp,
|
||||
k8sRevision: k8sRevisionProp,
|
||||
}: ArchitectureGraphPageProps) {
|
||||
const handleRef = useRef<GraphCanvasHandle | null>(null)
|
||||
|
||||
/* ── 0. Live K8s cache stream — feeds the K8s-side adapter
|
||||
* alongside the cloud-side hierarchy fetch. The hook opens an
|
||||
* EventSource against /api/v1/sovereigns/{deploymentId}/k8s/
|
||||
* stream?initialState=1 and accumulates a snapshot map as
|
||||
* ADDED/MODIFIED/DELETED events arrive. */
|
||||
const { snapshot: k8sSnapshot, revision: k8sRevision } = useK8sCacheStream(deploymentId)
|
||||
* alongside the cloud-side hierarchy fetch.
|
||||
*
|
||||
* PRIMARY: read from the page-level shared snapshot passed in
|
||||
* via props. CloudPage holds ONE EventSource subscribing to all
|
||||
* kinds; the snapshot is broadcast to (a) the chip counts in
|
||||
* the toolbar, (b) every K8sListPage in list view, and (c) this
|
||||
* graph view. Sharing one subscription avoids the HTTP/1.1
|
||||
* 6-connections-per-origin starvation that otherwise leaves
|
||||
* consumers stuck on the "connecting" placeholder forever.
|
||||
*
|
||||
* FALLBACK: when no snapshot is supplied (storybook / tests /
|
||||
* direct embed), open an internal stream so the widget remains
|
||||
* self-sufficient. */
|
||||
const fallback = useK8sCacheStream(deploymentId, {
|
||||
enabled: k8sSnapshotProp == null,
|
||||
})
|
||||
const k8sSnapshot = k8sSnapshotProp ?? fallback.snapshot
|
||||
const k8sRevision = k8sRevisionProp ?? fallback.revision
|
||||
|
||||
/* ── 1. Adapter — tree → nodes/edges, merged with K8s side ─── */
|
||||
const { nodes: allNodes, edges: allEdges } = useMemo(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user