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:
e3mrah 2026-05-07 22:48:43 +04:00 committed by GitHub
parent 0ce2bedd98
commit 111cd55ff7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 10 deletions

View File

@ -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}
/>
)
}

View File

@ -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`}>

View File

@ -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(() => {