feat: stream live Workspace updates to the dashboard via SSE - #1356
Draft
christian-heusel wants to merge 3 commits into
Draft
feat: stream live Workspace updates to the dashboard via SSE#1356christian-heusel wants to merge 3 commits into
christian-heusel wants to merge 3 commits into
Conversation
Introduce a concurrency-safe pub/sub Hub in internal/streaming that lets long-lived HTTP handlers be notified when a watched resource changes, so they can re-read and push a fresh snapshot. Subscribers register with a namespace filter (AllNamespaces matches every namespace); Publish fans out coalescing, non-blocking signals so a slow subscriber never stalls the producer. This is the foundation for the upcoming Server-Sent Events stream that replaces the frontend's 30s workspace-table polling. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Christian Heusel <christian@heusel.eu>
Expose the workspace list as a Server-Sent Events stream so clients can
receive live updates instead of polling. Reusing the existing list routes,
a `?watch=true` (or `watch=1`) query parameter upgrades GET /workspaces
and GET /workspaces/{namespace} to an event stream, mirroring the
Kubernetes watch convention and reusing the same auth check.
The stream emits an initial full-list snapshot and a fresh snapshot on
every change, sourced from the manager's Workspace informer via the
change broadcaster (bursts are debounced; idle connections are kept alive
with heartbeat comments). The server's 32s WriteTimeout is cleared per
connection via http.ResponseController, and gzip is configured to pass
text/event-stream through untouched so flushing works.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Christian Heusel <christian@heusel.eu>
Replace the 30s countdown poll on the Workspaces table with a live
Server-Sent Events stream. useWorkspacesByNamespaceLive connects to the
backend watch endpoint (/workspaces/{namespace}?watch=true) via
@microsoft/fetch-event-source, applying the dev-mode kubeflow-userid
headers the axios interceptor already uses, and exposes a FetchState
tuple identical to the polling hook plus a connection status.
The table shows a live/reconnecting indicator (LiveStatusIndicator) in
place of RefreshCounter, keeping the manual refresh control. The page
selects the streaming or polling variant from ENABLE_WORKSPACE_STREAM
(default on; forced off under the mock API), each variant owning its own
data hook so hook usage stays stable.
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Christian Heusel <christian@heusel.eu>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The idea of this PR is to be a conversation starter and not intended to be merged in its current state!
What this does
Replace the Workspaces table fixed interval polling with live updates over Server-Sent Events (SSE). The table now reflects backend changes (state transitions, pause/resume, pods coming up, create/delete) almost instantly, without fixed-interval refetching.
2026-08-28.01-39-50.mp4
Motivation
Today the frontend keeps the Workspaces table fresh by re-fetching the entire list every 30 s via a countdown (
RefreshCounter→POLL_INTERVAL). This means up to 30 s of staleness after a workspace changes, and a full list re-fetch on every tick regardless of whether anything changed.The backend already runs a controller-runtime manager with a shared informer cache on
Workspace, and its RBAC already grantswatch, so the real-time signals already exists but were never surfaced to the frontend.How it works
watchquery parameter on the existing list routes upgrades the response to an event stream, mirroring the Kubernetes API convention:GET /api/v1/workspaces/{namespace}?watch=true(alsowatch=1; cluster-wideGET /api/v1/workspaces?watch=true). Same route, same auth as the non-streaming list.Changes
Backend (
workspaces/backend)internal/streaming/hub.go: small concurrency-safe pub/sub broadcaster (namespace-filtered, non-blocking, coalescing) with unit tests.cmd/main.go: registers a Workspace informer event handler that publishes changes to the hub.api/workspaces_stream_handler.go+ awatchbranch in the existing list handler: the SSE endpoint. Clears the per-connectionWriteTimeoutviahttp.ResponseControllerso long-lived streams aren't torn down; gzip is configured to passtext/event-streamthrough untouched so flushing works.Frontend (
workspaces/frontend)hooks/useWorkspacesLive.ts: SSE client (via@microsoft/fetch-event-source, so it can set the devkubeflow-useridheader the axios layer already uses). Returns aFetchStatetuple identical to the polling hook, plus a connection status.components/LiveStatusIndicator.tsx: "Live"/"Reconnecting…" indicator that replaces the countdown in live mode (manual refresh preserved).pages/Workspaces/Workspaces.tsx: selects the live or polling variant.Testing
Click to see test report
SSE Live-Update: Test Report
Feature: Live Workspace table updates via Server-Sent Events (branch
feat/workspace-live-updates)Date: 2026-08-28
Target: Kubeflow Notebooks v2, Tilt deployment via Istio gateway (
https://localhost:8443), backend behindistio-envoyover HTTP/2.Endpoint under test:
GET /workspaces/api/v1/workspaces/{namespace}?watch=true(and cluster-wide/workspaces?watch=true).Method
Changes were driven through the BFF's own pause/start action
(
POST .../workspaces/{namespace}/{name}/actions/pause), which exercises thefull API → controller → informer → hub → SSE loop end-to-end. Tilt's Kind
cluster was not reachable via
kubectlfrom this shell (only a GKE context wasconfigured), so backend/pod restart was simulated at the network layer (abrupt
client disconnect + reconnect) rather than by killing the pod.
All local pre-checks passed before live testing: backend
go build/go vet/gofmt/golangci-lint(clean on changed files) + hub unit tests with-race;frontend
test:type-check,test:lint, andtest:jest(452 tests).Results summary
text/event-stream,cache-control: no-cache,x-accel-buffering: no; HTTP/2 via istio-envoyAccept-Encoding: gzip(browser-realistic)content-encoding: gzip; body is plaintextdata:Kubeflow-Userid→ 401401+ error envelopewatch=false/ absent → normal JSONapplication/json, one-shot listwatch=1aliasdata: {"data":[]}200: pingat exactly 20 s idleDetailed observations
Protocol & headers (T1 / T1b)
Critically, even when the client sends
Accept-Encoding: gzip(which everybrowser does), the response is not gzip-compressed — the
gzhttpExceptContentTypes("text/event-stream")configuration works, so the browserEventSource/parser receives readabledata:frames.Live push & lifecycle (T4, R2)
A single pause→unpause→pause sequence produced snapshots tracking the real
controller lifecycle, each pushed within ~1 s of the underlying change:
Precise latency measurement (first snapshot after a change):
Push latency is ≈271 ms, essentially the 250 ms debounce interval plus
network overhead.
Heartbeat (H)
Keeps idle connections alive through proxies/gateways.
Namespace scoping & edge cases (T5–T7)
/workspaces?watch=true) and namespaced(
/workspaces/default?watch=true) both stream correctly.data: {"data":[]}.Connection-loss behavior (focus area)
Frontend runtime path (
useWorkspacesByNamespaceLive+@microsoft/fetch-event-source):onerrorruns; non-fatal, so the hook setsconnectionStatus = 'reconnecting'(UI shows "Reconnecting…") and returnsundefined.DefaultRetryInterval; noexponential backoff — confirmed in the library source
fetch.js).onopenre-validates thetext/event-streamcontent type →
connectionStatus = 'live', error cleared →onmessagedelivers a fresh full snapshot.
during the gap are already present in that first snapshot — no missed
updates. Proven empirically by R1: the
pausedflag was flipped while nostream was connected, and the reconnected stream's initial snapshot already
reflected the new value (reconnect completed in ~300 ms).
4xx(e.g. 401/403) is treated as non-retryable →connectionStatus = 'error', stopping the reconnect loop (no reconnectstorm on an auth failure).
Backend robustness: 20 abrupt connect/disconnect cycles left the backend
fully responsive (both the list endpoint and a fresh stream returned
200).Per-connection resources (goroutine, hub subscription, heartbeat ticker) are
released via
deferonr.Context().Done().Findings & recommendations
(fast recovery), but during a prolonged outage every open browser tab
retries at ~1 req/s against the gateway. Consider adding jittered exponential
backoff (the hook's
onerrorcan return an increasing interval) before a widerollout. Low-effort, non-blocking.
a long-lived stream is authorized once at connect and not re-authorized
mid-connection (SAR result cached ~10 s). Acceptable for now; note it if
session-revocation latency ever becomes a requirement.
Verdict
The live-update feature works correctly end-to-end against the live Tilt
deployment, and the connection-loss path is safe and self-healing: brief
drops recover within ~1 s with no lost updates, and the backend stays healthy
under reconnect churn.
Try it out