Skip to content
13 changes: 10 additions & 3 deletions backend/src/lib/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,18 @@ export async function inflateResource(buffer: Buffer, dictionary: Dictionary): P
}

export async function inflateEvent(event: ServerSideEvent): Promise<ServerSideEvent> {
const { id, data } = event
const { type, object } = data as WatchEvent
const { id, name, namespace, data } = event
if (!data || typeof data !== 'object') return event
const watchEvent = data as WatchEvent & { meta?: unknown }
const { type, object } = watchEvent
return !object
? event
: { id, data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object } }
: {
id,
name,
namespace,
data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object },
Comment on lines +248 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the complete watch event during inflation.

WatchEvent declares optional meta, but Line [256] rebuilds data with only type and object. This drops meta from every object-bearing event, including events whose object is already inflated. Preserve the existing event and replace only object.

Proposed fix
-  const { id, name, namespace, data } = event
+  const { data } = event
...
-        id,
-        name,
-        namespace,
-        data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object },
+        ...event,
+        data: {
+          ...watchEvent,
+          object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object,
+        },

Add a regression test for an event containing meta.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const watchEvent = data as WatchEvent & { meta?: unknown }
const { type, object } = watchEvent
return !object
? event
: { id, data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object } }
: {
id,
name,
namespace,
data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object },
const { data } = event
const watchEvent = data as WatchEvent & { meta?: unknown }
const { type, object } = watchEvent
return !object
? event
: {
...event,
data: {
...watchEvent,
object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object,
},
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/lib/compression.ts` around lines 248 - 256, Update the
object-bearing branch around watchEvent so it preserves all existing event
fields, including optional meta, while replacing only object with its inflated
value when needed. Avoid reconstructing data with only type and object; retain
the existing event structure for already-inflated objects. Add a regression test
covering an event containing meta.

}
}

export async function inflateApps(apps: ICompressedResource[]): Promise<ITransformedResource[]> {
Expand Down
60 changes: 45 additions & 15 deletions backend/src/lib/server-side-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { constants } from 'node:http2'
import type { Transform } from 'node:stream'
import { clearInterval } from 'node:timers'
import type { Zlib } from 'node:zlib'
import { batchPromiseAll } from './batch-promise-all'
import { getEncodeStream, inflateEvent } from './compression'
import { setCookie } from './cookies'
import { logger } from './logger'
Expand Down Expand Up @@ -35,6 +34,15 @@ export interface ServerSideEvent<DataT = unknown> {
namespace?: string
data?: DataT
}

/** Lightweight resource identity for RBAC filtering without inflating compressed objects. */
export interface EventResourceMeta {
kind: string
apiVersion: string
name?: string
namespace?: string
}

export interface WatchEvent {
type: 'ADDED' | 'DELETED' | 'MODIFIED' | 'EOP'
object: {
Expand All @@ -46,6 +54,24 @@ export interface WatchEvent {
resourceVersion: string
}
}
meta?: EventResourceMeta
}

/** Resolve kind/apiVersion/name/namespace from meta or an already-inflated object. */
export function getEventResourceMeta(event: ServerSideEvent): EventResourceMeta | undefined {
const data = event.data as (WatchEvent & { type?: string }) | undefined
if (!data || typeof data !== 'object') return undefined
if (data.meta?.kind) return data.meta
const object = data.object as WatchEvent['object'] | Buffer | undefined
if (object && !Buffer.isBuffer(object) && typeof object === 'object' && object.kind) {
return {
kind: object.kind,
apiVersion: object.apiVersion,
name: object.metadata?.name,
namespace: object.metadata?.namespace,
}
}
return undefined
}

export interface ServerSideEventClient {
Expand Down Expand Up @@ -125,22 +151,26 @@ export class ServerSideEvents {
}
}

private static async sendEvent(clientID: string, event: ServerSideEvent): Promise<void> {
private static sendEvent(clientID: string, event: ServerSideEvent): Promise<void> {
const client = this.clients[clientID]
if (!client) return
if (client.events && !client.events[event.name]) return
if (client.namespaces && !client.namespaces[event.namespace]) return
event = await inflateEvent(event)
if (!client) return Promise.resolve()
if (client.events && !client.events[event.name]) return Promise.resolve()
if (client.namespaces && !client.namespaces[event.namespace]) return Promise.resolve()
// Filter before inflate so denied events never materialize full resource JSON in memory.
if (this.eventFilter) {
client.eventQueue.push(
this.eventFilter(client.token, event)
.then((shouldSendEvent) => (shouldSendEvent ? event : undefined))
.then((shouldSendEvent) => {
if (!shouldSendEvent) return undefined
return inflateEvent(event)
})
.catch((): undefined => undefined)
)
} else {
client.eventQueue.push(Promise.resolve(event))
client.eventQueue.push(inflateEvent(event))
}
void this.processClient(clientID)
return Promise.resolve()
}

private static async processClient(clientID: string): Promise<void> {
Expand Down Expand Up @@ -311,10 +341,10 @@ export class ServerSideEvents {

// SORT EVENTS INTO SMALLER PACKETS
// SO THAT BROWSER PAGE LOADS QUICKER
// uncompress and split events into packets
// Classify using meta / inflated object identity — do not inflate the whole cache up front.
const values = Object.values(this.events)
const compressed = sizeOf(values)
let parts = await batchPromiseAll(values, (event) => inflateEvent(event))
let parts: ServerSideEvent[] = [...values]
Comment on lines +344 to +347

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The logged compression ratio is no longer meaningful.

compressed at Line 345 measures the cached events while their object fields are still deflated buffers. Because inflation now happens inside sendEvent, uncompressed = sizeOf(sending) at Line 463 measures those same compressed objects. Both sides of the ratio at Line 465 now measure compressed payloads, so the reported percentage collapses toward zero and no longer reports compression effectiveness.

Either drop the field or compute it from a source that is still inflated.

🔧 Proposed fix: report byte counts instead of a misleading ratio
-    logger.info({ msg: 'event stream start', events: sentCount, compression: 100 - (compressed / uncompressed) * 100 })
+    logger.info({ msg: 'event stream start', events: sentCount, cachedBytes: compressed, sentBytes: uncompressed })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/lib/server-side-events.ts` around lines 343 - 346, Update the
compression metrics in the event-send flow around the values initialized from
this.events and the uncompressed calculation near sendEvent so the logged
compression field is no longer derived from two deflated payload sizes. Prefer
removing the misleading ratio, or compute it using a genuinely inflated source
while preserving the existing event delivery behavior.


// mock a large environment
if (process.env.MOCK_CLUSTERS) {
Expand Down Expand Up @@ -343,9 +373,9 @@ export class ServerSideEvents {
const other: ServerSideEvent<unknown>[] = []
const remainder: ServerSideEvent<unknown>[] = []
parts.forEach((event) => {
const data = event.data as WatchEvent
const meta = getEventResourceMeta(event)
// see frontend/src/components/LoadPluginData.tsx for what pages are fast loaded
switch (data.object.kind) {
switch (meta?.kind) {
case 'ManagedCluster':
case 'HostedCluster':
case 'ClusterDeployment':
Expand Down Expand Up @@ -384,9 +414,9 @@ export class ServerSideEvents {
// sort events alphabetically so that browser list fills from top to bottom
const compareFn =
(propName: 'name' | 'namespace') => (a: ServerSideEvent<unknown>, b: ServerSideEvent<unknown>) => {
const adata = a.data as WatchEvent
const bdata = b.data as WatchEvent
return adata.object.metadata[propName].localeCompare(bdata.object.metadata[propName])
const aVal = getEventResourceMeta(a)?.[propName] ?? ''
const bVal = getEventResourceMeta(b)?.[propName] ?? ''
return aVal.localeCompare(bVal)
}
clusters.sort(compareFn('name'))
infos.sort(compareFn('namespace'))
Expand Down
6 changes: 6 additions & 0 deletions backend/src/resources/watch-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ export interface IWatchOptions {
* Defaults to true when omitted.
*/
forwardEventsToClients?: boolean
/**
* True when the Kubernetes resource is cluster-scoped.
* Used by SSE RBAC to decide whether SelfSubjectRulesReview should probe `default`
* (cluster-scoped) or the resource namespace (namespaced).
*/
clusterScoped?: boolean
}
Loading