From c41b6bd74a5a2b15660a9f8ce175d88ad3ec18ec Mon Sep 17 00:00:00 2001 From: GT020 Date: Tue, 3 Nov 2026 17:44:17 +0530 Subject: [PATCH 1/8] perf: optimize watch fetch flow and retained-ui updates --- docs/watch-and-ui-updates-vs-freelens.md | 583 ++++++++++++++++++ src/cluster/mod.rs | 15 +- src/models/resources/store.rs | 212 ++++++- src/repository/mod.rs | 119 +++- src/repository/resources/detail.rs | 429 +++++++++++-- src/repository/resources/nodes/mod.rs | 266 ++++++-- src/repository/resources/pods/mod.rs | 263 ++++++-- src/repository/resources/watcher.rs | 434 +++++++++++-- src/repository/watch_prefs.rs | 31 +- src/ui/resources/list.rs | 2 + src/ui/shell/mod.rs | 3 +- .../resources/cluster_roles/mod.rs | 31 +- src/view_models/resources/config_maps/mod.rs | 31 +- src/view_models/resources/cron_jobs/mod.rs | 31 +- src/view_models/resources/custom.rs | 31 +- src/view_models/resources/daemon_sets/mod.rs | 31 +- src/view_models/resources/deployments/mod.rs | 31 +- src/view_models/resources/detail.rs | 33 + .../resources/endpoint_slices/mod.rs | 31 +- src/view_models/resources/endpoints/mod.rs | 31 +- src/view_models/resources/events/mod.rs | 31 +- .../horizontal_pod_autoscalers/mod.rs | 31 +- src/view_models/resources/ingresses/mod.rs | 31 +- src/view_models/resources/jobs/mod.rs | 31 +- src/view_models/resources/leases/mod.rs | 31 +- src/view_models/resources/namespaces/mod.rs | 31 +- .../resources/network_policies/mod.rs | 31 +- src/view_models/resources/node/mod.rs | 41 +- src/view_models/resources/overview.rs | 56 +- .../resources/persistent_volume_claims/mod.rs | 31 +- .../resources/persistent_volumes/mod.rs | 31 +- src/view_models/resources/pod/mod.rs | 41 +- .../resources/pod_disruption_budgets/mod.rs | 31 +- src/view_models/resources/replica_sets/mod.rs | 31 +- .../resources/replication_controllers/mod.rs | 31 +- .../resources/role_bindings/mod.rs | 31 +- src/view_models/resources/roles/mod.rs | 31 +- src/view_models/resources/secrets/mod.rs | 31 +- .../resources/service_accounts/mod.rs | 31 +- src/view_models/resources/services/mod.rs | 31 +- .../resources/stateful_sets/mod.rs | 31 +- .../resources/storage_classes/mod.rs | 31 +- 42 files changed, 2985 insertions(+), 380 deletions(-) create mode 100644 docs/watch-and-ui-updates-vs-freelens.md diff --git a/docs/watch-and-ui-updates-vs-freelens.md b/docs/watch-and-ui-updates-vs-freelens.md new file mode 100644 index 0000000..42ea80d --- /dev/null +++ b/docs/watch-and-ui-updates-vs-freelens.md @@ -0,0 +1,583 @@ +# Live updates in krusty vs. Freelens + +How each app gets from "Kubernetes object changed" to "the right pixels changed", +and where the two designs differ. + +Reference checkouts: `krusty` and `../freelens` (Freelens is a Lens fork). + +--- + +## TL;DR + +| Concern | Freelens | krusty | +| --- | --- | --- | +| Transport | `fetch` streaming in the Electron renderer (`KubeApi.watch`) | `kube-rs` watcher on a dedicated tokio runtime | +| Initial load | Full list, one request per selected namespace, run in parallel | Kubernetes 1.27+ streaming list (`sendInitialEvents`) when supported; chunked list (`limit=500` + `continue`) as the fallback | +| Watch request | `?watch=1&resourceVersion=…&timeoutSeconds=600` per namespace | `Api::watch` with `timeout=280s`, bookmark events enabled | +| Reconnect | 1 s after normal stream end, 5 s after an error; `410` reloads then re-watches | 250 ms after stream end; soft backoff 0.5 s → 30 s; `410` relists | +| Batching | Observable event buffer flushed by a mobx `reaction` after **1 s** (events: 5 s) | Timer every **50 ms**, or immediately after **256** pending events | +| State container | mobx `observable.array` mutated/replaced in place | `BTreeMap` + UID index owned by the watch task, never by the UI | +| Hand-off to UI | The UI reads the same observable store directly | Immutable `Arc` sent over `tokio::sync::watch` | +| UI invalidation | mobx dependency tracking + `@observer`, React re-render | Entity update + `cx.notify()`, whole list entity re-rendered | +| List virtualization | `VirtualList` renders viewport rows | `gpui-component` `TableState` (`UniformListScrollHandle`) | +| Detail pane freshness | Reads the live store, so it reacts to watch updates | Generic, pod, and node details follow single-object watches; pod/node metrics are merged in every 10 s | +| Watch denied by RBAC | No poll fallback; watch retries every 5 s | Falls back to per-kind polling; preference persisted | +| Watch sharing | Ref-counted shared watch per store for selected namespaces | One task per `(context epoch, kind, scope)` | + +The headline difference: **Freelens optimizes for fine-grained reactive UI over a +1-second event batching window; krusty optimizes for a simple, immutable +snapshot boundary flushed on a 50 ms cadence (with immediate escalation on +bursts), plus explicit polling fallbacks.** + +--- + +## Freelens pipeline + +``` +React view mounts + └─ KubeWatchApi.subscribeStores() + ├─ store.loadAll() → list per namespace (Promise.allSettled) + │ → mergeItems() → observable items + └─ store.subscribe() + └─ KubeApi.watch(ns) → streaming newline-delimited JSON + └─ eventsBuffer.push(event) + │ mobx reaction, delay = 1000 ms + ▼ + updateFromEventsBuffer() + │ ADDED / MODIFIED / DELETED, keyed by UID + │ items.replace(...) only if something changed + ▼ + @observer list + VirtualList → DOM +``` + +### 1. Listing + +`KubeObjectStore.loadAll()` (`packages/core/src/common/k8s-api/kube-object.store.ts:276`) +delegates to `loadItems()` (`:202`): + +- Cluster-scoped or "all namespaces" load: one `api.list()`. +- Otherwise: one `api.list({ namespace })` per selected namespace, all in + parallel with `Promise.allSettled` (`:240`). +- `mergeItems()` (`:327`) keeps items from namespaces outside the requested + set, sorts, and replaces the observable array. +- Aborted lists are treated as unmounts, not failures (`:216`, `:312`). + +There is no chunking/`continue` support for normal stores. The events store is +the exception: it passes `limit: 1000` +(`packages/core/src/renderer/components/events/store.ts:32`). + +### 2. Watch + +`KubeApi.watch()` (`packages/utility-features/kube-api/src/kube-api.ts:813`): + +- Opens a streaming `fetch` to `getWatchUrl()` with `watch=1`, + `resourceVersion` for that namespace, and `timeoutSeconds` (default 600). +- Reads the body with a `ReadableStream` reader and reassembles newline-framed + JSON events (`:930–969`). +- A separate watchdog fires at `timeout * 1.1` and reconnects if the server did + not close the stream (`:851–877`). +- Stream end → reconnect after 1 s; error → reconnect after 5 s. `401`/`404` + stop retrying, `410` triggers `loadAll()` and then restarts the watch + (`kube-object.store.ts:550–595`). +- Every event updates the API object's per-namespace resource version + (`kube-api.ts:990–1005`). + +Watches are started only after the list has populated a resource version: +`watchNamespace()` returns early without one (`kube-object.store.ts:542`). + +### 3. Batching + +`bindWatchEventsUpdater(delay = 1000)` installs a mobx `reaction` over the +observable `eventsBuffer` (`kube-object.store.ts:480–490`). Events are +**accumulated, not dropped**, for up to a second; the events store overrides the +delay to 5 s. + +`updateFromEventsBuffer()` (`:602`): + +- Builds a UID → array-index map once per flush. +- `ADDED`/`MODIFIED`: skips an event whose `resourceVersion` equals the stored + object's; otherwise constructs a new `KubeObject`. +- `DELETED`: writes a tombstone and removes the UID from the index. +- Sorts only when an inserted item or a changed sort key requires it. +- Replaces `items` only if something actually changed, then trims the event + path to `bufferSize` (default 50,000). + +### 4. UI updates + +The list is a mobx `@observer`; `ItemListLayout.items` is a `@computed` that +applies filters over `store.getItems()` +(`packages/core/src/renderer/components/item-object-list/list-layout.tsx:283`). +`ItemListLayoutContent` feeds that array to `Table`/`VirtualList`, which only +renders rows in the viewport +(`packages/core/src/renderer/components/item-object-list/content.tsx:483–570`). + +Rows and detail components are themselves observers, so a replaced array causes +the list to re-render while individual row/detail reads stay dependency-tracked. + +The details drawer resolves the selected object through +`apiManager.getStore(path).getByPath(path)` inside an `asyncComputed` +(`packages/core/src/renderer/components/kube-object-details/current-kube-object-in-details.injectable.ts`). +Because `getByPath()` reads the observable `items` synchronously, the drawer +updates as watch events merge into the store. + +Metrics are separate polling loops: + +- Pods list: 10 s (`packages/core/src/renderer/components/workloads-pods/pods.tsx:32`). +- Pod details list: 120 s. +- Nodes: 30 s. + +### 5. Sharing and cleanup + +`KubeWatchApi` (`packages/core/src/renderer/kube-watch-api/kube-watch-api.ts`): + +- For the currently selected namespaces, one shared watch per store is + ref-counted across views (`:115–135`). +- Explicit `namespaces` gets its own watch and re-lists on namespace changes + (`:140–200`). +- Every subscriber gets an `AbortController`; unmount aborts the watch and + clears retry timers. + +--- + +## krusty pipeline + +``` +ViewModel.subscribe(kind, scope) + └─ Repository (global map keyed by epoch + kind + scope) + └─ WatchTask on the tokio runtime + ├─ list chunks (500 at a time) ──┐ + └─ watch stream ─── events ──────┤ + ▼ + ResourceStore + BTreeMap + UID index + │ publish: 50 ms ticker + │ or 256 pending events + ▼ + Arc (immutable) + │ tokio::watch (latest value only) + ▼ + ViewModel pump: model.apply_snapshot(); cx.notify() + ▼ + TableState / UniformList (virtualized) +``` + +### 1. Subscription sharing + +`Repository::subscribe()` (`src/repository/mod.rs`) keys a task on +`(context_epoch, ResourceKind, NamespaceScope)`. A second view with the same +key attaches to the same `watch::Sender`; the entry is held weakly so dropping +the last `Subscription` cancels the background task. A context switch bumps +`context_epoch`, so old tasks are orphaned and dropped with their views. + +### 2. Listing + +`run_stream()` first tries a Kubernetes 1.27+ streaming list +(`WatchParams::initial_events()`), which sends synthetic `ADDED` events followed +by a `BOOKMARK` carrying `k8s.io/initial-events-end: "true"`. It applies those +events progressively, publishes on the normal 50 ms/256-event cadence, and +continues the regular watch from the bookmark's resource version. + +Compatibility fallbacks: + +- API server version `< 1.27` → chunked list immediately. +- Watch setup rejected, a watch `ERROR`, an early stream end, or a bookmark + without the initial-events annotation → chunked list. +- No initial-end bookmark within 5 s idle / 60 s total → chunked list. +- A fallback disables streaming for that watch task, so old servers are probed + at most once per subscription. + +The chunked list path (`WatchTask::list_progressively()` in +`src/repository/resources/watcher.rs`) remains: + +- `ListParams::limit(500)` with `continue` tokens. +- First chunk calls `store.begin_sync()`; later chunks upsert. +- Publishes a snapshot after **every chunk**, so a huge list renders + progressively instead of waiting for the whole response. +- `finish_sync()` removes unseen objects and keeps the list's `resourceVersion` + to start the watch from. + +### 3. Watch loop + +`WatchTask::run_stream()` (`watcher.rs:256`): + +- `WatchParams::default().timeout(280)`; kube enables bookmark events by + default, and bookmarks advance the stored resource version. +- `Added`/`Modified` map the typed object to a compact `Item` and call + `store.upsert`; `Deleted` removes by `(namespace, name)`. +- `Error` with code 410 / reason `Expired`/`Gone` schedules a relist. +- Normal stream end resumes from the last resource version after 250 ms. +- Soft failures (network, 429, 5xx, 20 s request timeout) retry forever with + exponential backoff from 0.5 s to 30 s (`src/repository/retry.rs`). +- Hard failures (400/401/403/404/405/422) get one retry (two attempts total), + then the kind is + marked denied in a process-global registry and the task switches to polling. +- Bookmarked resource versions mean a reconnect does not replay the list. + +### 4. Batching and snapshots + +`ResourceStore` (`src/models/resources/store.rs:119`) owns: + +- `BTreeMap>` — sorted by `(namespace, name)` with no sort + pass on read. +- `HashMap` — O(1) replacement/removal even if metadata changes. +- `pending` counter for throttled flushes. + +`WatchTask::publish()` (`watcher.rs:477`) builds an immutable +`ResourceSnapshot { Arc<[Arc]>, phase, mode }` and calls +`watch::Sender::send_replace`. Publishing happens when: + +- a 50 ms ticker fires and anything is pending (`watcher.rs:367`), or +- 256 events have accumulated (`watcher.rs:459`), or +- a list chunk completed, or the mode/phase changed. + +`FLUSH_INTERVAL = 50ms`, `FLUSH_AT = 256`, `LIST_CHUNK_SIZE = 500` +(`watcher.rs:39–44`). + +Because `tokio::sync::watch` stores only the latest value, a slow consumer +coalesces intermediate snapshots — it never blocks the watch task. + +### 5. UI updates + +A view model (e.g. `src/view_models/resources/pod/mod.rs:254–320`) keeps the +`Subscription`, applies the initial snapshot to a model entity, then spawns a +GPUI task: + +```rust +while let Some(snapshot) = pump.next().await { + model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + if active.load(Ordering::Relaxed) { + cx.notify(); + } + }); +} +``` + +- Inactive tabs pause their `Subscription`; the pump stops receiving snapshots. + On activation the view model applies `subscription.snapshot()` immediately, + then resumes the pump, which also returns the latest coalesced snapshot. +- Rendering reads the snapshot through the table delegate (`rows_count`, + `render_td`, `cell_text`). `TableState` uses a `UniformListScrollHandle`, so + only viewport rows are built. +- A manual refresh bumps a generation on the task's `refresh` channel, which + forces a relist. + +### 6. Details + +- Generic detail panes call `subscribe_detail()` (`src/repository/resources/detail.rs`), + which GETs the object and then follows it with a single-object watch + (`fieldSelector=metadata.name=`). On `410` it re-GETs; if watch is + denied it falls back to a 5 s GET poll. The view model cancels this stream + while its tab is hidden. +- Pod and node details GET the object once and then follow it with the same + single-object watch used by generic details, while a 10 s timer fetches + metrics (`src/repository/resources/pods/mod.rs:455–512`, + `src/repository/resources/nodes/mod.rs`). Object fields and usage therefore + stay live together. +- The 10 s metrics sampling matches Freelens' pods-list cadence. + +### 7. Failure and preference handling + +- `WATCH_DENIED` (`watcher.rs:50–74`) remembers per epoch/kind that the API + server rejected watch, so a new subscription for the same kind starts polling + immediately instead of retrying a doomed watch. +- `WatchPreference` / `PollRate` are persisted per kind in + `~/.config/krusty/poll-rates.json` (`src/repository/watch_prefs.rs`). + `PollRate::Every(5)` is the fallback; `Off` disables the timer but manual + refresh still relists. +- The UI can switch a kind between live watch and poll at runtime; every + running task for that kind receives the new preference. + +--- + +## Stage-by-stage comparison + +| Stage | Freelens | krusty | +| --- | --- | --- | +| Network execution | Renderer process (Electron) | Tokio worker threads, separate from the GPUI UI thread | +| Initial list requests | One per selected namespace, parallel | One streaming watch on 1.27+; otherwise one streamed list per scope with sequential chunks | +| Initial list progress | Spinner until `loadAll` resolves | Streaming `ADDED` events or the first 500 list objects appear immediately | +| Resource version | Per-namespace map in `KubeApi` | One version per watch task, advanced by events/bookmarks | +| Watch granularity | One watch per namespace (or one global watch) | One watch for the whole scope | +| Watch timeout | 600 s + 1.1× client watchdog | 280 s | +| Reconnect delay | 1 s normal end, 5 s error | 250 ms normal end, exponential backoff on errors | +| Batch window | 1 s (events 5 s) | 50 ms / 256 events | +| Store mutation | In-place observable array; replace only on change | Background `BTreeMap`; publish immutable snapshot | +| Sort | Explicit sort when a sort key changes | `BTreeMap` keeps rows sorted, no sort pass | +| UID handling | Rebuild UID→index map per flush | Persistent UID→key map | +| UI granularity | Row/detail-level mobx observers | Whole list entity invalidated per snapshot | +| Background views | React components unmount or keep observing; watches are aborted on unmount | Snapshot delivery pauses and detail/metrics work stops; the shared watch remains open | +| Virtualization | `VirtualList` | `TableState` + `UniformListScrollHandle` | +| Detail freshness | Live from store | One-shot, except metrics | +| RBAC watch denial | Retries watch every 5 s indefinitely | Falls back to polling automatically | +| Manual poll mode | None for lists | Per-kind, persisted (`Every(n)` / `Off`) | +| Shared watch | Ref-counted per store for selected namespaces | Keyed per `(epoch, kind, scope)` | +| Cluster/namespace switch | Abort + reload + resubscribe | New subscription key; old task cancelled by drop | +| Coalescing vs. loss | Buffers every event for 1 s; may trim at 50k items | Latest snapshot wins; intermediate snapshots may be skipped | + +--- + +## Connection lifecycle on UI switches + +"Invalidated" here means the watch stream is aborted and returning to the view +requires a new list + watch. + +| UI action | Freelens | krusty | +| --- | --- | --- | +| Navigate between resource pages (sidebar or sibling tabs) | **Yes.** `ClusterFrameLayout` renders only `currentRouteComponent`; the old page unmounts, `KubeObjectListLayout.componentWillUnmount` disposes its subscriptions, and `KubeWatchApi` aborts the watch. Returning re-lists and re-watches. | **No.** Every opened tab keeps a `WorkspacePane` entity in `Workspace.panes`; switching calls `sync_visibility()`, which pauses snapshot delivery and detail/metrics work. The shared watch keeps running. | +| Switch a sub-tab within a page (`TabLayout` with routes) | **Yes.** The route changes, so the previous route component unmounts. | **No.** There is no route-level page swap; each tab owns its view. | +| Switch cluster tab (Hotbar / cluster frame) | **No.** `ClusterFrameHandler` keeps one iframe per cluster, hides it with CSS (`visibility: hidden`), and only changes the visible frame. Watches in hidden cluster frames keep running. | **Yes.** `switch_context()` keeps the old client while the new one builds, then swaps it and bumps `context_epoch`. Visible tabs resubscribe immediately; hidden tabs drop their subscription and reconnect on activation. | +| Close a workspace tab | N/A (there are no app-level page tabs; leaving the page is the row above). | **Yes**, but only when the last subscription for that `(epoch, kind, scope)` is dropped. | +| Close a cluster | Yes; the iframe is removed after disconnect, so its watchers die with it. | N/A (single active cluster). | +| Change the namespace filter | **Yes.** A mobx `reaction` aborts the controller, unsubscribes, then `loadAll()` + `subscribe()` for the new selection. | **Yes for that tab.** `set_scope()` drops the tab's subscription and creates a new key; a watch for the old key stays alive only if another tab still holds it. | +| Open/close the details pane | No effect on the list watch. The drawer uses the list store. | No effect on the list watch. The detail fetch has its own task; closing cancels it. | +| Window blur / minimize | No pause. Watches keep streaming. | No pause. The visible tab keeps updating; hidden tabs pause snapshot delivery and details. | +| Restore a saved workspace | N/A (routes are not restored as tabs). | Only the **active** tab of each group is instantiated at startup (`ensure_active_panes`); restored hidden tabs connect lazily when first focused. | + +Two practical consequences: + +1. **Freelens trades freshness for churn on navigation.** Every page visit is a + fresh list + watch. The shared-watch refcount only helps when multiple + mounted components use the same store; it does not preserve a watch across + route changes. +2. **krusty trades watch count for instant tab switches.** Once a tab has been + visited, it stays subscribed and only pauses snapshot delivery while hidden. + With many tabs open, this can still mean many concurrent watches. A cluster + switch drops hidden tabs' subscriptions; they reconnect lazily on activation + instead of all resubscribing at once. + +--- + +## Optimization opportunities in krusty + +Ordered by expected impact per unit of risk. Status is tracked here and updated +as each item lands. + +| # | Suggestion | Status | Where it lives | +| --- | --- | --- | --- | +| 1 | Live details / derive from snapshot | **Done** — generic, pod, and node detail panes follow a single-object watch, with metrics merged in for pods/nodes | `repository/resources/{detail,pods,nodes}.rs`, `view_models/resources/detail.rs` | +| 2 | Pause detail/metrics work for inactive tabs | **Done** | `view_models/resources/{pod,node,overview,detail}.rs` | +| 3 | Share per-object and metrics subscriptions | **Partial** — generic detail watchers are ref-counted per `(epoch, kind, namespace, name)`; pod/node detail and metrics remain per-tab | `repository/resources/detail.rs` | +| 4 | Stage cluster switches | **Done** | `cluster/mod.rs` | +| 5 | Explicit hidden-tab policy | **Done** — hidden tabs pause snapshot delivery, cancel detail/metrics, drop their subscription on a cluster switch, and reconnect lazily on activation | `repository/mod.rs`, `view_models/resources/*` | +| 6 | Diff lists instead of `reset` | **Done** | `models/resources/store.rs`, `repository/resources/watcher.rs` | +| 7 | Skip unchanged watch events | **Done** | `models/resources/store.rs` | +| 8 | No-op snapshot / notify suppression | **Done** | `models/resources/store.rs`, `repository/resources/watcher.rs`, `repository/mod.rs` | +| 9 | Bound high-churn kinds (events/leases) | **Done** — Events are capped at 1000 newest rows and Events/Leases default to polling | `models/resources/store.rs`, `repository/{resources/watcher,watch_prefs}.rs` | +| 10 | Adaptive poll backoff | **Done** — unchanged polls double from the configured rate up to 60 s and reset on change or manual refresh | `repository/resources/watcher.rs` | +| 11 | Persistent collection / cheap snapshot | **Dropped** — list diffing means snapshots rebuild only on real changes; no measured need for another collection dependency | — | +| 12 | Row-level invalidation | **Dropped** — GPUI retained-mode element diffing owns row/paint invalidation | — | +| 13 | Streaming lists (`sendInitialEvents`) | **Done** — Kubernetes 1.27+ fast path with version check, idle/total timeouts, and chunked-list fallback; a fallback disables retries for that task | `repository/resources/watcher.rs` | +| 14 | Adaptive flush cadence | **Dropped** — GPUI coalesces paints per frame; the 50 ms data flush is the only throttle needed | — | +| 15 | Measure mixed-scope overlap | **Done** — subscription create/reuse is traced with kind, scope, and active count | `repository/mod.rs` | +| 16 | Re-probe denied watches | **Done** — automatic RBAC denials are retried every 10 minutes; an explicit polling preference is never overridden | `repository/resources/watcher.rs` | + +### Boundary rules followed by these changes + +- `models/` owns `Item`, `ResourceStore`, diff/pending semantics, and + snapshot types. It performs no I/O and knows nothing about GPUI. +- `repository/` owns API calls, list/watch loops, retries, and delivery + semantics such as pausing a subscription. It publishes snapshots only. +- `view_models/` decides when a tab is active, applies snapshots to model + entities, and starts/cancels detail or metrics streams. It never renders. +- `ui/` only forwards lifecycle events (for example `ResourceListView::set_active`) + and renders state. No data-store or retry logic was added there. + +GPUI retained-mode cleanup: the model no longer carries a `revision` counter +and the view-model pumps no longer compute a `should_notify` flag. A real data +change updates the model and marks the entity dirty; GPUI diffs and paints. +Data-layer batching/pausing remains because it controls network and background +CPU, not painting. + +`cargo check` and `cargo test` pass (106 tests), including store diff/trim +tests, adaptive poll backoff, streaming-list version/bookmark gates, and a +paused-subscription delivery test. + +### P0 — correctness and obvious waste + +1. **Make details live or derive them from the list snapshot. — DONE** + `repository/resources/detail.rs` GETs the object, then follows it with a + single-object watch (`fieldSelector=metadata.name=`), relists on `410`, + and falls back to a 5 s GET poll when watch is denied. Pod and node details + now do the same while sharing the same select loop with their 10 s metrics + sampler; one watch keeps object fields current and the metrics timer updates + usage. + +2. **Pause detail and metrics work for inactive tabs. — DONE** + `set_active(false)` now pauses the list subscription's snapshot delivery and + cancels the detail stream (Pods, Nodes, and the generic detail pane). Pod and + node metrics stop with it. The overview cancels its node-metrics poller while + hidden and restarts it on activation. No snapshot or metrics work is applied + in the background. + +3. **Share per-object and metrics subscriptions. — PARTIAL** + Generic detail watchers are now ref-counted per + `(epoch, kind, namespace, name)` in `repository/resources/detail.rs`: one + watch feeds every subscriber through a broadcast channel, with the latest + update seeded to late subscribers. Pod/node detail and metrics still create + one stream per open tab; sharing those needs a separate typed + detail-plus-metrics subscription keyed the same way. + +4. **Stage cluster switches instead of blanking first. — DONE** + `switch_context()` keeps the old client, data, and epoch until + `spawn_client_build` succeeds. The successful build swaps `client` and bumps + `context_epoch` in one update, so views resubscribe once. A failed build + clears the client and bumps the epoch so the new context shows an error + instead of stale data from the previous cluster. + +5. **Make the hidden-tab policy explicit. — DONE** + Hidden tabs pause their subscription (`Subscription::set_paused`), cancel + detail and metrics work, and apply the latest snapshot once when activated. + On a cluster switch they discard their subscription instead of reconnecting; + `set_active(true)` notices the missing subscription and re-subscribes against + the current epoch. Visible tabs resubscribe immediately. + +### P1 — poll and relist efficiency + +6. **Diff lists instead of `reset`. — DONE** + `ResourceStore::begin_sync()` / `finish_sync()` merge a paginated list into + the existing maps: unchanged `(key, uid, resourceVersion)` entries keep their + `Arc`, new or changed entries are inserted, and keys absent from the + completed list are removed once. `list_progressively` uses this path, so a + poll with no changes produces no pending objects and no snapshot. An + interrupted list calls `abort_sync()` and never deletes unseen objects. + +7. **Skip unchanged watch events. — DONE** + `ResourceStore::upsert()` returns `false` and leaves `pending` and the UID + index untouched when the key, UID, and non-empty resource version match the + stored item. Duplicate `MODIFIED` events and repeated poll entries are + therefore free. + +8. **Do not publish or notify on a no-op snapshot. — DONE** + The watcher publishes a list chunk or final list only when `pending > 0` (or + when the first `Ready` phase still has to be emitted). Combined with the + paused subscription, a stable poll cycle produces no snapshot, no model + update, and no `cx.notify()`. + +9. **Bound high-churn kinds. — DONE** + `ResourceStore::trim_oldest()` removes the oldest `created_at` rows, and the + watcher applies a 1000-row cap to Events after each chunk and watch event. + Events and Leases now default to polling (5 s), matching the existing + Namespaces default; users can switch any kind to live watch and the choice + persists. Freelens only limits its initial events list; krusty bounds the + live list too. + +10. **Adaptive poll backoff. — DONE** + The poll loop counts consecutive unchanged lists and doubles the configured + interval (`5 s → 10 → 20 → 40 → 60 s`, capped). A changed list, manual + refresh, or preference change resets to the configured base rate. The UI + keeps showing the configured rate; the trace logs show the effective delay. + +### P2 — large-list rendering and hand-off + +11. **Persistent collection / cheap snapshot. — DROPPED** + After the list diff, a snapshot is rebuilt only when an object actually + changes, not on every 50 ms tick or every poll. A persistent collection + would add a dependency and change the snapshot API for no measured win; + revisit only if profiling shows the O(n) rebuild is hot during a real + change storm. + +12. **Row-level invalidation. — DROPPED** + GPUI is retained mode and diffs the element tree itself. The view models no + longer carry a `should_notify`/`revision` gate; a published snapshot updates + the model and marks the entity dirty, and GPUI decides what to repaint. + Manual per-row revision tracking would duplicate that machinery. + +13. **Use streaming lists where supported. — DONE** + `run_stream()` checks the API server version once per context epoch and, + for 1.27+, opens a watch with `initial_events()`. `ADDED`/`MODIFIED` events + are applied progressively; the `k8s.io/initial-events-end` bookmark ends the + initial phase and seeds the regular watch's resource version. Any rejection, + early end, non-initial bookmark, or 5 s idle / 60 s total timeout falls back + to the chunked list, then disables streaming for that task. Verified against + the kind cluster (`v1.37`), which returns the expected initial-end bookmark. + +14. **Adaptive flush cadence. — DROPPED** + GPUI already coalesces paints to its frame cadence. The 50 ms repository + flush limits data hand-off; adding a second render-oriented throttle would + duplicate the UI framework. + +15. **Measure mixed-scope overlap. — DONE** + `Repository::subscribe_kind` now logs whether a subscription was reused or + created, including kind, scope, and the active subscription count. That + makes duplicate `All` vs `One(ns)` watches visible without changing the + sharing policy. + +16. **Re-probe denied watches occasionally. — DONE** + An automatic watch denial now schedules a 10-minute re-probe from the poll + loop. It clears the denied marker and returns to the watch path; if the + watch is still denied it falls back to polling again. If the user explicitly + chose polling, `prefs.mode == Poll` keeps the re-probe disabled. + +### What is already good + +- One watch task per `(epoch, kind, scope)`, shared through `Arc`/`Weak`. +- Chunked initial lists with progressive rendering. +- `BTreeMap` ordering (no sort pass) plus a UID index. +- Immutable snapshots with O(1) clone and no lock shared with the UI. +- Soft/hard retry split and a real polling fallback when watch is denied. +- Streaming initial lists on Kubernetes 1.27+ with a chunked-list fallback. +- Hidden tabs pause snapshot delivery and detail/metrics work; the shared watch + stays open for instant activation. + +--- + +## Trade-offs + +### Where Freelens' design wins + +- **Fine-grained renders.** Replacing `items` does not force every reader to + recompute: mobx observers only repaint what they read. +- **Event completeness.** The 1 s buffer applies every event, so short-lived + objects (create → delete inside one second) are not lost in a snapshot + collapse. +- **Live details for free.** Because the drawer reads the same store as the + list, spec/status changes appear without a second request. +- **Per-namespace watches.** A namespace filter change affects only those + watches; already-watched namespaces keep streaming. + +### Where krusty's design wins + +- **No UI-thread network or mutation.** All watch and store work stays on tokio; + the UI only swaps an `Arc`. +- **Cheap hand-off.** Snapshot clone is O(1), and `BTreeMap` removes the sort + pass. +- **Progressive large lists.** The first 500 objects appear immediately; the + rest stream in. +- **Predictable degradation.** RBAC-denied watches become polling instead of a + retry loop; per-kind polling is user-visible and persisted. +- **Bounded latency.** A quiet feed is flushed within 50 ms, and bursts of 256 + events publish immediately rather than waiting for the next tick. Inactive + tabs cost no rendering. + +--- + +## File map + +### krusty + +- `src/repository/mod.rs` — subscription registry, sharing, watch preferences. +- `src/repository/resources/watcher.rs` — list/watch loop, batching, polling. +- `src/repository/retry.rs` — soft/hard retry policy, 20 s request bound. +- `src/repository/watch_prefs.rs` — persisted watch/poll preference. +- `src/models/resources/store.rs` — `ResourceStore`, snapshots, modes. +- `src/view_models/resources/pod/mod.rs` — snapshot pump, `cx.notify()` gating. +- `src/repository/resources/detail.rs` — one-shot generic detail fetch. +- `src/repository/resources/pods/mod.rs` — pod detail + 10 s metrics loop. + +### Freelens + +- `packages/utility-features/kube-api/src/kube-api.ts` — streaming watch, + retries, resource-version tracking. +- `packages/core/src/common/k8s-api/kube-object.store.ts` — list/merge, event + buffer, 1 s flush, store updates. +- `packages/core/src/renderer/kube-watch-api/kube-watch-api.ts` — shared and + per-view subscriptions, namespace reactions. +- `packages/core/src/renderer/components/item-object-list/list-layout.tsx` — + computed filtering over the store. +- `packages/core/src/renderer/components/item-object-list/content.tsx` — + virtualized table content. +- `packages/core/src/renderer/components/kube-object-details/current-kube-object-in-details.injectable.ts` + — live detail lookup from the store. +- `packages/core/src/renderer/components/workloads-pods/pods.tsx` — 10 s pod + metrics polling. diff --git a/src/cluster/mod.rs b/src/cluster/mod.rs index 2bf16da..29a9a7b 100644 --- a/src/cluster/mod.rs +++ b/src/cluster/mod.rs @@ -363,13 +363,22 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { Ok(Err(error)) => { tracing::warn!(%error, context = %context_id, "cluster: client build failed"); cx.update_global::(|state, _| { + // The active label now points at a context with no + // working client. Drop the old one and let every view + // resubscribe into the visible error state. + state.client = None; state.reachable = Some(false); + state.namespaces.clear(); + state.context_epoch = state.context_epoch.wrapping_add(1); }); } Err(error) => { tracing::error!(%error, context = %context_id, "cluster: client build task failed"); cx.update_global::(|state, _| { + state.client = None; state.reachable = Some(false); + state.namespaces.clear(); + state.context_epoch = state.context_epoch.wrapping_add(1); }); } } @@ -390,11 +399,11 @@ pub fn switch_context(cx: &mut App, id: &str) -> Result<(), &'static str> { .find(|context| context.id == id) .cloned() .ok_or("unknown context")?; - state.client = None; + // Keep the old client and snapshots until the new client is ready. + // Bumping the epoch here would make every view resubscribe with no + // client, publish an error, and resubscribe again on success. state.active = Some(context.id.clone()); state.reachable = None; - state.namespaces.clear(); - state.context_epoch = state.context_epoch.wrapping_add(1); if let Some(cancel) = state.namespace_cancel.take() { let _ = cancel.send(true); } diff --git a/src/models/resources/store.rs b/src/models/resources/store.rs index ecdda16..4f67ba6 100644 --- a/src/models/resources/store.rs +++ b/src/models/resources/store.rs @@ -5,7 +5,7 @@ //! without a separate sort pass. A UID index lets deletes/updates find the prior //! key in O(1) even if metadata changes. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use super::Item; @@ -33,7 +33,6 @@ impl ObjectKey { #[derive(Clone, Debug)] pub struct ResourceSnapshot { items: Arc<[Arc]>, - revision: u64, phase: SnapshotPhase, mode: WatchMode, } @@ -79,7 +78,6 @@ impl ResourceSnapshot { pub fn loading() -> Self { Self { items: Arc::from(Vec::>::new()), - revision: 0, phase: SnapshotPhase::Loading, mode: WatchMode::Watch, } @@ -88,7 +86,6 @@ impl ResourceSnapshot { pub fn failed(message: impl Into) -> Self { Self { items: Arc::from(Vec::>::new()), - revision: 0, phase: SnapshotPhase::Error(message.into()), mode: WatchMode::Watch, } @@ -103,10 +100,6 @@ impl ResourceSnapshot { &self.items } - pub fn revision(&self) -> u64 { - self.revision - } - pub fn phase(&self) -> &SnapshotPhase { &self.phase } @@ -120,8 +113,10 @@ impl ResourceSnapshot { pub struct ResourceStore { objects: BTreeMap>, by_uid: HashMap, - revision: u64, pending: usize, + /// Keys seen since [`begin_sync`](Self::begin_sync). Present only while a + /// list is being merged into the store. + sync_seen: Option>, } impl Default for ResourceStore { @@ -135,8 +130,8 @@ impl ResourceStore { Self { objects: BTreeMap::new(), by_uid: HashMap::new(), - revision: 0, pending: 0, + sync_seen: None, } } @@ -148,21 +143,75 @@ impl ResourceStore { self.pending } + #[cfg(test)] pub fn reset(&mut self, items: I) where I: IntoIterator, { self.objects.clear(); self.by_uid.clear(); + self.sync_seen = None; for item in items { self.upsert(item); } self.pending = 0; } - pub fn upsert(&mut self, item: Item) { - let item = Arc::new(item); + /// Start merging a full list into the store. The old objects stay visible + /// until [`finish_sync`](Self::finish_sync), so a relist does not blank the + /// UI while its pages arrive. + pub fn begin_sync(&mut self) { + self.sync_seen = Some(HashSet::new()); + } + + /// Remove objects that were absent from the list started by + /// [`begin_sync`](Self::begin_sync). Returns whether anything was removed. + pub fn finish_sync(&mut self) -> bool { + let Some(seen) = self.sync_seen.take() else { + return false; + }; + let stale: Vec = self + .objects + .keys() + .filter(|key| !seen.contains(*key)) + .cloned() + .collect(); + let mut changed = false; + for key in stale { + changed |= self.remove(&key); + } + changed + } + + /// Abandon an interrupted list without deleting objects it did not reach. + pub fn abort_sync(&mut self) { + self.sync_seen = None; + } + + /// Insert or replace an object. Returns whether the store actually changed: + /// a repeated `(key, uid, resourceVersion)` is a no-op, which makes poll + /// relists and duplicate watch events cheap. + pub fn upsert(&mut self, item: Item) -> bool { let key = ObjectKey::from_item(&item); + if let Some(seen) = &mut self.sync_seen { + seen.insert(key.clone()); + } + + if let Some(existing) = self.objects.get(&key) + && !item.resource_version.is_empty() + && existing.uid == item.uid + && existing.resource_version == item.resource_version + { + return false; + } + + let item = Arc::new(item); + if let Some(existing) = self.objects.get(&key) + && !existing.uid.is_empty() + && existing.uid != item.uid + { + self.by_uid.remove(&existing.uid); + } if !item.uid.is_empty() && let Some(previous) = self.by_uid.insert(item.uid.clone(), key.clone()) && previous != key @@ -170,25 +219,46 @@ impl ResourceStore { self.objects.remove(&previous); } self.objects.insert(key, item); - self.revision = self.revision.wrapping_add(1); self.pending = self.pending.saturating_add(1); + true } - pub fn remove(&mut self, key: &ObjectKey) { - if let Some(item) = self.objects.remove(key) - && !item.uid.is_empty() - { + pub fn remove(&mut self, key: &ObjectKey) -> bool { + let Some(item) = self.objects.remove(key) else { + return false; + }; + if !item.uid.is_empty() { self.by_uid.remove(&item.uid); } - self.revision = self.revision.wrapping_add(1); self.pending = self.pending.saturating_add(1); + true + } + + /// Remove the oldest objects until at most `max_items` remain. Used for + /// high-churn kinds such as Events, where the API can retain thousands. + /// Returns whether anything was removed. + pub fn trim_oldest(&mut self, max_items: usize) -> bool { + if self.objects.len() <= max_items { + return false; + } + let mut by_age: Vec<(ObjectKey, i64)> = self + .objects + .iter() + .map(|(key, item)| (key.clone(), item.created_at.unwrap_or(i64::MIN))) + .collect(); + by_age.sort_by_key(|(_, created_at)| *created_at); + let remove = self.objects.len() - max_items; + let mut changed = false; + for (key, _) in by_age.into_iter().take(remove) { + changed |= self.remove(&key); + } + changed } pub fn snapshot(&mut self, phase: SnapshotPhase) -> ResourceSnapshot { self.pending = 0; ResourceSnapshot { items: self.objects.values().cloned().collect(), - revision: self.revision, phase, mode: WatchMode::Watch, } @@ -205,6 +275,12 @@ mod tests { item } + fn versioned_pod(name: &str, namespace: &str, uid: &str, version: &str) -> Item { + let mut item = pod(name, namespace, uid); + item.resource_version = version.into(); + item + } + #[test] fn keeps_rows_sorted_and_replaces_by_key() { let mut store = ResourceStore::new(); @@ -220,7 +296,6 @@ mod tests { let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); assert_eq!(names, ["a", "b", "c"]); assert_eq!(snapshot.items()[1].status, "crashloopbackoff"); - assert_eq!(snapshot.revision(), 4); } #[test] @@ -273,6 +348,103 @@ mod tests { assert_eq!(snapshot.items()[0].name, "b"); } + #[test] + fn upsert_skips_an_unchanged_resource_version() { + let mut store = ResourceStore::new(); + store.upsert(versioned_pod("a", "ns", "uid-a", "10")); + let _ = store.snapshot(SnapshotPhase::Ready); + + let mut same = versioned_pod("a", "ns", "uid-a", "10"); + same.status = "running".into(); + assert!(!store.upsert(same)); + assert_eq!(store.pending(), 0); + assert_eq!(store.snapshot(SnapshotPhase::Ready).items()[0].status, ""); + } + + #[test] + fn sync_keeps_unchanged_items_and_removes_absent_ones() { + let mut store = ResourceStore::new(); + store.reset([ + versioned_pod("a", "ns", "uid-a", "1"), + versioned_pod("b", "ns", "uid-b", "1"), + versioned_pod("c", "ns", "uid-c", "1"), + ]); + let _ = store.snapshot(SnapshotPhase::Ready); + + store.begin_sync(); + assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); + assert!(store.upsert(versioned_pod("b", "ns", "uid-b", "2"))); + assert!(store.upsert(versioned_pod("d", "ns", "uid-d", "1"))); + store.finish_sync(); + + let snapshot = store.snapshot(SnapshotPhase::Ready); + let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, ["a", "b", "d"]); + } + + #[test] + fn an_unchanged_sync_does_not_bump_pending() { + let mut store = ResourceStore::new(); + store.reset([versioned_pod("a", "ns", "uid-a", "1")]); + let _ = store.snapshot(SnapshotPhase::Ready); + + store.begin_sync(); + assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); + store.finish_sync(); + + assert_eq!(store.pending(), 0); + assert_eq!(store.snapshot(SnapshotPhase::Ready).items().len(), 1); + } + + #[test] + fn aborting_a_sync_does_not_delete_unseen_items() { + let mut store = ResourceStore::new(); + store.reset([ + versioned_pod("a", "ns", "uid-a", "1"), + versioned_pod("b", "ns", "uid-b", "1"), + ]); + let _ = store.snapshot(SnapshotPhase::Ready); + + store.begin_sync(); + store.upsert(versioned_pod("a", "ns", "uid-a", "2")); + store.abort_sync(); + + let snapshot = store.snapshot(SnapshotPhase::Ready); + assert_eq!(snapshot.items().len(), 2); + } + + #[test] + fn removing_an_unknown_key_is_a_no_op() { + let mut store = ResourceStore::new(); + let _ = store.snapshot(SnapshotPhase::Ready); + assert!(!store.remove(&ObjectKey::new("ns", "missing"))); + assert_eq!(store.pending(), 0); + } + + #[test] + fn trim_oldest_removes_the_oldest_objects_first() { + let mut store = ResourceStore::new(); + for (index, created_at) in [(0, 10), (1, 30), (2, 20), (3, 40)] { + let mut item = versioned_pod( + &format!("event-{index}"), + "ns", + &format!("uid-{index}"), + "1", + ); + item.created_at = Some(created_at); + store.upsert(item); + } + let _ = store.snapshot(SnapshotPhase::Ready); + + assert!(store.trim_oldest(2)); + let snapshot = store.snapshot(SnapshotPhase::Ready); + let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, ["event-1", "event-3"]); + + assert!(!store.trim_oldest(2)); + assert_eq!(store.pending(), 0); + } + #[test] fn snapshots_carry_the_watch_mode() { let mut store = ResourceStore::new(); diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 1b9a79f..9d964e4 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -50,6 +50,8 @@ pub fn init(cx: &mut App) { #[derive(Clone)] pub struct Subscription { rx: watch::Receiver>, + paused: watch::Sender, + paused_rx: watch::Receiver, _entry: Arc, } @@ -58,10 +60,50 @@ impl Subscription { self.rx.borrow().clone() } - /// Wait for the next snapshot. `None` means the subscription ended. + /// Stop delivering snapshots to this subscriber. The shared watch task + /// keeps running; [`next`](Self::next) returns the latest snapshot as soon + /// as the subscription is resumed. + pub fn set_paused(&self, paused: bool) { + self.paused.send_replace(paused); + } + + /// Wait for the next snapshot relevant to this subscriber. `None` means the + /// subscription ended. While paused, snapshots are coalesced and only the + /// latest one is returned on resume. pub async fn next(&mut self) -> Option> { - self.rx.changed().await.ok()?; - Some(self.rx.borrow_and_update().clone()) + let mut paused = *self.paused_rx.borrow_and_update(); + loop { + if paused { + tokio::select! { + result = self.paused_rx.changed() => { + result.ok()?; + if !*self.paused_rx.borrow_and_update() { + return Some(self.rx.borrow_and_update().clone()); + } + } + result = self.rx.changed() => { + result.ok()?; + self.rx.borrow_and_update(); + } + } + } else { + tokio::select! { + result = self.paused_rx.changed() => { + result.ok()?; + paused = *self.paused_rx.borrow_and_update(); + } + result = self.rx.changed() => { + result.ok()?; + if *self.paused_rx.borrow() { + self.rx.borrow_and_update(); + paused = true; + } else { + return Some(self.rx.borrow_and_update().clone()); + } + } + } + } + } } /// Ask the shared watch task to perform a fresh list. @@ -107,8 +149,17 @@ pub(crate) fn subscribe_kind( .retain(|_, entry| entry.strong_count() > 0); if let Some(entry) = repository.entries.get(&key).and_then(Weak::upgrade) { + tracing::trace!( + ?kind, + ?scope, + active = repository.entries.len(), + "repository: subscription reused" + ); + let (paused, paused_rx) = watch::channel(false); return Subscription { rx: entry.snapshots.subscribe(), + paused, + paused_rx, _entry: entry, }; } @@ -139,12 +190,25 @@ pub(crate) fn subscribe_kind( refresh, prefs, }); + let scope_log = scope.clone(); let watch_task = resources::watcher::run( client, kind, scope, key.epoch, snapshots, cancel_rx, refresh_rx, prefs_rx, ); tokio.spawn(watch_task); repository.entries.insert(key, Arc::downgrade(&entry)); - Subscription { rx, _entry: entry } + tracing::debug!( + ?kind, + scope = ?scope_log, + active = repository.entries.len(), + "repository: subscription created" + ); + let (paused, paused_rx) = watch::channel(false); + Subscription { + rx, + paused, + paused_rx, + _entry: entry, + } })) } @@ -186,3 +250,50 @@ fn update_watch_prefs(cx: &mut App, kind: ResourceKind, change: impl FnOnce(&mut } }); } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::models::SnapshotPhase; + + #[tokio::test] + async fn a_paused_subscription_coalesces_until_resumed() { + let (snapshots, rx) = watch::channel(Arc::new(ResourceSnapshot::loading())); + let (cancel, _) = watch::channel(false); + let (refresh, _) = watch::channel(0u64); + let (prefs, _) = watch::channel(WatchPrefs::default()); + let entry = Arc::new(Entry { + snapshots: snapshots.clone(), + _cancel: cancel, + refresh, + prefs, + }); + let (paused, paused_rx) = watch::channel(false); + let mut subscription = Subscription { + rx, + paused, + paused_rx, + _entry: entry, + }; + + subscription.set_paused(true); + snapshots.send_replace(Arc::new(ResourceSnapshot::failed("offline"))); + let resume = subscription.clone(); + let mut next = Box::pin(subscription.next()); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut next) + .await + .is_err(), + "a paused subscription must not deliver snapshots" + ); + + resume.set_paused(false); + let snapshot = tokio::time::timeout(Duration::from_millis(250), next) + .await + .expect("resume must wake the pump") + .expect("resume snapshot"); + assert!(matches!(snapshot.phase(), SnapshotPhase::Error(_))); + } +} diff --git a/src/repository/resources/detail.rs b/src/repository/resources/detail.rs index 8953377..e8b76a9 100644 --- a/src/repository/resources/detail.rs +++ b/src/repository/resources/detail.rs @@ -1,23 +1,69 @@ -//! Generic one-shot detail fetch for every Kubernetes resource kind. +//! Live detail follow for every Kubernetes resource kind. -use kube::api::{Api, DynamicObject}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::Duration; + +use futures::TryStreamExt as _; +use kube::ResourceExt; +use kube::api::{Api, DynamicObject, WatchEvent, WatchParams}; use kube::core::{ApiResource, GroupVersionKind}; use serde_json::Value; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{broadcast, watch}; use crate::cluster::ClusterState; use crate::models::ResourceKind; use crate::models::resources::detail::{ResourceDetail, ResourceDetailUpdate}; +use crate::repository::retry::{self, Retry, RetryAction}; use gpui_kit::App; +const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; +const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); +const DETAIL_POLL_INTERVAL: Duration = Duration::from_secs(5); +const DETAIL_BROADCAST_CAPACITY: usize = 8; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct DetailKey { + epoch: u64, + kind: ResourceKind, + namespace: String, + name: String, +} + +struct DetailEntry { + updates: broadcast::Sender, + latest: Arc>>, + _cancel: watch::Sender, +} + +fn detail_registry() -> &'static Mutex>> { + static REGISTRY: OnceLock>>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + pub struct ResourceDetailSubscription { - updates: mpsc::UnboundedReceiver, + updates: broadcast::Receiver, + initial: Option, cancel: watch::Sender, + cancel_rx: watch::Receiver, + _entry: Arc, } impl ResourceDetailSubscription { pub async fn next(&mut self) -> Option { - self.updates.recv().await + if let Some(initial) = self.initial.take() { + return Some(initial); + } + loop { + tokio::select! { + _ = self.cancel_rx.changed() => return None, + result = self.updates.recv() => match result { + Ok(update) => return Some(update), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } } pub fn cancel_handle(&self) -> watch::Sender { @@ -40,17 +86,64 @@ pub fn subscribe_detail( let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); - let (updates_tx, updates) = mpsc::unbounded_channel(); + let key = DetailKey { + epoch: state.context_epoch(), + kind, + namespace: namespace.to_string(), + name: name.to_string(), + }; + + let mut registry = detail_registry() + .lock() + .map_err(|_| "detail registry is poisoned".to_string())?; + registry.retain(|_, entry| entry.strong_count() > 0); + + if let Some(entry) = registry.get(&key).and_then(Weak::upgrade) { + return Ok(subscription_for(entry)); + } + + let (updates, _) = broadcast::channel(DETAIL_BROADCAST_CAPACITY); let (cancel, cancel_rx) = watch::channel(false); + let latest = Arc::new(Mutex::new(None)); + let entry = Arc::new(DetailEntry { + updates: updates.clone(), + latest: Arc::clone(&latest), + _cancel: cancel, + }); + let receiver = updates.subscribe(); tokio.spawn(watch_detail( client, kind, namespace.to_string(), name.to_string(), - updates_tx, + updates, + latest, cancel_rx, )); - Ok(ResourceDetailSubscription { updates, cancel }) + registry.insert(key, Arc::downgrade(&entry)); + drop(registry); + + let (sub_cancel, sub_cancel_rx) = watch::channel(false); + Ok(ResourceDetailSubscription { + updates: receiver, + initial: None, + cancel: sub_cancel, + cancel_rx: sub_cancel_rx, + _entry: entry, + }) +} + +fn subscription_for(entry: Arc) -> ResourceDetailSubscription { + let updates = entry.updates.subscribe(); + let initial = entry.latest.lock().ok().and_then(|latest| latest.clone()); + let (cancel, cancel_rx) = watch::channel(false); + ResourceDetailSubscription { + updates, + initial, + cancel, + cancel_rx, + _entry: entry, + } } async fn watch_detail( @@ -58,39 +151,301 @@ async fn watch_detail( kind: ResourceKind, namespace: String, name: String, - updates: mpsc::UnboundedSender, + updates: broadcast::Sender, + latest: Arc>>, mut cancel: watch::Receiver, ) { - let resource = api_resource(kind); - let api: Api = if kind.is_namespaced() { - Api::namespaced_with(client, &namespace, &resource) - } else { - Api::all_with(client, &resource) - }; - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(&name) => result, - }; - let result = match fetched { - Ok(object) => { - let mut value = serde_json::to_value(&object).unwrap_or(Value::Null); - sanitize_secret(kind, &mut value); - Ok(Box::new(ResourceDetail { - kind, - namespace: namespace.clone(), - name: name.clone(), - raw_json: serde_json::to_string_pretty(&value).unwrap_or_default(), - value, - })) + let mut retry = Retry::new(); + let mut resource_version = String::new(); + let mut need_get = true; + + loop { + let api = api_for(&client, kind, &namespace); + if need_get { + let fetched = tokio::select! { + _ = cancel.changed() => return, + result = api.get(&name) => result, + }; + match fetched { + Ok(object) => { + retry.list_succeeded(); + if let Some(version) = object.resource_version() { + resource_version = version; + } + if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { + return; + } + need_get = false; + } + Err(error) => { + if !publish( + &updates, + &latest, + ResourceDetailUpdate { + kind, + namespace: namespace.clone(), + name: name.clone(), + result: Err(format!( + "Could not load {} details: {error}", + kind.label() + )), + }, + ) { + return; + } + match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => return, + } + } + } } - Err(error) => Err(format!("Could not load {} details: {error}", kind.label())), - }; - let _ = updates.send(ResourceDetailUpdate { + + let params = WatchParams::default() + .timeout(DETAIL_WATCH_TIMEOUT_SECS) + .fields(&format!("metadata.name={name}")); + let watched = tokio::select! { + _ = cancel.changed() => return, + result = retry::bounded(api.watch(¶ms, &resource_version)) => result, + }; + let mut stream = match watched { + Ok(stream) => { + retry.watch_succeeded(); + Box::pin(stream) + } + Err(failure) => match failure.action(&mut retry) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => { + poll_detail( + &client, + kind, + &namespace, + &name, + &updates, + &latest, + &mut cancel, + ) + .await; + return; + } + }, + }; + + let mut relist = false; + loop { + tokio::select! { + _ = cancel.changed() => return, + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + retry.watch_succeeded(); + if let Some(version) = object.resource_version() { + resource_version = version; + } + if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { + return; + } + } + Ok(Some(WatchEvent::Deleted(_))) => { + publish( + &updates, + &latest, + ResourceDetailUpdate { + kind, + namespace: namespace.clone(), + name: name.clone(), + result: Err(format!("{} {} was deleted", kind.label(), name)), + }, + ); + return; + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + retry.watch_succeeded(); + resource_version = bookmark.metadata.resource_version; + } + Ok(Some(WatchEvent::Error(status))) => { + relist = status.code == 410 + || status.reason == "Expired" + || status.reason == "Gone"; + match retry.failed_status(status.code) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_detail( + &client, + kind, + &namespace, + &name, + &updates, + &latest, + &mut cancel, + ) + .await; + return; + } + } + } + Ok(None) => break, + Err(error) => match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_detail( + &client, + kind, + &namespace, + &name, + &updates, + &latest, + &mut cancel, + ) + .await; + return; + } + }, + } + } + } + + if relist { + need_get = true; + } + if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { + return; + } + } +} + +/// Watch was denied (or the object is not watchable): keep the detail fresh +/// with a low-frequency GET instead of hammering the watch endpoint. +async fn poll_detail( + client: &kube::Client, + kind: ResourceKind, + namespace: &str, + name: &str, + updates: &broadcast::Sender, + latest: &Arc>>, + cancel: &mut watch::Receiver, +) { + let mut retry = Retry::new(); + let api = api_for(client, kind, namespace); + loop { + let fetched = tokio::select! { + _ = cancel.changed() => return, + result = api.get(name) => result, + }; + match fetched { + Ok(object) => { + retry.list_succeeded(); + if !send_detail(updates, latest, kind, namespace, name, &object) { + return; + } + } + Err(error) => { + if !publish( + updates, + latest, + ResourceDetailUpdate { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + result: Err(format!("Could not load {} details: {error}", kind.label())), + }, + ) { + return; + } + match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => return, + } + } + } + if !sleep_or_cancel(cancel, DETAIL_POLL_INTERVAL).await { + return; + } + } +} + +fn fmt_detail( + kind: ResourceKind, + namespace: &str, + name: &str, + object: &DynamicObject, +) -> ResourceDetailUpdate { + let mut value = serde_json::to_value(object).unwrap_or(Value::Null); + sanitize_secret(kind, &mut value); + ResourceDetailUpdate { kind, - namespace, - name, - result, - }); + namespace: namespace.to_string(), + name: name.to_string(), + result: Ok(Box::new(ResourceDetail { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + raw_json: serde_json::to_string_pretty(&value).unwrap_or_default(), + value, + })), + } +} + +fn send_detail( + updates: &broadcast::Sender, + latest: &Arc>>, + kind: ResourceKind, + namespace: &str, + name: &str, + object: &DynamicObject, +) -> bool { + publish(updates, latest, fmt_detail(kind, namespace, name, object)) +} + +fn publish( + updates: &broadcast::Sender, + latest: &Arc>>, + update: ResourceDetailUpdate, +) -> bool { + if let Ok(mut latest) = latest.lock() { + *latest = Some(update.clone()); + } + updates.send(update).is_ok() +} + +async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = cancel.changed() => false, + _ = tokio::time::sleep(duration) => true, + } +} + +fn api_for(client: &kube::Client, kind: ResourceKind, namespace: &str) -> Api { + let resource = api_resource(kind); + if kind.is_namespaced() { + Api::namespaced_with(client.clone(), namespace, &resource) + } else { + Api::all_with(client.clone(), &resource) + } } fn api_resource(kind: ResourceKind) -> ApiResource { diff --git a/src/repository/resources/nodes/mod.rs b/src/repository/resources/nodes/mod.rs index 62b7aa2..df63642 100644 --- a/src/repository/resources/nodes/mod.rs +++ b/src/repository/resources/nodes/mod.rs @@ -3,8 +3,9 @@ use std::collections::BTreeMap; use std::time::Duration; +use futures::TryStreamExt as _; use k8s_openapi::api::core::v1::Node; -use kube::api::{Api, DynamicObject, ListParams}; +use kube::api::{Api, DynamicObject, ListParams, WatchEvent, WatchParams}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; use tokio::sync::{mpsc, watch}; @@ -15,11 +16,14 @@ use crate::models::resources::node::detail::{ NodeUsage, }; use crate::models::{NamespaceScope, ResourceKind}; +use crate::repository::retry::{self, Retry, RetryAction}; use gpui_kit::App; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); +const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; +const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Nodes stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -160,59 +164,241 @@ async fn watch_node_detail( mut cancel: watch::Receiver, ) { let api: Api = Api::all(client.clone()); - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(&node) => result, - }; - let fetched = match fetched { - Ok(node) => node, - Err(error) => { - let _ = updates.send(NodeDetailUpdate::Detail { - node, - result: Err(format!("Could not load node details: {error}")), - }); + let mut retry = Retry::new(); + let mut resource_version = String::new(); + let mut need_get = true; + let mut cpu_capacity = None; + let mut memory_capacity = None; + let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + if need_get { + let fetched = tokio::select! { + _ = cancel.changed() => return, + result = api.get(&node) => result, + }; + let object = match fetched { + Ok(object) => object, + Err(error) => { + let _ = updates.send(NodeDetailUpdate::Detail { + node, + result: Err(format!("Could not load node details: {error}")), + }); + return; + } + }; + retry.list_succeeded(); + if let Some(version) = object.resource_version() { + resource_version = version; + } + let detail = detail_from_node(&object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + need_get = false; + } + + let params = WatchParams::default() + .timeout(DETAIL_WATCH_TIMEOUT_SECS) + .fields(&format!("metadata.name={node}")); + let watched = tokio::select! { + _ = cancel.changed() => return, + result = retry::bounded(api.watch(¶ms, &resource_version)) => result, + }; + let mut stream = match watched { + Ok(stream) => { + retry.watch_succeeded(); + Box::pin(stream) + } + Err(failure) => match failure.action(&mut retry) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => { + poll_node_detail(&client, &node, &updates, &mut cancel).await; + return; + } + }, + }; + + let mut relist = false; + loop { + tokio::select! { + _ = cancel.changed() => return, + _ = ticker.tick() => { + let usage = fetch_usage(&client, &node, cpu_capacity, memory_capacity).await; + if updates + .send(NodeDetailUpdate::Usage { + node: node.clone(), + result: usage, + }) + .is_err() + { + return; + } + } + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + retry.watch_succeeded(); + if let Some(version) = object.resource_version() { + resource_version = version; + } + let detail = detail_from_node(&object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + } + Ok(Some(WatchEvent::Deleted(_))) => { + let _ = updates.send(NodeDetailUpdate::Detail { + node, + result: Err("This node was deleted".to_string()), + }); + return; + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + retry.watch_succeeded(); + resource_version = bookmark.metadata.resource_version; + } + Ok(Some(WatchEvent::Error(status))) => { + relist = status.code == 410 + || status.reason == "Expired" + || status.reason == "Gone"; + match retry.failed_status(status.code) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_node_detail(&client, &node, &updates, &mut cancel).await; + return; + } + } + } + Ok(None) => break, + Err(error) => match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_node_detail(&client, &node, &updates, &mut cancel).await; + return; + } + }, + } + } + } + + if relist { + need_get = true; + } + if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { return; } - }; - let detail = detail_from_node(&fetched); - let cpu_capacity = detail + } +} + +/// Watch is not permitted: keep both the object and its usage fresh with GETs. +async fn poll_node_detail( + client: &Client, + node: &str, + updates: &mpsc::UnboundedSender, + cancel: &mut watch::Receiver, +) { + let api: Api = Api::all(client.clone()); + let mut retry = Retry::new(); + loop { + let fetched = tokio::select! { + _ = cancel.changed() => return, + result = api.get(node) => result, + }; + match fetched { + Ok(object) => { + retry.list_succeeded(); + let detail = detail_from_node(&object); + let (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(updates, node, detail) { + return; + } + let usage = fetch_usage(client, node, cpu_capacity, memory_capacity).await; + if updates + .send(NodeDetailUpdate::Usage { + node: node.to_string(), + result: usage, + }) + .is_err() + { + return; + } + } + Err(error) => { + if updates + .send(NodeDetailUpdate::Detail { + node: node.to_string(), + result: Err(format!("Could not load node details: {error}")), + }) + .is_err() + { + return; + } + match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => return, + } + } + } + if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { + return; + } + } +} + +fn capacity_from_detail(detail: &NodeDetail) -> (Option, Option) { + let cpu = detail .allocatable .get("cpu") .and_then(|quantity| parse_quantity(quantity)) .map(|cores| (cores * 1000.0).round() as u64); - let memory_capacity = detail + let memory = detail .allocatable .get("memory") .and_then(|quantity| parse_quantity(quantity)) .map(|bytes| bytes.max(0.0).round() as u64); - if updates + (cpu, memory) +} + +fn send_node_detail( + updates: &mpsc::UnboundedSender, + node: &str, + detail: NodeDetail, +) -> bool { + updates .send(NodeDetailUpdate::Detail { - node: node.clone(), + node: node.to_string(), result: Ok(Box::new(detail)), }) - .is_err() - { - return; - } + .is_ok() +} - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = fetch_usage(&client, &node, cpu_capacity, memory_capacity) => result, - }; - if updates - .send(NodeDetailUpdate::Usage { - node: node.clone(), - result: fetched, - }) - .is_err() - { - return; - } - tokio::select! { - _ = cancel.changed() => return, - _ = tokio::time::sleep(METRICS_POLL_INTERVAL) => {} - } +async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = cancel.changed() => false, + _ = tokio::time::sleep(duration) => true, } } diff --git a/src/repository/resources/pods/mod.rs b/src/repository/resources/pods/mod.rs index c64162b..32b87a6 100644 --- a/src/repository/resources/pods/mod.rs +++ b/src/repository/resources/pods/mod.rs @@ -7,7 +7,9 @@ use futures::{AsyncBufReadExt, SinkExt, TryStreamExt}; use k8s_openapi::api::core::v1::{ Container, ContainerState, ContainerStatus, EnvVar, Pod, PodStatus, Toleration, Volume, }; -use kube::api::{Api, AttachParams, DynamicObject, LogParams, TerminalSize}; +use kube::api::{ + Api, AttachParams, DynamicObject, LogParams, TerminalSize, WatchEvent, WatchParams, +}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; @@ -21,10 +23,12 @@ use crate::models::resources::pod::detail::{ use crate::models::{NamespaceScope, ResourceKind}; use gpui_kit::App; -use crate::repository::retry::{Retry, RetryAction}; +use crate::repository::retry::{self, Retry, RetryAction}; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); +const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; +const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Pods stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -462,56 +466,237 @@ async fn watch_pod_detail( mut cancel: watch::Receiver, ) { let api: Api = Api::namespaced(client.clone(), &namespace); - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(&name) => result, - }; - let fetched = match fetched { - Ok(pod) => pod, - Err(error) => { - let _ = updates.send(PodDetailUpdate::Detail { - namespace, - name, - result: Err(format!("Could not load pod details: {error}")), - }); + let mut retry = Retry::new(); + let mut resource_version = String::new(); + let mut need_get = true; + let mut cpu_capacity = None; + let mut memory_capacity = None; + let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + if need_get { + let fetched = tokio::select! { + _ = cancel.changed() => return, + result = api.get(&name) => result, + }; + let pod = match fetched { + Ok(pod) => pod, + Err(error) => { + let _ = updates.send(PodDetailUpdate::Detail { + namespace, + name, + result: Err(format!("Could not load pod details: {error}")), + }); + return; + } + }; + retry.list_succeeded(); + if let Some(version) = pod.resource_version() { + resource_version = version; + } + (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); + if !send_pod_detail(&updates, &namespace, &name, &pod) { + return; + } + need_get = false; + } + + let params = WatchParams::default() + .timeout(DETAIL_WATCH_TIMEOUT_SECS) + .fields(&format!("metadata.name={name}")); + let watched = tokio::select! { + _ = cancel.changed() => return, + result = retry::bounded(api.watch(¶ms, &resource_version)) => result, + }; + let mut stream = match watched { + Ok(stream) => { + retry.watch_succeeded(); + Box::pin(stream) + } + Err(failure) => match failure.action(&mut retry) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => { + poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; + return; + } + }, + }; + + let mut relist = false; + loop { + tokio::select! { + _ = cancel.changed() => return, + _ = ticker.tick() => { + let usage = fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity).await; + if updates + .send(PodDetailUpdate::Usage { + namespace: namespace.clone(), + name: name.clone(), + result: usage, + }) + .is_err() + { + return; + } + } + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(pod) | WatchEvent::Modified(pod))) => { + retry.watch_succeeded(); + if let Some(version) = pod.resource_version() { + resource_version = version; + } + (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); + if !send_pod_detail(&updates, &namespace, &name, &pod) { + return; + } + } + Ok(Some(WatchEvent::Deleted(_))) => { + let _ = updates.send(PodDetailUpdate::Detail { + namespace, + name, + result: Err("This pod was deleted".to_string()), + }); + return; + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + retry.watch_succeeded(); + resource_version = bookmark.metadata.resource_version; + } + Ok(Some(WatchEvent::Error(status))) => { + relist = status.code == 410 + || status.reason == "Expired" + || status.reason == "Gone"; + match retry.failed_status(status.code) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel) + .await; + return; + } + } + } + Ok(None) => break, + Err(error) => match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(&mut cancel, delay).await { + return; + } + break; + } + RetryAction::GiveUp => { + poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; + return; + } + }, + } + } + } + + if relist { + need_get = true; + } + if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { return; } - }; - let detail = detail_from_pod(&fetched); - let (cpu_capacity, memory_capacity) = capacity_from_pod(&fetched); - if updates - .send(PodDetailUpdate::Detail { - namespace: namespace.clone(), - name: name.clone(), - result: Ok(Box::new(detail)), - }) - .is_err() - { - return; } +} +/// Watch is not permitted: keep both the object and its usage fresh with GETs. +async fn poll_pod_detail( + client: &Client, + namespace: &str, + name: &str, + updates: &mpsc::UnboundedSender, + cancel: &mut watch::Receiver, +) { + let api: Api = Api::namespaced(client.clone(), namespace); + let mut retry = Retry::new(); loop { let fetched = tokio::select! { _ = cancel.changed() => return, - result = fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity) => result, + result = api.get(name) => result, }; - if updates - .send(PodDetailUpdate::Usage { - namespace: namespace.clone(), - name: name.clone(), - result: fetched, - }) - .is_err() - { - return; + match fetched { + Ok(pod) => { + retry.list_succeeded(); + let (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); + if !send_pod_detail(updates, namespace, name, &pod) { + return; + } + let usage = + fetch_usage(client, namespace, name, cpu_capacity, memory_capacity).await; + if updates + .send(PodDetailUpdate::Usage { + namespace: namespace.to_string(), + name: name.to_string(), + result: usage, + }) + .is_err() + { + return; + } + } + Err(error) => { + if updates + .send(PodDetailUpdate::Detail { + namespace: namespace.to_string(), + name: name.to_string(), + result: Err(format!("Could not load pod details: {error}")), + }) + .is_err() + { + return; + } + match retry.failed(&error) { + RetryAction::Retry(delay) => { + if !sleep_or_cancel(cancel, delay).await { + return; + } + continue; + } + RetryAction::GiveUp => return, + } + } } - tokio::select! { - _ = cancel.changed() => return, - _ = tokio::time::sleep(METRICS_POLL_INTERVAL) => {} + if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { + return; } } } +fn send_pod_detail( + updates: &mpsc::UnboundedSender, + namespace: &str, + name: &str, + pod: &Pod, +) -> bool { + updates + .send(PodDetailUpdate::Detail { + namespace: namespace.to_string(), + name: name.to_string(), + result: Ok(Box::new(detail_from_pod(pod))), + }) + .is_ok() +} + +async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = cancel.changed() => false, + _ = tokio::time::sleep(duration) => true, + } +} + async fn fetch_usage( client: &Client, namespace: &str, diff --git a/src/repository/resources/watcher.rs b/src/repository/resources/watcher.rs index 27a00a1..8b781a9 100644 --- a/src/repository/resources/watcher.rs +++ b/src/repository/resources/watcher.rs @@ -4,7 +4,7 @@ //! [`ResourceStore`] and only publishes immutable snapshots to the UI, throttled //! to `FLUSH_INTERVAL` or every `FLUSH_AT` events, whichever comes first. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -39,10 +39,70 @@ use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; const FLUSH_INTERVAL: Duration = Duration::from_millis(50); const FLUSH_AT: usize = 256; +/// How often an automatically denied watch is retried. RBAC can change without +/// an app restart, but re-probing too often would spam the API server. +const WATCH_REPROBE_INTERVAL: Duration = Duration::from_secs(600); + +/// Streaming lists (`sendInitialEvents`) bounds. A server that ignores the +/// option never sends the initial-end bookmark, so an idle stream falls back to +/// the list path quickly. A busy initial stream gets longer, but is capped. +const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); +const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); + +/// Kubernetes version at which `sendInitialEvents` was introduced. 1.27 and +/// 1.28 may still have the WatchList feature gate disabled, so the stream is +/// still attempted and falls back to list if no initial-end bookmark arrives. +const STREAM_INITIAL_MIN_MINOR: u32 = 27; + +static STREAMING_SUPPORT: OnceLock>> = OnceLock::new(); + +fn streaming_support() -> &'static Mutex> { + STREAMING_SUPPORT.get_or_init(|| Mutex::new(HashMap::new())) +} + +async fn supports_streaming_initial(client: &Client, epoch: u64) -> bool { + if let Ok(cache) = streaming_support().lock() + && let Some(supported) = cache.get(&epoch) + { + return *supported; + } + + let supported = match retry::bounded(client.apiserver_version()).await { + Ok(info) => version_supports_streaming(&info.major, &info.minor), + Err(failure) => { + tracing::debug!(%failure, "repository: could not determine API server version"); + false + } + }; + if let Ok(mut cache) = streaming_support().lock() { + cache.insert(epoch, supported); + } + supported +} + +fn version_supports_streaming(major: &str, minor: &str) -> bool { + let major = major.parse::().unwrap_or(0); + let minor = minor + .trim_end_matches(|character: char| !character.is_ascii_digit()) + .parse::() + .unwrap_or(0); + major > 1 || (major == 1 && minor >= STREAM_INITIAL_MIN_MINOR) +} + +fn bookmark_ends_initial_events(annotations: &std::collections::BTreeMap) -> bool { + annotations + .get("k8s.io/initial-events-end") + .is_some_and(|value| value == "true") +} + /// List chunk size. Big kinds (thousands of pods/events) render their first /// chunk immediately instead of waiting for one huge response. const LIST_CHUNK_SIZE: u32 = 500; +/// Events are high-churn and grow without bound while the watch is open. +/// Keep the newest rows, matching Freelens' 1000-event initial list. +const EVENTS_LIMIT: usize = 1_000; + /// Kinds whose watch the API server denied (RBAC) for the current context /// epoch. New subscriptions for these kinds skip the doomed watch attempt and /// start polling right away, so changing the namespace filter does not reset @@ -96,6 +156,8 @@ pub async fn run( prefs, store: ResourceStore::new(), mode: WatchMode::Watch, + ready: false, + stream_initial_disabled: false, }; match kind { ResourceKind::Pods => task.run_namespaced::(scope, pod_item).await, @@ -196,6 +258,11 @@ struct WatchTask { prefs: watch::Receiver, store: ResourceStore, mode: WatchMode, + /// Whether a `Ready` snapshot has been published for this task. Used to + /// publish an empty first list without republishing unchanged poll results. + ready: bool, + /// Once streaming lists fall back, keep using the list path for this task. + stream_initial_disabled: bool, } /// Why the poll loop exited. @@ -209,13 +276,30 @@ enum PollOutcome { /// Outcome of a chunked list. enum ListOutcome { /// Full list published; watch from this resource version. - Listed(String), + Listed { + resource_version: String, + changed: bool, + }, /// Subscription cancelled mid-list. Cancelled, /// A chunk request failed or timed out; the caller decides whether to retry. Failed(RequestFailure), } +/// Outcome of attempting a Kubernetes streaming list. +enum InitialStream { + /// Initial events and the end-of-initial-events bookmark were received. + Listed { + resource_version: String, + changed: bool, + }, + /// Subscription cancelled. + Cancelled, + /// The server ignored/rejected streaming lists, the stream ended, or the + /// initial bookmark did not arrive in time. + Unsupported, +} + impl WatchTask { async fn run_namespaced(&mut self, scope: NamespaceScope, map: F) where @@ -282,28 +366,68 @@ impl WatchTask { } } if need_list { - match self.list_progressively(&api, map).await { - ListOutcome::Listed(version) => { - retry.list_succeeded(); - resource_version = version; - need_list = false; + let mut streamed = false; + if !self.stream_initial_disabled + && !self.ready + && self.store.len() == 0 + && !watch_denied(self.epoch, self.kind) + && supports_streaming_initial(&self.client, self.epoch).await + { + match self.stream_initial(&api, map).await { + InitialStream::Listed { + resource_version: version, + changed, + } => { + tracing::debug!( + changed, + "repository: initial state came from a streaming list" + ); + retry.list_succeeded(); + resource_version = version; + need_list = false; + streamed = true; + } + InitialStream::Cancelled => return, + InitialStream::Unsupported => { + tracing::debug!( + "repository: streaming list unsupported or timed out, falling back to list" + ); + self.stream_initial_disabled = true; + } } - ListOutcome::Cancelled => return, - ListOutcome::Failed(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: list failed, retrying"); - self.publish(SnapshotPhase::Error(format!("list failed: {failure}"))); - if !self.sleep(delay).await { + } + + if !streamed { + match self.list_progressively(&api, map).await { + ListOutcome::Listed { + resource_version: version, + .. + } => { + retry.list_succeeded(); + resource_version = version; + need_list = false; + } + ListOutcome::Cancelled => return, + ListOutcome::Failed(failure) => match failure.action(&mut retry) { + RetryAction::Retry(delay) => { + tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: list failed, retrying"); + self.publish(SnapshotPhase::Error(format!( + "list failed: {failure}" + ))); + if !self.sleep(delay).await { + return; + } + continue; + } + RetryAction::GiveUp => { + tracing::error!(%failure, ?api, "repository: list rejected, stopping watch"); + self.publish(SnapshotPhase::Error(format!( + "list rejected: {failure}" + ))); return; } - continue; - } - RetryAction::GiveUp => { - tracing::error!(%failure, ?api, "repository: list rejected, stopping watch"); - self.publish(SnapshotPhase::Error(format!("list rejected: {failure}"))); - return; - } - }, + }, + } } } @@ -456,6 +580,7 @@ impl WatchTask { } }, } + self.trim_high_churn(); if self.store.pending() >= FLUSH_AT { self.publish(SnapshotPhase::Ready); } @@ -474,11 +599,18 @@ impl WatchTask { } } + fn trim_high_churn(&mut self) -> bool { + if self.kind == ResourceKind::Events { + return self.store.trim_oldest(EVENTS_LIMIT); + } + false + } + fn publish(&mut self, phase: SnapshotPhase) { + self.ready = matches!(&phase, SnapshotPhase::Ready); let snapshot = Arc::new(self.store.snapshot(phase).with_mode(self.mode)); tracing::trace!( objects = self.store.len(), - revision = snapshot.revision(), "repository: publishing snapshot" ); self.tx.send_replace(snapshot); @@ -492,8 +624,131 @@ impl WatchTask { } } - /// List every object in chunks, publishing each chunk so large kinds - /// render progressively instead of waiting for one huge response. + /// Try Kubernetes 1.27+ streaming lists (`sendInitialEvents`) before the + /// chunked list path. Returns `Unsupported` for old API servers, disabled + /// feature gates, RBAC errors, or a stream that never emits the + /// `k8s.io/initial-events-end` bookmark. The caller then falls back to the + /// existing list+watch code. + async fn stream_initial(&mut self, api: &Api, map: F) -> InitialStream + where + K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, + F: Fn(&K) -> Item + Copy, + { + let params = WatchParams::streaming_lists().timeout(280); + let watched = tokio::select! { + _ = self.cancel.changed() => return InitialStream::Cancelled, + result = retry::bounded(api.watch(¶ms, "")) => result, + }; + let mut stream = match watched { + Ok(stream) => Box::pin(stream), + Err(failure) => { + tracing::debug!(%failure, ?api, "repository: streaming list setup failed"); + return InitialStream::Unsupported; + } + }; + + let mut resource_version = String::new(); + let mut changed = false; + let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; + let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + let mut ticker = tokio::time::interval(FLUSH_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + let deadline = total_deadline.min(idle_deadline); + tokio::select! { + _ = self.cancel.changed() => return InitialStream::Cancelled, + _ = ticker.tick() => { + if self.store.pending() > 0 { + self.publish(SnapshotPhase::Ready); + } + } + _ = tokio::time::sleep_until(deadline) => { + tracing::debug!( + ?api, + "repository: streaming list did not finish, falling back to list" + ); + return InitialStream::Unsupported; + } + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + let item = map(&object); + if !item.resource_version.is_empty() { + resource_version.clone_from(&item.resource_version); + } + changed |= self.store.upsert(item); + changed |= self.trim_high_churn(); + if self.store.pending() >= FLUSH_AT { + self.publish(SnapshotPhase::Ready); + } + } + Ok(Some(WatchEvent::Deleted(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + if let Some(version) = object.resource_version() { + resource_version = version; + } + changed |= self.store.remove(&ObjectKey::new( + object.namespace().unwrap_or_default(), + object.name_any(), + )); + changed |= self.trim_high_churn(); + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + let initial_end = + bookmark_ends_initial_events(&bookmark.metadata.annotations); + if !bookmark.metadata.resource_version.is_empty() { + resource_version = bookmark.metadata.resource_version; + } + if !initial_end { + tracing::debug!( + ?api, + "repository: streaming list bookmark was not the initial-events-end" + ); + return InitialStream::Unsupported; + } + if self.store.pending() > 0 || !self.ready { + self.publish(SnapshotPhase::Ready); + } + return InitialStream::Listed { + resource_version, + changed, + }; + } + Ok(Some(WatchEvent::Error(status))) => { + tracing::debug!( + code = status.code, + message = %status.message, + ?api, + "repository: streaming list error, falling back to list" + ); + return InitialStream::Unsupported; + } + Ok(None) => { + tracing::debug!( + ?api, + "repository: streaming list ended early, falling back to list" + ); + return InitialStream::Unsupported; + } + Err(error) => { + tracing::debug!( + %error, + ?api, + "repository: streaming list failed, falling back to list" + ); + return InitialStream::Unsupported; + } + } + } + } + } + + /// Merge every object in the list into the store, publishing each chunk so + /// large kinds render progressively. Objects missing from the completed + /// list are removed at the end; unchanged objects are left untouched. /// Returns the resource version to watch from. async fn list_progressively(&mut self, api: &Api, map: F) -> ListOutcome where @@ -502,29 +757,34 @@ impl WatchTask { { let mut params = ListParams::default().limit(LIST_CHUNK_SIZE); let mut resource_version = String::new(); - let mut first_chunk = true; + let mut changed = false; + self.store.begin_sync(); loop { let listed = tokio::select! { - _ = self.cancel.changed() => return ListOutcome::Cancelled, + _ = self.cancel.changed() => { + self.store.abort_sync(); + return ListOutcome::Cancelled; + } result = retry::bounded(api.list(¶ms)) => result, }; let chunk = match listed { Ok(chunk) => chunk, - Err(failure) => return ListOutcome::Failed(failure), + Err(failure) => { + self.store.abort_sync(); + return ListOutcome::Failed(failure); + } }; // Every chunk carries the version of the same list snapshot. if let Some(version) = chunk.metadata.resource_version { resource_version = version; } - if first_chunk { - self.store.reset(chunk.items.iter().map(map)); - first_chunk = false; - } else { - for object in &chunk.items { - self.store.upsert(map(object)); - } + for object in &chunk.items { + changed |= self.store.upsert(map(object)); + } + changed |= self.trim_high_churn(); + if self.store.pending() > 0 { + self.publish(SnapshotPhase::Ready); } - self.publish(SnapshotPhase::Ready); match chunk.metadata.continue_ { Some(token) if !token.is_empty() => { params = params.continue_token(&token); @@ -532,7 +792,14 @@ impl WatchTask { _ => break, } } - ListOutcome::Listed(resource_version) + changed |= self.store.finish_sync(); + if self.store.pending() > 0 || !self.ready { + self.publish(SnapshotPhase::Ready); + } + ListOutcome::Listed { + resource_version, + changed, + } } /// Poll instead of watching — because watch was denied (RBAC) or the user @@ -551,6 +818,10 @@ impl WatchTask { self.publish(SnapshotPhase::Ready); } let mut retry = Retry::new(); + let mut unchanged_polls: u32 = 0; + let mut reprobe = tokio::time::interval(WATCH_REPROBE_INTERVAL); + reprobe.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + reprobe.tick().await; loop { if self.cancel.has_changed().is_err() { @@ -558,8 +829,13 @@ impl WatchTask { } match self.list_progressively(api, map).await { - ListOutcome::Listed(_) => { + ListOutcome::Listed { changed, .. } => { retry.list_succeeded(); + if changed { + unchanged_polls = 0; + } else { + unchanged_polls = unchanged_polls.saturating_add(1); + } } ListOutcome::Cancelled => return PollOutcome::Stop, ListOutcome::Failed(failure) => match failure.action(&mut retry) { @@ -580,16 +856,20 @@ impl WatchTask { } // Wait for the next tick. Refresh or a preference change re-list - // immediately; `Off` waits without a timer. + // immediately; `Off` waits without a timer. Repeated unchanged + // polls back off up to one minute. let rate = self.prefs.borrow().rate; + let delay = poll_delay(rate, unchanged_polls); tokio::select! { _ = self.cancel.changed() => return PollOutcome::Stop, _ = self.refresh.changed() => { self.refresh.borrow_and_update(); + unchanged_polls = 0; } _ = self.prefs.changed() => { let prefs = *self.prefs.borrow_and_update(); self.mode = WatchMode::Poll(prefs.rate); + unchanged_polls = 0; if prefs.mode == WatchPreference::Watch { self.mode = WatchMode::Watch; self.publish(SnapshotPhase::Ready); @@ -599,12 +879,23 @@ impl WatchTask { // happens on the next loop turn. self.publish(SnapshotPhase::Ready); } + _ = reprobe.tick() => { + // Only an automatic denial is re-probed; an explicit + // polling preference is left alone. + if self.prefs.borrow().mode == WatchPreference::Watch + && watch_denied(self.epoch, self.kind) + { + tracing::info!(?api, "repository: re-probing previously denied watch"); + clear_watch_denied(self.epoch, self.kind); + self.mode = WatchMode::Watch; + self.publish(SnapshotPhase::Ready); + return PollOutcome::ResumeWatch; + } + } _ = async { - match rate { - PollRate::Every(secs) => { - tokio::time::sleep(Duration::from_secs(secs)).await - } - PollRate::Off => std::future::pending().await, + match delay { + Some(delay) => tokio::time::sleep(delay).await, + None => std::future::pending().await, } } => {} } @@ -612,6 +903,18 @@ impl WatchTask { } } +fn poll_delay(rate: PollRate, unchanged_polls: u32) -> Option { + match rate { + PollRate::Off => None, + PollRate::Every(secs) => { + let factor = 1u64 << unchanged_polls.min(3); + Some(Duration::from_secs( + secs.saturating_mul(factor).clamp(1, 60), + )) + } + } +} + fn base_item(object: &K) -> Item where K: ResourceExt, @@ -1189,4 +1492,49 @@ mod tests { clear_watch_denied(epoch, ResourceKind::Pods); assert!(!watch_denied(epoch, ResourceKind::Pods)); } + + #[test] + fn poll_backoff_doubles_until_the_cap() { + assert_eq!( + poll_delay(PollRate::Every(5), 0), + Some(Duration::from_secs(5)) + ); + assert_eq!( + poll_delay(PollRate::Every(5), 1), + Some(Duration::from_secs(10)) + ); + assert_eq!( + poll_delay(PollRate::Every(5), 2), + Some(Duration::from_secs(20)) + ); + assert_eq!( + poll_delay(PollRate::Every(5), 3), + Some(Duration::from_secs(40)) + ); + assert_eq!( + poll_delay(PollRate::Every(10), 4), + Some(Duration::from_secs(60)) + ); + assert_eq!(poll_delay(PollRate::Off, 3), None); + } + + #[test] + fn streaming_lists_require_kubernetes_1_27() { + assert!(!version_supports_streaming("1", "26")); + assert!(version_supports_streaming("1", "27")); + assert!(version_supports_streaming("1", "37")); + assert!(version_supports_streaming("1", "37+")); + assert!(version_supports_streaming("2", "0")); + assert!(!version_supports_streaming("0", "99")); + } + + #[test] + fn only_the_initial_events_bookmark_ends_the_stream() { + let mut annotations = std::collections::BTreeMap::new(); + assert!(!bookmark_ends_initial_events(&annotations)); + annotations.insert("k8s.io/initial-events-end".into(), "false".into()); + assert!(!bookmark_ends_initial_events(&annotations)); + annotations.insert("k8s.io/initial-events-end".into(), "true".into()); + assert!(bookmark_ends_initial_events(&annotations)); + } } diff --git a/src/repository/watch_prefs.rs b/src/repository/watch_prefs.rs index 89e2cc8..d5d4029 100644 --- a/src/repository/watch_prefs.rs +++ b/src/repository/watch_prefs.rs @@ -42,12 +42,13 @@ pub struct WatchPrefsStore { kinds: HashMap, } -/// The factory preference for a kind. Namespaces default to polling: many -/// clusters deny `watch` on them and they change rarely. Picking "Live watch" -/// in the UI saves and sticks across launches. +/// The factory preference for a kind. Namespaces, Events, and Leases default +/// to polling: many clusters deny `watch` on them and they are high-churn or +/// rarely change. Picking "Live watch" in the UI saves and sticks across +/// launches. fn default_prefs_for(kind: ResourceKind) -> WatchPrefs { match kind { - ResourceKind::Namespaces => WatchPrefs { + ResourceKind::Namespaces | ResourceKind::Events | ResourceKind::Leases => WatchPrefs { mode: WatchPreference::Poll, rate: PollRate::DEFAULT, }, @@ -153,15 +154,21 @@ mod tests { } #[test] - fn namespaces_default_to_polling_until_watch_is_chosen() { + fn high_churn_kinds_default_to_polling_until_watch_is_chosen() { let mut store = WatchPrefsStore::default(); - assert_eq!( - store.prefs_for(ResourceKind::Namespaces), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(5), - } - ); + for kind in [ + ResourceKind::Namespaces, + ResourceKind::Events, + ResourceKind::Leases, + ] { + assert_eq!( + store.prefs_for(kind), + WatchPrefs { + mode: WatchPreference::Poll, + rate: PollRate::Every(5), + } + ); + } // Choosing live watch is saved and sticks. store.update(ResourceKind::Namespaces, |prefs| { diff --git a/src/ui/resources/list.rs b/src/ui/resources/list.rs index 8118614..f6fcb61 100644 --- a/src/ui/resources/list.rs +++ b/src/ui/resources/list.rs @@ -123,6 +123,8 @@ impl ResourceListView { pub fn set_active(&mut self, active: bool, cx: &mut Context) { self.vm.set_active(active, cx); + self.detail + .update(cx, |detail, cx| detail.set_active(active, cx)); } pub fn close_detail(&mut self, cx: &mut Context) { diff --git a/src/ui/shell/mod.rs b/src/ui/shell/mod.rs index fe1336d..1b92177 100644 --- a/src/ui/shell/mod.rs +++ b/src/ui/shell/mod.rs @@ -620,7 +620,8 @@ impl Workspace { } /// Only the active tab of each visible group is live. Hidden tabs keep - /// their retained views but stop receiving watch updates until shown again. + /// their retained views and shared watch, but pause snapshot delivery and + /// detail/metrics work until shown again. fn sync_visibility(&mut self, cx: &mut Context) { let visible = self .shell diff --git a/src/view_models/resources/cluster_roles/mod.rs b/src/view_models/resources/cluster_roles/mod.rs index a2b9130..cad01bc 100644 --- a/src/view_models/resources/cluster_roles/mod.rs +++ b/src/view_models/resources/cluster_roles/mod.rs @@ -48,6 +48,10 @@ impl ClusterRolesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl ClusterRolesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl ClusterRolesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl ClusterRolesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl ClusterRolesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/config_maps/mod.rs b/src/view_models/resources/config_maps/mod.rs index 49be129..84b1834 100644 --- a/src/view_models/resources/config_maps/mod.rs +++ b/src/view_models/resources/config_maps/mod.rs @@ -44,6 +44,10 @@ impl ConfigMapsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -110,8 +114,26 @@ impl ConfigMapsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -149,6 +171,7 @@ impl ConfigMapsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -160,7 +183,6 @@ impl ConfigMapsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -171,12 +193,9 @@ impl ConfigMapsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/cron_jobs/mod.rs b/src/view_models/resources/cron_jobs/mod.rs index 7a948be..1e0e0c7 100644 --- a/src/view_models/resources/cron_jobs/mod.rs +++ b/src/view_models/resources/cron_jobs/mod.rs @@ -43,6 +43,10 @@ impl CronJobsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl CronJobsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -148,6 +170,7 @@ impl CronJobsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -159,7 +182,6 @@ impl CronJobsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -170,12 +192,9 @@ impl CronJobsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/custom.rs b/src/view_models/resources/custom.rs index 74ebb31..ec86f73 100644 --- a/src/view_models/resources/custom.rs +++ b/src/view_models/resources/custom.rs @@ -54,6 +54,10 @@ impl CustomResourceViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -116,8 +120,26 @@ impl CustomResourceViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl CustomResourceViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl CustomResourceViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl CustomResourceViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/daemon_sets/mod.rs b/src/view_models/resources/daemon_sets/mod.rs index 6d68e71..8b27628 100644 --- a/src/view_models/resources/daemon_sets/mod.rs +++ b/src/view_models/resources/daemon_sets/mod.rs @@ -44,6 +44,10 @@ impl DaemonSetsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -110,8 +114,26 @@ impl DaemonSetsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -149,6 +171,7 @@ impl DaemonSetsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -160,7 +183,6 @@ impl DaemonSetsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -171,12 +193,9 @@ impl DaemonSetsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/deployments/mod.rs b/src/view_models/resources/deployments/mod.rs index e3fe0c4..7318046 100644 --- a/src/view_models/resources/deployments/mod.rs +++ b/src/view_models/resources/deployments/mod.rs @@ -52,6 +52,10 @@ impl DeploymentsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -118,8 +122,26 @@ impl DeploymentsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -157,6 +179,7 @@ impl DeploymentsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -168,7 +191,6 @@ impl DeploymentsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -179,12 +201,9 @@ impl DeploymentsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/detail.rs b/src/view_models/resources/detail.rs index c8d2803..f9d56b5 100644 --- a/src/view_models/resources/detail.rs +++ b/src/view_models/resources/detail.rs @@ -11,6 +11,7 @@ use gpui_kit::Context; pub struct ResourceDetailViewModel { state: ResourceDetailState, epoch: u64, + active: bool, cancel: Option>, #[allow(dead_code)] // held for its Drop side-effect: cancels the GPUI observer cluster_sub: gpui_kit::Subscription, @@ -32,6 +33,7 @@ impl ResourceDetailViewModel { Self { state: ResourceDetailState::Closed, epoch, + active: true, cancel: None, cluster_sub, } @@ -41,6 +43,25 @@ impl ResourceDetailViewModel { &self.state } + /// Cancel the live detail stream while the owning tab is hidden, and + /// resume it without resetting the visible detail when the tab returns. + pub fn set_active(&mut self, active: bool, cx: &mut Context) { + if self.active == active { + return; + } + self.active = active; + if !active { + self.cancel_detail(); + return; + } + if let Some((kind, namespace, name)) = self.state.identity() { + let (kind, namespace, name) = (kind, namespace.to_string(), name.to_string()); + if self.cancel.is_none() { + self.subscribe(kind, namespace, name, cx); + } + } + } + pub fn open( &mut self, kind: ResourceKind, @@ -60,6 +81,18 @@ impl ResourceDetailViewModel { name: name.clone(), }; cx.notify(); + if self.active { + self.subscribe(kind, namespace, name, cx); + } + } + + fn subscribe( + &mut self, + kind: ResourceKind, + namespace: String, + name: String, + cx: &mut Context, + ) { let mut subscription = match repository::resources::detail::subscribe_detail(cx, kind, &namespace, &name) { Ok(subscription) => subscription, diff --git a/src/view_models/resources/endpoint_slices/mod.rs b/src/view_models/resources/endpoint_slices/mod.rs index ec0a9e8..566a6be 100644 --- a/src/view_models/resources/endpoint_slices/mod.rs +++ b/src/view_models/resources/endpoint_slices/mod.rs @@ -48,6 +48,10 @@ impl EndpointSlicesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl EndpointSlicesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl EndpointSlicesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl EndpointSlicesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl EndpointSlicesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/endpoints/mod.rs b/src/view_models/resources/endpoints/mod.rs index a1e4602..6e8cffb 100644 --- a/src/view_models/resources/endpoints/mod.rs +++ b/src/view_models/resources/endpoints/mod.rs @@ -43,6 +43,10 @@ impl EndpointsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl EndpointsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -148,6 +170,7 @@ impl EndpointsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -159,7 +182,6 @@ impl EndpointsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -170,12 +192,9 @@ impl EndpointsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/events/mod.rs b/src/view_models/resources/events/mod.rs index e0561f3..e8a5512 100644 --- a/src/view_models/resources/events/mod.rs +++ b/src/view_models/resources/events/mod.rs @@ -43,6 +43,10 @@ impl EventsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl EventsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -147,6 +169,7 @@ impl EventsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -158,7 +181,6 @@ impl EventsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -169,12 +191,9 @@ impl EventsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs index 90bcc0d..d1959c6 100644 --- a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs +++ b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs @@ -48,6 +48,10 @@ impl HorizontalPodAutoscalersViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl HorizontalPodAutoscalersViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -155,6 +177,7 @@ impl HorizontalPodAutoscalersViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -166,7 +189,6 @@ impl HorizontalPodAutoscalersViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -177,12 +199,9 @@ impl HorizontalPodAutoscalersViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/ingresses/mod.rs b/src/view_models/resources/ingresses/mod.rs index edf0627..0263e39 100644 --- a/src/view_models/resources/ingresses/mod.rs +++ b/src/view_models/resources/ingresses/mod.rs @@ -43,6 +43,10 @@ impl IngressesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl IngressesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -148,6 +170,7 @@ impl IngressesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -159,7 +182,6 @@ impl IngressesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -170,12 +192,9 @@ impl IngressesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/jobs/mod.rs b/src/view_models/resources/jobs/mod.rs index 32a77f5..350b6ac 100644 --- a/src/view_models/resources/jobs/mod.rs +++ b/src/view_models/resources/jobs/mod.rs @@ -43,6 +43,10 @@ impl JobsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl JobsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -147,6 +169,7 @@ impl JobsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -158,7 +181,6 @@ impl JobsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -169,12 +191,9 @@ impl JobsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/leases/mod.rs b/src/view_models/resources/leases/mod.rs index 642c0b8..c4169a1 100644 --- a/src/view_models/resources/leases/mod.rs +++ b/src/view_models/resources/leases/mod.rs @@ -43,6 +43,10 @@ impl LeasesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl LeasesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -147,6 +169,7 @@ impl LeasesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -158,7 +181,6 @@ impl LeasesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -169,12 +191,9 @@ impl LeasesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/namespaces/mod.rs b/src/view_models/resources/namespaces/mod.rs index f1d864f..bf4aa32 100644 --- a/src/view_models/resources/namespaces/mod.rs +++ b/src/view_models/resources/namespaces/mod.rs @@ -44,6 +44,10 @@ impl NamespacesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -110,8 +114,26 @@ impl NamespacesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -148,6 +170,7 @@ impl NamespacesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -159,7 +182,6 @@ impl NamespacesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -170,12 +192,9 @@ impl NamespacesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/network_policies/mod.rs b/src/view_models/resources/network_policies/mod.rs index e558817..7150378 100644 --- a/src/view_models/resources/network_policies/mod.rs +++ b/src/view_models/resources/network_policies/mod.rs @@ -48,6 +48,10 @@ impl NetworkPoliciesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl NetworkPoliciesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl NetworkPoliciesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl NetworkPoliciesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl NetworkPoliciesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/node/mod.rs b/src/view_models/resources/node/mod.rs index a3f6150..ed61fa1 100644 --- a/src/view_models/resources/node/mod.rs +++ b/src/view_models/resources/node/mod.rs @@ -54,6 +54,11 @@ impl NodesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + this.cancel_detail(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -134,8 +139,30 @@ impl NodesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } + if let Some(node) = self.current_detail_node(cx) { + self.attach_detail(node, cx); + } cx.notify(); + } else { + self.cancel_detail(); } } @@ -205,6 +232,13 @@ impl NodesViewModel { model.begin_detail(node.clone()); cx.notify(); }); + self.attach_detail(node, cx); + } + + /// Restart the detail stream without resetting the selected detail state, + /// used when a hidden tab becomes visible again. + fn attach_detail(&mut self, node: String, cx: &mut Context) { + self.cancel_detail(); let mut detail_sub = match repository::resources::nodes::subscribe_detail(cx, &node) { Ok(subscription) => subscription, Err(error) => { @@ -257,6 +291,7 @@ impl NodesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -268,7 +303,6 @@ impl NodesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -279,12 +313,9 @@ impl NodesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/overview.rs b/src/view_models/resources/overview.rs index 31fdd6f..7fae225 100644 --- a/src/view_models/resources/overview.rs +++ b/src/view_models/resources/overview.rs @@ -43,6 +43,11 @@ impl ClusterOverviewViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + this.cancel_metrics(); + return; + } this.subscribe(cx); this.subscribe_metrics(cx); }); @@ -86,14 +91,39 @@ impl ClusterOverviewViewModel { self.metrics_error.as_deref() } + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { let was_active = self.active.swap(active, Ordering::Relaxed); if was_active == active { return; } + if let Some(nodes) = &self.nodes { + nodes.set_paused(!active); + } + if let Some(pods) = &self.pods { + pods.set_paused(!active); + } if active { + if self.nodes.is_none() || self.pods.is_none() { + self.subscribe(cx); + self.subscribe_metrics(cx); + cx.notify(); + return; + } + if let Some(nodes) = &self.nodes { + self.node_snapshot = nodes.snapshot(); + } + if let Some(pods) = &self.pods { + self.pod_snapshot = pods.snapshot(); + } + self.subscribe_metrics(cx); self.recompute(); cx.notify(); + } else { + self.cancel_metrics(); } } @@ -130,9 +160,11 @@ impl ClusterOverviewViewModel { } }; if let Some(nodes) = &self.nodes { + nodes.set_paused(!self.is_active()); self.node_snapshot = nodes.snapshot(); } if let Some(pods) = &self.pods { + pods.set_paused(!self.is_active()); self.pod_snapshot = pods.snapshot(); } self.recompute(); @@ -141,7 +173,6 @@ impl ClusterOverviewViewModel { self.pump_cancel = Some(cancel); let mut nodes = self.nodes.clone(); let mut pods = self.pods.clone(); - let active = Arc::clone(&self.active); cx.spawn(async move |this, cx| { loop { tokio::select! { @@ -151,13 +182,10 @@ impl ClusterOverviewViewModel { nodes = None; continue; }; - let should_notify = active.load(Ordering::Relaxed); let applied = this.update(cx, |this, cx| { this.node_snapshot = snapshot; this.recompute(); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; @@ -168,13 +196,10 @@ impl ClusterOverviewViewModel { pods = None; continue; }; - let should_notify = active.load(Ordering::Relaxed); let applied = this.update(cx, |this, cx| { this.pod_snapshot = snapshot; this.recompute(); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; @@ -200,7 +225,20 @@ impl ClusterOverviewViewModel { self.pods = None; } + fn cancel_metrics(&mut self) { + if let Some(cancel) = self.metrics_cancel.take() { + let _ = cancel.send(true); + } + self.metrics.clear(); + self.metrics_error = None; + self.metrics_generation = self.metrics_generation.wrapping_add(1); + } + fn subscribe_metrics(&mut self, cx: &mut Context) { + if !self.is_active() { + self.cancel_metrics(); + return; + } if let Some(cancel) = self.metrics_cancel.take() { let _ = cancel.send(true); } diff --git a/src/view_models/resources/persistent_volume_claims/mod.rs b/src/view_models/resources/persistent_volume_claims/mod.rs index 7ce2e32..eef76f3 100644 --- a/src/view_models/resources/persistent_volume_claims/mod.rs +++ b/src/view_models/resources/persistent_volume_claims/mod.rs @@ -48,6 +48,10 @@ impl PersistentVolumeClaimsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl PersistentVolumeClaimsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -155,6 +177,7 @@ impl PersistentVolumeClaimsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -166,7 +189,6 @@ impl PersistentVolumeClaimsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -177,12 +199,9 @@ impl PersistentVolumeClaimsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/persistent_volumes/mod.rs b/src/view_models/resources/persistent_volumes/mod.rs index 008785d..7e9adeb 100644 --- a/src/view_models/resources/persistent_volumes/mod.rs +++ b/src/view_models/resources/persistent_volumes/mod.rs @@ -48,6 +48,10 @@ impl PersistentVolumesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl PersistentVolumesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -152,6 +174,7 @@ impl PersistentVolumesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -163,7 +186,6 @@ impl PersistentVolumesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -174,12 +196,9 @@ impl PersistentVolumesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/pod/mod.rs b/src/view_models/resources/pod/mod.rs index 1499138..1128dd6 100644 --- a/src/view_models/resources/pod/mod.rs +++ b/src/view_models/resources/pod/mod.rs @@ -57,6 +57,11 @@ impl PodsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + this.cancel_detail(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -137,8 +142,30 @@ impl PodsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } + if let Some((namespace, name)) = self.current_detail_identity(cx) { + self.attach_detail(namespace, name, cx); + } cx.notify(); + } else { + self.cancel_detail(); } } @@ -214,6 +241,13 @@ impl PodsViewModel { model.begin_detail(namespace.clone(), name.clone()); cx.notify(); }); + self.attach_detail(namespace, name, cx); + } + + /// Restart the detail stream without resetting the selected detail state, + /// used when a hidden tab becomes visible again. + fn attach_detail(&mut self, namespace: String, name: String, cx: &mut Context) { + self.cancel_detail(); let mut detail_sub = match repository::resources::pods::subscribe_detail(cx, &namespace, &name) { Ok(subscription) => subscription, @@ -266,6 +300,7 @@ impl PodsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -277,7 +312,6 @@ impl PodsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -288,12 +322,9 @@ impl PodsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/pod_disruption_budgets/mod.rs b/src/view_models/resources/pod_disruption_budgets/mod.rs index cb432a2..ef52404 100644 --- a/src/view_models/resources/pod_disruption_budgets/mod.rs +++ b/src/view_models/resources/pod_disruption_budgets/mod.rs @@ -48,6 +48,10 @@ impl PodDisruptionBudgetsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl PodDisruptionBudgetsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -155,6 +177,7 @@ impl PodDisruptionBudgetsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -166,7 +189,6 @@ impl PodDisruptionBudgetsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -177,12 +199,9 @@ impl PodDisruptionBudgetsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/replica_sets/mod.rs b/src/view_models/resources/replica_sets/mod.rs index d1cbfa8..87aeaa7 100644 --- a/src/view_models/resources/replica_sets/mod.rs +++ b/src/view_models/resources/replica_sets/mod.rs @@ -48,6 +48,10 @@ impl ReplicaSetsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl ReplicaSetsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl ReplicaSetsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl ReplicaSetsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl ReplicaSetsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/replication_controllers/mod.rs b/src/view_models/resources/replication_controllers/mod.rs index 0aeb064..0d46a2c 100644 --- a/src/view_models/resources/replication_controllers/mod.rs +++ b/src/view_models/resources/replication_controllers/mod.rs @@ -48,6 +48,10 @@ impl ReplicationControllersViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl ReplicationControllersViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -154,6 +176,7 @@ impl ReplicationControllersViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -165,7 +188,6 @@ impl ReplicationControllersViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -176,12 +198,9 @@ impl ReplicationControllersViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/role_bindings/mod.rs b/src/view_models/resources/role_bindings/mod.rs index d1b9ea2..f86597c 100644 --- a/src/view_models/resources/role_bindings/mod.rs +++ b/src/view_models/resources/role_bindings/mod.rs @@ -48,6 +48,10 @@ impl RoleBindingsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl RoleBindingsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl RoleBindingsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl RoleBindingsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl RoleBindingsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/roles/mod.rs b/src/view_models/resources/roles/mod.rs index d3b315b..07d22cc 100644 --- a/src/view_models/resources/roles/mod.rs +++ b/src/view_models/resources/roles/mod.rs @@ -43,6 +43,10 @@ impl RolesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl RolesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -147,6 +169,7 @@ impl RolesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -158,7 +181,6 @@ impl RolesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -169,12 +191,9 @@ impl RolesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/secrets/mod.rs b/src/view_models/resources/secrets/mod.rs index 03f6baa..c5cd142 100644 --- a/src/view_models/resources/secrets/mod.rs +++ b/src/view_models/resources/secrets/mod.rs @@ -43,6 +43,10 @@ impl SecretsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl SecretsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -147,6 +169,7 @@ impl SecretsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -158,7 +181,6 @@ impl SecretsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -169,12 +191,9 @@ impl SecretsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/service_accounts/mod.rs b/src/view_models/resources/service_accounts/mod.rs index e8e99ed..a7ce13e 100644 --- a/src/view_models/resources/service_accounts/mod.rs +++ b/src/view_models/resources/service_accounts/mod.rs @@ -48,6 +48,10 @@ impl ServiceAccountsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl ServiceAccountsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl ServiceAccountsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl ServiceAccountsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl ServiceAccountsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/services/mod.rs b/src/view_models/resources/services/mod.rs index 6f2e518..11a3c52 100644 --- a/src/view_models/resources/services/mod.rs +++ b/src/view_models/resources/services/mod.rs @@ -43,6 +43,10 @@ impl ServicesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -109,8 +113,26 @@ impl ServicesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -148,6 +170,7 @@ impl ServicesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -159,7 +182,6 @@ impl ServicesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -170,12 +192,9 @@ impl ServicesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/stateful_sets/mod.rs b/src/view_models/resources/stateful_sets/mod.rs index 4d6b653..741c40a 100644 --- a/src/view_models/resources/stateful_sets/mod.rs +++ b/src/view_models/resources/stateful_sets/mod.rs @@ -48,6 +48,10 @@ impl StatefulSetsViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl StatefulSetsViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -153,6 +175,7 @@ impl StatefulSetsViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -164,7 +187,6 @@ impl StatefulSetsViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -175,12 +197,9 @@ impl StatefulSetsViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; diff --git a/src/view_models/resources/storage_classes/mod.rs b/src/view_models/resources/storage_classes/mod.rs index 57781e3..00b188e 100644 --- a/src/view_models/resources/storage_classes/mod.rs +++ b/src/view_models/resources/storage_classes/mod.rs @@ -48,6 +48,10 @@ impl StorageClassesViewModel { return; } this.epoch = epoch; + if !this.is_active() { + this.cancel_pump(); + return; + } this.subscribe(cx); cx.notify(); }); @@ -114,8 +118,26 @@ impl StorageClassesViewModel { if was_active == active { return; } + if let Some(subscription) = &self.subscription { + subscription.set_paused(!active); + } if active { - cx.notify(); + if self.subscription.is_none() { + self.subscribe(cx); + return; + } + let snapshot = self + .subscription + .as_ref() + .map(|subscription| subscription.snapshot()); + if let Some(snapshot) = snapshot { + self.model.update(cx, |model, cx| { + model.apply_snapshot(snapshot); + cx.notify(); + }); + } else { + cx.notify(); + } } } @@ -152,6 +174,7 @@ impl StorageClassesViewModel { return; } }; + subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); self.model.update(cx, |model, cx| { @@ -163,7 +186,6 @@ impl StorageClassesViewModel { let (cancel, mut cancel_rx) = watch::channel(()); self.pump_cancel = Some(cancel); let model = self.model.downgrade(); - let active = Arc::clone(&self.active); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { loop { @@ -174,12 +196,9 @@ impl StorageClassesViewModel { let Some(snapshot) = snapshot else { break; }; - let should_notify = active.load(Ordering::Relaxed); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); - if should_notify { - cx.notify(); - } + cx.notify(); }); if applied.is_err() { break; From 857d9663b73dba820ba1df5694ae5d7363d95674 Mon Sep 17 00:00:00 2001 From: GT020 Date: Tue, 3 Nov 2026 18:12:16 +0530 Subject: [PATCH 2/8] perf: batch streaming initial events for large lists --- docs/watch-and-ui-updates-vs-freelens.md | 6 ++++-- src/repository/resources/watcher.rs | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/watch-and-ui-updates-vs-freelens.md b/docs/watch-and-ui-updates-vs-freelens.md index 42ea80d..9dfe7e9 100644 --- a/docs/watch-and-ui-updates-vs-freelens.md +++ b/docs/watch-and-ui-updates-vs-freelens.md @@ -174,8 +174,10 @@ the last `Subscription` cancels the background task. A context switch bumps `run_stream()` first tries a Kubernetes 1.27+ streaming list (`WatchParams::initial_events()`), which sends synthetic `ADDED` events followed by a `BOOKMARK` carrying `k8s.io/initial-events-end: "true"`. It applies those -events progressively, publishes on the normal 50 ms/256-event cadence, and -continues the regular watch from the bookmark's resource version. +events progressively, publishes on a bulk-load cadence (4,096 events or +200 ms, whichever comes first), and continues the regular watch from the +bookmark's resource version. The regular watch keeps the fresher 50 ms / +256-event cadence. Compatibility fallbacks: diff --git a/src/repository/resources/watcher.rs b/src/repository/resources/watcher.rs index 8b781a9..e4e2729 100644 --- a/src/repository/resources/watcher.rs +++ b/src/repository/resources/watcher.rs @@ -49,6 +49,13 @@ const WATCH_REPROBE_INTERVAL: Duration = Duration::from_secs(600); const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); +/// Streaming initial events are a bulk load. Publishing every 256 events makes +/// a 13k-object namespace rebuild and repaint the list ~50 times before the +/// user sees the final count. Larger batches keep the bulk phase cheap while +/// the bookmark still forces one final snapshot. +const STREAM_INITIAL_FLUSH_AT: usize = 4_096; +const STREAM_INITIAL_FLUSH_INTERVAL: Duration = Duration::from_millis(200); + /// Kubernetes version at which `sendInitialEvents` was introduced. 1.27 and /// 1.28 may still have the WatchList feature gate disabled, so the stream is /// still attempted and falls back to list if no initial-end bookmark arrives. @@ -651,7 +658,7 @@ impl WatchTask { let mut changed = false; let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let mut ticker = tokio::time::interval(FLUSH_INTERVAL); + let mut ticker = tokio::time::interval(STREAM_INITIAL_FLUSH_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { @@ -680,7 +687,7 @@ impl WatchTask { } changed |= self.store.upsert(item); changed |= self.trim_high_churn(); - if self.store.pending() >= FLUSH_AT { + if self.store.pending() >= STREAM_INITIAL_FLUSH_AT { self.publish(SnapshotPhase::Ready); } } From 57b246087af6dd00c29e6a245a6555fb9e862d7a Mon Sep 17 00:00:00 2001 From: GT020 Date: Tue, 3 Nov 2026 18:19:04 +0530 Subject: [PATCH 3/8] Revert "perf: batch streaming initial events for large lists" This reverts commit 857d9663b73dba820ba1df5694ae5d7363d95674. --- docs/watch-and-ui-updates-vs-freelens.md | 6 ++---- src/repository/resources/watcher.rs | 11 ++--------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/watch-and-ui-updates-vs-freelens.md b/docs/watch-and-ui-updates-vs-freelens.md index 9dfe7e9..42ea80d 100644 --- a/docs/watch-and-ui-updates-vs-freelens.md +++ b/docs/watch-and-ui-updates-vs-freelens.md @@ -174,10 +174,8 @@ the last `Subscription` cancels the background task. A context switch bumps `run_stream()` first tries a Kubernetes 1.27+ streaming list (`WatchParams::initial_events()`), which sends synthetic `ADDED` events followed by a `BOOKMARK` carrying `k8s.io/initial-events-end: "true"`. It applies those -events progressively, publishes on a bulk-load cadence (4,096 events or -200 ms, whichever comes first), and continues the regular watch from the -bookmark's resource version. The regular watch keeps the fresher 50 ms / -256-event cadence. +events progressively, publishes on the normal 50 ms/256-event cadence, and +continues the regular watch from the bookmark's resource version. Compatibility fallbacks: diff --git a/src/repository/resources/watcher.rs b/src/repository/resources/watcher.rs index e4e2729..8b781a9 100644 --- a/src/repository/resources/watcher.rs +++ b/src/repository/resources/watcher.rs @@ -49,13 +49,6 @@ const WATCH_REPROBE_INTERVAL: Duration = Duration::from_secs(600); const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); -/// Streaming initial events are a bulk load. Publishing every 256 events makes -/// a 13k-object namespace rebuild and repaint the list ~50 times before the -/// user sees the final count. Larger batches keep the bulk phase cheap while -/// the bookmark still forces one final snapshot. -const STREAM_INITIAL_FLUSH_AT: usize = 4_096; -const STREAM_INITIAL_FLUSH_INTERVAL: Duration = Duration::from_millis(200); - /// Kubernetes version at which `sendInitialEvents` was introduced. 1.27 and /// 1.28 may still have the WatchList feature gate disabled, so the stream is /// still attempted and falls back to list if no initial-end bookmark arrives. @@ -658,7 +651,7 @@ impl WatchTask { let mut changed = false; let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let mut ticker = tokio::time::interval(STREAM_INITIAL_FLUSH_INTERVAL); + let mut ticker = tokio::time::interval(FLUSH_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { @@ -687,7 +680,7 @@ impl WatchTask { } changed |= self.store.upsert(item); changed |= self.trim_high_churn(); - if self.store.pending() >= STREAM_INITIAL_FLUSH_AT { + if self.store.pending() >= FLUSH_AT { self.publish(SnapshotPhase::Ready); } } From ba413679cb94ba8f8b97e2d0d3a791ee0a650125 Mon Sep 17 00:00:00 2001 From: GT020 Date: Tue, 3 Nov 2026 20:20:55 +0530 Subject: [PATCH 4/8] docs: remove watch and UI comparison notes --- docs/watch-and-ui-updates-vs-freelens.md | 583 ----------------------- 1 file changed, 583 deletions(-) delete mode 100644 docs/watch-and-ui-updates-vs-freelens.md diff --git a/docs/watch-and-ui-updates-vs-freelens.md b/docs/watch-and-ui-updates-vs-freelens.md deleted file mode 100644 index 42ea80d..0000000 --- a/docs/watch-and-ui-updates-vs-freelens.md +++ /dev/null @@ -1,583 +0,0 @@ -# Live updates in krusty vs. Freelens - -How each app gets from "Kubernetes object changed" to "the right pixels changed", -and where the two designs differ. - -Reference checkouts: `krusty` and `../freelens` (Freelens is a Lens fork). - ---- - -## TL;DR - -| Concern | Freelens | krusty | -| --- | --- | --- | -| Transport | `fetch` streaming in the Electron renderer (`KubeApi.watch`) | `kube-rs` watcher on a dedicated tokio runtime | -| Initial load | Full list, one request per selected namespace, run in parallel | Kubernetes 1.27+ streaming list (`sendInitialEvents`) when supported; chunked list (`limit=500` + `continue`) as the fallback | -| Watch request | `?watch=1&resourceVersion=…&timeoutSeconds=600` per namespace | `Api::watch` with `timeout=280s`, bookmark events enabled | -| Reconnect | 1 s after normal stream end, 5 s after an error; `410` reloads then re-watches | 250 ms after stream end; soft backoff 0.5 s → 30 s; `410` relists | -| Batching | Observable event buffer flushed by a mobx `reaction` after **1 s** (events: 5 s) | Timer every **50 ms**, or immediately after **256** pending events | -| State container | mobx `observable.array` mutated/replaced in place | `BTreeMap` + UID index owned by the watch task, never by the UI | -| Hand-off to UI | The UI reads the same observable store directly | Immutable `Arc` sent over `tokio::sync::watch` | -| UI invalidation | mobx dependency tracking + `@observer`, React re-render | Entity update + `cx.notify()`, whole list entity re-rendered | -| List virtualization | `VirtualList` renders viewport rows | `gpui-component` `TableState` (`UniformListScrollHandle`) | -| Detail pane freshness | Reads the live store, so it reacts to watch updates | Generic, pod, and node details follow single-object watches; pod/node metrics are merged in every 10 s | -| Watch denied by RBAC | No poll fallback; watch retries every 5 s | Falls back to per-kind polling; preference persisted | -| Watch sharing | Ref-counted shared watch per store for selected namespaces | One task per `(context epoch, kind, scope)` | - -The headline difference: **Freelens optimizes for fine-grained reactive UI over a -1-second event batching window; krusty optimizes for a simple, immutable -snapshot boundary flushed on a 50 ms cadence (with immediate escalation on -bursts), plus explicit polling fallbacks.** - ---- - -## Freelens pipeline - -``` -React view mounts - └─ KubeWatchApi.subscribeStores() - ├─ store.loadAll() → list per namespace (Promise.allSettled) - │ → mergeItems() → observable items - └─ store.subscribe() - └─ KubeApi.watch(ns) → streaming newline-delimited JSON - └─ eventsBuffer.push(event) - │ mobx reaction, delay = 1000 ms - ▼ - updateFromEventsBuffer() - │ ADDED / MODIFIED / DELETED, keyed by UID - │ items.replace(...) only if something changed - ▼ - @observer list + VirtualList → DOM -``` - -### 1. Listing - -`KubeObjectStore.loadAll()` (`packages/core/src/common/k8s-api/kube-object.store.ts:276`) -delegates to `loadItems()` (`:202`): - -- Cluster-scoped or "all namespaces" load: one `api.list()`. -- Otherwise: one `api.list({ namespace })` per selected namespace, all in - parallel with `Promise.allSettled` (`:240`). -- `mergeItems()` (`:327`) keeps items from namespaces outside the requested - set, sorts, and replaces the observable array. -- Aborted lists are treated as unmounts, not failures (`:216`, `:312`). - -There is no chunking/`continue` support for normal stores. The events store is -the exception: it passes `limit: 1000` -(`packages/core/src/renderer/components/events/store.ts:32`). - -### 2. Watch - -`KubeApi.watch()` (`packages/utility-features/kube-api/src/kube-api.ts:813`): - -- Opens a streaming `fetch` to `getWatchUrl()` with `watch=1`, - `resourceVersion` for that namespace, and `timeoutSeconds` (default 600). -- Reads the body with a `ReadableStream` reader and reassembles newline-framed - JSON events (`:930–969`). -- A separate watchdog fires at `timeout * 1.1` and reconnects if the server did - not close the stream (`:851–877`). -- Stream end → reconnect after 1 s; error → reconnect after 5 s. `401`/`404` - stop retrying, `410` triggers `loadAll()` and then restarts the watch - (`kube-object.store.ts:550–595`). -- Every event updates the API object's per-namespace resource version - (`kube-api.ts:990–1005`). - -Watches are started only after the list has populated a resource version: -`watchNamespace()` returns early without one (`kube-object.store.ts:542`). - -### 3. Batching - -`bindWatchEventsUpdater(delay = 1000)` installs a mobx `reaction` over the -observable `eventsBuffer` (`kube-object.store.ts:480–490`). Events are -**accumulated, not dropped**, for up to a second; the events store overrides the -delay to 5 s. - -`updateFromEventsBuffer()` (`:602`): - -- Builds a UID → array-index map once per flush. -- `ADDED`/`MODIFIED`: skips an event whose `resourceVersion` equals the stored - object's; otherwise constructs a new `KubeObject`. -- `DELETED`: writes a tombstone and removes the UID from the index. -- Sorts only when an inserted item or a changed sort key requires it. -- Replaces `items` only if something actually changed, then trims the event - path to `bufferSize` (default 50,000). - -### 4. UI updates - -The list is a mobx `@observer`; `ItemListLayout.items` is a `@computed` that -applies filters over `store.getItems()` -(`packages/core/src/renderer/components/item-object-list/list-layout.tsx:283`). -`ItemListLayoutContent` feeds that array to `Table`/`VirtualList`, which only -renders rows in the viewport -(`packages/core/src/renderer/components/item-object-list/content.tsx:483–570`). - -Rows and detail components are themselves observers, so a replaced array causes -the list to re-render while individual row/detail reads stay dependency-tracked. - -The details drawer resolves the selected object through -`apiManager.getStore(path).getByPath(path)` inside an `asyncComputed` -(`packages/core/src/renderer/components/kube-object-details/current-kube-object-in-details.injectable.ts`). -Because `getByPath()` reads the observable `items` synchronously, the drawer -updates as watch events merge into the store. - -Metrics are separate polling loops: - -- Pods list: 10 s (`packages/core/src/renderer/components/workloads-pods/pods.tsx:32`). -- Pod details list: 120 s. -- Nodes: 30 s. - -### 5. Sharing and cleanup - -`KubeWatchApi` (`packages/core/src/renderer/kube-watch-api/kube-watch-api.ts`): - -- For the currently selected namespaces, one shared watch per store is - ref-counted across views (`:115–135`). -- Explicit `namespaces` gets its own watch and re-lists on namespace changes - (`:140–200`). -- Every subscriber gets an `AbortController`; unmount aborts the watch and - clears retry timers. - ---- - -## krusty pipeline - -``` -ViewModel.subscribe(kind, scope) - └─ Repository (global map keyed by epoch + kind + scope) - └─ WatchTask on the tokio runtime - ├─ list chunks (500 at a time) ──┐ - └─ watch stream ─── events ──────┤ - ▼ - ResourceStore - BTreeMap + UID index - │ publish: 50 ms ticker - │ or 256 pending events - ▼ - Arc (immutable) - │ tokio::watch (latest value only) - ▼ - ViewModel pump: model.apply_snapshot(); cx.notify() - ▼ - TableState / UniformList (virtualized) -``` - -### 1. Subscription sharing - -`Repository::subscribe()` (`src/repository/mod.rs`) keys a task on -`(context_epoch, ResourceKind, NamespaceScope)`. A second view with the same -key attaches to the same `watch::Sender`; the entry is held weakly so dropping -the last `Subscription` cancels the background task. A context switch bumps -`context_epoch`, so old tasks are orphaned and dropped with their views. - -### 2. Listing - -`run_stream()` first tries a Kubernetes 1.27+ streaming list -(`WatchParams::initial_events()`), which sends synthetic `ADDED` events followed -by a `BOOKMARK` carrying `k8s.io/initial-events-end: "true"`. It applies those -events progressively, publishes on the normal 50 ms/256-event cadence, and -continues the regular watch from the bookmark's resource version. - -Compatibility fallbacks: - -- API server version `< 1.27` → chunked list immediately. -- Watch setup rejected, a watch `ERROR`, an early stream end, or a bookmark - without the initial-events annotation → chunked list. -- No initial-end bookmark within 5 s idle / 60 s total → chunked list. -- A fallback disables streaming for that watch task, so old servers are probed - at most once per subscription. - -The chunked list path (`WatchTask::list_progressively()` in -`src/repository/resources/watcher.rs`) remains: - -- `ListParams::limit(500)` with `continue` tokens. -- First chunk calls `store.begin_sync()`; later chunks upsert. -- Publishes a snapshot after **every chunk**, so a huge list renders - progressively instead of waiting for the whole response. -- `finish_sync()` removes unseen objects and keeps the list's `resourceVersion` - to start the watch from. - -### 3. Watch loop - -`WatchTask::run_stream()` (`watcher.rs:256`): - -- `WatchParams::default().timeout(280)`; kube enables bookmark events by - default, and bookmarks advance the stored resource version. -- `Added`/`Modified` map the typed object to a compact `Item` and call - `store.upsert`; `Deleted` removes by `(namespace, name)`. -- `Error` with code 410 / reason `Expired`/`Gone` schedules a relist. -- Normal stream end resumes from the last resource version after 250 ms. -- Soft failures (network, 429, 5xx, 20 s request timeout) retry forever with - exponential backoff from 0.5 s to 30 s (`src/repository/retry.rs`). -- Hard failures (400/401/403/404/405/422) get one retry (two attempts total), - then the kind is - marked denied in a process-global registry and the task switches to polling. -- Bookmarked resource versions mean a reconnect does not replay the list. - -### 4. Batching and snapshots - -`ResourceStore` (`src/models/resources/store.rs:119`) owns: - -- `BTreeMap>` — sorted by `(namespace, name)` with no sort - pass on read. -- `HashMap` — O(1) replacement/removal even if metadata changes. -- `pending` counter for throttled flushes. - -`WatchTask::publish()` (`watcher.rs:477`) builds an immutable -`ResourceSnapshot { Arc<[Arc]>, phase, mode }` and calls -`watch::Sender::send_replace`. Publishing happens when: - -- a 50 ms ticker fires and anything is pending (`watcher.rs:367`), or -- 256 events have accumulated (`watcher.rs:459`), or -- a list chunk completed, or the mode/phase changed. - -`FLUSH_INTERVAL = 50ms`, `FLUSH_AT = 256`, `LIST_CHUNK_SIZE = 500` -(`watcher.rs:39–44`). - -Because `tokio::sync::watch` stores only the latest value, a slow consumer -coalesces intermediate snapshots — it never blocks the watch task. - -### 5. UI updates - -A view model (e.g. `src/view_models/resources/pod/mod.rs:254–320`) keeps the -`Subscription`, applies the initial snapshot to a model entity, then spawns a -GPUI task: - -```rust -while let Some(snapshot) = pump.next().await { - model.update(cx, |model, cx| { - model.apply_snapshot(snapshot); - if active.load(Ordering::Relaxed) { - cx.notify(); - } - }); -} -``` - -- Inactive tabs pause their `Subscription`; the pump stops receiving snapshots. - On activation the view model applies `subscription.snapshot()` immediately, - then resumes the pump, which also returns the latest coalesced snapshot. -- Rendering reads the snapshot through the table delegate (`rows_count`, - `render_td`, `cell_text`). `TableState` uses a `UniformListScrollHandle`, so - only viewport rows are built. -- A manual refresh bumps a generation on the task's `refresh` channel, which - forces a relist. - -### 6. Details - -- Generic detail panes call `subscribe_detail()` (`src/repository/resources/detail.rs`), - which GETs the object and then follows it with a single-object watch - (`fieldSelector=metadata.name=`). On `410` it re-GETs; if watch is - denied it falls back to a 5 s GET poll. The view model cancels this stream - while its tab is hidden. -- Pod and node details GET the object once and then follow it with the same - single-object watch used by generic details, while a 10 s timer fetches - metrics (`src/repository/resources/pods/mod.rs:455–512`, - `src/repository/resources/nodes/mod.rs`). Object fields and usage therefore - stay live together. -- The 10 s metrics sampling matches Freelens' pods-list cadence. - -### 7. Failure and preference handling - -- `WATCH_DENIED` (`watcher.rs:50–74`) remembers per epoch/kind that the API - server rejected watch, so a new subscription for the same kind starts polling - immediately instead of retrying a doomed watch. -- `WatchPreference` / `PollRate` are persisted per kind in - `~/.config/krusty/poll-rates.json` (`src/repository/watch_prefs.rs`). - `PollRate::Every(5)` is the fallback; `Off` disables the timer but manual - refresh still relists. -- The UI can switch a kind between live watch and poll at runtime; every - running task for that kind receives the new preference. - ---- - -## Stage-by-stage comparison - -| Stage | Freelens | krusty | -| --- | --- | --- | -| Network execution | Renderer process (Electron) | Tokio worker threads, separate from the GPUI UI thread | -| Initial list requests | One per selected namespace, parallel | One streaming watch on 1.27+; otherwise one streamed list per scope with sequential chunks | -| Initial list progress | Spinner until `loadAll` resolves | Streaming `ADDED` events or the first 500 list objects appear immediately | -| Resource version | Per-namespace map in `KubeApi` | One version per watch task, advanced by events/bookmarks | -| Watch granularity | One watch per namespace (or one global watch) | One watch for the whole scope | -| Watch timeout | 600 s + 1.1× client watchdog | 280 s | -| Reconnect delay | 1 s normal end, 5 s error | 250 ms normal end, exponential backoff on errors | -| Batch window | 1 s (events 5 s) | 50 ms / 256 events | -| Store mutation | In-place observable array; replace only on change | Background `BTreeMap`; publish immutable snapshot | -| Sort | Explicit sort when a sort key changes | `BTreeMap` keeps rows sorted, no sort pass | -| UID handling | Rebuild UID→index map per flush | Persistent UID→key map | -| UI granularity | Row/detail-level mobx observers | Whole list entity invalidated per snapshot | -| Background views | React components unmount or keep observing; watches are aborted on unmount | Snapshot delivery pauses and detail/metrics work stops; the shared watch remains open | -| Virtualization | `VirtualList` | `TableState` + `UniformListScrollHandle` | -| Detail freshness | Live from store | One-shot, except metrics | -| RBAC watch denial | Retries watch every 5 s indefinitely | Falls back to polling automatically | -| Manual poll mode | None for lists | Per-kind, persisted (`Every(n)` / `Off`) | -| Shared watch | Ref-counted per store for selected namespaces | Keyed per `(epoch, kind, scope)` | -| Cluster/namespace switch | Abort + reload + resubscribe | New subscription key; old task cancelled by drop | -| Coalescing vs. loss | Buffers every event for 1 s; may trim at 50k items | Latest snapshot wins; intermediate snapshots may be skipped | - ---- - -## Connection lifecycle on UI switches - -"Invalidated" here means the watch stream is aborted and returning to the view -requires a new list + watch. - -| UI action | Freelens | krusty | -| --- | --- | --- | -| Navigate between resource pages (sidebar or sibling tabs) | **Yes.** `ClusterFrameLayout` renders only `currentRouteComponent`; the old page unmounts, `KubeObjectListLayout.componentWillUnmount` disposes its subscriptions, and `KubeWatchApi` aborts the watch. Returning re-lists and re-watches. | **No.** Every opened tab keeps a `WorkspacePane` entity in `Workspace.panes`; switching calls `sync_visibility()`, which pauses snapshot delivery and detail/metrics work. The shared watch keeps running. | -| Switch a sub-tab within a page (`TabLayout` with routes) | **Yes.** The route changes, so the previous route component unmounts. | **No.** There is no route-level page swap; each tab owns its view. | -| Switch cluster tab (Hotbar / cluster frame) | **No.** `ClusterFrameHandler` keeps one iframe per cluster, hides it with CSS (`visibility: hidden`), and only changes the visible frame. Watches in hidden cluster frames keep running. | **Yes.** `switch_context()` keeps the old client while the new one builds, then swaps it and bumps `context_epoch`. Visible tabs resubscribe immediately; hidden tabs drop their subscription and reconnect on activation. | -| Close a workspace tab | N/A (there are no app-level page tabs; leaving the page is the row above). | **Yes**, but only when the last subscription for that `(epoch, kind, scope)` is dropped. | -| Close a cluster | Yes; the iframe is removed after disconnect, so its watchers die with it. | N/A (single active cluster). | -| Change the namespace filter | **Yes.** A mobx `reaction` aborts the controller, unsubscribes, then `loadAll()` + `subscribe()` for the new selection. | **Yes for that tab.** `set_scope()` drops the tab's subscription and creates a new key; a watch for the old key stays alive only if another tab still holds it. | -| Open/close the details pane | No effect on the list watch. The drawer uses the list store. | No effect on the list watch. The detail fetch has its own task; closing cancels it. | -| Window blur / minimize | No pause. Watches keep streaming. | No pause. The visible tab keeps updating; hidden tabs pause snapshot delivery and details. | -| Restore a saved workspace | N/A (routes are not restored as tabs). | Only the **active** tab of each group is instantiated at startup (`ensure_active_panes`); restored hidden tabs connect lazily when first focused. | - -Two practical consequences: - -1. **Freelens trades freshness for churn on navigation.** Every page visit is a - fresh list + watch. The shared-watch refcount only helps when multiple - mounted components use the same store; it does not preserve a watch across - route changes. -2. **krusty trades watch count for instant tab switches.** Once a tab has been - visited, it stays subscribed and only pauses snapshot delivery while hidden. - With many tabs open, this can still mean many concurrent watches. A cluster - switch drops hidden tabs' subscriptions; they reconnect lazily on activation - instead of all resubscribing at once. - ---- - -## Optimization opportunities in krusty - -Ordered by expected impact per unit of risk. Status is tracked here and updated -as each item lands. - -| # | Suggestion | Status | Where it lives | -| --- | --- | --- | --- | -| 1 | Live details / derive from snapshot | **Done** — generic, pod, and node detail panes follow a single-object watch, with metrics merged in for pods/nodes | `repository/resources/{detail,pods,nodes}.rs`, `view_models/resources/detail.rs` | -| 2 | Pause detail/metrics work for inactive tabs | **Done** | `view_models/resources/{pod,node,overview,detail}.rs` | -| 3 | Share per-object and metrics subscriptions | **Partial** — generic detail watchers are ref-counted per `(epoch, kind, namespace, name)`; pod/node detail and metrics remain per-tab | `repository/resources/detail.rs` | -| 4 | Stage cluster switches | **Done** | `cluster/mod.rs` | -| 5 | Explicit hidden-tab policy | **Done** — hidden tabs pause snapshot delivery, cancel detail/metrics, drop their subscription on a cluster switch, and reconnect lazily on activation | `repository/mod.rs`, `view_models/resources/*` | -| 6 | Diff lists instead of `reset` | **Done** | `models/resources/store.rs`, `repository/resources/watcher.rs` | -| 7 | Skip unchanged watch events | **Done** | `models/resources/store.rs` | -| 8 | No-op snapshot / notify suppression | **Done** | `models/resources/store.rs`, `repository/resources/watcher.rs`, `repository/mod.rs` | -| 9 | Bound high-churn kinds (events/leases) | **Done** — Events are capped at 1000 newest rows and Events/Leases default to polling | `models/resources/store.rs`, `repository/{resources/watcher,watch_prefs}.rs` | -| 10 | Adaptive poll backoff | **Done** — unchanged polls double from the configured rate up to 60 s and reset on change or manual refresh | `repository/resources/watcher.rs` | -| 11 | Persistent collection / cheap snapshot | **Dropped** — list diffing means snapshots rebuild only on real changes; no measured need for another collection dependency | — | -| 12 | Row-level invalidation | **Dropped** — GPUI retained-mode element diffing owns row/paint invalidation | — | -| 13 | Streaming lists (`sendInitialEvents`) | **Done** — Kubernetes 1.27+ fast path with version check, idle/total timeouts, and chunked-list fallback; a fallback disables retries for that task | `repository/resources/watcher.rs` | -| 14 | Adaptive flush cadence | **Dropped** — GPUI coalesces paints per frame; the 50 ms data flush is the only throttle needed | — | -| 15 | Measure mixed-scope overlap | **Done** — subscription create/reuse is traced with kind, scope, and active count | `repository/mod.rs` | -| 16 | Re-probe denied watches | **Done** — automatic RBAC denials are retried every 10 minutes; an explicit polling preference is never overridden | `repository/resources/watcher.rs` | - -### Boundary rules followed by these changes - -- `models/` owns `Item`, `ResourceStore`, diff/pending semantics, and - snapshot types. It performs no I/O and knows nothing about GPUI. -- `repository/` owns API calls, list/watch loops, retries, and delivery - semantics such as pausing a subscription. It publishes snapshots only. -- `view_models/` decides when a tab is active, applies snapshots to model - entities, and starts/cancels detail or metrics streams. It never renders. -- `ui/` only forwards lifecycle events (for example `ResourceListView::set_active`) - and renders state. No data-store or retry logic was added there. - -GPUI retained-mode cleanup: the model no longer carries a `revision` counter -and the view-model pumps no longer compute a `should_notify` flag. A real data -change updates the model and marks the entity dirty; GPUI diffs and paints. -Data-layer batching/pausing remains because it controls network and background -CPU, not painting. - -`cargo check` and `cargo test` pass (106 tests), including store diff/trim -tests, adaptive poll backoff, streaming-list version/bookmark gates, and a -paused-subscription delivery test. - -### P0 — correctness and obvious waste - -1. **Make details live or derive them from the list snapshot. — DONE** - `repository/resources/detail.rs` GETs the object, then follows it with a - single-object watch (`fieldSelector=metadata.name=`), relists on `410`, - and falls back to a 5 s GET poll when watch is denied. Pod and node details - now do the same while sharing the same select loop with their 10 s metrics - sampler; one watch keeps object fields current and the metrics timer updates - usage. - -2. **Pause detail and metrics work for inactive tabs. — DONE** - `set_active(false)` now pauses the list subscription's snapshot delivery and - cancels the detail stream (Pods, Nodes, and the generic detail pane). Pod and - node metrics stop with it. The overview cancels its node-metrics poller while - hidden and restarts it on activation. No snapshot or metrics work is applied - in the background. - -3. **Share per-object and metrics subscriptions. — PARTIAL** - Generic detail watchers are now ref-counted per - `(epoch, kind, namespace, name)` in `repository/resources/detail.rs`: one - watch feeds every subscriber through a broadcast channel, with the latest - update seeded to late subscribers. Pod/node detail and metrics still create - one stream per open tab; sharing those needs a separate typed - detail-plus-metrics subscription keyed the same way. - -4. **Stage cluster switches instead of blanking first. — DONE** - `switch_context()` keeps the old client, data, and epoch until - `spawn_client_build` succeeds. The successful build swaps `client` and bumps - `context_epoch` in one update, so views resubscribe once. A failed build - clears the client and bumps the epoch so the new context shows an error - instead of stale data from the previous cluster. - -5. **Make the hidden-tab policy explicit. — DONE** - Hidden tabs pause their subscription (`Subscription::set_paused`), cancel - detail and metrics work, and apply the latest snapshot once when activated. - On a cluster switch they discard their subscription instead of reconnecting; - `set_active(true)` notices the missing subscription and re-subscribes against - the current epoch. Visible tabs resubscribe immediately. - -### P1 — poll and relist efficiency - -6. **Diff lists instead of `reset`. — DONE** - `ResourceStore::begin_sync()` / `finish_sync()` merge a paginated list into - the existing maps: unchanged `(key, uid, resourceVersion)` entries keep their - `Arc`, new or changed entries are inserted, and keys absent from the - completed list are removed once. `list_progressively` uses this path, so a - poll with no changes produces no pending objects and no snapshot. An - interrupted list calls `abort_sync()` and never deletes unseen objects. - -7. **Skip unchanged watch events. — DONE** - `ResourceStore::upsert()` returns `false` and leaves `pending` and the UID - index untouched when the key, UID, and non-empty resource version match the - stored item. Duplicate `MODIFIED` events and repeated poll entries are - therefore free. - -8. **Do not publish or notify on a no-op snapshot. — DONE** - The watcher publishes a list chunk or final list only when `pending > 0` (or - when the first `Ready` phase still has to be emitted). Combined with the - paused subscription, a stable poll cycle produces no snapshot, no model - update, and no `cx.notify()`. - -9. **Bound high-churn kinds. — DONE** - `ResourceStore::trim_oldest()` removes the oldest `created_at` rows, and the - watcher applies a 1000-row cap to Events after each chunk and watch event. - Events and Leases now default to polling (5 s), matching the existing - Namespaces default; users can switch any kind to live watch and the choice - persists. Freelens only limits its initial events list; krusty bounds the - live list too. - -10. **Adaptive poll backoff. — DONE** - The poll loop counts consecutive unchanged lists and doubles the configured - interval (`5 s → 10 → 20 → 40 → 60 s`, capped). A changed list, manual - refresh, or preference change resets to the configured base rate. The UI - keeps showing the configured rate; the trace logs show the effective delay. - -### P2 — large-list rendering and hand-off - -11. **Persistent collection / cheap snapshot. — DROPPED** - After the list diff, a snapshot is rebuilt only when an object actually - changes, not on every 50 ms tick or every poll. A persistent collection - would add a dependency and change the snapshot API for no measured win; - revisit only if profiling shows the O(n) rebuild is hot during a real - change storm. - -12. **Row-level invalidation. — DROPPED** - GPUI is retained mode and diffs the element tree itself. The view models no - longer carry a `should_notify`/`revision` gate; a published snapshot updates - the model and marks the entity dirty, and GPUI decides what to repaint. - Manual per-row revision tracking would duplicate that machinery. - -13. **Use streaming lists where supported. — DONE** - `run_stream()` checks the API server version once per context epoch and, - for 1.27+, opens a watch with `initial_events()`. `ADDED`/`MODIFIED` events - are applied progressively; the `k8s.io/initial-events-end` bookmark ends the - initial phase and seeds the regular watch's resource version. Any rejection, - early end, non-initial bookmark, or 5 s idle / 60 s total timeout falls back - to the chunked list, then disables streaming for that task. Verified against - the kind cluster (`v1.37`), which returns the expected initial-end bookmark. - -14. **Adaptive flush cadence. — DROPPED** - GPUI already coalesces paints to its frame cadence. The 50 ms repository - flush limits data hand-off; adding a second render-oriented throttle would - duplicate the UI framework. - -15. **Measure mixed-scope overlap. — DONE** - `Repository::subscribe_kind` now logs whether a subscription was reused or - created, including kind, scope, and the active subscription count. That - makes duplicate `All` vs `One(ns)` watches visible without changing the - sharing policy. - -16. **Re-probe denied watches occasionally. — DONE** - An automatic watch denial now schedules a 10-minute re-probe from the poll - loop. It clears the denied marker and returns to the watch path; if the - watch is still denied it falls back to polling again. If the user explicitly - chose polling, `prefs.mode == Poll` keeps the re-probe disabled. - -### What is already good - -- One watch task per `(epoch, kind, scope)`, shared through `Arc`/`Weak`. -- Chunked initial lists with progressive rendering. -- `BTreeMap` ordering (no sort pass) plus a UID index. -- Immutable snapshots with O(1) clone and no lock shared with the UI. -- Soft/hard retry split and a real polling fallback when watch is denied. -- Streaming initial lists on Kubernetes 1.27+ with a chunked-list fallback. -- Hidden tabs pause snapshot delivery and detail/metrics work; the shared watch - stays open for instant activation. - ---- - -## Trade-offs - -### Where Freelens' design wins - -- **Fine-grained renders.** Replacing `items` does not force every reader to - recompute: mobx observers only repaint what they read. -- **Event completeness.** The 1 s buffer applies every event, so short-lived - objects (create → delete inside one second) are not lost in a snapshot - collapse. -- **Live details for free.** Because the drawer reads the same store as the - list, spec/status changes appear without a second request. -- **Per-namespace watches.** A namespace filter change affects only those - watches; already-watched namespaces keep streaming. - -### Where krusty's design wins - -- **No UI-thread network or mutation.** All watch and store work stays on tokio; - the UI only swaps an `Arc`. -- **Cheap hand-off.** Snapshot clone is O(1), and `BTreeMap` removes the sort - pass. -- **Progressive large lists.** The first 500 objects appear immediately; the - rest stream in. -- **Predictable degradation.** RBAC-denied watches become polling instead of a - retry loop; per-kind polling is user-visible and persisted. -- **Bounded latency.** A quiet feed is flushed within 50 ms, and bursts of 256 - events publish immediately rather than waiting for the next tick. Inactive - tabs cost no rendering. - ---- - -## File map - -### krusty - -- `src/repository/mod.rs` — subscription registry, sharing, watch preferences. -- `src/repository/resources/watcher.rs` — list/watch loop, batching, polling. -- `src/repository/retry.rs` — soft/hard retry policy, 20 s request bound. -- `src/repository/watch_prefs.rs` — persisted watch/poll preference. -- `src/models/resources/store.rs` — `ResourceStore`, snapshots, modes. -- `src/view_models/resources/pod/mod.rs` — snapshot pump, `cx.notify()` gating. -- `src/repository/resources/detail.rs` — one-shot generic detail fetch. -- `src/repository/resources/pods/mod.rs` — pod detail + 10 s metrics loop. - -### Freelens - -- `packages/utility-features/kube-api/src/kube-api.ts` — streaming watch, - retries, resource-version tracking. -- `packages/core/src/common/k8s-api/kube-object.store.ts` — list/merge, event - buffer, 1 s flush, store updates. -- `packages/core/src/renderer/kube-watch-api/kube-watch-api.ts` — shared and - per-view subscriptions, namespace reactions. -- `packages/core/src/renderer/components/item-object-list/list-layout.tsx` — - computed filtering over the store. -- `packages/core/src/renderer/components/item-object-list/content.tsx` — - virtualized table content. -- `packages/core/src/renderer/components/kube-object-details/current-kube-object-in-details.injectable.ts` - — live detail lookup from the store. -- `packages/core/src/renderer/components/workloads-pods/pods.tsx` — 10 s pod - metrics polling. From 78026501e34fc1d1a51ace58329b7b53370d3136 Mon Sep 17 00:00:00 2001 From: GT020 Date: Thu, 5 Nov 2026 16:35:30 +0530 Subject: [PATCH 5/8] refactor: share cluster stores across resource views Replace per-view list/watch subscriptions with a shared per-kind store engine, add polling fallback, store-backed details, credential capability probes, and repository-owned terminal sessions. Remove legacy watchers and per-kind subscription wrappers. --- src/cluster/mod.rs | 115 ++ src/models/cluster/mod.rs | 2 +- src/models/mod.rs | 2 +- src/models/resources/mod.rs | 2 +- src/models/resources/overview.rs | 44 +- src/models/resources/store.rs | 368 +--- src/repository/mod.rs | 175 +- src/repository/resources/cluster_roles/mod.rs | 11 - src/repository/resources/config_maps/mod.rs | 11 - src/repository/resources/cron_jobs/mod.rs | 11 - src/repository/resources/daemon_sets/mod.rs | 11 - src/repository/resources/detail.rs | 308 ++-- .../resources/endpoint_slices/mod.rs | 11 - src/repository/resources/endpoints/mod.rs | 11 - src/repository/resources/events/mod.rs | 11 - .../horizontal_pod_autoscalers/mod.rs | 11 - src/repository/resources/ingresses/mod.rs | 11 - src/repository/resources/jobs/mod.rs | 11 - src/repository/resources/leases/mod.rs | 11 - src/repository/resources/mod.rs | 26 - src/repository/resources/namespaces/mod.rs | 11 - .../resources/network_policies/mod.rs | 11 - src/repository/resources/nodes/mod.rs | 273 +-- .../resources/persistent_volume_claims/mod.rs | 11 - .../resources/persistent_volumes/mod.rs | 15 - .../resources/pod_disruption_budgets/mod.rs | 11 - src/repository/resources/pods/mod.rs | 517 ++---- src/repository/resources/replica_sets/mod.rs | 11 - .../resources/replication_controllers/mod.rs | 11 - src/repository/resources/role_bindings/mod.rs | 11 - src/repository/resources/roles/mod.rs | 11 - src/repository/resources/secrets/mod.rs | 11 - .../resources/service_accounts/mod.rs | 11 - src/repository/resources/services/mod.rs | 11 - src/repository/resources/stateful_sets/mod.rs | 11 - .../resources/storage_classes/mod.rs | 11 - src/repository/resources/watcher.rs | 1540 ----------------- src/repository/retry.rs | 59 +- src/repository/store/data.rs | 536 ++++++ src/repository/store/engine.rs | 1265 ++++++++++++++ src/repository/store/mapping.rs | 584 +++++++ src/repository/store/mod.rs | 119 ++ src/repository/store/plan.rs | 137 ++ src/repository/store/record.rs | 77 + src/repository/store/view.rs | 239 +++ src/repository/terminal.rs | 357 ++++ src/ui/resources/overview.rs | 50 +- src/ui/resources/pod/mod.rs | 7 + src/ui/resources/pod/sessions.rs | 2 +- .../resources/cluster_roles/mod.rs | 2 +- src/view_models/resources/config_maps/mod.rs | 2 +- src/view_models/resources/cron_jobs/mod.rs | 24 +- src/view_models/resources/daemon_sets/mod.rs | 2 +- .../resources/endpoint_slices/mod.rs | 2 +- src/view_models/resources/endpoints/mod.rs | 24 +- src/view_models/resources/events/mod.rs | 3 +- .../horizontal_pod_autoscalers/mod.rs | 3 +- src/view_models/resources/ingresses/mod.rs | 24 +- src/view_models/resources/jobs/mod.rs | 2 +- src/view_models/resources/leases/mod.rs | 3 +- src/view_models/resources/namespaces/mod.rs | 23 +- .../resources/network_policies/mod.rs | 2 +- src/view_models/resources/node/mod.rs | 22 + src/view_models/resources/overview.rs | 131 +- .../resources/persistent_volume_claims/mod.rs | 3 +- .../resources/persistent_volumes/mod.rs | 23 +- src/view_models/resources/pod/exec.rs | 81 +- .../resources/pod_disruption_budgets/mod.rs | 27 +- src/view_models/resources/replica_sets/mod.rs | 2 +- .../resources/replication_controllers/mod.rs | 28 +- .../resources/role_bindings/mod.rs | 2 +- src/view_models/resources/roles/mod.rs | 3 +- src/view_models/resources/secrets/mod.rs | 23 +- .../resources/service_accounts/mod.rs | 2 +- src/view_models/resources/services/mod.rs | 24 +- .../resources/stateful_sets/mod.rs | 2 +- .../resources/storage_classes/mod.rs | 23 +- 77 files changed, 4162 insertions(+), 3433 deletions(-) delete mode 100644 src/repository/resources/cluster_roles/mod.rs delete mode 100644 src/repository/resources/config_maps/mod.rs delete mode 100644 src/repository/resources/cron_jobs/mod.rs delete mode 100644 src/repository/resources/daemon_sets/mod.rs delete mode 100644 src/repository/resources/endpoint_slices/mod.rs delete mode 100644 src/repository/resources/endpoints/mod.rs delete mode 100644 src/repository/resources/events/mod.rs delete mode 100644 src/repository/resources/horizontal_pod_autoscalers/mod.rs delete mode 100644 src/repository/resources/ingresses/mod.rs delete mode 100644 src/repository/resources/jobs/mod.rs delete mode 100644 src/repository/resources/leases/mod.rs delete mode 100644 src/repository/resources/namespaces/mod.rs delete mode 100644 src/repository/resources/network_policies/mod.rs delete mode 100644 src/repository/resources/persistent_volume_claims/mod.rs delete mode 100644 src/repository/resources/persistent_volumes/mod.rs delete mode 100644 src/repository/resources/pod_disruption_budgets/mod.rs delete mode 100644 src/repository/resources/replica_sets/mod.rs delete mode 100644 src/repository/resources/replication_controllers/mod.rs delete mode 100644 src/repository/resources/role_bindings/mod.rs delete mode 100644 src/repository/resources/roles/mod.rs delete mode 100644 src/repository/resources/secrets/mod.rs delete mode 100644 src/repository/resources/service_accounts/mod.rs delete mode 100644 src/repository/resources/services/mod.rs delete mode 100644 src/repository/resources/stateful_sets/mod.rs delete mode 100644 src/repository/resources/storage_classes/mod.rs delete mode 100644 src/repository/resources/watcher.rs create mode 100644 src/repository/store/data.rs create mode 100644 src/repository/store/engine.rs create mode 100644 src/repository/store/mapping.rs create mode 100644 src/repository/store/mod.rs create mode 100644 src/repository/store/plan.rs create mode 100644 src/repository/store/record.rs create mode 100644 src/repository/store/view.rs create mode 100644 src/repository/terminal.rs diff --git a/src/cluster/mod.rs b/src/cluster/mod.rs index 29a9a7b..edcb5ee 100644 --- a/src/cluster/mod.rs +++ b/src/cluster/mod.rs @@ -3,6 +3,10 @@ pub mod kubeconfig; use std::time::Duration; use gpui_kit::{App, BorrowAppContext as _, Global}; +use k8s_openapi::api::authorization::v1::{ + ResourceAttributes, SelfSubjectAccessReview, SelfSubjectAccessReviewSpec, +}; +use kube::api::{Api, PostParams}; use kube::{Client, Config}; use tokio::sync::watch; @@ -44,6 +48,13 @@ pub struct ClusterState { store: kubeconfig::ConfigStore, reachable: Option, namespaces: Vec, + /// Whether the current credentials may watch across all namespaces. This + /// is the Freelens `isGlobalWatchEnabled` equivalent. + global_watch: bool, + /// Whether the current credentials may list across all namespaces. Polling + /// uses this when watch is denied, so a list-capable identity can still + /// keep the all-namespaces store warm. + global_list: bool, context_epoch: u64, namespace_cancel: Option>, /// Kept alive for the app lifetime: the selector's handle on the shared @@ -92,6 +103,14 @@ impl ClusterState { pub fn namespaces(&self) -> &[String] { &self.namespaces } + + pub fn global_watch(&self) -> bool { + self.global_watch + } + + pub fn global_list(&self) -> bool { + self.global_list + } } /// Wire the global before the first window opens. @@ -167,6 +186,8 @@ pub fn init(cx: &mut App) { store, reachable: None, namespaces: Vec::new(), + global_watch: true, + global_list: true, context_epoch: 0, namespace_cancel: None, namespace_subscription: None, @@ -324,6 +345,54 @@ async fn build_client( Ok(client) } +/// Ask the API server whether this identity may watch every namespace. +/// +/// This is the krusty equivalent of Freelens's +/// `canI({ verb: "watch", resource: "*" })` check. If the review itself cannot +/// be created we stay optimistic and let the store's error state report any +/// real denial; a missing authorization.k8s.io API should not disable the +/// normal all-namespace path. +async fn probe_global_access(client: &Client, verb: &str) -> bool { + let review = SelfSubjectAccessReview { + spec: SelfSubjectAccessReviewSpec { + resource_attributes: Some(ResourceAttributes { + verb: Some(verb.to_string()), + resource: Some("*".to_string()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + let api: Api = Api::all(client.clone()); + match api.create(&PostParams::default(), &review).await { + Ok(review) => { + let allowed = review_allows_watch(&review); + tracing::debug!(verb, allowed, "cluster: global capability probed"); + allowed + } + Err(error) => { + tracing::warn!(verb, %error, "cluster: global capability probe failed, staying optimistic"); + true + } + } +} + +async fn probe_capabilities(client: &Client) -> (bool, bool) { + let global_watch = probe_global_access(client, "watch").await; + let global_list = probe_global_access(client, "list").await; + (global_watch, global_list) +} + +fn review_allows_watch(review: &SelfSubjectAccessReview) -> bool { + review + .status + .as_ref() + .map(|status| status.allowed) + .unwrap_or(false) +} + /// Build the client for `context` in the background and publish it when ready. /// The epoch bump resubscribes every resource view; namespace polling starts /// here too. Stale builds (superseded by a newer switch) are discarded. @@ -338,9 +407,29 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { }; let context_id = context.id.clone(); let started = std::time::Instant::now(); + let probe_tokio = tokio.clone(); let building = tokio.spawn(build_client(kubeconfig, context.source, store)); cx.spawn(async move |cx: &mut gpui_kit::AsyncApp| { let built = building.await; + let (global_watch, global_list) = match &built { + Ok(Ok(client)) => { + // The capability probe performs HTTP requests and must run on + // the cluster runtime, not on GPUI's executor: hyper's connect + // timeout needs the Tokio reactor. + let client = client.clone(); + match probe_tokio + .spawn(async move { probe_capabilities(&client).await }) + .await + { + Ok(capabilities) => capabilities, + Err(error) => { + tracing::warn!(%error, "cluster: capability probe task failed"); + (true, true) + } + } + } + _ => (true, true), + }; cx.update(|cx| { let state = cx.global::(); if state.active_id() != Some(context_id.as_str()) { @@ -351,11 +440,15 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { Ok(Ok(client)) => { tracing::info!( context = %context_id, + global_watch, + global_list, elapsed_ms = started.elapsed().as_millis() as u64, "cluster: client ready" ); cx.update_global::(|state, _| { state.client = Some(client); + state.global_watch = global_watch; + state.global_list = global_list; state.context_epoch = state.context_epoch.wrapping_add(1); }); refresh_namespaces(cx); @@ -552,6 +645,28 @@ users: assert!(client.is_ok()); } + #[test] + fn global_watch_review_requires_an_allowed_status() { + let mut review = SelfSubjectAccessReview::default(); + assert!(!review_allows_watch(&review)); + + review.status = Some( + k8s_openapi::api::authorization::v1::SubjectAccessReviewStatus { + allowed: false, + ..Default::default() + }, + ); + assert!(!review_allows_watch(&review)); + + review.status = Some( + k8s_openapi::api::authorization::v1::SubjectAccessReviewStatus { + allowed: true, + ..Default::default() + }, + ); + assert!(review_allows_watch(&review)); + } + #[test] fn parses_inline_kubeconfig() { let runtime = tokio::runtime::Runtime::new().expect("runtime"); diff --git a/src/models/cluster/mod.rs b/src/models/cluster/mod.rs index f2fc37b..d930bda 100644 --- a/src/models/cluster/mod.rs +++ b/src/models/cluster/mod.rs @@ -1,6 +1,6 @@ //! Namespace filter shared by repositories and view-models. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum NamespaceScope { #[default] All, diff --git a/src/models/mod.rs b/src/models/mod.rs index fea9336..f9fa17c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,5 +10,5 @@ pub use resources::{ ConfigMapsModel, DaemonSetsModel, DeploymentsModel, IngressesModel, Item, JobsModel, NodesModel, ObjectKey, PersistentVolumeClaimsModel, PersistentVolumesModel, PodsModel, PollRate, ResourceDetail, ResourceDetailState, ResourceDetailUpdate, ResourceKind, - ResourceSnapshot, ResourceStore, SecretsModel, ServicesModel, SnapshotPhase, WatchMode, + ResourceSnapshot, SecretsModel, ServicesModel, SnapshotPhase, WatchMode, }; diff --git a/src/models/resources/mod.rs b/src/models/resources/mod.rs index 789cda6..b818cce 100644 --- a/src/models/resources/mod.rs +++ b/src/models/resources/mod.rs @@ -68,4 +68,4 @@ pub use service_accounts::ServiceAccountsModel; pub use services::ServicesModel; pub use stateful_sets::StatefulSetsModel; pub use storage_classes::StorageClassesModel; -pub use store::{ObjectKey, PollRate, ResourceSnapshot, ResourceStore, SnapshotPhase, WatchMode}; +pub use store::{ObjectKey, PollRate, ResourceSnapshot, SnapshotPhase, WatchMode}; diff --git a/src/models/resources/overview.rs b/src/models/resources/overview.rs index 8a5ce12..e6e47ef 100644 --- a/src/models/resources/overview.rs +++ b/src/models/resources/overview.rs @@ -17,14 +17,23 @@ pub enum PodHealth { } impl PodHealth { + /// Case-insensitive without allocating: this runs for every pod on every + /// overview refresh, so a `to_ascii_lowercase` allocation per pod adds up + /// quickly on large clusters. + #[inline] pub fn from_status(status: &str) -> Self { - match status.to_ascii_lowercase().as_str() { - "running" => Self::Running, - "pending" => Self::Pending, - "succeeded" => Self::Succeeded, - "failed" => Self::Failed, - "crashloopbackoff" => Self::CrashLoopBackOff, - _ => Self::Unknown, + if status.eq_ignore_ascii_case("running") { + Self::Running + } else if status.eq_ignore_ascii_case("pending") { + Self::Pending + } else if status.eq_ignore_ascii_case("succeeded") { + Self::Succeeded + } else if status.eq_ignore_ascii_case("failed") { + Self::Failed + } else if status.eq_ignore_ascii_case("crashloopbackoff") { + Self::CrashLoopBackOff + } else { + Self::Unknown } } @@ -76,7 +85,13 @@ impl ClusterOverviewStats { for pod in pods { stats.pod_restarts += pod.restarts; if !pod.node.is_empty() { - *stats.pods_by_node.entry(pod.node.clone()).or_default() += 1; + // Only clone the node name when a new node appears; a cluster + // with 10k pods schedules most of them on a few hundred nodes. + if let Some(count) = stats.pods_by_node.get_mut(&pod.node) { + *count += 1; + } else { + stats.pods_by_node.insert(pod.node.clone(), 1); + } } match PodHealth::from_status(&pod.status) { PodHealth::Running => stats.pods_running += 1, @@ -118,6 +133,19 @@ mod tests { Arc::new(item) } + #[test] + fn normalizes_pod_status_without_allocating() { + assert_eq!(PodHealth::from_status("Running"), PodHealth::Running); + assert_eq!( + PodHealth::from_status("CrashLoopBackOff"), + PodHealth::CrashLoopBackOff + ); + assert_eq!( + PodHealth::from_status("containercreating"), + PodHealth::Unknown + ); + } + #[test] fn summarizes_node_readiness() { let nodes = vec![item("worker-a", "ready"), item("worker-b", "notready")]; diff --git a/src/models/resources/store.rs b/src/models/resources/store.rs index 4f67ba6..96bc2f5 100644 --- a/src/models/resources/store.rs +++ b/src/models/resources/store.rs @@ -1,11 +1,8 @@ -//! Authoritative, incrementally maintained collection for one resource kind. +//! Shared snapshot types for table views. //! -//! The store is owned by the watch task (never by the UI thread). Objects live in -//! a `BTreeMap` ordered by `(namespace, name)`, so rows are stable and sorted -//! without a separate sort pass. A UID index lets deletes/updates find the prior -//! key in O(1) even if metadata changes. +//! The authoritative object store lives in `repository::store`. These types are +//! the small, UI-facing snapshot contract that every resource view consumes. -use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use super::Item; @@ -23,10 +20,6 @@ impl ObjectKey { name: name.into(), } } - - pub fn from_item(item: &Item) -> Self { - Self::new(item.namespace.clone(), item.name.clone()) - } } /// Immutable view of a store handed to the UI. Cloning a snapshot is O(1). @@ -91,9 +84,10 @@ impl ResourceSnapshot { } } - pub fn with_mode(mut self, mode: WatchMode) -> Self { - self.mode = mode; - self + /// Build a snapshot from an already-filtered item set. Used by the shared + /// store views so views can reuse the existing table/rendering contract. + pub fn from_items(items: Arc<[Arc]>, phase: SnapshotPhase, mode: WatchMode) -> Self { + Self { items, phase, mode } } pub fn items(&self) -> &[Arc] { @@ -108,351 +102,3 @@ impl ResourceSnapshot { self.mode } } - -/// Accumulates watch events; the repository flushes snapshots at a bounded rate. -pub struct ResourceStore { - objects: BTreeMap>, - by_uid: HashMap, - pending: usize, - /// Keys seen since [`begin_sync`](Self::begin_sync). Present only while a - /// list is being merged into the store. - sync_seen: Option>, -} - -impl Default for ResourceStore { - fn default() -> Self { - Self::new() - } -} - -impl ResourceStore { - pub fn new() -> Self { - Self { - objects: BTreeMap::new(), - by_uid: HashMap::new(), - pending: 0, - sync_seen: None, - } - } - - pub fn len(&self) -> usize { - self.objects.len() - } - - pub fn pending(&self) -> usize { - self.pending - } - - #[cfg(test)] - pub fn reset(&mut self, items: I) - where - I: IntoIterator, - { - self.objects.clear(); - self.by_uid.clear(); - self.sync_seen = None; - for item in items { - self.upsert(item); - } - self.pending = 0; - } - - /// Start merging a full list into the store. The old objects stay visible - /// until [`finish_sync`](Self::finish_sync), so a relist does not blank the - /// UI while its pages arrive. - pub fn begin_sync(&mut self) { - self.sync_seen = Some(HashSet::new()); - } - - /// Remove objects that were absent from the list started by - /// [`begin_sync`](Self::begin_sync). Returns whether anything was removed. - pub fn finish_sync(&mut self) -> bool { - let Some(seen) = self.sync_seen.take() else { - return false; - }; - let stale: Vec = self - .objects - .keys() - .filter(|key| !seen.contains(*key)) - .cloned() - .collect(); - let mut changed = false; - for key in stale { - changed |= self.remove(&key); - } - changed - } - - /// Abandon an interrupted list without deleting objects it did not reach. - pub fn abort_sync(&mut self) { - self.sync_seen = None; - } - - /// Insert or replace an object. Returns whether the store actually changed: - /// a repeated `(key, uid, resourceVersion)` is a no-op, which makes poll - /// relists and duplicate watch events cheap. - pub fn upsert(&mut self, item: Item) -> bool { - let key = ObjectKey::from_item(&item); - if let Some(seen) = &mut self.sync_seen { - seen.insert(key.clone()); - } - - if let Some(existing) = self.objects.get(&key) - && !item.resource_version.is_empty() - && existing.uid == item.uid - && existing.resource_version == item.resource_version - { - return false; - } - - let item = Arc::new(item); - if let Some(existing) = self.objects.get(&key) - && !existing.uid.is_empty() - && existing.uid != item.uid - { - self.by_uid.remove(&existing.uid); - } - if !item.uid.is_empty() - && let Some(previous) = self.by_uid.insert(item.uid.clone(), key.clone()) - && previous != key - { - self.objects.remove(&previous); - } - self.objects.insert(key, item); - self.pending = self.pending.saturating_add(1); - true - } - - pub fn remove(&mut self, key: &ObjectKey) -> bool { - let Some(item) = self.objects.remove(key) else { - return false; - }; - if !item.uid.is_empty() { - self.by_uid.remove(&item.uid); - } - self.pending = self.pending.saturating_add(1); - true - } - - /// Remove the oldest objects until at most `max_items` remain. Used for - /// high-churn kinds such as Events, where the API can retain thousands. - /// Returns whether anything was removed. - pub fn trim_oldest(&mut self, max_items: usize) -> bool { - if self.objects.len() <= max_items { - return false; - } - let mut by_age: Vec<(ObjectKey, i64)> = self - .objects - .iter() - .map(|(key, item)| (key.clone(), item.created_at.unwrap_or(i64::MIN))) - .collect(); - by_age.sort_by_key(|(_, created_at)| *created_at); - let remove = self.objects.len() - max_items; - let mut changed = false; - for (key, _) in by_age.into_iter().take(remove) { - changed |= self.remove(&key); - } - changed - } - - pub fn snapshot(&mut self, phase: SnapshotPhase) -> ResourceSnapshot { - self.pending = 0; - ResourceSnapshot { - items: self.objects.values().cloned().collect(), - phase, - mode: WatchMode::Watch, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pod(name: &str, namespace: &str, uid: &str) -> Item { - let mut item = Item::new(name.into(), Some(namespace.into())); - item.uid = uid.into(); - item - } - - fn versioned_pod(name: &str, namespace: &str, uid: &str, version: &str) -> Item { - let mut item = pod(name, namespace, uid); - item.resource_version = version.into(); - item - } - - #[test] - fn keeps_rows_sorted_and_replaces_by_key() { - let mut store = ResourceStore::new(); - store.upsert(pod("b", "ns-2", "1")); - store.upsert(pod("a", "ns-1", "2")); - store.upsert(pod("c", "ns-2", "3")); - - let mut updated = pod("b", "ns-2", "1"); - updated.status = "crashloopbackoff".into(); - store.upsert(updated); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["a", "b", "c"]); - assert_eq!(snapshot.items()[1].status, "crashloopbackoff"); - } - - #[test] - fn removes_by_key_and_uid() { - let mut store = ResourceStore::new(); - store.upsert(pod("a", "ns", "uid-a")); - store.remove(&ObjectKey::new("ns", "a")); - assert_eq!(store.len(), 0); - assert!(store.by_uid.is_empty()); - } - - #[test] - fn handles_150_namespaces_with_100_pods_each() { - let mut store = ResourceStore::new(); - for namespace in 0..150 { - for index in 0..100 { - store.upsert(pod( - &format!("pod-{index:03}"), - &format!("namespace-{namespace:03}"), - &format!("uid-{namespace}-{index}"), - )); - } - } - assert_eq!(store.len(), 15_000); - - let mut updated = pod("pod-050", "namespace-075", "uid-75-50"); - updated.status = "crashloopbackoff".into(); - store.upsert(updated); - store.remove(&ObjectKey::new("namespace-000", "pod-000")); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 14_999); - assert_eq!(snapshot.items()[0].namespace, "namespace-000"); - assert_eq!(snapshot.items()[0].name, "pod-001"); - assert!( - snapshot - .items() - .windows(2) - .all(|pair| ObjectKey::from_item(&pair[0]) <= ObjectKey::from_item(&pair[1])) - ); - } - - #[test] - fn reset_discards_objects_missing_from_a_relist() { - let mut store = ResourceStore::new(); - store.upsert(pod("a", "ns", "1")); - store.reset([pod("b", "ns", "2")]); - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 1); - assert_eq!(snapshot.items()[0].name, "b"); - } - - #[test] - fn upsert_skips_an_unchanged_resource_version() { - let mut store = ResourceStore::new(); - store.upsert(versioned_pod("a", "ns", "uid-a", "10")); - let _ = store.snapshot(SnapshotPhase::Ready); - - let mut same = versioned_pod("a", "ns", "uid-a", "10"); - same.status = "running".into(); - assert!(!store.upsert(same)); - assert_eq!(store.pending(), 0); - assert_eq!(store.snapshot(SnapshotPhase::Ready).items()[0].status, ""); - } - - #[test] - fn sync_keeps_unchanged_items_and_removes_absent_ones() { - let mut store = ResourceStore::new(); - store.reset([ - versioned_pod("a", "ns", "uid-a", "1"), - versioned_pod("b", "ns", "uid-b", "1"), - versioned_pod("c", "ns", "uid-c", "1"), - ]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); - assert!(store.upsert(versioned_pod("b", "ns", "uid-b", "2"))); - assert!(store.upsert(versioned_pod("d", "ns", "uid-d", "1"))); - store.finish_sync(); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["a", "b", "d"]); - } - - #[test] - fn an_unchanged_sync_does_not_bump_pending() { - let mut store = ResourceStore::new(); - store.reset([versioned_pod("a", "ns", "uid-a", "1")]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); - store.finish_sync(); - - assert_eq!(store.pending(), 0); - assert_eq!(store.snapshot(SnapshotPhase::Ready).items().len(), 1); - } - - #[test] - fn aborting_a_sync_does_not_delete_unseen_items() { - let mut store = ResourceStore::new(); - store.reset([ - versioned_pod("a", "ns", "uid-a", "1"), - versioned_pod("b", "ns", "uid-b", "1"), - ]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - store.upsert(versioned_pod("a", "ns", "uid-a", "2")); - store.abort_sync(); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 2); - } - - #[test] - fn removing_an_unknown_key_is_a_no_op() { - let mut store = ResourceStore::new(); - let _ = store.snapshot(SnapshotPhase::Ready); - assert!(!store.remove(&ObjectKey::new("ns", "missing"))); - assert_eq!(store.pending(), 0); - } - - #[test] - fn trim_oldest_removes_the_oldest_objects_first() { - let mut store = ResourceStore::new(); - for (index, created_at) in [(0, 10), (1, 30), (2, 20), (3, 40)] { - let mut item = versioned_pod( - &format!("event-{index}"), - "ns", - &format!("uid-{index}"), - "1", - ); - item.created_at = Some(created_at); - store.upsert(item); - } - let _ = store.snapshot(SnapshotPhase::Ready); - - assert!(store.trim_oldest(2)); - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["event-1", "event-3"]); - - assert!(!store.trim_oldest(2)); - assert_eq!(store.pending(), 0); - } - - #[test] - fn snapshots_carry_the_watch_mode() { - let mut store = ResourceStore::new(); - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.mode(), WatchMode::Watch); - let snapshot = snapshot.with_mode(WatchMode::Poll(PollRate::Every(5))); - assert_eq!(snapshot.mode(), WatchMode::Poll(PollRate::Every(5))); - assert_eq!(ResourceSnapshot::loading().mode(), WatchMode::Watch); - assert_eq!(ResourceSnapshot::failed("x").mode(), WatchMode::Watch); - } -} diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 9d964e4..738f7cb 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -4,58 +4,75 @@ pub mod custom_resources; pub mod resources; pub mod retry; +pub mod store; +pub mod terminal; pub mod watch_prefs; pub use watch_prefs::{WatchPreference, WatchPrefs}; +use std::any::Any; use std::collections::HashMap; -use std::sync::{Arc, Weak}; +use std::sync::Arc; use gpui_kit::{App, BorrowAppContext as _, Global}; use tokio::sync::watch; -use crate::cluster::ClusterState; -use crate::models::{NamespaceScope, PollRate, ResourceKind, ResourceSnapshot, WatchMode}; +use crate::models::{NamespaceScope, PollRate, ResourceKind, ResourceSnapshot}; -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct SubscriptionKey { - epoch: u64, - kind: ResourceKind, - scope: NamespaceScope, -} - -struct Entry { - snapshots: watch::Sender>, - _cancel: watch::Sender, - refresh: watch::Sender, - prefs: watch::Sender, +pub(crate) struct StoreEntry { + pub epoch: u64, + /// Held strongly for the cluster lifetime. The watch itself still stops + /// when the last subscriber leaves; only the cached data is retained. + pub store: Arc, } pub struct Repository { - entries: HashMap>, watch_prefs: watch_prefs::WatchPrefsStore, + pub(crate) stores: HashMap, + pub(crate) terminal: terminal::TerminalSessionStore, } impl Global for Repository {} pub fn init(cx: &mut App) { cx.set_global(Repository { - entries: HashMap::new(), watch_prefs: watch_prefs::WatchPrefsStore::load(), + stores: HashMap::new(), + terminal: terminal::TerminalSessionStore::default(), }); } -/// A live list/watch subscription. Dropping the last handle for a key cancels -/// the background watch task. +/// A live store subscription. Dropping the last handle releases the view and +/// stops the shared watch when no other view needs it. +/// +/// The source is intentionally generic: views consume the same snapshot API +/// regardless of which store feeds them. #[derive(Clone)] pub struct Subscription { rx: watch::Receiver>, paused: watch::Sender, paused_rx: watch::Receiver, - _entry: Arc, + refresh: Arc, + _keep_alive: Arc, } impl Subscription { + pub(crate) fn new( + rx: watch::Receiver>, + paused: watch::Sender, + paused_rx: watch::Receiver, + refresh: Arc, + keep_alive: Arc, + ) -> Self { + Self { + rx, + paused, + paused_rx, + refresh, + _keep_alive: keep_alive, + } + } + pub fn snapshot(&self) -> Arc { self.rx.borrow().clone() } @@ -108,9 +125,7 @@ impl Subscription { /// Ask the shared watch task to perform a fresh list. pub fn refresh(&self) { - self._entry.refresh.send_modify(|generation| { - *generation = generation.wrapping_add(1); - }); + (self.refresh)(); } } @@ -129,97 +144,12 @@ pub(crate) fn subscribe_kind( kind: ResourceKind, scope: NamespaceScope, ) -> Result { - let state = cx.global::(); - let client = state.client().ok_or_else(|| "not connected".to_string())?; - let tokio = state.tokio(); - let scope = if kind.is_namespaced() { - scope - } else { - NamespaceScope::All - }; - let key = SubscriptionKey { - epoch: state.context_epoch(), - kind, - scope: scope.clone(), - }; - - Ok(cx.update_global::(|repository, _| { - repository - .entries - .retain(|_, entry| entry.strong_count() > 0); - - if let Some(entry) = repository.entries.get(&key).and_then(Weak::upgrade) { - tracing::trace!( - ?kind, - ?scope, - active = repository.entries.len(), - "repository: subscription reused" - ); - let (paused, paused_rx) = watch::channel(false); - return Subscription { - rx: entry.snapshots.subscribe(), - paused, - paused_rx, - _entry: entry, - }; - } - - let mut initial_prefs = repository.watch_prefs.prefs_for(kind); - if initial_prefs.mode == WatchPreference::Watch - && resources::watcher::watch_denied(key.epoch, kind) - { - // Watch is known to be denied for this kind: go straight to - // polling instead of re-trying the doomed watch on every - // namespace filter change. The saved preference stays `Watch`. - initial_prefs.mode = WatchPreference::Poll; - } - let initial_mode = match initial_prefs.mode { - WatchPreference::Watch => WatchMode::Watch, - WatchPreference::Poll => WatchMode::Poll(initial_prefs.rate), - }; - - let (snapshots, rx) = watch::channel(Arc::new( - ResourceSnapshot::loading().with_mode(initial_mode), - )); - let (cancel, cancel_rx) = watch::channel(false); - let (refresh, refresh_rx) = watch::channel(0u64); - let (prefs, prefs_rx) = watch::channel(initial_prefs); - let entry = Arc::new(Entry { - snapshots: snapshots.clone(), - _cancel: cancel, - refresh, - prefs, - }); - let scope_log = scope.clone(); - let watch_task = resources::watcher::run( - client, kind, scope, key.epoch, snapshots, cancel_rx, refresh_rx, prefs_rx, - ); - tokio.spawn(watch_task); - repository.entries.insert(key, Arc::downgrade(&entry)); - tracing::debug!( - ?kind, - scope = ?scope_log, - active = repository.entries.len(), - "repository: subscription created" - ); - let (paused, paused_rx) = watch::channel(false); - Subscription { - rx, - paused, - paused_rx, - _entry: entry, - } - })) + store::subscribe(cx, kind, scope) } /// Set whether a resource kind is watched live or polled. The saved poll rate /// is kept. Applies to every running watch task for the kind and is saved. pub fn set_watch_preference(cx: &mut App, kind: ResourceKind, mode: WatchPreference) { - if mode == WatchPreference::Watch { - // Explicit request: forget any RBAC denial so the watch is retried. - let epoch = cx.global::().context_epoch(); - resources::watcher::clear_watch_denied(epoch, kind); - } update_watch_prefs(cx, kind, |prefs| prefs.mode = mode); } @@ -233,20 +163,14 @@ pub fn set_poll_rate(cx: &mut App, kind: ResourceKind, rate: PollRate) { } fn update_watch_prefs(cx: &mut App, kind: ResourceKind, change: impl FnOnce(&mut WatchPrefs)) { - let epoch = cx.global::().context_epoch(); cx.update_global::(|repository, _| { repository.watch_prefs.update(kind, change); if let Err(error) = repository.watch_prefs.save() { tracing::warn!(%error, "repository: failed to save watch prefs"); } let prefs = repository.watch_prefs.prefs_for(kind); - for (key, entry) in &repository.entries { - if key.kind == kind - && key.epoch == epoch - && let Some(entry) = entry.upgrade() - { - entry.prefs.send_replace(prefs); - } + if let Some(entry) = repository.stores.get(&kind) { + entry.store.set_prefs(prefs); } }); } @@ -261,22 +185,9 @@ mod tests { #[tokio::test] async fn a_paused_subscription_coalesces_until_resumed() { let (snapshots, rx) = watch::channel(Arc::new(ResourceSnapshot::loading())); - let (cancel, _) = watch::channel(false); - let (refresh, _) = watch::channel(0u64); - let (prefs, _) = watch::channel(WatchPrefs::default()); - let entry = Arc::new(Entry { - snapshots: snapshots.clone(), - _cancel: cancel, - refresh, - prefs, - }); let (paused, paused_rx) = watch::channel(false); - let mut subscription = Subscription { - rx, - paused, - paused_rx, - _entry: entry, - }; + let refresh: Arc = Arc::new(|| {}); + let mut subscription = Subscription::new(rx, paused, paused_rx, refresh, Arc::new(())); subscription.set_paused(true); snapshots.send_replace(Arc::new(ResourceSnapshot::failed("offline"))); diff --git a/src/repository/resources/cluster_roles/mod.rs b/src/repository/resources/cluster_roles/mod.rs deleted file mode 100644 index ffe232d..0000000 --- a/src/repository/resources/cluster_roles/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ClusterRoles: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ClusterRoles stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ClusterRoles, scope) -} diff --git a/src/repository/resources/config_maps/mod.rs b/src/repository/resources/config_maps/mod.rs deleted file mode 100644 index a7bab02..0000000 --- a/src/repository/resources/config_maps/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ConfigMaps: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ConfigMaps stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ConfigMaps, scope) -} diff --git a/src/repository/resources/cron_jobs/mod.rs b/src/repository/resources/cron_jobs/mod.rs deleted file mode 100644 index 873479c..0000000 --- a/src/repository/resources/cron_jobs/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for CronJobs: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live CronJobs stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::CronJobs, scope) -} diff --git a/src/repository/resources/daemon_sets/mod.rs b/src/repository/resources/daemon_sets/mod.rs deleted file mode 100644 index d0f09eb..0000000 --- a/src/repository/resources/daemon_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for DaemonSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live DaemonSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::DaemonSets, scope) -} diff --git a/src/repository/resources/detail.rs b/src/repository/resources/detail.rs index e8b76a9..fecf673 100644 --- a/src/repository/resources/detail.rs +++ b/src/repository/resources/detail.rs @@ -2,24 +2,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock, Weak}; -use std::time::Duration; -use futures::TryStreamExt as _; -use kube::ResourceExt; -use kube::api::{Api, DynamicObject, WatchEvent, WatchParams}; +use kube::api::{Api, DynamicObject}; use kube::core::{ApiResource, GroupVersionKind}; use serde_json::Value; use tokio::sync::{broadcast, watch}; use crate::cluster::ClusterState; -use crate::models::ResourceKind; use crate::models::resources::detail::{ResourceDetail, ResourceDetailUpdate}; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::models::{ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); -const DETAIL_POLL_INTERVAL: Duration = Duration::from_secs(5); const DETAIL_BROADCAST_CAPACITY: usize = 8; #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -86,7 +79,10 @@ pub fn subscribe_detail( let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); - let key = DetailKey { + let key = ObjectKey::new(namespace, name); + let initial = crate::repository::store::cached_object(cx, kind, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, kind); + let detail_key = DetailKey { epoch: state.context_epoch(), kind, namespace: namespace.to_string(), @@ -98,7 +94,7 @@ pub fn subscribe_detail( .map_err(|_| "detail registry is poisoned".to_string())?; registry.retain(|_, entry| entry.strong_count() > 0); - if let Some(entry) = registry.get(&key).and_then(Weak::upgrade) { + if let Some(entry) = registry.get(&detail_key).and_then(Weak::upgrade) { return Ok(subscription_for(entry)); } @@ -116,11 +112,13 @@ pub fn subscribe_detail( kind, namespace.to_string(), name.to_string(), + initial, + snapshots, updates, latest, cancel_rx, )); - registry.insert(key, Arc::downgrade(&entry)); + registry.insert(detail_key, Arc::downgrade(&entry)); drop(registry); let (sub_cancel, sub_cancel_rx) = watch::channel(false); @@ -146,39 +144,84 @@ fn subscription_for(entry: Arc) -> ResourceDetailSubscription { } } +#[allow(clippy::too_many_arguments)] async fn watch_detail( client: kube::Client, kind: ResourceKind, namespace: String, name: String, + initial: Option>, + mut snapshots: Option>>, updates: broadcast::Sender, latest: Arc>>, mut cancel: watch::Receiver, ) { - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; + let key = ObjectKey::new(&namespace, &name); + let mut last_version = String::new(); + let mut saw_store_object = false; + let mut initial_get_done = false; + + if let Some(record) = initial.as_ref() { + last_version.clone_from(&record.resource_version); + if !send_record_detail(&updates, &latest, kind, &namespace, &name, record) { + return; + } + saw_store_object = true; + initial_get_done = true; + } loop { - let api = api_for(&client, kind, &namespace); - if need_get { + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if record.resource_version != last_version { + last_version.clone_from(&record.resource_version); + if !send_record_detail(&updates, &latest, kind, &namespace, &name, record) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + } else if saw_store_object && matches!(snapshot.phase(), SnapshotPhase::Ready) { + publish( + &updates, + &latest, + ResourceDetailUpdate { + kind, + namespace: namespace.clone(), + name: name.clone(), + result: Err(format!("{} {} was deleted", kind.label(), name)), + }, + ); + return; + } + } + + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + initial_get_done = true; + let api = api_for(&client, kind, &namespace); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&name) => result, }; match fetched { Ok(object) => { - retry.list_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { return; } - need_get = false; } Err(error) => { - if !publish( + publish( &updates, &latest, ResourceDetailUpdate { @@ -190,202 +233,54 @@ async fn watch_detail( kind.label() )), }, - ) { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } + ); + return; } } } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={name}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - publish( - &updates, - &latest, - ResourceDetailUpdate { - kind, - namespace: namespace.clone(), - name: name.clone(), - result: Err(format!("{} {} was deleted", kind.label(), name)), - }, - ); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; - return; - } - }, - } } } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } } } -/// Watch was denied (or the object is not watchable): keep the detail fresh -/// with a low-frequency GET instead of hammering the watch endpoint. -async fn poll_detail( - client: &kube::Client, +fn send_record_detail( + updates: &broadcast::Sender, + latest: &Arc>>, kind: ResourceKind, namespace: &str, name: &str, - updates: &broadcast::Sender, - latest: &Arc>>, - cancel: &mut watch::Receiver, -) { - let mut retry = Retry::new(); - let api = api_for(client, kind, namespace); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(name) => result, - }; - match fetched { - Ok(object) => { - retry.list_succeeded(); - if !send_detail(updates, latest, kind, namespace, name, &object) { - return; - } - } - Err(error) => { - if !publish( - updates, - latest, - ResourceDetailUpdate { - kind, - namespace: namespace.to_string(), - name: name.to_string(), - result: Err(format!("Could not load {} details: {error}", kind.label())), - }, - ) { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, DETAIL_POLL_INTERVAL).await { - return; - } - } + record: &crate::repository::store::KubeObjectRecord, +) -> bool { + let mut value = record.to_json(); + sanitize_secret(kind, &mut value); + publish( + updates, + latest, + ResourceDetailUpdate { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + result: Ok(Box::new(ResourceDetail { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + raw_json: serde_json::to_string_pretty(&value).unwrap_or_default(), + value, + })), + }, + ) } fn fmt_detail( @@ -432,13 +327,6 @@ fn publish( updates.send(update).is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - fn api_for(client: &kube::Client, kind: ResourceKind, namespace: &str) -> Api { let resource = api_resource(kind); if kind.is_namespaced() { diff --git a/src/repository/resources/endpoint_slices/mod.rs b/src/repository/resources/endpoint_slices/mod.rs deleted file mode 100644 index 83ef98d..0000000 --- a/src/repository/resources/endpoint_slices/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for EndpointSlices: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live EndpointSlices stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::EndpointSlices, scope) -} diff --git a/src/repository/resources/endpoints/mod.rs b/src/repository/resources/endpoints/mod.rs deleted file mode 100644 index b87ed04..0000000 --- a/src/repository/resources/endpoints/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Endpoints: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Endpoints stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Endpoints, scope) -} diff --git a/src/repository/resources/events/mod.rs b/src/repository/resources/events/mod.rs deleted file mode 100644 index 7ebc246..0000000 --- a/src/repository/resources/events/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Events: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Events stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Events, scope) -} diff --git a/src/repository/resources/horizontal_pod_autoscalers/mod.rs b/src/repository/resources/horizontal_pod_autoscalers/mod.rs deleted file mode 100644 index f3a8d46..0000000 --- a/src/repository/resources/horizontal_pod_autoscalers/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for HorizontalPodAutoscalers: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live HorizontalPodAutoscalers stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::HorizontalPodAutoscalers, scope) -} diff --git a/src/repository/resources/ingresses/mod.rs b/src/repository/resources/ingresses/mod.rs deleted file mode 100644 index e2ef347..0000000 --- a/src/repository/resources/ingresses/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Ingresses: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Ingresses stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Ingresses, scope) -} diff --git a/src/repository/resources/jobs/mod.rs b/src/repository/resources/jobs/mod.rs deleted file mode 100644 index d79e1a9..0000000 --- a/src/repository/resources/jobs/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Jobs: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Jobs stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Jobs, scope) -} diff --git a/src/repository/resources/leases/mod.rs b/src/repository/resources/leases/mod.rs deleted file mode 100644 index 2ffa238..0000000 --- a/src/repository/resources/leases/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Leases: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Leases stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Leases, scope) -} diff --git a/src/repository/resources/mod.rs b/src/repository/resources/mod.rs index bf1748e..6f013e8 100644 --- a/src/repository/resources/mod.rs +++ b/src/repository/resources/mod.rs @@ -1,32 +1,6 @@ //! Repository streams for Kubernetes resources. -pub mod cluster_roles; -pub mod config_maps; -pub mod cron_jobs; -pub mod daemon_sets; pub mod deployments; pub mod detail; -pub mod endpoint_slices; -pub mod endpoints; -pub mod events; -pub mod horizontal_pod_autoscalers; -pub mod ingresses; -pub mod jobs; -pub mod leases; -pub mod namespaces; -pub mod network_policies; pub mod nodes; -pub mod persistent_volume_claims; -pub mod persistent_volumes; -pub mod pod_disruption_budgets; pub mod pods; -pub mod replica_sets; -pub mod replication_controllers; -pub mod role_bindings; -pub mod roles; -pub mod secrets; -pub mod service_accounts; -pub mod services; -pub mod stateful_sets; -pub mod storage_classes; -pub mod watcher; diff --git a/src/repository/resources/namespaces/mod.rs b/src/repository/resources/namespaces/mod.rs deleted file mode 100644 index 8f21372..0000000 --- a/src/repository/resources/namespaces/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Namespaces: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Namespaces stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind(cx, ResourceKind::Namespaces, NamespaceScope::All) -} diff --git a/src/repository/resources/network_policies/mod.rs b/src/repository/resources/network_policies/mod.rs deleted file mode 100644 index ba76fd2..0000000 --- a/src/repository/resources/network_policies/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for NetworkPolicies: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live NetworkPolicies stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::NetworkPolicies, scope) -} diff --git a/src/repository/resources/nodes/mod.rs b/src/repository/resources/nodes/mod.rs index df63642..625fed1 100644 --- a/src/repository/resources/nodes/mod.rs +++ b/src/repository/resources/nodes/mod.rs @@ -1,11 +1,11 @@ //! Repository for Nodes: live list/watch plus one-shot detail and metrics reads. use std::collections::BTreeMap; +use std::sync::Arc; use std::time::Duration; -use futures::TryStreamExt as _; use k8s_openapi::api::core::v1::Node; -use kube::api::{Api, DynamicObject, ListParams, WatchEvent, WatchParams}; +use kube::api::{Api, DynamicObject, ListParams}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; use tokio::sync::{mpsc, watch}; @@ -15,15 +15,12 @@ use crate::models::resources::node::detail::{ NodeAddress, NodeCondition, NodeDetail, NodeDetailUpdate, NodeImage, NodeSystemInfo, NodeTaint, NodeUsage, }; -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Nodes stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -141,199 +138,137 @@ async fn fetch_all_usage(client: &Client) -> Result, Ok(usages) } -/// Start loading a node's full description and live usage samples. +/// Start loading a node's full description from the shared store, with one +/// `GET` fallback when the object is not cached. Usage samples remain on the +/// existing metrics-server cadence. pub fn subscribe_detail(cx: &mut App, node: &str) -> Result { let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); + let key = ObjectKey::new("", node); + let initial = crate::repository::store::cached_object(cx, ResourceKind::Nodes, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, ResourceKind::Nodes); let (updates_tx, updates) = mpsc::unbounded_channel(); let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(watch_node_detail( + tokio.spawn(watch_node_detail_from_store( client, node.to_string(), + initial, + snapshots, updates_tx, cancel_rx, )); Ok(NodeDetailSubscription { updates, cancel }) } -async fn watch_node_detail( +#[allow(clippy::too_many_arguments)] +async fn watch_node_detail_from_store( client: Client, node: String, + initial: Option>, + mut snapshots: Option>>, updates: mpsc::UnboundedSender, mut cancel: watch::Receiver, ) { - let api: Api = Api::all(client.clone()); - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; + let key = ObjectKey::new("", &node); + let mut last_version = String::new(); let mut cpu_capacity = None; let mut memory_capacity = None; + let mut saw_store_object = false; + let mut initial_get_done = false; let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + if let Some(record) = initial.as_ref() + && let Some(object) = record.typed::() + { + last_version.clone_from(&record.resource_version); + let detail = detail_from_node(object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + loop { - if need_get { + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if let Some(object) = record.typed::() + && record.resource_version != last_version + { + last_version.clone_from(&record.resource_version); + let detail = detail_from_node(object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + } else if saw_store_object && matches!(snapshot.phase(), SnapshotPhase::Ready) { + let _ = updates.send(NodeDetailUpdate::Detail { + node: node.clone(), + result: Err("This node was deleted".to_string()), + }); + return; + } + } + + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + initial_get_done = true; + let api: Api = Api::all(client.clone()); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&node) => result, }; - let object = match fetched { - Ok(object) => object, + match fetched { + Ok(object) => { + let detail = detail_from_node(&object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + } Err(error) => { let _ = updates.send(NodeDetailUpdate::Detail { - node, + node: node.clone(), result: Err(format!("Could not load node details: {error}")), }); return; } - }; - retry.list_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; } - let detail = detail_from_node(&object); - (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(&updates, &node, detail) { - return; - } - need_get = false; } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={node}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - _ = ticker.tick() => { - let usage = fetch_usage(&client, &node, cpu_capacity, memory_capacity).await; - if updates - .send(NodeDetailUpdate::Usage { - node: node.clone(), - result: usage, - }) - .is_err() - { - return; - } - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - let detail = detail_from_node(&object); - (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(&updates, &node, detail) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - let _ = updates.send(NodeDetailUpdate::Detail { - node, - result: Err("This node was deleted".to_string()), - }); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; - return; - } - }, - } } - } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } - } -} - -/// Watch is not permitted: keep both the object and its usage fresh with GETs. -async fn poll_node_detail( - client: &Client, - node: &str, - updates: &mpsc::UnboundedSender, - cancel: &mut watch::Receiver, -) { - let api: Api = Api::all(client.clone()); - let mut retry = Retry::new(); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(node) => result, - }; - match fetched { - Ok(object) => { - retry.list_succeeded(); - let detail = detail_from_node(&object); - let (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(updates, node, detail) { - return; - } - let usage = fetch_usage(client, node, cpu_capacity, memory_capacity).await; + _ = ticker.tick() => { + let usage = fetch_usage(&client, &node, cpu_capacity, memory_capacity).await; if updates .send(NodeDetailUpdate::Usage { - node: node.to_string(), + node: node.clone(), result: usage, }) .is_err() @@ -341,29 +276,6 @@ async fn poll_node_detail( return; } } - Err(error) => { - if updates - .send(NodeDetailUpdate::Detail { - node: node.to_string(), - result: Err(format!("Could not load node details: {error}")), - }) - .is_err() - { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { - return; } } } @@ -395,13 +307,6 @@ fn send_node_detail( .is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - async fn fetch_usage( client: &Client, node: &str, diff --git a/src/repository/resources/persistent_volume_claims/mod.rs b/src/repository/resources/persistent_volume_claims/mod.rs deleted file mode 100644 index e9d68bb..0000000 --- a/src/repository/resources/persistent_volume_claims/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for PersistentVolumeClaims: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PersistentVolumeClaims stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::PersistentVolumeClaims, scope) -} diff --git a/src/repository/resources/persistent_volumes/mod.rs b/src/repository/resources/persistent_volumes/mod.rs deleted file mode 100644 index 6b3c0b6..0000000 --- a/src/repository/resources/persistent_volumes/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Repository for PersistentVolumes: subscribe to the live list/watch stream. - -use crate::models::ResourceKind; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PersistentVolumes stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind( - cx, - ResourceKind::PersistentVolumes, - crate::models::NamespaceScope::All, - ) -} diff --git a/src/repository/resources/pod_disruption_budgets/mod.rs b/src/repository/resources/pod_disruption_budgets/mod.rs deleted file mode 100644 index cc379ec..0000000 --- a/src/repository/resources/pod_disruption_budgets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for PodDisruptionBudgets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PodDisruptionBudgets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::PodDisruptionBudgets, scope) -} diff --git a/src/repository/resources/pods/mod.rs b/src/repository/resources/pods/mod.rs index 32b87a6..38c8185 100644 --- a/src/repository/resources/pods/mod.rs +++ b/src/repository/resources/pods/mod.rs @@ -1,18 +1,16 @@ //! Repository for Pods: live list/watch plus one-shot detail and metrics reads. use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use std::time::Duration; -use futures::{AsyncBufReadExt, SinkExt, TryStreamExt}; +use futures::{AsyncBufReadExt, TryStreamExt}; use k8s_openapi::api::core::v1::{ Container, ContainerState, ContainerStatus, EnvVar, Pod, PodStatus, Toleration, Volume, }; -use kube::api::{ - Api, AttachParams, DynamicObject, LogParams, TerminalSize, WatchEvent, WatchParams, -}; +use kube::api::{Api, DynamicObject, LogParams}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; use crate::cluster::ClusterState; @@ -20,15 +18,13 @@ use crate::models::resources::pod::detail::{ PodCondition, PodContainer, PodDetail, PodDetailUpdate, PodOwner, PodToleration, PodUsage, PodVolume, }; -use crate::models::{NamespaceScope, ResourceKind}; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::repository::retry::{Retry, RetryAction}; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Pods stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -222,424 +218,170 @@ async fn watch_logs( } } -/// An interactive TTY exec session for one pod container. -pub struct PodExecSession { - updates: mpsc::UnboundedReceiver, String>>, - input: mpsc::UnboundedSender>, - resize: mpsc::UnboundedSender<(u16, u16)>, - cancel: watch::Sender, -} - -impl PodExecSession { - pub async fn next(&mut self) -> Option, String>> { - self.updates.recv().await - } - - /// Clone the input channel so the UI can keep sending after the session - /// itself has been moved into a background task. - pub fn input_handle(&self) -> mpsc::UnboundedSender> { - self.input.clone() - } - - /// Notify the remote TTY about a new size in columns and rows. - pub fn resize_handle(&self) -> mpsc::UnboundedSender<(u16, u16)> { - self.resize.clone() - } - - pub fn cancel_handle(&self) -> watch::Sender { - self.cancel.clone() - } -} - -impl Drop for PodExecSession { - fn drop(&mut self) { - let _ = self.cancel.send(true); - } +/// Read the cached container names without creating a subscription or a +/// network request. Returns `None` when the pod is not cached yet. +pub fn cached_containers(cx: &App, namespace: &str, name: &str) -> Option> { + let key = ObjectKey::new(namespace, name); + let record = crate::repository::store::cached_object(cx, ResourceKind::Pods, &key)?; + let pod = record.typed::()?; + Some( + pod.spec + .as_ref()? + .containers + .iter() + .map(|container| container.name.clone()) + .collect(), + ) } -/// Start an interactive TTY shell in a running pod container. -pub fn exec_shell( +/// Start loading a pod's full description from the shared store, with one +/// `GET` fallback when the object is not cached yet. A detail pane never +/// starts a watch; it observes the store only when a list view already runs. +/// Usage samples still come from metrics-server on the existing cadence. +pub fn subscribe_detail( cx: &mut App, namespace: &str, name: &str, - container: &str, - shell: &str, -) -> Result { - if container.is_empty() { - return Err("This pod has no containers to exec into.".to_string()); - } +) -> Result { let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); + let key = ObjectKey::new(namespace, name); + let initial = crate::repository::store::cached_object(cx, ResourceKind::Pods, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, ResourceKind::Pods); let (updates_tx, updates) = mpsc::unbounded_channel(); - let (input_tx, input_rx) = mpsc::unbounded_channel(); - let (resize_tx, resize_rx) = mpsc::unbounded_channel(); let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(run_exec( + tokio.spawn(watch_pod_detail_from_store( client, namespace.to_string(), name.to_string(), - container.to_string(), - shell.to_string(), + initial, + snapshots, updates_tx, - input_rx, - resize_rx, cancel_rx, )); - Ok(PodExecSession { - updates, - input: input_tx, - resize: resize_tx, - cancel, - }) + Ok(PodDetailSubscription { updates, cancel }) } #[allow(clippy::too_many_arguments)] -async fn run_exec( +async fn watch_pod_detail_from_store( client: Client, namespace: String, name: String, - container: String, - shell: String, - updates: mpsc::UnboundedSender, String>>, - mut input: mpsc::UnboundedReceiver>, - mut resize: mpsc::UnboundedReceiver<(u16, u16)>, + initial: Option>, + mut snapshots: Option>>, + updates: mpsc::UnboundedSender, mut cancel: watch::Receiver, ) { - let api: Api = Api::namespaced(client, &namespace); - let params = AttachParams { - container: Some(container.clone()), - stdin: true, - stdout: true, - stderr: false, - tty: true, - ..AttachParams::default() - }; - tracing::debug!( - namespace = %namespace, - pod = %name, - container = %container, - shell = %shell, - "pod exec: connecting" - ); - let mut process = match api.exec(&name, [shell.as_str()], ¶ms).await { - Ok(process) => process, - Err(error) => { - tracing::warn!(%error, pod = %name, container = %container, "pod exec: connect failed"); - let _ = updates.send(Err(format!("Could not start exec session: {error}"))); - return; - } - }; - tracing::debug!(pod = %name, container = %container, "pod exec: connected"); - let Some(mut stdin) = process.stdin() else { - let _ = updates.send(Err("Exec session has no stdin channel.".to_string())); - process.abort(); - return; - }; - let Some(stdout) = process.stdout() else { - let _ = updates.send(Err("Exec session has no stdout channel.".to_string())); - process.abort(); - return; - }; - let stderr = process.stderr(); - - let writer = tokio::spawn(async move { - while let Some(bytes) = input.recv().await { - if stdin.write_all(&bytes).await.is_err() || stdin.flush().await.is_err() { - break; - } - } - }); - let stdout_task = tokio::spawn(read_exec_output(stdout, updates.clone())); - let stderr_task = stderr.map(|stderr| tokio::spawn(read_exec_output(stderr, updates.clone()))); - - let mut resize_task = process.terminal_size().map(|size_sender| { - tokio::spawn(async move { - let mut size_sender = size_sender; - while let Some((cols, rows)) = resize.recv().await { - let size = TerminalSize { - width: cols, - height: rows, - }; - if size_sender.send(size).await.is_err() { - break; - } - } - }) - }); + let key = ObjectKey::new(&namespace, &name); + let mut last_version = String::new(); + let mut cpu_capacity = None; + let mut memory_capacity = None; + let mut saw_store_object = false; + let mut deleted_reported = false; + let mut initial_get_done = false; + let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut status_future = process.take_status(); - let status = tokio::select! { - _ = cancel.changed() => { - process.abort(); - writer.abort(); - stdout_task.abort(); - if let Some(task) = stderr_task { task.abort(); } - if let Some(task) = resize_task.take() { task.abort(); } + if let Some(record) = initial.as_ref() + && let Some(pod) = record.typed::() + { + last_version.clone_from(&record.resource_version); + (cpu_capacity, memory_capacity) = capacity_from_pod(pod); + if !send_pod_detail(&updates, &namespace, &name, pod) { return; } - status = async { - match status_future.take() { - Some(status) => status.await, - None => None, - } - } => status, - }; - process.abort(); - writer.abort(); - stdout_task.abort(); - if let Some(task) = stderr_task { - task.abort(); - } - if let Some(task) = resize_task.take() { - task.abort(); - } - match status { - Some(status) if status.status.as_deref() == Some("Success") => { - tracing::debug!(pod = %name, container = %container, "pod exec: session ended"); - let _ = updates.send(Ok(b"\r\n[session ended]\r\n".to_vec())); - } - Some(status) => { - let message = status - .message - .as_deref() - .filter(|message| !message.is_empty()) - .map(|message| format!("Exec session ended: {message}")) - .unwrap_or_else(|| "Exec session ended".to_string()); - tracing::warn!(%message, pod = %name, container = %container, "pod exec: session failed"); - let _ = updates.send(Err(message)); - } - None => { - tracing::debug!(pod = %name, container = %container, "pod exec: session closed"); - let _ = updates.send(Ok(b"\r\n[session closed]\r\n".to_vec())); - } + saw_store_object = true; + initial_get_done = true; } -} -async fn read_exec_output(mut reader: R, updates: mpsc::UnboundedSender, String>>) -where - R: AsyncRead + Unpin, -{ - let mut buffer = [0u8; 8192]; loop { - match reader.read(&mut buffer).await { - Ok(0) => return, - Ok(read) => { - if updates.send(Ok(buffer[..read].to_vec())).is_err() { - return; + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if let Some(pod) = record.typed::() + && record.resource_version != last_version + { + last_version.clone_from(&record.resource_version); + (cpu_capacity, memory_capacity) = capacity_from_pod(pod); + if !send_pod_detail(&updates, &namespace, &name, pod) { + return; + } + saw_store_object = true; + deleted_reported = false; + initial_get_done = true; } - } - Err(error) => { - let _ = updates.send(Err(format!("Exec stream error: {error}"))); - return; + } else if saw_store_object + && !deleted_reported + && matches!(snapshot.phase(), SnapshotPhase::Ready) + { + let _ = updates.send(PodDetailUpdate::Detail { + namespace: namespace.clone(), + name: name.clone(), + result: Err("This pod was deleted".to_string()), + }); + deleted_reported = true; } } - } -} - -/// Start loading a pod's full description and live usage samples. -pub fn subscribe_detail( - cx: &mut App, - namespace: &str, - name: &str, -) -> Result { - let state = cx.global::(); - let client = state.client().ok_or_else(|| "not connected".to_string())?; - let tokio = state.tokio(); - let (updates_tx, updates) = mpsc::unbounded_channel(); - let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(watch_pod_detail( - client, - namespace.to_string(), - name.to_string(), - updates_tx, - cancel_rx, - )); - Ok(PodDetailSubscription { updates, cancel }) -} - -async fn watch_pod_detail( - client: Client, - namespace: String, - name: String, - updates: mpsc::UnboundedSender, - mut cancel: watch::Receiver, -) { - let api: Api = Api::namespaced(client.clone(), &namespace); - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; - let mut cpu_capacity = None; - let mut memory_capacity = None; - let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - if need_get { + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + // The store does not know this object yet (for example, a kubectl + // describe target outside the loaded namespace). A detail miss is + // one GET, never a watch. + initial_get_done = true; + let api: Api = Api::namespaced(client.clone(), &namespace); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&name) => result, }; - let pod = match fetched { - Ok(pod) => pod, + + match fetched { + Ok(pod) => { + (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); + if !send_pod_detail(&updates, &namespace, &name, &pod) { + return; + } + } Err(error) => { let _ = updates.send(PodDetailUpdate::Detail { - namespace, - name, + namespace: namespace.clone(), + name: name.clone(), result: Err(format!("Could not load pod details: {error}")), }); return; } - }; - retry.list_succeeded(); - if let Some(version) = pod.resource_version() { - resource_version = version; - } - (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(&updates, &namespace, &name, &pod) { - return; } - need_get = false; } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={name}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - _ = ticker.tick() => { - let usage = fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity).await; - if updates - .send(PodDetailUpdate::Usage { - namespace: namespace.clone(), - name: name.clone(), - result: usage, - }) - .is_err() - { - return; - } - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(pod) | WatchEvent::Modified(pod))) => { - retry.watch_succeeded(); - if let Some(version) = pod.resource_version() { - resource_version = version; - } - (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(&updates, &namespace, &name, &pod) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - let _ = updates.send(PodDetailUpdate::Detail { - namespace, - name, - result: Err("This pod was deleted".to_string()), - }); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel) - .await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; - return; - } - }, - } } - } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } - } -} - -/// Watch is not permitted: keep both the object and its usage fresh with GETs. -async fn poll_pod_detail( - client: &Client, - namespace: &str, - name: &str, - updates: &mpsc::UnboundedSender, - cancel: &mut watch::Receiver, -) { - let api: Api = Api::namespaced(client.clone(), namespace); - let mut retry = Retry::new(); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(name) => result, - }; - match fetched { - Ok(pod) => { - retry.list_succeeded(); - let (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(updates, namespace, name, &pod) { - return; - } + _ = ticker.tick() => { let usage = - fetch_usage(client, namespace, name, cpu_capacity, memory_capacity).await; + fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity).await; if updates .send(PodDetailUpdate::Usage { - namespace: namespace.to_string(), - name: name.to_string(), + namespace: namespace.clone(), + name: name.clone(), result: usage, }) .is_err() @@ -647,30 +389,6 @@ async fn poll_pod_detail( return; } } - Err(error) => { - if updates - .send(PodDetailUpdate::Detail { - namespace: namespace.to_string(), - name: name.to_string(), - result: Err(format!("Could not load pod details: {error}")), - }) - .is_err() - { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { - return; } } } @@ -690,13 +408,6 @@ fn send_pod_detail( .is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - async fn fetch_usage( client: &Client, namespace: &str, diff --git a/src/repository/resources/replica_sets/mod.rs b/src/repository/resources/replica_sets/mod.rs deleted file mode 100644 index 85c8fa5..0000000 --- a/src/repository/resources/replica_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ReplicaSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ReplicaSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ReplicaSets, scope) -} diff --git a/src/repository/resources/replication_controllers/mod.rs b/src/repository/resources/replication_controllers/mod.rs deleted file mode 100644 index f9cc32e..0000000 --- a/src/repository/resources/replication_controllers/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ReplicationControllers: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ReplicationControllers stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ReplicationControllers, scope) -} diff --git a/src/repository/resources/role_bindings/mod.rs b/src/repository/resources/role_bindings/mod.rs deleted file mode 100644 index e96620f..0000000 --- a/src/repository/resources/role_bindings/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for RoleBindings: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live RoleBindings stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::RoleBindings, scope) -} diff --git a/src/repository/resources/roles/mod.rs b/src/repository/resources/roles/mod.rs deleted file mode 100644 index e980eba..0000000 --- a/src/repository/resources/roles/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Roles: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Roles stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Roles, scope) -} diff --git a/src/repository/resources/secrets/mod.rs b/src/repository/resources/secrets/mod.rs deleted file mode 100644 index dd2c9c5..0000000 --- a/src/repository/resources/secrets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Secrets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Secrets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Secrets, scope) -} diff --git a/src/repository/resources/service_accounts/mod.rs b/src/repository/resources/service_accounts/mod.rs deleted file mode 100644 index b230f98..0000000 --- a/src/repository/resources/service_accounts/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ServiceAccounts: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ServiceAccounts stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ServiceAccounts, scope) -} diff --git a/src/repository/resources/services/mod.rs b/src/repository/resources/services/mod.rs deleted file mode 100644 index 36e67c5..0000000 --- a/src/repository/resources/services/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Services: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Services stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Services, scope) -} diff --git a/src/repository/resources/stateful_sets/mod.rs b/src/repository/resources/stateful_sets/mod.rs deleted file mode 100644 index 1d42d43..0000000 --- a/src/repository/resources/stateful_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for StatefulSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live StatefulSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::StatefulSets, scope) -} diff --git a/src/repository/resources/storage_classes/mod.rs b/src/repository/resources/storage_classes/mod.rs deleted file mode 100644 index ac5801c..0000000 --- a/src/repository/resources/storage_classes/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for StorageClasses: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live StorageClasses stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind(cx, ResourceKind::StorageClasses, NamespaceScope::All) -} diff --git a/src/repository/resources/watcher.rs b/src/repository/resources/watcher.rs deleted file mode 100644 index 8b781a9..0000000 --- a/src/repository/resources/watcher.rs +++ /dev/null @@ -1,1540 +0,0 @@ -//! Kubernetes list + watch loop for one resource kind / namespace scope. -//! -//! This module runs entirely on the cluster tokio runtime. It owns the -//! [`ResourceStore`] and only publishes immutable snapshots to the UI, throttled -//! to `FLUSH_INTERVAL` or every `FLUSH_AT` events, whichever comes first. - -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; - -use futures::TryStreamExt as _; -use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; -use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; -use k8s_openapi::api::batch::v1::{CronJob, Job}; -use k8s_openapi::api::coordination::v1::Lease; -use k8s_openapi::api::core::v1::{ - ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, - ReplicationController, Secret, Service, ServiceAccount, -}; -use k8s_openapi::api::discovery::v1::EndpointSlice; -use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; -use k8s_openapi::api::policy::v1::PodDisruptionBudget; -use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; -use k8s_openapi::api::storage::v1::StorageClass; -use kube::api::{Api, DynamicObject, ListParams, ResourceExt, WatchEvent, WatchParams}; -use kube::core::{ApiResource, GroupVersionKind, NamespaceResourceScope}; -use kube::{Client, Resource}; -use serde::de::DeserializeOwned; -use tokio::sync::watch; - -use crate::models::{ - Item, NamespaceScope, ObjectKey, PollRate, ResourceKind, ResourceSnapshot, ResourceStore, - SnapshotPhase, WatchMode, -}; -use crate::repository::retry::{self, RequestFailure, Retry, RetryAction}; -use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; - -const FLUSH_INTERVAL: Duration = Duration::from_millis(50); -const FLUSH_AT: usize = 256; - -/// How often an automatically denied watch is retried. RBAC can change without -/// an app restart, but re-probing too often would spam the API server. -const WATCH_REPROBE_INTERVAL: Duration = Duration::from_secs(600); - -/// Streaming lists (`sendInitialEvents`) bounds. A server that ignores the -/// option never sends the initial-end bookmark, so an idle stream falls back to -/// the list path quickly. A busy initial stream gets longer, but is capped. -const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); - -/// Kubernetes version at which `sendInitialEvents` was introduced. 1.27 and -/// 1.28 may still have the WatchList feature gate disabled, so the stream is -/// still attempted and falls back to list if no initial-end bookmark arrives. -const STREAM_INITIAL_MIN_MINOR: u32 = 27; - -static STREAMING_SUPPORT: OnceLock>> = OnceLock::new(); - -fn streaming_support() -> &'static Mutex> { - STREAMING_SUPPORT.get_or_init(|| Mutex::new(HashMap::new())) -} - -async fn supports_streaming_initial(client: &Client, epoch: u64) -> bool { - if let Ok(cache) = streaming_support().lock() - && let Some(supported) = cache.get(&epoch) - { - return *supported; - } - - let supported = match retry::bounded(client.apiserver_version()).await { - Ok(info) => version_supports_streaming(&info.major, &info.minor), - Err(failure) => { - tracing::debug!(%failure, "repository: could not determine API server version"); - false - } - }; - if let Ok(mut cache) = streaming_support().lock() { - cache.insert(epoch, supported); - } - supported -} - -fn version_supports_streaming(major: &str, minor: &str) -> bool { - let major = major.parse::().unwrap_or(0); - let minor = minor - .trim_end_matches(|character: char| !character.is_ascii_digit()) - .parse::() - .unwrap_or(0); - major > 1 || (major == 1 && minor >= STREAM_INITIAL_MIN_MINOR) -} - -fn bookmark_ends_initial_events(annotations: &std::collections::BTreeMap) -> bool { - annotations - .get("k8s.io/initial-events-end") - .is_some_and(|value| value == "true") -} - -/// List chunk size. Big kinds (thousands of pods/events) render their first -/// chunk immediately instead of waiting for one huge response. -const LIST_CHUNK_SIZE: u32 = 500; - -/// Events are high-churn and grow without bound while the watch is open. -/// Keep the newest rows, matching Freelens' 1000-event initial list. -const EVENTS_LIMIT: usize = 1_000; - -/// Kinds whose watch the API server denied (RBAC) for the current context -/// epoch. New subscriptions for these kinds skip the doomed watch attempt and -/// start polling right away, so changing the namespace filter does not reset -/// polling back to a live-watch attempt. -static WATCH_DENIED: OnceLock>> = OnceLock::new(); - -fn denied_registry() -> &'static Mutex> { - WATCH_DENIED.get_or_init(|| Mutex::new(HashSet::new())) -} - -pub fn watch_denied(epoch: u64, kind: ResourceKind) -> bool { - denied_registry() - .lock() - .is_ok_and(|set| set.contains(&(epoch, kind))) -} - -/// Forget a denial: the user explicitly asked for live watch again. -pub fn clear_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.remove(&(epoch, kind)); - } -} - -fn mark_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.retain(|(stale_epoch, _)| *stale_epoch == epoch); - set.insert((epoch, kind)); - } -} - -/// Spawned once per subscription. Keep it generic over the store; the typed -/// kind dispatch lives in `run`. -#[allow(clippy::too_many_arguments)] -pub async fn run( - client: Client, - kind: ResourceKind, - scope: NamespaceScope, - epoch: u64, - tx: watch::Sender>, - cancel: watch::Receiver, - refresh: watch::Receiver, - prefs: watch::Receiver, -) { - let mut task = WatchTask { - client, - kind, - epoch, - tx, - cancel, - refresh, - prefs, - store: ResourceStore::new(), - mode: WatchMode::Watch, - ready: false, - stream_initial_disabled: false, - }; - match kind { - ResourceKind::Pods => task.run_namespaced::(scope, pod_item).await, - ResourceKind::Deployments => { - task.run_namespaced::(scope, deployment_item) - .await - } - ResourceKind::StatefulSets => { - task.run_namespaced::(scope, stateful_set_item) - .await - } - ResourceKind::ReplicaSets => { - task.run_namespaced::(scope, replica_set_item) - .await - } - ResourceKind::ReplicationControllers => { - task.run_namespaced::(scope, replication_controller_item) - .await - } - ResourceKind::DaemonSets => { - task.run_namespaced::(scope, daemon_set_item) - .await - } - ResourceKind::Jobs => task.run_namespaced::(scope, job_item).await, - ResourceKind::CronJobs => { - task.run_namespaced::(scope, cron_job_item) - .await - } - ResourceKind::HorizontalPodAutoscalers => { - task.run_namespaced::(scope, horizontal_pod_autoscaler_item) - .await - } - ResourceKind::PodDisruptionBudgets => { - task.run_namespaced::(scope, pod_disruption_budget_item) - .await - } - ResourceKind::Services => task.run_namespaced::(scope, service_item).await, - ResourceKind::Ingresses => task.run_namespaced::(scope, ingress_item).await, - ResourceKind::Endpoints => { - task.run_namespaced::(scope, endpoints_item) - .await - } - ResourceKind::EndpointSlices => { - task.run_namespaced::(scope, endpoint_slice_item) - .await - } - ResourceKind::NetworkPolicies => { - task.run_namespaced::(scope, network_policy_item) - .await - } - ResourceKind::PersistentVolumeClaims => { - task.run_namespaced::(scope, pvc_item) - .await - } - ResourceKind::StorageClasses => { - task.run_cluster::(storage_class_item) - .await - } - ResourceKind::ConfigMaps => { - task.run_namespaced::(scope, config_map_item) - .await - } - ResourceKind::Secrets => task.run_namespaced::(scope, secret_item).await, - ResourceKind::ServiceAccounts => { - task.run_namespaced::(scope, service_account_item) - .await - } - ResourceKind::PersistentVolumes => task.run_cluster::(pv_item).await, - ResourceKind::Namespaces => task.run_cluster::(namespace_item).await, - ResourceKind::Nodes => task.run_cluster::(node_item).await, - ResourceKind::Leases => task.run_namespaced::(scope, lease_item).await, - ResourceKind::Events => task.run_namespaced::(scope, event_item).await, - ResourceKind::ClusterRoles => task.run_cluster::(cluster_role_item).await, - ResourceKind::Roles => task.run_namespaced::(scope, role_item).await, - ResourceKind::RoleBindings => { - task.run_namespaced::(scope, role_binding_item) - .await - } - ResourceKind::Custom(index) => { - if let Some(def) = crate::models::resources::catalog::custom_def(index) { - let resource = ApiResource::from_gvk_with_plural( - &GroupVersionKind::gvk(&def.group, &def.version, &def.kind), - &def.plural, - ); - task.run_dynamic(resource, scope).await; - } - } - } -} - -struct WatchTask { - client: Client, - kind: ResourceKind, - epoch: u64, - tx: watch::Sender>, - cancel: watch::Receiver, - refresh: watch::Receiver, - prefs: watch::Receiver, - store: ResourceStore, - mode: WatchMode, - /// Whether a `Ready` snapshot has been published for this task. Used to - /// publish an empty first list without republishing unchanged poll results. - ready: bool, - /// Once streaming lists fall back, keep using the list path for this task. - stream_initial_disabled: bool, -} - -/// Why the poll loop exited. -enum PollOutcome { - /// Subscription cancelled or listing denied: stop the task. - Stop, - /// The user switched this kind back to watch mode. - ResumeWatch, -} - -/// Outcome of a chunked list. -enum ListOutcome { - /// Full list published; watch from this resource version. - Listed { - resource_version: String, - changed: bool, - }, - /// Subscription cancelled mid-list. - Cancelled, - /// A chunk request failed or timed out; the caller decides whether to retry. - Failed(RequestFailure), -} - -/// Outcome of attempting a Kubernetes streaming list. -enum InitialStream { - /// Initial events and the end-of-initial-events bookmark were received. - Listed { - resource_version: String, - changed: bool, - }, - /// Subscription cancelled. - Cancelled, - /// The server ignored/rejected streaming lists, the stream ended, or the - /// initial bookmark did not arrive in time. - Unsupported, -} - -impl WatchTask { - async fn run_namespaced(&mut self, scope: NamespaceScope, map: F) - where - K: Resource - + Clone - + Debug - + DeserializeOwned - + Send - + 'static, - F: Fn(&K) -> Item + Copy, - { - let api = match &scope { - NamespaceScope::All => Api::::all(self.client.clone()), - NamespaceScope::One(namespace) => Api::::namespaced(self.client.clone(), namespace), - }; - self.run_stream(api, map).await; - } - - async fn run_cluster(&mut self, map: F) - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let api = Api::::all(self.client.clone()); - self.run_stream(api, map).await; - } - - async fn run_dynamic(&mut self, resource: ApiResource, scope: NamespaceScope) { - let api = match &scope { - NamespaceScope::All => Api::::all_with(self.client.clone(), &resource), - NamespaceScope::One(namespace) => { - Api::::namespaced_with(self.client.clone(), namespace, &resource) - } - }; - self.run_stream(api, custom_item).await; - } - - async fn run_stream(&mut self, api: Api, map: F) - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let watch_params = WatchParams::default().timeout(280); - let mut resource_version = String::new(); - let mut need_list = true; - let mut retry = Retry::new(); - let mut ticker = tokio::time::interval(FLUSH_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - ticker.tick().await; - - loop { - if self.cancel.has_changed().is_err() { - return; - } - // The user prefers polling for this kind: skip the watch entirely. - if self.prefs.borrow().mode == WatchPreference::Poll { - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - need_list = true; - retry = Retry::new(); - continue; - } - } - } - if need_list { - let mut streamed = false; - if !self.stream_initial_disabled - && !self.ready - && self.store.len() == 0 - && !watch_denied(self.epoch, self.kind) - && supports_streaming_initial(&self.client, self.epoch).await - { - match self.stream_initial(&api, map).await { - InitialStream::Listed { - resource_version: version, - changed, - } => { - tracing::debug!( - changed, - "repository: initial state came from a streaming list" - ); - retry.list_succeeded(); - resource_version = version; - need_list = false; - streamed = true; - } - InitialStream::Cancelled => return, - InitialStream::Unsupported => { - tracing::debug!( - "repository: streaming list unsupported or timed out, falling back to list" - ); - self.stream_initial_disabled = true; - } - } - } - - if !streamed { - match self.list_progressively(&api, map).await { - ListOutcome::Listed { - resource_version: version, - .. - } => { - retry.list_succeeded(); - resource_version = version; - need_list = false; - } - ListOutcome::Cancelled => return, - ListOutcome::Failed(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: list failed, retrying"); - self.publish(SnapshotPhase::Error(format!( - "list failed: {failure}" - ))); - if !self.sleep(delay).await { - return; - } - continue; - } - RetryAction::GiveUp => { - tracing::error!(%failure, ?api, "repository: list rejected, stopping watch"); - self.publish(SnapshotPhase::Error(format!( - "list rejected: {failure}" - ))); - return; - } - }, - } - } - } - - let watched = tokio::select! { - _ = self.cancel.changed() => return, - result = retry::bounded(api.watch(&watch_params, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - clear_watch_denied(self.epoch, self.kind); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, delay_ms = delay.as_millis() as u64, "repository: watch setup failed, retrying"); - if !self.sleep(delay).await { - return; - } - need_list = true; - continue; - } - RetryAction::GiveUp => { - tracing::warn!(%failure, ?api, "repository: watch denied, polling instead"); - mark_watch_denied(self.epoch, self.kind); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - need_list = true; - retry = Retry::new(); - continue; - } - } - } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = self.cancel.changed() => return, - _ = self.refresh.changed() => { - self.refresh.borrow_and_update(); - relist = true; - break; - } - _ = self.prefs.changed() => { - let prefs = *self.prefs.borrow_and_update(); - if prefs.mode == WatchPreference::Poll { - tracing::info!(?api, "repository: watch disabled by preference, polling instead"); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - } - _ = ticker.tick() => { - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - } - event = stream.try_next() => { - match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - let item = map(&object); - if !item.resource_version.is_empty() { - resource_version.clone_from(&item.resource_version); - } - self.store.upsert(item); - } - Ok(Some(WatchEvent::Deleted(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - self.store.remove(&ObjectKey::new( - object.namespace().unwrap_or_default(), - object.name_any(), - )); - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - tracing::warn!( - code = status.code, - message = %status.message, - delay_ms = delay.as_millis() as u64, - "repository: watch error event, retrying" - ); - if !self.sleep(delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - tracing::warn!( - code = status.code, - message = %status.message, - "repository: watch denied, polling instead" - ); - mark_watch_denied(self.epoch, self.kind); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - } - } - Ok(None) => { - tracing::debug!("repository: watch stream ended, resuming"); - break; - } - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - tracing::warn!(%error, delay_ms = delay.as_millis() as u64, "repository: watch stream failed, retrying"); - if !self.sleep(delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - tracing::warn!(%error, ?api, "repository: watch denied, polling instead"); - mark_watch_denied(self.epoch, self.kind); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - }, - } - self.trim_high_churn(); - if self.store.pending() >= FLUSH_AT { - self.publish(SnapshotPhase::Ready); - } - } - } - } - - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - if relist { - need_list = true; - } else if !self.sleep(Duration::from_millis(250)).await { - return; - } - } - } - - fn trim_high_churn(&mut self) -> bool { - if self.kind == ResourceKind::Events { - return self.store.trim_oldest(EVENTS_LIMIT); - } - false - } - - fn publish(&mut self, phase: SnapshotPhase) { - self.ready = matches!(&phase, SnapshotPhase::Ready); - let snapshot = Arc::new(self.store.snapshot(phase).with_mode(self.mode)); - tracing::trace!( - objects = self.store.len(), - "repository: publishing snapshot" - ); - self.tx.send_replace(snapshot); - } - - /// Returns false when the subscription was cancelled. - async fn sleep(&mut self, duration: Duration) -> bool { - tokio::select! { - _ = self.cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } - } - - /// Try Kubernetes 1.27+ streaming lists (`sendInitialEvents`) before the - /// chunked list path. Returns `Unsupported` for old API servers, disabled - /// feature gates, RBAC errors, or a stream that never emits the - /// `k8s.io/initial-events-end` bookmark. The caller then falls back to the - /// existing list+watch code. - async fn stream_initial(&mut self, api: &Api, map: F) -> InitialStream - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let params = WatchParams::streaming_lists().timeout(280); - let watched = tokio::select! { - _ = self.cancel.changed() => return InitialStream::Cancelled, - result = retry::bounded(api.watch(¶ms, "")) => result, - }; - let mut stream = match watched { - Ok(stream) => Box::pin(stream), - Err(failure) => { - tracing::debug!(%failure, ?api, "repository: streaming list setup failed"); - return InitialStream::Unsupported; - } - }; - - let mut resource_version = String::new(); - let mut changed = false; - let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; - let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let mut ticker = tokio::time::interval(FLUSH_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - let deadline = total_deadline.min(idle_deadline); - tokio::select! { - _ = self.cancel.changed() => return InitialStream::Cancelled, - _ = ticker.tick() => { - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - } - _ = tokio::time::sleep_until(deadline) => { - tracing::debug!( - ?api, - "repository: streaming list did not finish, falling back to list" - ); - return InitialStream::Unsupported; - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - idle_deadline = - tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let item = map(&object); - if !item.resource_version.is_empty() { - resource_version.clone_from(&item.resource_version); - } - changed |= self.store.upsert(item); - changed |= self.trim_high_churn(); - if self.store.pending() >= FLUSH_AT { - self.publish(SnapshotPhase::Ready); - } - } - Ok(Some(WatchEvent::Deleted(object))) => { - idle_deadline = - tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - if let Some(version) = object.resource_version() { - resource_version = version; - } - changed |= self.store.remove(&ObjectKey::new( - object.namespace().unwrap_or_default(), - object.name_any(), - )); - changed |= self.trim_high_churn(); - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - let initial_end = - bookmark_ends_initial_events(&bookmark.metadata.annotations); - if !bookmark.metadata.resource_version.is_empty() { - resource_version = bookmark.metadata.resource_version; - } - if !initial_end { - tracing::debug!( - ?api, - "repository: streaming list bookmark was not the initial-events-end" - ); - return InitialStream::Unsupported; - } - if self.store.pending() > 0 || !self.ready { - self.publish(SnapshotPhase::Ready); - } - return InitialStream::Listed { - resource_version, - changed, - }; - } - Ok(Some(WatchEvent::Error(status))) => { - tracing::debug!( - code = status.code, - message = %status.message, - ?api, - "repository: streaming list error, falling back to list" - ); - return InitialStream::Unsupported; - } - Ok(None) => { - tracing::debug!( - ?api, - "repository: streaming list ended early, falling back to list" - ); - return InitialStream::Unsupported; - } - Err(error) => { - tracing::debug!( - %error, - ?api, - "repository: streaming list failed, falling back to list" - ); - return InitialStream::Unsupported; - } - } - } - } - } - - /// Merge every object in the list into the store, publishing each chunk so - /// large kinds render progressively. Objects missing from the completed - /// list are removed at the end; unchanged objects are left untouched. - /// Returns the resource version to watch from. - async fn list_progressively(&mut self, api: &Api, map: F) -> ListOutcome - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let mut params = ListParams::default().limit(LIST_CHUNK_SIZE); - let mut resource_version = String::new(); - let mut changed = false; - self.store.begin_sync(); - loop { - let listed = tokio::select! { - _ = self.cancel.changed() => { - self.store.abort_sync(); - return ListOutcome::Cancelled; - } - result = retry::bounded(api.list(¶ms)) => result, - }; - let chunk = match listed { - Ok(chunk) => chunk, - Err(failure) => { - self.store.abort_sync(); - return ListOutcome::Failed(failure); - } - }; - // Every chunk carries the version of the same list snapshot. - if let Some(version) = chunk.metadata.resource_version { - resource_version = version; - } - for object in &chunk.items { - changed |= self.store.upsert(map(object)); - } - changed |= self.trim_high_churn(); - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - match chunk.metadata.continue_ { - Some(token) if !token.is_empty() => { - params = params.continue_token(&token); - } - _ => break, - } - } - changed |= self.store.finish_sync(); - if self.store.pending() > 0 || !self.ready { - self.publish(SnapshotPhase::Ready); - } - ListOutcome::Listed { - resource_version, - changed, - } - } - - /// Poll instead of watching — because watch was denied (RBAC) or the user - /// prefers polling for this kind. Runs until the subscription is - /// cancelled, listing is denied, or the user switches back to watch mode. - /// `PollRate::Off` disables the timer; manual refresh still re-lists. - async fn poll_loop(&mut self, api: &Api, map: F) -> PollOutcome - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - self.mode = WatchMode::Poll(self.prefs.borrow().rate); - // Reflect the mode switch in the UI immediately, not after the first - // poll roundtrip. - if self.store.len() > 0 { - self.publish(SnapshotPhase::Ready); - } - let mut retry = Retry::new(); - let mut unchanged_polls: u32 = 0; - let mut reprobe = tokio::time::interval(WATCH_REPROBE_INTERVAL); - reprobe.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - reprobe.tick().await; - - loop { - if self.cancel.has_changed().is_err() { - return PollOutcome::Stop; - } - - match self.list_progressively(api, map).await { - ListOutcome::Listed { changed, .. } => { - retry.list_succeeded(); - if changed { - unchanged_polls = 0; - } else { - unchanged_polls = unchanged_polls.saturating_add(1); - } - } - ListOutcome::Cancelled => return PollOutcome::Stop, - ListOutcome::Failed(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: poll list failed, retrying"); - self.publish(SnapshotPhase::Error(format!("list failed: {failure}"))); - if !self.sleep(delay).await { - return PollOutcome::Stop; - } - continue; - } - RetryAction::GiveUp => { - tracing::error!(%failure, ?api, "repository: poll list rejected, stopping watch"); - self.publish(SnapshotPhase::Error(format!("list rejected: {failure}"))); - return PollOutcome::Stop; - } - }, - } - - // Wait for the next tick. Refresh or a preference change re-list - // immediately; `Off` waits without a timer. Repeated unchanged - // polls back off up to one minute. - let rate = self.prefs.borrow().rate; - let delay = poll_delay(rate, unchanged_polls); - tokio::select! { - _ = self.cancel.changed() => return PollOutcome::Stop, - _ = self.refresh.changed() => { - self.refresh.borrow_and_update(); - unchanged_polls = 0; - } - _ = self.prefs.changed() => { - let prefs = *self.prefs.borrow_and_update(); - self.mode = WatchMode::Poll(prefs.rate); - unchanged_polls = 0; - if prefs.mode == WatchPreference::Watch { - self.mode = WatchMode::Watch; - self.publish(SnapshotPhase::Ready); - return PollOutcome::ResumeWatch; - } - // Show the new rate/off state instantly; the re-list - // happens on the next loop turn. - self.publish(SnapshotPhase::Ready); - } - _ = reprobe.tick() => { - // Only an automatic denial is re-probed; an explicit - // polling preference is left alone. - if self.prefs.borrow().mode == WatchPreference::Watch - && watch_denied(self.epoch, self.kind) - { - tracing::info!(?api, "repository: re-probing previously denied watch"); - clear_watch_denied(self.epoch, self.kind); - self.mode = WatchMode::Watch; - self.publish(SnapshotPhase::Ready); - return PollOutcome::ResumeWatch; - } - } - _ = async { - match delay { - Some(delay) => tokio::time::sleep(delay).await, - None => std::future::pending().await, - } - } => {} - } - } - } -} - -fn poll_delay(rate: PollRate, unchanged_polls: u32) -> Option { - match rate { - PollRate::Off => None, - PollRate::Every(secs) => { - let factor = 1u64 << unchanged_polls.min(3); - Some(Duration::from_secs( - secs.saturating_mul(factor).clamp(1, 60), - )) - } - } -} - -fn base_item(object: &K) -> Item -where - K: ResourceExt, -{ - let mut item = Item::new(object.name_any(), object.namespace()); - item.uid = object.uid().unwrap_or_default(); - item.resource_version = object.resource_version().unwrap_or_default(); - item.created_at = object.creation_timestamp().map(|time| time.0.as_second()); - item -} - -fn version_label(labels: Option<&std::collections::BTreeMap>) -> String { - labels - .and_then(|labels| labels.get("app.kubernetes.io/version").cloned()) - .unwrap_or_default() -} - -fn pod_item(pod: &Pod) -> Item { - let mut item = base_item(pod); - item.node = pod - .spec - .as_ref() - .and_then(|spec| spec.node_name.clone()) - .unwrap_or_default(); - let statuses = pod - .status - .as_ref() - .and_then(|status| status.container_statuses.as_ref()); - let total = statuses.map(|statuses| statuses.len()).unwrap_or(0); - let ready = statuses - .map(|statuses| statuses.iter().filter(|status| status.ready).count()) - .unwrap_or(0); - item.ready = format!("{ready}/{total}"); - item.restarts = statuses - .map(|statuses| { - statuses - .iter() - .map(|status| status.restart_count as usize) - .sum() - }) - .unwrap_or(0); - item.status = pod - .status - .as_ref() - .and_then(|status| status.phase.as_deref()) - .unwrap_or("unknown") - .to_lowercase(); - let crashing = statuses.is_some_and(|statuses| { - statuses.iter().any(|status| { - status - .state - .as_ref() - .and_then(|state| state.waiting.as_ref()) - .and_then(|waiting| waiting.reason.as_deref()) - .is_some_and(|reason| reason == "CrashLoopBackOff") - }) - }); - if crashing { - item.status = "crashloopbackoff".to_string(); - } - item -} - -fn deployment_item(deployment: &Deployment) -> Item { - let mut item = base_item(deployment); - let desired = deployment - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = deployment - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(deployment.metadata.labels.as_ref()); - item -} - -fn daemon_set_item(daemon_set: &DaemonSet) -> Item { - let mut item = base_item(daemon_set); - let desired = daemon_set - .status - .as_ref() - .map(|status| status.desired_number_scheduled) - .unwrap_or(0); - let ready = daemon_set - .status - .as_ref() - .map(|status| status.number_ready) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(daemon_set.metadata.labels.as_ref()); - item -} - -fn job_item(job: &Job) -> Item { - let mut item = base_item(job); - let want = job - .spec - .as_ref() - .and_then(|spec| spec.completions) - .unwrap_or(1); - let done = job - .status - .as_ref() - .and_then(|status| status.succeeded) - .unwrap_or(0); - item.ready = format!("{done}/{want}"); - item.capacity = want.to_string(); - item.status = if done >= want { "succeeded" } else { "running" }.to_string(); - item -} - -fn service_item(service: &Service) -> Item { - let mut item = base_item(service); - let spec = service.spec.as_ref(); - item.status = spec - .and_then(|spec| spec.type_.clone()) - .unwrap_or_else(|| "ClusterIP".to_string()) - .to_lowercase(); - item.version = spec - .and_then(|spec| spec.ports.as_ref()) - .and_then(|ports| ports.first()) - .map(|port| port.port.to_string()) - .unwrap_or_default(); - item -} - -fn ingress_item(ingress: &Ingress) -> Item { - let mut item = base_item(ingress); - item.status = "ingress".to_string(); - item.version = ingress - .spec - .as_ref() - .and_then(|spec| spec.rules.as_ref()) - .and_then(|rules| rules.first()) - .and_then(|rule| rule.host.clone()) - .unwrap_or_default(); - item -} - -fn pv_item(persistent_volume: &PersistentVolume) -> Item { - let mut item = base_item(persistent_volume); - item.capacity = persistent_volume - .spec - .as_ref() - .and_then(|spec| spec.capacity.as_ref()) - .and_then(|capacity| capacity.get("storage")) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.status = persistent_volume - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_default() - .to_lowercase(); - item -} - -fn pvc_item(persistent_volume_claim: &PersistentVolumeClaim) -> Item { - let mut item = base_item(persistent_volume_claim); - item.capacity = persistent_volume_claim - .status - .as_ref() - .and_then(|status| status.capacity.as_ref()) - .and_then(|capacity| capacity.get("storage")) - .or_else(|| { - persistent_volume_claim - .spec - .as_ref() - .and_then(|spec| spec.resources.as_ref()) - .and_then(|resources| resources.requests.as_ref()) - .and_then(|requests| requests.get("storage")) - }) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.status = persistent_volume_claim - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_default() - .to_lowercase(); - item -} - -fn config_map_item(config_map: &ConfigMap) -> Item { - let mut item = base_item(config_map); - let data_keys = config_map.data.as_ref().map(|data| data.len()).unwrap_or(0); - let binary_keys = config_map - .binary_data - .as_ref() - .map(|data| data.len()) - .unwrap_or(0); - item.status = "keys".to_string(); - item.capacity = (data_keys + binary_keys).to_string(); - item.version = config_map - .data - .as_ref() - .and_then(|data| data.keys().next().cloned()) - .unwrap_or_default(); - item -} - -fn secret_item(secret: &Secret) -> Item { - let mut item = base_item(secret); - item.status = "keys".to_string(); - item.capacity = secret - .data - .as_ref() - .map(|data| data.len()) - .unwrap_or(0) - .to_string(); - item.version = secret - .data - .as_ref() - .and_then(|data| data.keys().next().cloned()) - .unwrap_or_default(); - item -} - -fn stateful_set_item(stateful_set: &StatefulSet) -> Item { - let mut item = base_item(stateful_set); - let desired = stateful_set - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = stateful_set - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(stateful_set.metadata.labels.as_ref()); - item -} - -fn replica_set_item(replica_set: &ReplicaSet) -> Item { - let mut item = base_item(replica_set); - let desired = replica_set - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = replica_set - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(replica_set.metadata.labels.as_ref()); - item -} - -fn replication_controller_item(controller: &ReplicationController) -> Item { - let mut item = base_item(controller); - let desired = controller - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = controller - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(controller.metadata.labels.as_ref()); - item -} - -fn horizontal_pod_autoscaler_item(autoscaler: &HorizontalPodAutoscaler) -> Item { - let mut item = base_item(autoscaler); - let spec = autoscaler.spec.as_ref(); - let max = spec.map(|spec| spec.max_replicas).unwrap_or(0); - let min = spec.and_then(|spec| spec.min_replicas).unwrap_or(0); - let current = autoscaler - .status - .as_ref() - .and_then(|status| status.current_replicas) - .unwrap_or(0); - item.ready = format!("{current}/{min}"); - item.capacity = max.to_string(); - item.status = if current >= min { "active" } else { "idle" }.to_string(); - item.version = spec - .map(|spec| { - format!( - "{}/{}", - spec.scale_target_ref.kind, spec.scale_target_ref.name - ) - }) - .unwrap_or_default(); - item -} - -fn pod_disruption_budget_item(budget: &PodDisruptionBudget) -> Item { - let mut item = base_item(budget); - let status = budget.status.as_ref(); - let current = status.map(|status| status.current_healthy).unwrap_or(0); - let desired = status.map(|status| status.desired_healthy).unwrap_or(0); - let allowed = status.map(|status| status.disruptions_allowed).unwrap_or(0); - item.ready = format!("{current}/{desired}"); - item.capacity = allowed.to_string(); - item.status = if allowed > 0 { "ready" } else { "blocked" }.to_string(); - item -} - -fn lease_item(lease: &Lease) -> Item { - let mut item = base_item(lease); - let spec = lease.spec.as_ref(); - item.version = spec - .and_then(|spec| spec.holder_identity.clone()) - .unwrap_or_default(); - item.capacity = spec - .and_then(|spec| spec.lease_duration_seconds) - .map(|seconds| format!("{seconds}s")) - .unwrap_or_default(); - item.status = "active".to_string(); - item -} - -fn endpoints_item(endpoints: &Endpoints) -> Item { - let mut item = base_item(endpoints); - let addresses = endpoints - .subsets - .as_ref() - .map(|subsets| { - subsets - .iter() - .map(|subset| { - subset.addresses.as_ref().map(Vec::len).unwrap_or(0) - + subset - .not_ready_addresses - .as_ref() - .map(Vec::len) - .unwrap_or(0) - }) - .sum::() - }) - .unwrap_or(0); - item.capacity = addresses.to_string(); - item.status = "active".to_string(); - item -} - -fn endpoint_slice_item(slice: &EndpointSlice) -> Item { - let mut item = base_item(slice); - item.capacity = slice.endpoints.len().to_string(); - item.version = slice.address_type.clone(); - item.status = "active".to_string(); - item -} - -fn network_policy_item(policy: &NetworkPolicy) -> Item { - let mut item = base_item(policy); - item.capacity = policy - .spec - .as_ref() - .and_then(|spec| spec.policy_types.as_ref()) - .map(|types| types.join(", ")) - .unwrap_or_default(); - item.status = "active".to_string(); - item -} - -fn event_item(event: &Event) -> Item { - let mut item = base_item(event); - item.status = event - .type_ - .clone() - .unwrap_or_else(|| "normal".to_string()) - .to_lowercase(); - item.version = event.reason.clone().unwrap_or_default(); - item.capacity = event.count.unwrap_or(1).to_string(); - item -} - -fn cluster_role_item(cluster_role: &ClusterRole) -> Item { - let mut item = base_item(cluster_role); - item.capacity = cluster_role - .rules - .as_ref() - .map(|rules| rules.len()) - .unwrap_or(0) - .to_string(); - item.status = "active".to_string(); - item -} - -fn role_item(role: &Role) -> Item { - let mut item = base_item(role); - item.capacity = role - .rules - .as_ref() - .map(|rules| rules.len()) - .unwrap_or(0) - .to_string(); - item.status = "active".to_string(); - item -} - -fn role_binding_item(binding: &RoleBinding) -> Item { - let mut item = base_item(binding); - item.capacity = binding - .subjects - .as_ref() - .map(|subjects| subjects.len()) - .unwrap_or(0) - .to_string(); - item.version = format!("{}/{}", binding.role_ref.kind, binding.role_ref.name); - item.status = "active".to_string(); - item -} - -fn custom_item(object: &DynamicObject) -> Item { - let mut item = base_item(object); - let Some(status) = object.data.get("status") else { - return item; - }; - if let Some(phase) = status.get("phase").and_then(serde_json::Value::as_str) { - item.status = phase.to_lowercase(); - } else if let Some(conditions) = status - .get("conditions") - .and_then(serde_json::Value::as_array) - { - let ready = conditions.iter().find(|condition| { - condition.get("type").and_then(serde_json::Value::as_str) == Some("Ready") - }); - if let Some(ready) = ready { - item.status = - if ready.get("status").and_then(serde_json::Value::as_str) == Some("True") { - "ready" - } else { - "notready" - } - .to_string(); - } - } - item -} - -fn cron_job_item(cron_job: &CronJob) -> Item { - let mut item = base_item(cron_job); - let active = cron_job - .status - .as_ref() - .and_then(|status| status.active.as_ref()) - .map(|active| active.len()) - .unwrap_or(0); - let suspended = cron_job - .spec - .as_ref() - .and_then(|spec| spec.suspend) - .unwrap_or(false); - item.capacity = active.to_string(); - item.status = if suspended { "suspended" } else { "active" }.to_string(); - item.version = cron_job - .spec - .as_ref() - .map(|spec| spec.schedule.clone()) - .unwrap_or_default(); - item -} - -fn service_account_item(service_account: &ServiceAccount) -> Item { - let mut item = base_item(service_account); - let secrets = service_account.secrets.as_ref().map(Vec::len).unwrap_or(0); - item.status = "account".to_string(); - item.capacity = secrets.to_string(); - item.version = service_account - .secrets - .as_ref() - .and_then(|secrets| secrets.first()) - .and_then(|secret| secret.name.clone()) - .unwrap_or_default(); - item -} - -fn storage_class_item(storage_class: &StorageClass) -> Item { - let mut item = base_item(storage_class); - item.version = storage_class.provisioner.clone(); - item.capacity = storage_class.reclaim_policy.clone().unwrap_or_default(); - item.ready = storage_class - .volume_binding_mode - .clone() - .unwrap_or_default(); - item -} - -fn namespace_item(namespace: &Namespace) -> Item { - let mut item = base_item(namespace); - item.status = namespace - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_else(|| "unknown".to_string()) - .to_lowercase(); - item -} - -fn node_item(node: &Node) -> Item { - let mut item = base_item(node); - let status = node.status.as_ref(); - let ready = status - .and_then(|status| status.conditions.as_ref()) - .and_then(|conditions| { - conditions - .iter() - .find(|condition| condition.type_ == "Ready") - }) - .map(|condition| condition.status == "True") - .unwrap_or(false); - item.status = if ready { "ready" } else { "notready" }.to_string(); - item.capacity = status - .and_then(|status| status.allocatable.as_ref()) - .and_then(|allocatable| allocatable.get("cpu")) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.version = status - .and_then(|status| status.node_info.as_ref()) - .map(|info| info.kubelet_version.clone()) - .unwrap_or_default(); - item -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn watch_denied_registry_roundtrip() { - // Unique epoch so the process-global registry is not disturbed. - let epoch = u64::MAX; - assert!(!watch_denied(epoch, ResourceKind::Pods)); - mark_watch_denied(epoch, ResourceKind::Pods); - assert!(watch_denied(epoch, ResourceKind::Pods)); - assert!(!watch_denied(epoch, ResourceKind::Nodes)); - clear_watch_denied(epoch, ResourceKind::Pods); - assert!(!watch_denied(epoch, ResourceKind::Pods)); - } - - #[test] - fn poll_backoff_doubles_until_the_cap() { - assert_eq!( - poll_delay(PollRate::Every(5), 0), - Some(Duration::from_secs(5)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 1), - Some(Duration::from_secs(10)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 2), - Some(Duration::from_secs(20)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 3), - Some(Duration::from_secs(40)) - ); - assert_eq!( - poll_delay(PollRate::Every(10), 4), - Some(Duration::from_secs(60)) - ); - assert_eq!(poll_delay(PollRate::Off, 3), None); - } - - #[test] - fn streaming_lists_require_kubernetes_1_27() { - assert!(!version_supports_streaming("1", "26")); - assert!(version_supports_streaming("1", "27")); - assert!(version_supports_streaming("1", "37")); - assert!(version_supports_streaming("1", "37+")); - assert!(version_supports_streaming("2", "0")); - assert!(!version_supports_streaming("0", "99")); - } - - #[test] - fn only_the_initial_events_bookmark_ends_the_stream() { - let mut annotations = std::collections::BTreeMap::new(); - assert!(!bookmark_ends_initial_events(&annotations)); - annotations.insert("k8s.io/initial-events-end".into(), "false".into()); - assert!(!bookmark_ends_initial_events(&annotations)); - annotations.insert("k8s.io/initial-events-end".into(), "true".into()); - assert!(bookmark_ends_initial_events(&annotations)); - } -} diff --git a/src/repository/retry.rs b/src/repository/retry.rs index 3a41a48..b6217b2 100644 --- a/src/repository/retry.rs +++ b/src/repository/retry.rs @@ -28,51 +28,6 @@ pub enum RetryAction { GiveUp, } -/// Longest a single list/watch-establishment request may take before it feeds -/// the retry policy. kube leaves `read_timeout` unset, so without this a -/// stalled connection hangs until the OS gives up on the TCP connection -/// (~40s+). Watch streams and log follows are unaffected. -pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); - -/// Run one API request with a timeout so a stalled connection fails fast. -pub async fn bounded( - request: impl std::future::Future>, -) -> Result { - match tokio::time::timeout(REQUEST_TIMEOUT, request).await { - Ok(Ok(value)) => Ok(value), - Ok(Err(error)) => Err(RequestFailure::Api(error)), - Err(_) => Err(RequestFailure::TimedOut), - } -} - -/// A failed Kubernetes API request. -#[derive(Debug)] -pub enum RequestFailure { - /// The API server answered with an error status. - Api(kube::Error), - /// The request produced nothing within [`REQUEST_TIMEOUT`]. - TimedOut, -} - -impl RequestFailure { - /// Classify into the retry policy; timeouts are always soft. - pub fn action(&self, retry: &mut Retry) -> RetryAction { - match self { - Self::Api(error) => retry.failed(error), - Self::TimedOut => retry.failed_soft(), - } - } -} - -impl std::fmt::Display for RequestFailure { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Api(error) => write!(f, "{error}"), - Self::TimedOut => write!(f, "no response for {}s", REQUEST_TIMEOUT.as_secs()), - } - } -} - /// True when the HTTP status will never succeed on retry for a list/watch. pub fn is_hard_status(code: u16) -> bool { matches!(code, 400 | 401 | 403 | 404 | 405 | 422) @@ -118,6 +73,7 @@ impl Retry { } /// Decide what to do after a watch `Error` event carrying only a status. + #[allow(dead_code)] pub fn failed_status(&mut self, code: u16) -> RetryAction { self.fail(is_hard_status(code)) } @@ -229,17 +185,4 @@ mod tests { assert!(!is_hard_status(code), "{code} should be soft"); } } - - #[test] - fn timeouts_are_soft_and_back_off() { - let mut retry = Retry::new(); - assert_eq!( - RequestFailure::TimedOut.action(&mut retry), - RetryAction::Retry(Duration::from_millis(500)) - ); - assert_eq!( - RequestFailure::TimedOut.action(&mut retry), - RetryAction::Retry(Duration::from_secs(1)) - ); - } } diff --git a/src/repository/store/data.rs b/src/repository/store/data.rs new file mode 100644 index 0000000..9ec5f16 --- /dev/null +++ b/src/repository/store/data.rs @@ -0,0 +1,536 @@ +//! Incrementally maintained full-object store for one resource kind. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use crate::models::{ObjectKey, SnapshotPhase, WatchMode}; + +use super::record::KubeObjectRecord; + +/// Immutable store state published after a flush. +pub struct StoreSnapshot { + revision: u64, + phase: SnapshotPhase, + mode: WatchMode, + objects: Arc<[Arc]>, + /// Last revision at which each namespace changed. `All` views use the + /// global revision; `One(ns)` views can skip unrelated namespaces. + namespace_revisions: Arc>, +} + +impl Clone for StoreSnapshot { + fn clone(&self) -> Self { + Self { + revision: self.revision, + phase: self.phase.clone(), + mode: self.mode, + objects: self.objects.clone(), + namespace_revisions: self.namespace_revisions.clone(), + } + } +} + +impl std::fmt::Debug for StoreSnapshot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoreSnapshot") + .field("revision", &self.revision) + .field("phase", &self.phase) + .field("mode", &self.mode) + .field("objects", &self.objects.len()) + .finish_non_exhaustive() + } +} + +impl StoreSnapshot { + pub fn phase(&self) -> &SnapshotPhase { + &self.phase + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn mode(&self) -> WatchMode { + self.mode + } + + pub fn objects(&self) -> &[Arc] { + &self.objects + } + + pub fn object(&self, key: &ObjectKey) -> Option<&Arc> { + let index = self + .objects + .binary_search_by(|record| record.key.cmp(key)) + .ok()?; + self.objects.get(index) + } + + pub fn namespace_revision(&self, namespace: &str) -> Option { + self.namespace_revisions.get(namespace).copied() + } + + #[cfg(test)] + pub fn summary_items(&self) -> Arc<[Arc]> { + self.objects + .iter() + .map(|record| record.summary.clone()) + .collect() + } +} + +/// Authoritative object collection for one kind. Owned by the store task and +/// mutated only from that task, so no shared lock is needed on the hot path. +#[derive(Debug, Default)] +pub struct StoreData { + objects: BTreeMap>, + by_uid: HashMap, + pending: usize, + revision: u64, + namespace_revisions: BTreeMap, + changed_namespaces: HashSet, +} + +impl StoreData { + pub fn new() -> Self { + Self::default() + } + + pub fn pending(&self) -> usize { + self.pending + } + + #[cfg(test)] + pub fn get_by_uid(&self, uid: &str) -> Option<&Arc> { + self.by_uid.get(uid).and_then(|key| self.objects.get(key)) + } + + /// Insert or replace an object. A repeated `(key, uid, resourceVersion)` is + /// a no-op, which makes poll relists and duplicate watch events cheap. + pub fn upsert(&mut self, record: KubeObjectRecord) -> bool { + let key = record.key.clone(); + if let Some(existing) = self.objects.get(&key) + && !record.resource_version.is_empty() + && existing.uid == record.uid + && existing.resource_version == record.resource_version + { + return false; + } + + let record = Arc::new(record); + if let Some(existing) = self.objects.get(&key) + && !existing.uid.is_empty() + && existing.uid != record.uid + { + self.by_uid.remove(&existing.uid); + } + + if !record.uid.is_empty() + && let Some(previous) = self.by_uid.insert(record.uid.clone(), key.clone()) + && previous != key + { + self.objects.remove(&previous); + } + + self.objects.insert(key.clone(), record); + self.note_changed(&key); + true + } + + pub fn remove(&mut self, key: &ObjectKey) -> bool { + let Some(record) = self.objects.remove(key) else { + return false; + }; + + if !record.uid.is_empty() { + self.by_uid.remove(&record.uid); + } + + self.note_changed(key); + true + } + + /// Replace one namespace with the result of a namespace-scoped list. + /// Objects in other namespaces are retained, matching Freelens `mergeItems`. + pub fn replace_namespace(&mut self, namespace: &str, records: I) -> bool + where + I: IntoIterator, + { + let mut seen = HashSet::new(); + let mut changed = false; + + for record in records { + seen.insert(record.key.clone()); + changed |= self.upsert(record); + } + + let stale: Vec = self + .objects + .range(namespace_range(namespace)) + .filter(|(key, _)| !seen.contains(*key)) + .map(|(key, _)| key.clone()) + .collect(); + + for key in stale { + changed |= self.remove(&key); + } + + if changed { + self.changed_namespaces.insert(namespace.to_string()); + } + changed + } + + /// Replace the whole store with the result of an all-namespace list. + pub fn replace_all(&mut self, records: I) -> bool + where + I: IntoIterator, + { + let mut seen = HashSet::new(); + let mut changed = false; + + for record in records { + seen.insert(record.key.clone()); + changed |= self.upsert(record); + } + + let stale: Vec = self + .objects + .keys() + .filter(|key| !seen.contains(*key)) + .cloned() + .collect(); + + for key in stale { + changed |= self.remove(&key); + } + + changed + } + + /// Remove the oldest objects until at most `max_items` remain. + pub fn trim_oldest(&mut self, max_items: usize) -> bool { + if self.objects.len() <= max_items { + return false; + } + + let mut by_age: Vec<(ObjectKey, i64)> = self + .objects + .iter() + .map(|(key, record)| (key.clone(), record.summary.created_at.unwrap_or(i64::MIN))) + .collect(); + by_age.sort_by_key(|(_, created_at)| *created_at); + + let remove = self.objects.len() - max_items; + let mut changed = false; + for (key, _) in by_age.into_iter().take(remove) { + changed |= self.remove(&key); + } + changed + } + + /// Produce an immutable snapshot and advance the revision if anything + /// changed since the previous snapshot. + pub fn snapshot(&mut self, phase: SnapshotPhase, mode: WatchMode) -> StoreSnapshot { + let changed = self.pending > 0; + if changed { + self.revision = self.revision.wrapping_add(1); + for namespace in self.changed_namespaces.drain() { + self.namespace_revisions.insert(namespace, self.revision); + } + } + + self.pending = 0; + StoreSnapshot { + revision: self.revision, + phase, + mode, + objects: self.objects.values().cloned().collect(), + namespace_revisions: Arc::new(self.namespace_revisions.clone()), + } + } + + fn note_changed(&mut self, key: &ObjectKey) { + self.pending = self.pending.saturating_add(1); + self.changed_namespaces.insert(key.namespace.clone()); + } +} + +fn namespace_range(namespace: &str) -> impl std::ops::RangeBounds { + use std::ops::Bound::{Excluded, Included}; + + ( + Included(ObjectKey::new(namespace, "")), + Excluded(ObjectKey::new(namespace, "\u{10FFFF}")), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use kube::ResourceExt; + + use super::*; + use crate::models::Item; + use crate::models::{NamespaceScope, WatchMode}; + use crate::repository::store::StoreView; + + fn record(name: &str, namespace: &str, uid: &str, version: &str) -> KubeObjectRecord { + let object: kube::api::DynamicObject = serde_json::from_value(json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": namespace, + "uid": uid, + "resourceVersion": version, + "labels": { "app": "demo" }, + "ownerReferences": [ + { "apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "demo", "uid": "owner-1" } + ] + } + })) + .expect("dynamic object"); + + let mut item = Item::new(name.to_string(), Some(namespace.to_string())); + item.uid = uid.to_string(); + item.resource_version = version.to_string(); + item.status = "running".to_string(); + KubeObjectRecord::new(object, item) + } + + #[test] + fn stores_full_objects_and_summaries() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 1); + assert_eq!(snapshot.summary_items().len(), 1); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .uid, + "uid-a" + ); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .typed::() + .unwrap() + .name_any(), + "pod-a" + ); + let object = snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .typed::() + .unwrap(); + assert_eq!( + object + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app")) + .map(String::as_str), + Some("demo") + ); + } + + #[test] + fn unchanged_resource_version_is_a_no_op() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "10")); + let _ = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut same = record("pod-a", "ns-a", "uid-a", "10"); + same.summary = Arc::new(Item::new("pod-a".into(), Some("ns-a".into()))); + assert!(!data.upsert(same)); + assert_eq!(data.pending(), 0); + } + + #[test] + fn replacing_a_namespace_keeps_other_namespaces() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + data.upsert(record("pod-c", "ns-a", "uid-c", "1")); + + assert!(data.replace_namespace( + "ns-a", + [ + record("pod-a", "ns-a", "uid-a", "2"), + record("pod-d", "ns-a", "uid-d", "1") + ] + )); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let names: Vec<_> = snapshot + .objects() + .iter() + .map(|record| (record.key.namespace.as_str(), record.key.name.as_str())) + .collect(); + assert_eq!( + names, + [("ns-a", "pod-a"), ("ns-a", "pod-d"), ("ns-b", "pod-b")] + ); + assert!( + snapshot.object(&ObjectKey::new("ns-a", "pod-c")).is_none(), + "objects absent from the namespace list are removed" + ); + } + + #[test] + fn replacing_all_marks_each_namespace_that_changed() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + + assert!(data.replace_all([ + record("pod-b", "ns-a", "uid-b", "1"), + record("pod-c", "ns-b", "uid-c", "1"), + ])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + assert_eq!(snapshot.namespace_revision("ns-a"), Some(1)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + + let mut ns_a = crate::repository::store::StoreView::new(NamespaceScope::One("ns-a".into())); + assert!(ns_a.apply(&snapshot).is_some()); + assert_eq!(ns_a.len(), 1); + } + + #[test] + fn replacing_all_removes_everything_absent() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + + assert!(data.replace_all([record("pod-c", "ns-c", "uid-c", "1")])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 1); + assert_eq!(snapshot.objects()[0].key, ObjectKey::new("ns-c", "pod-c")); + } + + #[test] + fn uid_index_follows_replaced_objects() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-old", "1")); + assert!(data.get_by_uid("uid-old").is_some()); + + data.upsert(record("pod-a", "ns-a", "uid-new", "2")); + assert!(data.get_by_uid("uid-old").is_none()); + assert!(data.get_by_uid("uid-new").is_some()); + } + + #[test] + fn trims_the_oldest_objects_first() { + let mut data = StoreData::new(); + for (name, age) in [("a", 10), ("b", 30), ("c", 20)] { + let mut record = record(name, "ns", &format!("uid-{name}"), "1"); + record.summary = Arc::new({ + let mut item = Item::new(name.to_string(), Some("ns".to_string())); + item.created_at = Some(age); + item + }); + data.upsert(record); + } + + assert!(data.trim_oldest(2)); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let names: Vec<_> = snapshot + .objects() + .iter() + .map(|record| record.key.name.as_str()) + .collect(); + assert_eq!(names, ["b", "c"]); + } + + #[test] + fn revision_advances_only_when_a_flush_changed_something() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + let first = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(first.revision(), 1); + + let second = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(second.revision(), 1); + assert_eq!(second.objects().len(), 1); + + data.upsert(record("pod-b", "ns-a", "uid-b", "1")); + let third = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(third.revision(), 2); + } + + #[test] + fn snapshot_carries_namespace_revisions() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.namespace_revision("ns-a"), Some(1)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + + data.upsert(record("pod-c", "ns-a", "uid-c", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.namespace_revision("ns-a"), Some(2)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + } + + #[test] + fn snapshot_exposes_scope_and_summary() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + let snapshot = data.snapshot( + SnapshotPhase::Error("offline".into()), + WatchMode::Poll(crate::models::PollRate::Every(5)), + ); + assert_eq!(snapshot.phase(), &SnapshotPhase::Error("offline".into())); + assert_eq!( + snapshot.mode(), + WatchMode::Poll(crate::models::PollRate::Every(5)) + ); + assert_eq!(snapshot.summary_items().len(), 1); + } + + #[test] + fn namespace_range_contains_only_the_requested_namespace() { + let mut data = StoreData::new(); + data.upsert(record("a", "ns-1", "uid-1", "1")); + data.upsert(record("b", "ns-2", "uid-2", "1")); + data.upsert(record("c", "ns-10", "uid-3", "1")); + + assert!(data.replace_namespace("ns-1", [record("a", "ns-1", "uid-1", "2")])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 3); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-1", "a")) + .expect("ns-1 object") + .resource_version, + "2" + ); + } + + #[test] + fn store_view_filters_by_scope() { + let mut data = StoreData::new(); + data.upsert(record("a", "ns-1", "uid-1", "1")); + data.upsert(record("b", "ns-2", "uid-2", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut all = StoreView::new(NamespaceScope::All); + let mut one = StoreView::new(NamespaceScope::One("ns-2".into())); + let all = all.apply(&snapshot).unwrap(); + let one = one.apply(&snapshot).unwrap(); + assert_eq!(all.items().len(), 2); + assert_eq!(one.items().len(), 1); + assert_eq!(one.items()[0].name, "b"); + } +} diff --git a/src/repository/store/engine.rs b/src/repository/store/engine.rs new file mode 100644 index 0000000..de08539 --- /dev/null +++ b/src/repository/store/engine.rs @@ -0,0 +1,1265 @@ +//! Shared store engine for every built-in Kubernetes kind. +//! +//! The engine keeps the Freelens ownership model: one store per cluster epoch +//! and kind, a refcounted watch set, and 1-second coalesced snapshots. Typed +//! list/watch dispatch stays per kind; this module owns the shared lifecycle. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; + +use futures::TryStreamExt as _; +use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; +use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; +use k8s_openapi::api::batch::v1::{CronJob, Job}; +use k8s_openapi::api::coordination::v1::Lease; +use k8s_openapi::api::core::v1::{ + ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, + ReplicationController, Secret, Service, ServiceAccount, +}; +use k8s_openapi::api::discovery::v1::EndpointSlice; +use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; +use k8s_openapi::api::policy::v1::PodDisruptionBudget; +use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; +use k8s_openapi::api::storage::v1::StorageClass; +use kube::api::{Api, DynamicObject, ListParams, WatchEvent, WatchParams}; +use kube::core::{ApiResource, GroupVersionKind, NamespaceResourceScope}; +use kube::{Client, Resource, ResourceExt}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::{mpsc, watch}; + +use crate::models::{ + Item, NamespaceScope, ObjectKey, PollRate, ResourceKind, SnapshotPhase, WatchMode, +}; +use crate::repository::Subscription; +use crate::repository::store::mapping::{ + cluster_role_item, config_map_item, cron_job_item, custom_item, daemon_set_item, + deployment_item, endpoint_slice_item, endpoints_item, event_item, + horizontal_pod_autoscaler_item, ingress_item, job_item, lease_item, namespace_item, + network_policy_item, node_item, pod_disruption_budget_item, pod_item, pv_item, pvc_item, + replica_set_item, replication_controller_item, role_binding_item, role_item, secret_item, + service_account_item, service_item, stateful_set_item, storage_class_item, +}; +use crate::repository::store::{ + ClusterCapabilities, KubeObjectRecord, StoreData, StoreSnapshot, StoreView, StoreWatchPlan, + watch_plan, +}; +use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; + +const EVENT_FLUSH_INTERVAL: Duration = Duration::from_secs(1); +const WATCH_TIMEOUT_SECS: u32 = 280; +const RECONNECT_DELAY: Duration = Duration::from_millis(500); +const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); +const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); +const STORE_ITEM_CAP: usize = 50_000; + +type ApiFactory = Arc Api + Send + Sync>; + +/// Handle used by the repository registry and by view subscriptions. +pub(crate) struct KubeObjectStoreHandle { + commands: mpsc::UnboundedSender, + snapshots: watch::Sender>, +} + +impl KubeObjectStoreHandle { + pub(crate) fn object(&self, key: &ObjectKey) -> Option> { + self.snapshots.borrow().object(key).cloned() + } + + pub(crate) fn snapshot_receiver(&self) -> watch::Receiver> { + self.snapshots.subscribe() + } + + pub(crate) fn set_prefs(&self, prefs: WatchPrefs) { + let _ = self.commands.send(Command::SetPrefs(prefs)); + } + + pub(crate) fn subscribe( + &self, + scope: NamespaceScope, + tokio: &tokio::runtime::Handle, + ) -> Subscription { + let _ = self.commands.send(Command::Subscribe(scope.clone())); + + let mut view = StoreView::new(scope.clone()); + let current = self.snapshots.borrow().clone(); + let initial = match view.apply(¤t) { + Some(snapshot) => snapshot, + None => view.snapshot(), + }; + let (view_tx, view_rx) = watch::channel(initial); + let mut store_rx = self.snapshots.subscribe(); + let (cancel, mut cancel_rx) = watch::channel(false); + + let lease = Arc::new(StoreLease { + cancel, + commands: self.commands.clone(), + scope, + }); + let (paused, paused_rx) = watch::channel(false); + let refresh_commands = self.commands.clone(); + let refresh = Arc::new(move || { + let _ = refresh_commands.send(Command::Refresh); + }); + + tokio.spawn(async move { + loop { + tokio::select! { + _ = cancel_rx.changed() => break, + result = store_rx.changed() => { + if result.is_err() { + break; + } + let snapshot = store_rx.borrow_and_update().clone(); + if let Some(next) = view.apply(&snapshot) { + view_tx.send_replace(next); + } + } + } + } + }); + + Subscription::new(view_rx, paused, paused_rx, refresh, lease) + } +} + +struct StoreLease { + cancel: watch::Sender, + commands: mpsc::UnboundedSender, + scope: NamespaceScope, +} + +impl Drop for StoreLease { + fn drop(&mut self) { + let _ = self.cancel.send(true); + let _ = self.commands.send(Command::Unsubscribe(self.scope.clone())); + } +} + +enum Command { + Subscribe(NamespaceScope), + Unsubscribe(NamespaceScope), + Refresh, + SetPrefs(WatchPrefs), +} + +enum InitialStream { + Listed { resource_version: String }, + Unsupported, + Denied(String), + Cancelled, +} + +enum StoreEvent { + Replace(NamespaceScope, Vec), + Upsert(KubeObjectRecord), + Remove(ObjectKey), + Error(NamespaceScope, String), + WatchDenied(NamespaceScope, String), +} + +struct StoreTask +where + K: Resource + Clone + Debug + DeserializeOwned + Serialize + Send + Sync + 'static, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + kind: ResourceKind, + global_watch: bool, + global_list: bool, + data: StoreData, + scopes: HashMap, + plan: Option, + watches: Vec>, + events: mpsc::UnboundedSender, + event_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, + snapshots: watch::Sender>, + phase: SnapshotPhase, + mode: WatchMode, + prefs: WatchPrefs, + watch_denied: bool, + map: F, + make_api: ApiFactory, +} + +impl StoreTask +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + async fn run(mut self) { + let mut ticker = tokio::time::interval(EVENT_FLUSH_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + + loop { + tokio::select! { + command = self.command_rx.recv() => { + let Some(command) = command else { break }; + self.handle_command(command); + } + event = self.event_rx.recv() => { + let Some(event) = event else { break }; + self.handle_event(event); + } + _ = ticker.tick() => { + self.flush(false); + } + } + } + + self.stop_watches(); + } + + fn handle_command(&mut self, command: Command) { + match command { + Command::Subscribe(scope) => { + *self.scopes.entry(scope).or_default() += 1; + self.reconcile_watches(); + } + Command::Unsubscribe(scope) => { + let remove = if let Some(count) = self.scopes.get_mut(&scope) { + if *count > 1 { + *count -= 1; + false + } else { + true + } + } else { + false + }; + if remove { + self.scopes.remove(&scope); + } + self.reconcile_watches(); + } + Command::Refresh => { + let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); + self.restart_watches(plan); + } + Command::SetPrefs(prefs) => { + self.prefs = prefs; + self.watch_denied = false; + self.mode = match prefs.mode { + WatchPreference::Watch => WatchMode::Watch, + WatchPreference::Poll => WatchMode::Poll(prefs.rate), + }; + tracing::info!( + kind = ?self.kind, + mode = ?self.mode, + "store: update mode" + ); + let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); + self.restart_watches(plan); + } + } + } + + fn handle_event(&mut self, event: StoreEvent) { + match event { + StoreEvent::Replace(scope, records) => { + let changed = match &scope { + NamespaceScope::All => self.data.replace_all(records), + NamespaceScope::One(namespace) => { + self.data.replace_namespace(namespace, records) + } + }; + let trimmed = self.data.trim_oldest(STORE_ITEM_CAP); + tracing::trace!( + kind = ?self.kind, + ?scope, + changed, + trimmed, + "store: list replaced" + ); + self.phase = SnapshotPhase::Ready; + self.flush(true); + } + StoreEvent::Upsert(record) => { + self.data.upsert(record); + self.phase = SnapshotPhase::Ready; + self.flush(false); + } + StoreEvent::Remove(key) => { + self.data.remove(&key); + self.phase = SnapshotPhase::Ready; + self.flush(false); + } + StoreEvent::Error(scope, message) => { + tracing::warn!(kind = ?self.kind, ?scope, %message, "store: watch error"); + self.phase = SnapshotPhase::Error(message); + self.flush(true); + } + StoreEvent::WatchDenied(scope, message) => { + if self.prefs.mode == WatchPreference::Watch && !self.watch_denied { + tracing::warn!( + kind = ?self.kind, + ?scope, + %message, + "store: watch denied, falling back to polling" + ); + self.watch_denied = true; + self.mode = WatchMode::Poll(self.prefs.rate); + let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); + self.restart_watches(plan); + } else { + // Even polling cannot read this resource, so surface the + // permission problem instead of silently retaining stale + // data. + self.phase = SnapshotPhase::Error(message); + self.flush(true); + } + } + } + } + + fn reconcile_watches(&mut self) { + let plan = self.compute_plan(); + + if self.scopes.is_empty() { + self.plan = Some(plan); + self.stop_watches(); + return; + } + + if self.plan.as_ref() != Some(&plan) { + self.plan = Some(plan.clone()); + self.restart_watches(plan); + } else if self.watches.is_empty() { + self.restart_watches(plan); + } + } + + fn compute_plan(&self) -> StoreWatchPlan { + let global = match self.mode { + WatchMode::Watch => self.global_watch, + WatchMode::Poll(_) => self.global_list, + }; + watch_plan( + self.scopes.keys().cloned(), + &ClusterCapabilities { + global_watch: global, + accessible_namespaces: Vec::new(), + }, + ) + } + + fn restart_watches(&mut self, plan: StoreWatchPlan) { + self.stop_watches(); + + match plan { + StoreWatchPlan::All => { + self.spawn_watch(NamespaceScope::All); + } + StoreWatchPlan::PerNamespace(namespaces) => { + for namespace in namespaces { + self.spawn_watch(NamespaceScope::One(namespace)); + } + } + StoreWatchPlan::UnavailableAll => { + self.phase = SnapshotPhase::Error(format!( + "Cluster-wide {} watch is not permitted; select a namespace.", + self.kind.label() + )); + self.flush(true); + } + } + } + + fn spawn_watch(&mut self, scope: NamespaceScope) { + let (cancel, cancel_rx) = watch::channel(false); + let events = self.events.clone(); + let client = self.client.clone(); + let map = self.map; + let make_api = self.make_api.clone(); + match self.mode { + WatchMode::Watch => { + self.tokio + .spawn(run_watch(client, scope, events, cancel_rx, map, make_api)); + } + WatchMode::Poll(rate) => { + self.tokio.spawn(run_poll( + client, scope, events, cancel_rx, map, make_api, rate, + )); + } + } + self.watches.push(cancel); + } + + fn stop_watches(&mut self) { + for cancel in self.watches.drain(..) { + let _ = cancel.send(true); + } + } + + fn flush(&mut self, force: bool) { + if !force && self.data.pending() == 0 { + return; + } + + let snapshot = Arc::new(self.data.snapshot(self.phase.clone(), self.mode)); + tracing::trace!( + epoch = self.epoch, + kind = ?self.kind, + revision = snapshot.revision(), + objects = snapshot.objects().len(), + "store: flush" + ); + self.snapshots.send_replace(snapshot); + } +} + +fn bookmark_ends_initial_events(annotations: &BTreeMap) -> bool { + annotations + .get("k8s.io/initial-events-end") + .is_some_and(|value| value == "true") +} + +async fn stream_initial( + api: &Api, + scope: &NamespaceScope, + events: &mpsc::UnboundedSender, + cancel: &mut watch::Receiver, + map: F, +) -> InitialStream +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + let params = WatchParams::streaming_lists().timeout(WATCH_TIMEOUT_SECS); + let watched = tokio::select! { + _ = cancel.changed() => return InitialStream::Cancelled, + result = api.watch(¶ms, "") => result, + }; + let mut stream = match watched { + Ok(stream) => Box::pin(stream), + Err(error) => { + if is_forbidden(&error) { + return InitialStream::Denied(format!("Watch denied: {error}")); + } + return InitialStream::Unsupported; + } + }; + + let mut records: BTreeMap = BTreeMap::new(); + let mut resource_version = String::new(); + let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; + let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + + loop { + let deadline = total_deadline.min(idle_deadline); + tokio::select! { + _ = cancel.changed() => return InitialStream::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return InitialStream::Unsupported; + } + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + if let Some(version) = object.resource_version() { + resource_version = version; + } + let record = KubeObjectRecord::new(object.clone(), map(&object)); + records.insert(record.key.clone(), record); + } + Ok(Some(WatchEvent::Deleted(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + if let Some(version) = object.resource_version() { + resource_version = version; + } + records.remove(&ObjectKey::new( + object.namespace().unwrap_or_default(), + object.name_any(), + )); + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + if !bookmark.metadata.resource_version.is_empty() { + resource_version = bookmark.metadata.resource_version.clone(); + } + if !bookmark_ends_initial_events(&bookmark.metadata.annotations) { + return InitialStream::Unsupported; + } + if events + .send(StoreEvent::Replace( + scope.clone(), + records.into_values().collect(), + )) + .is_err() + { + return InitialStream::Cancelled; + } + return InitialStream::Listed { resource_version }; + } + Ok(Some(WatchEvent::Error(status))) => { + if status.code == 403 { + return InitialStream::Denied(format!( + "Watch denied: {}", + status.message + )); + } + return InitialStream::Unsupported; + } + Ok(None) | Err(_) => return InitialStream::Unsupported, + } + } + } +} + +async fn run_watch( + client: Client, + scope: NamespaceScope, + events: mpsc::UnboundedSender, + mut cancel: watch::Receiver, + map: F, + make_api: ApiFactory, +) where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + let api = make_api(&client, &scope); + let mut resource_version = String::new(); + let mut need_list = true; + let mut stream_initial_disabled = false; + + loop { + if need_list { + let mut streamed = false; + if !stream_initial_disabled { + match stream_initial(&api, &scope, &events, &mut cancel, map).await { + InitialStream::Listed { + resource_version: version, + } => { + tracing::debug!(?scope, "store: initial state came from a streaming list"); + resource_version = version; + need_list = false; + streamed = true; + } + InitialStream::Unsupported => { + tracing::debug!( + ?scope, + "store: streaming list unsupported, falling back to list" + ); + stream_initial_disabled = true; + } + InitialStream::Denied(message) => { + let _ = events.send(StoreEvent::WatchDenied(scope.clone(), message)); + return; + } + InitialStream::Cancelled => return, + } + } + + if !streamed { + let params = ListParams::default(); + let listed = tokio::select! { + _ = cancel.changed() => return, + result = api.list(¶ms) => result, + }; + + match listed { + Ok(list) => { + if let Some(version) = list.metadata.resource_version { + resource_version = version; + } + let records = list + .items + .iter() + .map(|object| KubeObjectRecord::new(object.clone(), map(object))) + .collect(); + if events + .send(StoreEvent::Replace(scope.clone(), records)) + .is_err() + { + return; + } + need_list = false; + } + Err(error) => { + if is_forbidden(&error) { + let _ = events.send(StoreEvent::WatchDenied( + scope.clone(), + format!("Watch denied: {error}"), + )); + return; + } + let message = format!("Could not list resources: {error}"); + let _ = events.send(StoreEvent::Error(scope.clone(), message)); + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + return; + } + continue; + } + } + } + } + + let params = WatchParams::default().timeout(WATCH_TIMEOUT_SECS); + let watched = tokio::select! { + _ = cancel.changed() => return, + result = api.watch(¶ms, &resource_version) => result, + }; + + let mut stream = match watched { + Ok(stream) => Box::pin(stream), + Err(error) => { + if is_forbidden(&error) { + let _ = events.send(StoreEvent::WatchDenied( + scope.clone(), + format!("Watch denied: {error}"), + )); + return; + } + let message = format!("Could not watch resources: {error}"); + let _ = events.send(StoreEvent::Error(scope.clone(), message)); + need_list = true; + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + return; + } + continue; + } + }; + + let mut relist = false; + loop { + let event = tokio::select! { + _ = cancel.changed() => return, + event = stream.try_next() => event, + }; + + match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + if let Some(version) = object.resource_version() { + resource_version = version; + } + if events + .send(StoreEvent::Upsert(KubeObjectRecord::new( + object.clone(), + map(&object), + ))) + .is_err() + { + return; + } + } + Ok(Some(WatchEvent::Deleted(object))) => { + if let Some(version) = object.resource_version() { + resource_version = version; + } + let key = + ObjectKey::new(object.namespace().unwrap_or_default(), object.name_any()); + if events.send(StoreEvent::Remove(key)).is_err() { + return; + } + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + resource_version = bookmark.metadata.resource_version; + } + Ok(Some(WatchEvent::Error(status))) => { + relist = + status.code == 410 || status.reason == "Expired" || status.reason == "Gone"; + if status.code == 403 { + let _ = events.send(StoreEvent::WatchDenied( + scope.clone(), + format!("Watch denied: {}", status.message), + )); + return; + } + if relist { + tracing::debug!(code = status.code, "store: watch expired, relisting"); + } else { + let message = format!("Watch error ({}): {}", status.code, status.message); + let _ = events.send(StoreEvent::Error(scope.clone(), message)); + } + break; + } + Ok(None) => break, + Err(error) => { + let _ = events.send(StoreEvent::Error( + scope.clone(), + format!("Watch stream failed: {error}"), + )); + break; + } + } + } + + if relist { + need_list = true; + } + + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + return; + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_poll( + client: Client, + scope: NamespaceScope, + events: mpsc::UnboundedSender, + mut cancel: watch::Receiver, + map: F, + make_api: ApiFactory, + rate: PollRate, +) where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + loop { + let api = make_api(&client, &scope); + let params = ListParams::default(); + let listed = tokio::select! { + _ = cancel.changed() => return, + result = api.list(¶ms) => result, + }; + + match listed { + Ok(list) => { + let records = list + .items + .iter() + .map(|object| KubeObjectRecord::new(object.clone(), map(object))) + .collect(); + if events + .send(StoreEvent::Replace(scope.clone(), records)) + .is_err() + { + return; + } + } + Err(error) => { + if is_forbidden(&error) { + let _ = events.send(StoreEvent::WatchDenied( + scope.clone(), + format!("List denied: {error}"), + )); + return; + } + let message = format!("Could not list resources: {error}"); + let _ = events.send(StoreEvent::Error(scope.clone(), message)); + } + } + + let delay = match rate { + PollRate::Off => None, + PollRate::Every(seconds) => Some(Duration::from_secs(seconds.max(1))), + }; + tokio::select! { + _ = cancel.changed() => return, + _ = async { + match delay { + Some(delay) => tokio::time::sleep(delay).await, + None => std::future::pending().await, + } + } => {} + } + } +} + +fn is_forbidden(error: &kube::Error) -> bool { + matches!(error, kube::Error::Api(response) if response.code == 403) +} + +async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = cancel.changed() => false, + _ = tokio::time::sleep(duration) => true, + } +} + +/// Spawn the store for one kind. The typed match mirrors the existing watcher +/// dispatch and keeps built-in kinds on their concrete Kubernetes types. +pub(crate) fn spawn( + kind: ResourceKind, + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + global_watch: bool, + global_list: bool, + prefs: WatchPrefs, +) -> Option> { + match kind { + ResourceKind::Pods => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + pod_item, + namespaced_api::, + )), + ResourceKind::Deployments => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + deployment_item, + namespaced_api::, + )), + ResourceKind::StatefulSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + stateful_set_item, + namespaced_api::, + )), + ResourceKind::ReplicaSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + replica_set_item, + namespaced_api::, + )), + ResourceKind::ReplicationControllers => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + replication_controller_item, + namespaced_api::, + )), + ResourceKind::DaemonSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + daemon_set_item, + namespaced_api::, + )), + ResourceKind::Jobs => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + job_item, + namespaced_api::, + )), + ResourceKind::CronJobs => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + cron_job_item, + namespaced_api::, + )), + ResourceKind::HorizontalPodAutoscalers => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + horizontal_pod_autoscaler_item, + namespaced_api::, + )), + ResourceKind::PodDisruptionBudgets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + pod_disruption_budget_item, + namespaced_api::, + )), + ResourceKind::Services => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + service_item, + namespaced_api::, + )), + ResourceKind::Ingresses => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + ingress_item, + namespaced_api::, + )), + ResourceKind::Endpoints => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + endpoints_item, + namespaced_api::, + )), + ResourceKind::EndpointSlices => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + endpoint_slice_item, + namespaced_api::, + )), + ResourceKind::NetworkPolicies => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + network_policy_item, + namespaced_api::, + )), + ResourceKind::PersistentVolumeClaims => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + pvc_item, + namespaced_api::, + )), + ResourceKind::StorageClasses => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + storage_class_item, + cluster_api::, + )), + ResourceKind::ConfigMaps => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + config_map_item, + namespaced_api::, + )), + ResourceKind::Secrets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + secret_item, + namespaced_api::, + )), + ResourceKind::ServiceAccounts => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + service_account_item, + namespaced_api::, + )), + ResourceKind::PersistentVolumes => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + pv_item, + cluster_api::, + )), + ResourceKind::Namespaces => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + namespace_item, + cluster_api::, + )), + ResourceKind::Nodes => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + node_item, + cluster_api::, + )), + ResourceKind::Leases => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + lease_item, + namespaced_api::, + )), + ResourceKind::Events => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + event_item, + namespaced_api::, + )), + ResourceKind::ClusterRoles => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + cluster_role_item, + cluster_api::, + )), + ResourceKind::Roles => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + role_item, + namespaced_api::, + )), + ResourceKind::RoleBindings => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + role_binding_item, + namespaced_api::, + )), + ResourceKind::Custom(index) => { + let def = crate::models::resources::catalog::custom_def(index)?; + let resource = ApiResource::from_gvk_with_plural( + &GroupVersionKind::gvk(&def.group, &def.version, &def.kind), + &def.plural, + ); + let make_api = move |client: &Client, scope: &NamespaceScope| match scope { + NamespaceScope::All => Api::::all_with(client.clone(), &resource), + NamespaceScope::One(namespace) => { + Api::::namespaced_with(client.clone(), namespace, &resource) + } + }; + Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + global_list, + prefs, + custom_item, + make_api, + )) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn spawn_typed( + kind: ResourceKind, + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + global_watch: bool, + global_list: bool, + prefs: WatchPrefs, + map: F, + make_api: M, +) -> Arc +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, + M: Fn(&Client, &NamespaceScope) -> Api + Send + Sync + 'static, +{ + let mut data = StoreData::new(); + let initial = Arc::new(data.snapshot(SnapshotPhase::Loading, WatchMode::Watch)); + let (snapshots, _) = watch::channel(initial); + let (commands, command_rx) = mpsc::unbounded_channel(); + let (events, event_rx) = mpsc::unbounded_channel(); + + let handle = Arc::new(KubeObjectStoreHandle { + commands: commands.clone(), + snapshots: snapshots.clone(), + }); + + let task = StoreTask { + client, + tokio: tokio.clone(), + epoch, + kind, + global_watch, + global_list, + data, + scopes: HashMap::new(), + plan: None, + watches: Vec::new(), + events: events.clone(), + event_rx, + command_rx, + snapshots, + phase: SnapshotPhase::Loading, + mode: match prefs.mode { + WatchPreference::Watch => WatchMode::Watch, + WatchPreference::Poll => WatchMode::Poll(prefs.rate), + }, + prefs, + watch_denied: false, + map, + make_api: Arc::new(make_api), + }; + tokio.spawn(task.run()); + + handle +} + +fn namespaced_api(client: &Client, scope: &NamespaceScope) -> Api +where + K: Resource + + Clone + + Debug + + DeserializeOwned + + Send + + 'static, +{ + match scope { + NamespaceScope::All => Api::::all(client.clone()), + NamespaceScope::One(namespace) => Api::::namespaced(client.clone(), namespace), + } +} + +fn cluster_api(client: &Client, _: &NamespaceScope) -> Api +where + K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, +{ + Api::::all(client.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_initial_events_bookmark_ends_the_stream() { + let mut annotations = BTreeMap::new(); + assert!(!bookmark_ends_initial_events(&annotations)); + + annotations.insert("k8s.io/initial-events-end".into(), "false".into()); + assert!(!bookmark_ends_initial_events(&annotations)); + + annotations.insert("k8s.io/initial-events-end".into(), "true".into()); + assert!(bookmark_ends_initial_events(&annotations)); + } +} diff --git a/src/repository/store/mapping.rs b/src/repository/store/mapping.rs new file mode 100644 index 0000000..47894a2 --- /dev/null +++ b/src/repository/store/mapping.rs @@ -0,0 +1,584 @@ +//! Item summaries for each built-in resource kind. +//! +//! These mappers are shared by the store engine. They are pure conversions from +//! a typed Kubernetes object to the table row shown by the UI. + +use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; +use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; +use k8s_openapi::api::batch::v1::{CronJob, Job}; +use k8s_openapi::api::coordination::v1::Lease; +use k8s_openapi::api::core::v1::{ + ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, + ReplicationController, Secret, Service, ServiceAccount, +}; +use k8s_openapi::api::discovery::v1::EndpointSlice; +use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; +use k8s_openapi::api::policy::v1::PodDisruptionBudget; +use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; +use k8s_openapi::api::storage::v1::StorageClass; +use kube::ResourceExt; +use kube::api::DynamicObject; + +use crate::models::Item; + +fn base_item(object: &K) -> Item +where + K: ResourceExt, +{ + let mut item = Item::new(object.name_any(), object.namespace()); + item.uid = object.uid().unwrap_or_default(); + item.resource_version = object.resource_version().unwrap_or_default(); + item.created_at = object.creation_timestamp().map(|time| time.0.as_second()); + item +} + +fn version_label(labels: Option<&std::collections::BTreeMap>) -> String { + labels + .and_then(|labels| labels.get("app.kubernetes.io/version").cloned()) + .unwrap_or_default() +} + +pub(crate) fn pod_item(pod: &Pod) -> Item { + let mut item = base_item(pod); + item.node = pod + .spec + .as_ref() + .and_then(|spec| spec.node_name.clone()) + .unwrap_or_default(); + let statuses = pod + .status + .as_ref() + .and_then(|status| status.container_statuses.as_ref()); + let total = statuses.map(|statuses| statuses.len()).unwrap_or(0); + let ready = statuses + .map(|statuses| statuses.iter().filter(|status| status.ready).count()) + .unwrap_or(0); + item.ready = format!("{ready}/{total}"); + item.restarts = statuses + .map(|statuses| { + statuses + .iter() + .map(|status| status.restart_count as usize) + .sum() + }) + .unwrap_or(0); + item.status = pod + .status + .as_ref() + .and_then(|status| status.phase.as_deref()) + .unwrap_or("unknown") + .to_lowercase(); + let crashing = statuses.is_some_and(|statuses| { + statuses.iter().any(|status| { + status + .state + .as_ref() + .and_then(|state| state.waiting.as_ref()) + .and_then(|waiting| waiting.reason.as_deref()) + .is_some_and(|reason| reason == "CrashLoopBackOff") + }) + }); + if crashing { + item.status = "crashloopbackoff".to_string(); + } + item +} + +pub(crate) fn deployment_item(deployment: &Deployment) -> Item { + let mut item = base_item(deployment); + let desired = deployment + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = deployment + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(deployment.metadata.labels.as_ref()); + item +} + +pub(crate) fn daemon_set_item(daemon_set: &DaemonSet) -> Item { + let mut item = base_item(daemon_set); + let desired = daemon_set + .status + .as_ref() + .map(|status| status.desired_number_scheduled) + .unwrap_or(0); + let ready = daemon_set + .status + .as_ref() + .map(|status| status.number_ready) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(daemon_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn job_item(job: &Job) -> Item { + let mut item = base_item(job); + let want = job + .spec + .as_ref() + .and_then(|spec| spec.completions) + .unwrap_or(1); + let done = job + .status + .as_ref() + .and_then(|status| status.succeeded) + .unwrap_or(0); + item.ready = format!("{done}/{want}"); + item.capacity = want.to_string(); + item.status = if done >= want { "succeeded" } else { "running" }.to_string(); + item +} + +pub(crate) fn service_item(service: &Service) -> Item { + let mut item = base_item(service); + let spec = service.spec.as_ref(); + item.status = spec + .and_then(|spec| spec.type_.clone()) + .unwrap_or_else(|| "ClusterIP".to_string()) + .to_lowercase(); + item.version = spec + .and_then(|spec| spec.ports.as_ref()) + .and_then(|ports| ports.first()) + .map(|port| port.port.to_string()) + .unwrap_or_default(); + item +} + +pub(crate) fn ingress_item(ingress: &Ingress) -> Item { + let mut item = base_item(ingress); + item.status = "ingress".to_string(); + item.version = ingress + .spec + .as_ref() + .and_then(|spec| spec.rules.as_ref()) + .and_then(|rules| rules.first()) + .and_then(|rule| rule.host.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn pv_item(persistent_volume: &PersistentVolume) -> Item { + let mut item = base_item(persistent_volume); + item.capacity = persistent_volume + .spec + .as_ref() + .and_then(|spec| spec.capacity.as_ref()) + .and_then(|capacity| capacity.get("storage")) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.status = persistent_volume + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_default() + .to_lowercase(); + item +} + +pub(crate) fn pvc_item(persistent_volume_claim: &PersistentVolumeClaim) -> Item { + let mut item = base_item(persistent_volume_claim); + item.capacity = persistent_volume_claim + .status + .as_ref() + .and_then(|status| status.capacity.as_ref()) + .and_then(|capacity| capacity.get("storage")) + .or_else(|| { + persistent_volume_claim + .spec + .as_ref() + .and_then(|spec| spec.resources.as_ref()) + .and_then(|resources| resources.requests.as_ref()) + .and_then(|requests| requests.get("storage")) + }) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.status = persistent_volume_claim + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_default() + .to_lowercase(); + item +} + +pub(crate) fn config_map_item(config_map: &ConfigMap) -> Item { + let mut item = base_item(config_map); + let data_keys = config_map.data.as_ref().map(|data| data.len()).unwrap_or(0); + let binary_keys = config_map + .binary_data + .as_ref() + .map(|data| data.len()) + .unwrap_or(0); + item.status = "keys".to_string(); + item.capacity = (data_keys + binary_keys).to_string(); + item.version = config_map + .data + .as_ref() + .and_then(|data| data.keys().next().cloned()) + .unwrap_or_default(); + item +} + +pub(crate) fn secret_item(secret: &Secret) -> Item { + let mut item = base_item(secret); + item.status = "keys".to_string(); + item.capacity = secret + .data + .as_ref() + .map(|data| data.len()) + .unwrap_or(0) + .to_string(); + item.version = secret + .data + .as_ref() + .and_then(|data| data.keys().next().cloned()) + .unwrap_or_default(); + item +} + +pub(crate) fn stateful_set_item(stateful_set: &StatefulSet) -> Item { + let mut item = base_item(stateful_set); + let desired = stateful_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = stateful_set + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(stateful_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn replica_set_item(replica_set: &ReplicaSet) -> Item { + let mut item = base_item(replica_set); + let desired = replica_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = replica_set + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(replica_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn replication_controller_item(controller: &ReplicationController) -> Item { + let mut item = base_item(controller); + let desired = controller + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = controller + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(controller.metadata.labels.as_ref()); + item +} + +pub(crate) fn horizontal_pod_autoscaler_item(autoscaler: &HorizontalPodAutoscaler) -> Item { + let mut item = base_item(autoscaler); + let spec = autoscaler.spec.as_ref(); + let max = spec.map(|spec| spec.max_replicas).unwrap_or(0); + let min = spec.and_then(|spec| spec.min_replicas).unwrap_or(0); + let current = autoscaler + .status + .as_ref() + .and_then(|status| status.current_replicas) + .unwrap_or(0); + item.ready = format!("{current}/{min}"); + item.capacity = max.to_string(); + item.status = if current >= min { "active" } else { "idle" }.to_string(); + item.version = spec + .map(|spec| { + format!( + "{}/{}", + spec.scale_target_ref.kind, spec.scale_target_ref.name + ) + }) + .unwrap_or_default(); + item +} + +pub(crate) fn pod_disruption_budget_item(budget: &PodDisruptionBudget) -> Item { + let mut item = base_item(budget); + let status = budget.status.as_ref(); + let current = status.map(|status| status.current_healthy).unwrap_or(0); + let desired = status.map(|status| status.desired_healthy).unwrap_or(0); + let allowed = status.map(|status| status.disruptions_allowed).unwrap_or(0); + item.ready = format!("{current}/{desired}"); + item.capacity = allowed.to_string(); + item.status = if allowed > 0 { "ready" } else { "blocked" }.to_string(); + item +} + +pub(crate) fn lease_item(lease: &Lease) -> Item { + let mut item = base_item(lease); + let spec = lease.spec.as_ref(); + item.version = spec + .and_then(|spec| spec.holder_identity.clone()) + .unwrap_or_default(); + item.capacity = spec + .and_then(|spec| spec.lease_duration_seconds) + .map(|seconds| format!("{seconds}s")) + .unwrap_or_default(); + item.status = "active".to_string(); + item +} + +pub(crate) fn endpoints_item(endpoints: &Endpoints) -> Item { + let mut item = base_item(endpoints); + let addresses = endpoints + .subsets + .as_ref() + .map(|subsets| { + subsets + .iter() + .map(|subset| { + subset.addresses.as_ref().map(Vec::len).unwrap_or(0) + + subset + .not_ready_addresses + .as_ref() + .map(Vec::len) + .unwrap_or(0) + }) + .sum::() + }) + .unwrap_or(0); + item.capacity = addresses.to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn endpoint_slice_item(slice: &EndpointSlice) -> Item { + let mut item = base_item(slice); + item.capacity = slice.endpoints.len().to_string(); + item.version = slice.address_type.clone(); + item.status = "active".to_string(); + item +} + +pub(crate) fn network_policy_item(policy: &NetworkPolicy) -> Item { + let mut item = base_item(policy); + item.capacity = policy + .spec + .as_ref() + .and_then(|spec| spec.policy_types.as_ref()) + .map(|types| types.join(", ")) + .unwrap_or_default(); + item.status = "active".to_string(); + item +} + +pub(crate) fn event_item(event: &Event) -> Item { + let mut item = base_item(event); + item.status = event + .type_ + .clone() + .unwrap_or_else(|| "normal".to_string()) + .to_lowercase(); + item.version = event.reason.clone().unwrap_or_default(); + item.capacity = event.count.unwrap_or(1).to_string(); + item +} + +pub(crate) fn cluster_role_item(cluster_role: &ClusterRole) -> Item { + let mut item = base_item(cluster_role); + item.capacity = cluster_role + .rules + .as_ref() + .map(|rules| rules.len()) + .unwrap_or(0) + .to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn role_item(role: &Role) -> Item { + let mut item = base_item(role); + item.capacity = role + .rules + .as_ref() + .map(|rules| rules.len()) + .unwrap_or(0) + .to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn role_binding_item(binding: &RoleBinding) -> Item { + let mut item = base_item(binding); + item.capacity = binding + .subjects + .as_ref() + .map(|subjects| subjects.len()) + .unwrap_or(0) + .to_string(); + item.version = format!("{}/{}", binding.role_ref.kind, binding.role_ref.name); + item.status = "active".to_string(); + item +} + +pub(crate) fn custom_item(object: &DynamicObject) -> Item { + let mut item = base_item(object); + let Some(status) = object.data.get("status") else { + return item; + }; + if let Some(phase) = status.get("phase").and_then(serde_json::Value::as_str) { + item.status = phase.to_lowercase(); + } else if let Some(conditions) = status + .get("conditions") + .and_then(serde_json::Value::as_array) + { + let ready = conditions.iter().find(|condition| { + condition.get("type").and_then(serde_json::Value::as_str) == Some("Ready") + }); + if let Some(ready) = ready { + item.status = + if ready.get("status").and_then(serde_json::Value::as_str) == Some("True") { + "ready" + } else { + "notready" + } + .to_string(); + } + } + item +} + +pub(crate) fn cron_job_item(cron_job: &CronJob) -> Item { + let mut item = base_item(cron_job); + let active = cron_job + .status + .as_ref() + .and_then(|status| status.active.as_ref()) + .map(|active| active.len()) + .unwrap_or(0); + let suspended = cron_job + .spec + .as_ref() + .and_then(|spec| spec.suspend) + .unwrap_or(false); + item.capacity = active.to_string(); + item.status = if suspended { "suspended" } else { "active" }.to_string(); + item.version = cron_job + .spec + .as_ref() + .map(|spec| spec.schedule.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn service_account_item(service_account: &ServiceAccount) -> Item { + let mut item = base_item(service_account); + let secrets = service_account.secrets.as_ref().map(Vec::len).unwrap_or(0); + item.status = "account".to_string(); + item.capacity = secrets.to_string(); + item.version = service_account + .secrets + .as_ref() + .and_then(|secrets| secrets.first()) + .and_then(|secret| secret.name.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn storage_class_item(storage_class: &StorageClass) -> Item { + let mut item = base_item(storage_class); + item.version = storage_class.provisioner.clone(); + item.capacity = storage_class.reclaim_policy.clone().unwrap_or_default(); + item.ready = storage_class + .volume_binding_mode + .clone() + .unwrap_or_default(); + item +} + +pub(crate) fn namespace_item(namespace: &Namespace) -> Item { + let mut item = base_item(namespace); + item.status = namespace + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_else(|| "unknown".to_string()) + .to_lowercase(); + item +} + +pub(crate) fn node_item(node: &Node) -> Item { + let mut item = base_item(node); + let status = node.status.as_ref(); + let ready = status + .and_then(|status| status.conditions.as_ref()) + .and_then(|conditions| { + conditions + .iter() + .find(|condition| condition.type_ == "Ready") + }) + .map(|condition| condition.status == "True") + .unwrap_or(false); + item.status = if ready { "ready" } else { "notready" }.to_string(); + item.capacity = status + .and_then(|status| status.allocatable.as_ref()) + .and_then(|allocatable| allocatable.get("cpu")) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.version = status + .and_then(|status| status.node_info.as_ref()) + .map(|info| info.kubelet_version.clone()) + .unwrap_or_default(); + item +} diff --git a/src/repository/store/mod.rs b/src/repository/store/mod.rs new file mode 100644 index 0000000..5b6a91b --- /dev/null +++ b/src/repository/store/mod.rs @@ -0,0 +1,119 @@ +//! Shared, per-kind store primitives. +//! +//! This module is the Freelens-style replacement for the current +//! per-`(kind, scope)` watch subscriptions. A [`KubeObjectRecord`] keeps the +//! full typed Kubernetes object alongside the small [`Item`] row summary. +//! [`StoreData`] owns the authoritative object map and UID index, while +//! [`StoreView`] provides a namespace-filtered view for one screen. + +mod data; +mod engine; +mod mapping; +mod plan; +mod record; +mod view; + +pub use data::{StoreData, StoreSnapshot}; +pub(crate) use engine::KubeObjectStoreHandle; +pub use plan::{ClusterCapabilities, StoreWatchPlan, watch_plan}; +pub use record::{KubeObjectRecord, StoredObject}; +pub use view::StoreView; + +use std::sync::Arc; + +use gpui_kit::{App, BorrowAppContext as _}; +use tokio::sync::watch; + +use crate::cluster::ClusterState; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind}; +use crate::repository::{Repository, StoreEntry, Subscription}; + +/// Subscribe a view to the shared store for `kind`. +pub(crate) fn subscribe( + cx: &mut App, + kind: ResourceKind, + scope: NamespaceScope, +) -> Result { + let (store, tokio) = ensure_store(cx, kind)?; + let scope = if kind.is_namespaced() { + scope + } else { + NamespaceScope::All + }; + Ok(store.subscribe(scope, &tokio)) +} + +/// Read a cached full object without creating a store or a network request. +pub(crate) fn cached_object( + cx: &App, + kind: ResourceKind, + key: &ObjectKey, +) -> Option> { + let repository = cx.global::(); + let epoch = cx.global::().context_epoch(); + let entry = repository.stores.get(&kind)?; + if entry.epoch != epoch { + return None; + } + entry.store.object(key) +} + +/// Observe an already-running store, if one exists. Detail panes use this for +/// live updates when a list view is already watching; they never start a store +/// or a per-object watch themselves. +pub(crate) fn existing_store_snapshot( + cx: &App, + kind: ResourceKind, +) -> Option>> { + let repository = cx.global::(); + let epoch = cx.global::().context_epoch(); + let entry = repository.stores.get(&kind)?; + if entry.epoch != epoch { + return None; + } + Some(entry.store.snapshot_receiver()) +} + +fn ensure_store( + cx: &mut App, + kind: ResourceKind, +) -> Result<(Arc, tokio::runtime::Handle), String> { + let state = cx.global::(); + let client = state.client().ok_or_else(|| "not connected".to_string())?; + let tokio = state.tokio(); + let epoch = state.context_epoch(); + let global_watch = state.global_watch(); + let global_list = state.global_list(); + + let store = cx.update_global::(|repository, _| { + if let Some(entry) = repository.stores.get(&kind) + && entry.epoch == epoch + { + return Ok(entry.store.clone()); + } + + let prefs = repository.watch_prefs.prefs_for(kind); + let Some(store) = engine::spawn( + kind, + client, + tokio.clone(), + epoch, + global_watch, + global_list, + prefs, + ) else { + return Err(format!("{} is not available in this build.", kind.label())); + }; + repository.stores.insert( + kind, + StoreEntry { + epoch, + store: store.clone(), + }, + ); + tracing::debug!(?kind, epoch, "store: created"); + Ok(store) + })?; + + Ok((store, tokio)) +} diff --git a/src/repository/store/plan.rs b/src/repository/store/plan.rs new file mode 100644 index 0000000..03afffa --- /dev/null +++ b/src/repository/store/plan.rs @@ -0,0 +1,137 @@ +//! Namespace watch planning, mirroring Freelens's selection model. + +use std::collections::BTreeSet; + +use crate::models::NamespaceScope; + +/// Cluster-level capabilities discovered when connecting. +/// +/// `global_watch` is populated from a `SelfSubjectAccessReview` for the +/// `watch` verb on `*`, the same probe Freelens performs as +/// `isGlobalWatchEnabled`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ClusterCapabilities { + pub global_watch: bool, + pub accessible_namespaces: Vec, +} + +/// How a store should keep its namespaces fresh. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StoreWatchPlan { + /// One watch for the whole cluster. + All, + /// One watch per namespace. + PerNamespace(Vec), + /// A view asked for all namespaces but cluster-wide watch is not allowed, + /// and no namespace-scoped subscription can be watched instead. + UnavailableAll, +} + +/// Compute the shared watch plan for the active subscription scopes. +/// +/// A cluster-wide watch is used only when all namespaces are requested *and* +/// the cluster permits global watch. Otherwise each requested namespace gets +/// its own watch. If an all-namespace subscription is present without global +/// watch permission but other namespace-scoped subscriptions exist, the plan +/// keeps the namespace watches; the all-namespace view itself reports the +/// permission error. +pub fn watch_plan(scopes: I, capabilities: &ClusterCapabilities) -> StoreWatchPlan +where + I: IntoIterator, +{ + let mut namespaces = BTreeSet::new(); + let mut requested_all = false; + + for scope in scopes { + match scope { + NamespaceScope::All => requested_all = true, + NamespaceScope::One(namespace) => { + namespaces.insert(namespace); + } + } + } + + if requested_all { + if capabilities.global_watch { + return StoreWatchPlan::All; + } + if namespaces.is_empty() { + return StoreWatchPlan::UnavailableAll; + } + } + + StoreWatchPlan::PerNamespace(namespaces.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(global_watch: bool) -> ClusterCapabilities { + ClusterCapabilities { + global_watch, + accessible_namespaces: vec!["ns-1".into(), "ns-2".into()], + } + } + + #[test] + fn all_namespaces_use_one_global_watch() { + assert_eq!( + watch_plan([NamespaceScope::All], &capabilities(true)), + StoreWatchPlan::All + ); + } + + #[test] + fn all_namespaces_without_permission_are_unavailable() { + assert_eq!( + watch_plan([NamespaceScope::All], &capabilities(false)), + StoreWatchPlan::UnavailableAll + ); + } + + #[test] + fn namespace_scopes_are_deduplicated_and_sorted() { + assert_eq!( + watch_plan( + [ + NamespaceScope::One("ns-2".into()), + NamespaceScope::One("ns-1".into()), + NamespaceScope::One("ns-2".into()), + ], + &capabilities(false), + ), + StoreWatchPlan::PerNamespace(vec!["ns-1".into(), "ns-2".into()]) + ); + } + + #[test] + fn all_plus_namespace_falls_back_to_namespace_watches() { + assert_eq!( + watch_plan( + [NamespaceScope::All, NamespaceScope::One("ns-1".into()),], + &capabilities(false), + ), + StoreWatchPlan::PerNamespace(vec!["ns-1".into()]) + ); + } + + #[test] + fn all_plus_namespace_uses_global_watch_when_allowed() { + assert_eq!( + watch_plan( + [NamespaceScope::All, NamespaceScope::One("ns-1".into()),], + &capabilities(true), + ), + StoreWatchPlan::All + ); + } + + #[test] + fn no_scopes_produce_an_empty_namespace_plan() { + assert_eq!( + watch_plan([], &capabilities(true)), + StoreWatchPlan::PerNamespace(Vec::new()) + ); + } +} diff --git a/src/repository/store/record.rs b/src/repository/store/record.rs new file mode 100644 index 0000000..ce998c2 --- /dev/null +++ b/src/repository/store/record.rs @@ -0,0 +1,77 @@ +//! Full-object cache records. +//! +//! The typed Kubernetes object is retained behind a type-erased trait so one +//! registry can hold every built-in kind and custom resource. Details downcast +//! to the concrete kind; list tables use the precomputed `Item` summary. + +use std::any::Any; +use std::sync::Arc; + +use kube::ResourceExt; + +use crate::models::{Item, ObjectKey}; + +/// A Kubernetes object stored without knowing its concrete Rust type. +pub trait StoredObject: Any + Send + Sync { + fn as_any(&self) -> &dyn Any; + fn to_json(&self) -> serde_json::Value; +} + +impl StoredObject for T +where + T: Any + Send + Sync + serde::Serialize + ResourceExt, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + } +} + +/// One object in the shared store. +pub struct KubeObjectRecord { + pub key: ObjectKey, + pub uid: String, + pub resource_version: String, + pub summary: Arc, + raw: Arc, +} + +impl KubeObjectRecord { + pub fn new(object: K, summary: Item) -> Self + where + K: StoredObject + ResourceExt, + { + let key = ObjectKey::new(object.namespace().unwrap_or_default(), object.name_any()); + let uid = object.uid().unwrap_or_default(); + let resource_version = object.resource_version().unwrap_or_default(); + Self { + key, + uid, + resource_version, + summary: Arc::new(summary), + raw: Arc::new(object), + } + } + + pub fn typed(&self) -> Option<&T> { + self.raw.as_any().downcast_ref::() + } + + pub fn to_json(&self) -> serde_json::Value { + self.raw.to_json() + } +} + +impl std::fmt::Debug for KubeObjectRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("KubeObjectRecord") + .field("key", &self.key) + .field("uid", &self.uid) + .field("resource_version", &self.resource_version) + .finish_non_exhaustive() + } +} diff --git a/src/repository/store/view.rs b/src/repository/store/view.rs new file mode 100644 index 0000000..9a9195e --- /dev/null +++ b/src/repository/store/view.rs @@ -0,0 +1,239 @@ +//! Namespace-filtered view over a shared store. +//! +//! A `StoreView` is not a cache and owns no network. It keeps the sorted `Item` +//! rows for one screen scope and rebuilds them only when the store flush is +//! relevant to that scope. + +use std::sync::Arc; + +use crate::models::{Item, NamespaceScope, ResourceSnapshot, SnapshotPhase, WatchMode}; + +use super::data::StoreSnapshot; +use super::record::KubeObjectRecord; + +pub struct StoreView { + scope: NamespaceScope, + items: Arc<[Arc]>, + phase: SnapshotPhase, + mode: WatchMode, + last_revision: u64, + last_namespace_revision: Option, + initialized: bool, +} + +impl StoreView { + pub fn new(scope: NamespaceScope) -> Self { + Self { + scope, + items: Arc::from(Vec::>::new()), + phase: SnapshotPhase::Loading, + mode: WatchMode::Watch, + last_revision: 0, + last_namespace_revision: None, + initialized: false, + } + } + + #[cfg(test)] + pub fn items(&self) -> &[Arc] { + &self.items + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.items.len() + } + + #[cfg(test)] + pub fn phase(&self) -> &SnapshotPhase { + &self.phase + } + + pub fn snapshot(&self) -> Arc { + Arc::new(ResourceSnapshot::from_items( + self.items.clone(), + self.phase.clone(), + self.mode, + )) + } + + /// Apply a store snapshot. Returns the new view snapshot when the scope saw + /// a relevant change; `None` means the view is unchanged. + pub fn apply(&mut self, snapshot: &StoreSnapshot) -> Option> { + let phase_changed = self.phase != *snapshot.phase(); + let mode_changed = self.mode != snapshot.mode(); + + let namespace_changed = match &self.scope { + NamespaceScope::All => self.last_revision != snapshot.revision(), + NamespaceScope::One(namespace) => { + snapshot.namespace_revision(namespace) != self.last_namespace_revision + } + }; + + if self.initialized && !phase_changed && !mode_changed && !namespace_changed { + return None; + } + + let objects = visible_objects(snapshot.objects(), &self.scope); + self.items = objects + .iter() + .map(|record| record.summary.clone()) + .collect(); + self.phase = snapshot.phase().clone(); + self.mode = snapshot.mode(); + self.last_revision = snapshot.revision(); + self.last_namespace_revision = match &self.scope { + NamespaceScope::All => None, + NamespaceScope::One(namespace) => snapshot.namespace_revision(namespace), + }; + self.initialized = true; + + Some(self.snapshot()) + } +} + +fn visible_objects<'a>( + objects: &'a [Arc], + scope: &NamespaceScope, +) -> &'a [Arc] { + match scope { + NamespaceScope::All => objects, + NamespaceScope::One(namespace) => { + let start = objects.partition_point(|record| record.key.namespace < *namespace); + let end = objects.partition_point(|record| record.key.namespace <= *namespace); + &objects[start..end] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{NamespaceScope, PollRate, ResourceSnapshot, SnapshotPhase, WatchMode}; + + fn view_for(scope: NamespaceScope, snapshot: &StoreSnapshot) -> StoreView { + let mut view = StoreView::new(scope); + view.apply(snapshot).expect("initial apply always emits"); + view + } + + #[test] + fn starts_loading_until_a_snapshot_arrives() { + let view = StoreView::new(NamespaceScope::All); + assert!(matches!(view.phase(), SnapshotPhase::Loading)); + assert_eq!(view.len(), 0); + assert_eq!(view.snapshot().mode(), WatchMode::Watch); + } + + #[test] + fn all_scope_includes_every_namespace() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let view = view_for(NamespaceScope::All, &snapshot); + assert_eq!(view.len(), 2); + assert_eq!(view.snapshot().items().len(), 2); + } + + #[test] + fn namespace_scope_includes_only_that_namespace() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + data.upsert(test_record("c", "ns-10", "uid-c", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let view = view_for(NamespaceScope::One("ns-2".into()), &snapshot); + assert_eq!(view.len(), 1); + assert_eq!(view.items()[0].name, "b"); + } + + #[test] + fn unchanged_global_revision_does_not_emit() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut view = view_for(NamespaceScope::All, &snapshot); + assert!(view.apply(&snapshot).is_none()); + } + + #[test] + fn unrelated_namespace_change_does_not_emit_for_a_namespace_view() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let first = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let mut view = view_for(NamespaceScope::One("ns-1".into()), &first); + + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + let second = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert!( + view.apply(&second).is_none(), + "ns-2 change must not repaint ns-1" + ); + + data.upsert(test_record("c", "ns-1", "uid-c", "1")); + let third = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert!(view.apply(&third).is_some()); + assert_eq!(view.len(), 2); + } + + #[test] + fn phase_and_mode_changes_always_emit() { + let mut data = super::super::data::StoreData::new(); + let mut view = StoreView::new(NamespaceScope::All); + let ready = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let first = view.apply(&ready).expect("initial emit"); + assert_eq!(first.mode(), WatchMode::Watch); + + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let polled = data.snapshot(SnapshotPhase::Ready, WatchMode::Poll(PollRate::Every(5))); + let emitted = view.apply(&polled).expect("mode change emits"); + assert_eq!(emitted.mode(), WatchMode::Poll(PollRate::Every(5))); + + let mut errored_data = super::super::data::StoreData::new(); + errored_data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let errored = + errored_data.snapshot(SnapshotPhase::Error("offline".into()), WatchMode::Watch); + let emitted = view.apply(&errored).expect("phase change emits"); + assert!(matches!(emitted.phase(), SnapshotPhase::Error(message) if message == "offline")); + } + + #[test] + fn snapshot_is_a_resource_snapshot_for_existing_models() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let store = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let view = view_for(NamespaceScope::All, &store); + let snapshot: Arc = view.snapshot(); + assert_eq!(snapshot.items().len(), 1); + assert_eq!(snapshot.phase(), &SnapshotPhase::Ready); + } + + fn test_record( + name: &str, + namespace: &str, + uid: &str, + version: &str, + ) -> super::super::record::KubeObjectRecord { + let object: kube::api::DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": namespace, + "uid": uid, + "resourceVersion": version + } + })) + .expect("dynamic object"); + + let mut item = Item::new(name.to_string(), Some(namespace.to_string())); + item.uid = uid.to_string(); + item.resource_version = version.to_string(); + super::super::record::KubeObjectRecord::new(object, item) + } +} diff --git a/src/repository/terminal.rs b/src/repository/terminal.rs new file mode 100644 index 0000000..1e27c89 --- /dev/null +++ b/src/repository/terminal.rs @@ -0,0 +1,357 @@ +//! Persistent terminal sessions owned by the repository, not by a view. +//! +//! A terminal view attaches to a session and can detach without killing the +//! shell. Closing the terminal tab destroys the session explicitly. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::SinkExt as _; +use gpui_kit::{App, BorrowAppContext as _}; +use k8s_openapi::api::core::v1::Pod; +use kube::Client; +use kube::api::{Api, AttachParams, TerminalSize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{broadcast, mpsc, watch}; + +use crate::cluster::ClusterState; +use crate::repository::Repository; + +const HISTORY_BYTES: usize = 1024 * 1024; +const OUTPUT_CAPACITY: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TerminalKey { + epoch: u64, + namespace: String, + pod: String, + container: String, + shell: String, +} + +#[derive(Clone, Debug)] +pub(crate) enum TerminalEvent { + Data(Vec), + Error(String), + Ended, +} + +pub(crate) struct TerminalAttachment { + pub history: Vec, + pub updates: broadcast::Receiver, +} + +pub(crate) struct TerminalSession { + key: TerminalKey, + history: Mutex>, + output: broadcast::Sender, + input: mpsc::UnboundedSender>, + resize: mpsc::UnboundedSender<(u16, u16)>, + cancel: watch::Sender, + finished: AtomicBool, +} + +impl TerminalSession { + pub(crate) fn key(&self) -> &TerminalKey { + &self.key + } + + pub(crate) fn attach(&self) -> TerminalAttachment { + let history = self + .history + .lock() + .map(|history| history.iter().copied().collect()) + .unwrap_or_default(); + TerminalAttachment { + history, + updates: self.output.subscribe(), + } + } + + pub(crate) fn input_handle(&self) -> mpsc::UnboundedSender> { + self.input.clone() + } + + pub(crate) fn resize_handle(&self) -> mpsc::UnboundedSender<(u16, u16)> { + self.resize.clone() + } + + pub(crate) fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } + + pub(crate) fn destroy(&self) { + let _ = self.cancel.send(true); + } + + fn push_data(&self, bytes: Vec) { + if let Ok(mut history) = self.history.lock() { + history.extend(bytes.iter().copied()); + let overflow = history.len().saturating_sub(HISTORY_BYTES); + if overflow > 0 { + history.drain(..overflow); + } + } + let _ = self.output.send(TerminalEvent::Data(bytes)); + } + + fn push_error(&self, message: String) { + let _ = self.output.send(TerminalEvent::Error(message)); + } + + fn finish(&self) { + self.finished.store(true, Ordering::Relaxed); + let _ = self.output.send(TerminalEvent::Ended); + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + let _ = self.cancel.send(true); + } +} + +#[derive(Default)] +pub(crate) struct TerminalSessionStore { + sessions: HashMap>, +} + +impl TerminalSessionStore { + fn retain_epoch(&mut self, epoch: u64) { + self.sessions.retain(|key, session| { + if key.epoch == epoch { + true + } else { + session.destroy(); + false + } + }); + } + + fn get(&self, key: &TerminalKey) -> Option> { + self.sessions.get(key).cloned() + } + + fn insert(&mut self, key: TerminalKey, session: Arc) { + self.sessions.insert(key, session); + } + + fn remove(&mut self, key: &TerminalKey) -> Option> { + self.sessions.remove(key) + } +} + +/// Open or reuse a terminal session for one pod container shell. +pub(crate) fn open( + cx: &mut App, + namespace: &str, + name: &str, + container: &str, + shell: &str, +) -> Result, String> { + if container.is_empty() { + return Err("This pod has no containers to exec into.".to_string()); + } + + let state = cx.global::(); + let client = state.client().ok_or_else(|| "not connected".to_string())?; + let tokio = state.tokio(); + let epoch = state.context_epoch(); + let key = TerminalKey { + epoch, + namespace: namespace.to_string(), + pod: name.to_string(), + container: container.to_string(), + shell: shell.to_string(), + }; + + if let Some(session) = cx.update_global::(|repository, _| { + repository.terminal.retain_epoch(epoch); + repository.terminal.get(&key) + }) && !session.is_finished() + { + return Ok(session); + } + + if let Some(old) = + cx.update_global::(|repository, _| repository.terminal.remove(&key)) + { + old.destroy(); + } + + let (output, _) = broadcast::channel(OUTPUT_CAPACITY); + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let (resize_tx, resize_rx) = mpsc::unbounded_channel(); + let (cancel, cancel_rx) = watch::channel(false); + let session = Arc::new(TerminalSession { + key: key.clone(), + history: Mutex::new(VecDeque::new()), + output, + input: input_tx, + resize: resize_tx, + cancel, + finished: AtomicBool::new(false), + }); + + cx.update_global::(|repository, _| { + repository.terminal.insert(key.clone(), session.clone()); + }); + + tokio.spawn(run_session( + client, + key, + session.clone(), + input_rx, + resize_rx, + cancel_rx, + )); + Ok(session) +} + +/// Explicitly destroy the session for a key. Detaching a view must not call +/// this; closing the terminal tab or switching clusters must. +pub(crate) fn destroy(cx: &mut App, key: &TerminalKey) { + let session = + cx.update_global::(|repository, _| repository.terminal.remove(key)); + if let Some(session) = session { + session.destroy(); + } +} + +async fn run_session( + client: Client, + key: TerminalKey, + session: Arc, + mut input: mpsc::UnboundedReceiver>, + mut resize: mpsc::UnboundedReceiver<(u16, u16)>, + mut cancel: watch::Receiver, +) { + let api: Api = Api::namespaced(client, &key.namespace); + let params = AttachParams { + container: Some(key.container.clone()), + stdin: true, + stdout: true, + stderr: false, + tty: true, + ..AttachParams::default() + }; + tracing::debug!( + namespace = %key.namespace, + pod = %key.pod, + container = %key.container, + shell = %key.shell, + "terminal: connecting" + ); + let mut process = match api.exec(&key.pod, [key.shell.as_str()], ¶ms).await { + Ok(process) => process, + Err(error) => { + session.push_error(format!("Could not start exec session: {error}")); + session.finish(); + return; + } + }; + tracing::debug!(pod = %key.pod, container = %key.container, "terminal: connected"); + + let Some(mut stdin) = process.stdin() else { + session.push_error("Exec session has no stdin channel.".to_string()); + process.abort(); + session.finish(); + return; + }; + let Some(stdout) = process.stdout() else { + session.push_error("Exec session has no stdout channel.".to_string()); + process.abort(); + session.finish(); + return; + }; + let stderr = process.stderr(); + + let writer = tokio::spawn(async move { + while let Some(bytes) = input.recv().await { + if stdin.write_all(&bytes).await.is_err() || stdin.flush().await.is_err() { + break; + } + } + }); + let stdout_task = tokio::spawn(read_output(stdout, session.clone())); + let stderr_task = stderr.map(|stderr| tokio::spawn(read_output(stderr, session.clone()))); + + let mut resize_task = process.terminal_size().map(|size_sender| { + tokio::spawn(async move { + let mut size_sender = size_sender; + while let Some((cols, rows)) = resize.recv().await { + let size = TerminalSize { + width: cols, + height: rows, + }; + if size_sender.send(size).await.is_err() { + break; + } + } + }) + }); + + let mut status_future = process.take_status(); + let status = tokio::select! { + _ = cancel.changed() => { + process.abort(); + writer.abort(); + stdout_task.abort(); + if let Some(task) = stderr_task { task.abort(); } + if let Some(task) = resize_task.take() { task.abort(); } + session.finish(); + return; + } + status = async { + match status_future.take() { + Some(status) => status.await, + None => None, + } + } => status, + }; + process.abort(); + writer.abort(); + stdout_task.abort(); + if let Some(task) = stderr_task { + task.abort(); + } + if let Some(task) = resize_task.take() { + task.abort(); + } + match status { + Some(status) if status.status.as_deref() == Some("Success") => { + session.push_data(b"\r\n[session ended]\r\n".to_vec()); + } + Some(status) => { + let message = status + .message + .as_deref() + .filter(|message| !message.is_empty()) + .map(|message| format!("Exec session ended: {message}")) + .unwrap_or_else(|| "Exec session ended".to_string()); + session.push_error(message); + } + None => { + session.push_data(b"\r\n[session closed]\r\n".to_vec()); + } + } + session.finish(); +} + +async fn read_output(mut reader: R, session: Arc) +where + R: AsyncRead + Unpin, +{ + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer).await { + Ok(0) => return, + Ok(read) => session.push_data(buffer[..read].to_vec()), + Err(error) => { + session.push_error(format!("Exec stream error: {error}")); + return; + } + } + } +} diff --git a/src/ui/resources/overview.rs b/src/ui/resources/overview.rs index 7881ecb..c644e57 100644 --- a/src/ui/resources/overview.rs +++ b/src/ui/resources/overview.rs @@ -18,7 +18,7 @@ use gpui_kit::{ use crate::cluster::ClusterState; use crate::models::Item; use crate::models::resources::node::detail::NodeUsage; -use crate::models::resources::overview::{ClusterOverviewStats, PodHealth, is_unhealthy_pod}; +use crate::models::resources::overview::{ClusterOverviewStats, PodHealth}; use crate::view_models::ClusterOverviewViewModel; pub struct OverviewView { @@ -96,12 +96,18 @@ impl OverviewView { impl Render for OverviewView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let vm = self.vm.read(cx); - let stats = vm.stats().clone(); - let nodes = vm.nodes().to_vec(); - let pods = vm.pods().to_vec(); - let metrics = vm.metrics().clone(); - let metrics_error = vm.metrics_error().map(str::to_string); + // Clone cheap handles only: the VM already cached the derived pod + // preview, and node snapshots/metrics are `Arc`s. + let (stats, nodes, unhealthy, metrics, metrics_error) = { + let vm = self.vm.read(cx); + ( + vm.stats().clone(), + vm.node_snapshot(), + vm.unhealthy_pods(), + vm.metrics().clone(), + vm.metrics_error().map(str::to_string), + ) + }; let header = self.header(&stats, cx); let cards = h_flex() @@ -164,13 +170,13 @@ impl Render for OverviewView { .p_4() .child(cards) .child(node_section( - &nodes, + nodes.items(), &stats, - &metrics, + metrics.as_ref(), metrics_error.as_deref(), cx, )) - .child(pod_section(&stats, &pods, cx)); + .child(pod_section(&stats, &unhealthy, cx)); v_flex() .size_full() @@ -349,7 +355,7 @@ fn node_header_row(cx: &App) -> AnyElement { .into_any_element() } -fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> AnyElement { +fn pod_section(stats: &ClusterOverviewStats, unhealthy: &[Arc], cx: &App) -> AnyElement { let mut health = h_flex().gap_2().items_center(); for (label, count, color) in [ ("Running", stats.pods_running, cx.theme().success), @@ -370,18 +376,9 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An health = health.child(health_chip(label, count, color, cx)); } - let mut unhealthy = pods - .iter() - .filter(|pod| is_unhealthy_pod(pod)) - .collect::>(); - unhealthy.sort_by(|left, right| { - left.namespace - .cmp(&right.namespace) - .then_with(|| left.name.cmp(&right.name)) - }); - + let unhealthy_total = stats.pods_unhealthy(); let mut body = v_flex().gap_3().child(health); - if unhealthy.is_empty() { + if unhealthy_total == 0 { body = body.child( div() .text_xs() @@ -394,7 +391,7 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An .pt_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("{} pods need attention", unhealthy.len())), + .child(format!("{unhealthy_total} pods need attention")), ); body = body.child( h_flex() @@ -409,7 +406,7 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An .child(div().w(px(72.)).text_right().child("Restarts")) .child(div().w(px(56.)).text_right().child("Age")), ); - for pod in unhealthy.iter().take(8) { + for pod in unhealthy { let status = PodHealth::from_status(&pod.status); body = body.child( h_flex() @@ -461,13 +458,14 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An ), ); } - if unhealthy.len() > 8 { + let hidden = unhealthy_total.saturating_sub(unhealthy.len()); + if hidden > 0 { body = body.child( div() .pt_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("+{} more", unhealthy.len() - 8)), + .child(format!("+{hidden} more")), ); } } diff --git a/src/ui/resources/pod/mod.rs b/src/ui/resources/pod/mod.rs index 6541b6a..5564b5b 100644 --- a/src/ui/resources/pod/mod.rs +++ b/src/ui/resources/pod/mod.rs @@ -144,6 +144,13 @@ impl PodsView { return; } + if let Some(containers) = + repository::resources::pods::cached_containers(cx, &namespace, &name) + { + self.open_logs(namespace, name, containers, container, follow, cx); + return; + } + let mut subscription = match repository::resources::pods::subscribe_detail(cx, &namespace, &name) { Ok(subscription) => subscription, diff --git a/src/ui/resources/pod/sessions.rs b/src/ui/resources/pod/sessions.rs index 2668e5d..e060cd2 100644 --- a/src/ui/resources/pod/sessions.rs +++ b/src/ui/resources/pod/sessions.rs @@ -101,7 +101,7 @@ impl PodSession { pub fn shutdown(self, cx: &mut App) { match self { Self::Logs { vm, .. } => vm.update(cx, |vm, _| vm.shutdown()), - Self::Exec { vm, .. } => vm.update(cx, |vm, _| vm.shutdown()), + Self::Exec { vm, .. } => vm.update(cx, |vm, cx| vm.shutdown(cx)), } } } diff --git a/src/view_models/resources/cluster_roles/mod.rs b/src/view_models/resources/cluster_roles/mod.rs index cad01bc..b1d5475 100644 --- a/src/view_models/resources/cluster_roles/mod.rs +++ b/src/view_models/resources/cluster_roles/mod.rs @@ -164,7 +164,7 @@ impl ClusterRolesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::cluster_roles::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ClusterRoles, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/config_maps/mod.rs b/src/view_models/resources/config_maps/mod.rs index 84b1834..ade1484 100644 --- a/src/view_models/resources/config_maps/mod.rs +++ b/src/view_models/resources/config_maps/mod.rs @@ -160,7 +160,7 @@ impl ConfigMapsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::config_maps::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ConfigMaps, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/cron_jobs/mod.rs b/src/view_models/resources/cron_jobs/mod.rs index 1e0e0c7..739f814 100644 --- a/src/view_models/resources/cron_jobs/mod.rs +++ b/src/view_models/resources/cron_jobs/mod.rs @@ -158,18 +158,18 @@ impl CronJobsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::cron_jobs::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::CronJobs, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/daemon_sets/mod.rs b/src/view_models/resources/daemon_sets/mod.rs index 8b27628..da57169 100644 --- a/src/view_models/resources/daemon_sets/mod.rs +++ b/src/view_models/resources/daemon_sets/mod.rs @@ -160,7 +160,7 @@ impl DaemonSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::daemon_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::DaemonSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/endpoint_slices/mod.rs b/src/view_models/resources/endpoint_slices/mod.rs index 566a6be..7cf9dff 100644 --- a/src/view_models/resources/endpoint_slices/mod.rs +++ b/src/view_models/resources/endpoint_slices/mod.rs @@ -164,7 +164,7 @@ impl EndpointSlicesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::endpoint_slices::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::EndpointSlices, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/endpoints/mod.rs b/src/view_models/resources/endpoints/mod.rs index 6e8cffb..5460061 100644 --- a/src/view_models/resources/endpoints/mod.rs +++ b/src/view_models/resources/endpoints/mod.rs @@ -158,18 +158,18 @@ impl EndpointsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::endpoints::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Endpoints, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/events/mod.rs b/src/view_models/resources/events/mod.rs index e8a5512..09846ad 100644 --- a/src/view_models/resources/events/mod.rs +++ b/src/view_models/resources/events/mod.rs @@ -158,7 +158,8 @@ impl EventsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::events::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Events, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs index d1959c6..2205fad 100644 --- a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs +++ b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs @@ -163,8 +163,9 @@ impl HorizontalPodAutoscalersViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::horizontal_pod_autoscalers::subscribe( + let subscription = match repository::subscribe( cx, + ResourceKind::HorizontalPodAutoscalers, self.scope.clone(), ) { Ok(s) => s, diff --git a/src/view_models/resources/ingresses/mod.rs b/src/view_models/resources/ingresses/mod.rs index 0263e39..58dc6d7 100644 --- a/src/view_models/resources/ingresses/mod.rs +++ b/src/view_models/resources/ingresses/mod.rs @@ -158,18 +158,18 @@ impl IngressesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::ingresses::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Ingresses, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/jobs/mod.rs b/src/view_models/resources/jobs/mod.rs index 350b6ac..0ae7d96 100644 --- a/src/view_models/resources/jobs/mod.rs +++ b/src/view_models/resources/jobs/mod.rs @@ -158,7 +158,7 @@ impl JobsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::jobs::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Jobs, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/leases/mod.rs b/src/view_models/resources/leases/mod.rs index c4169a1..4869ee0 100644 --- a/src/view_models/resources/leases/mod.rs +++ b/src/view_models/resources/leases/mod.rs @@ -158,7 +158,8 @@ impl LeasesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::leases::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Leases, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/namespaces/mod.rs b/src/view_models/resources/namespaces/mod.rs index bf4aa32..2b1ad8f 100644 --- a/src/view_models/resources/namespaces/mod.rs +++ b/src/view_models/resources/namespaces/mod.rs @@ -159,17 +159,18 @@ impl NamespacesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::namespaces::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Namespaces, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/network_policies/mod.rs b/src/view_models/resources/network_policies/mod.rs index 7150378..01fe464 100644 --- a/src/view_models/resources/network_policies/mod.rs +++ b/src/view_models/resources/network_policies/mod.rs @@ -164,7 +164,7 @@ impl NetworkPoliciesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::network_policies::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::NetworkPolicies, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/node/mod.rs b/src/view_models/resources/node/mod.rs index ed61fa1..5aa7dc5 100644 --- a/src/view_models/resources/node/mod.rs +++ b/src/view_models/resources/node/mod.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use gpui_kit::component::table::{Column, TableDelegate, TableEvent, TableState}; use gpui_kit::px; @@ -17,6 +18,10 @@ use crate::models::resources::node::detail::{NodeDetailState, UsageHistory}; use crate::models::{Item, NamespaceScope, ResourceKind, SnapshotPhase, WatchMode}; use crate::repository; +/// Large clusters emit node updates continuously. Repainting the table for +/// every repository flush makes it feel sluggish, so coalesce snapshots. +const NODES_REFRESH_INTERVAL: Duration = Duration::from_millis(250); + pub struct NodesViewModel { active: Arc, scope: NamespaceScope, @@ -305,6 +310,7 @@ impl NodesViewModel { let model = self.model.downgrade(); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { + let mut last_refresh: Option = None; loop { let snapshot = tokio::select! { _ = cancel_rx.changed() => break, @@ -313,6 +319,22 @@ impl NodesViewModel { let Some(snapshot) = snapshot else { break; }; + + // The watch channel keeps only the latest snapshot, so waiting + // here skips intermediate flushes instead of rendering them. + if let Some(last) = last_refresh { + let elapsed = last.elapsed(); + if elapsed < NODES_REFRESH_INTERVAL { + tokio::select! { + _ = cancel_rx.changed() => break, + _ = cx + .background_executor() + .timer(NODES_REFRESH_INTERVAL - elapsed) => {} + } + } + } + last_refresh = Some(Instant::now()); + let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); cx.notify(); diff --git a/src/view_models/resources/overview.rs b/src/view_models/resources/overview.rs index 7fae225..f50e557 100644 --- a/src/view_models/resources/overview.rs +++ b/src/view_models/resources/overview.rs @@ -7,23 +7,34 @@ use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use gpui_kit::{Context, Subscription, Window}; use tokio::sync::watch; use crate::cluster::ClusterState; use crate::models::resources::node::detail::NodeUsage; -use crate::models::resources::overview::ClusterOverviewStats; -use crate::models::{NamespaceScope, ResourceKind, ResourceSnapshot}; +use crate::models::resources::overview::{ClusterOverviewStats, is_unhealthy_pod}; +use crate::models::{Item, NamespaceScope, ResourceKind, ResourceSnapshot}; use crate::repository; +/// Pods change constantly on a busy cluster. The overview only shows +/// aggregate counts and a handful of rows, so coalesce watch snapshots instead +/// of recomputing and repainting for every 50 ms repository flush. +const OVERVIEW_REFRESH_INTERVAL: Duration = Duration::from_millis(250); +/// Rows shown in the "Pods needing attention" preview. +const UNHEALTHY_PREVIEW: usize = 8; + pub struct ClusterOverviewViewModel { active: Arc, epoch: u64, node_snapshot: Arc, pod_snapshot: Arc, stats: ClusterOverviewStats, - metrics: BTreeMap, + /// Sorted unhealthy pods, truncated to [`UNHEALTHY_PREVIEW`], derived once + /// per refresh so rendering does not rescan every pod. + unhealthy: Arc<[Arc]>, + metrics: Arc>, metrics_error: Option, metrics_generation: u64, nodes: Option, @@ -57,7 +68,8 @@ impl ClusterOverviewViewModel { node_snapshot: Arc::new(ResourceSnapshot::loading()), pod_snapshot: Arc::new(ResourceSnapshot::loading()), stats: ClusterOverviewStats::default(), - metrics: BTreeMap::new(), + unhealthy: Arc::from(Vec::>::new()), + metrics: Arc::new(BTreeMap::new()), metrics_error: None, metrics_generation: 0, nodes: None, @@ -75,15 +87,18 @@ impl ClusterOverviewViewModel { &self.stats } - pub fn nodes(&self) -> &[Arc] { - self.node_snapshot.items() + /// O(1) clone of the current nodes snapshot; the render path uses it + /// instead of copying every `Arc` into a temporary `Vec`. + pub fn node_snapshot(&self) -> Arc { + self.node_snapshot.clone() } - pub fn pods(&self) -> &[Arc] { - self.pod_snapshot.items() + /// Top unhealthy pods, already sorted and truncated for the preview. + pub fn unhealthy_pods(&self) -> Arc<[Arc]> { + self.unhealthy.clone() } - pub fn metrics(&self) -> &BTreeMap { + pub fn metrics(&self) -> &Arc> { &self.metrics } @@ -174,37 +189,64 @@ impl ClusterOverviewViewModel { let mut nodes = self.nodes.clone(); let mut pods = self.pods.clone(); cx.spawn(async move |this, cx| { + let mut last_refresh: Option = None; loop { - tokio::select! { + let update = tokio::select! { _ = cancel_rx.changed() => break, - snapshot = next_snapshot(&mut nodes) => { - let Some(snapshot) = snapshot else { + snapshot = next_snapshot(&mut nodes) => match snapshot { + Some(snapshot) => Some((false, snapshot)), + None => { nodes = None; - continue; - }; - let applied = this.update(cx, |this, cx| { - this.node_snapshot = snapshot; - this.recompute(); - cx.notify(); - }); - if applied.is_err() { - break; + None } - } - snapshot = next_snapshot(&mut pods) => { - let Some(snapshot) = snapshot else { + }, + snapshot = next_snapshot(&mut pods) => match snapshot { + Some(snapshot) => Some((true, snapshot)), + None => { pods = None; - continue; - }; - let applied = this.update(cx, |this, cx| { - this.pod_snapshot = snapshot; - this.recompute(); - cx.notify(); - }); - if applied.is_err() { - break; + None } + }, + }; + let Some((pod_update, snapshot)) = update else { + if nodes.is_none() && pods.is_none() { + break; + } + continue; + }; + + // Coalesce bursts: watch channels keep only the latest value, + // so waiting here drops intermediate snapshots instead of + // recomputing and repainting for each one. + if let Some(last) = last_refresh { + let elapsed = last.elapsed(); + if elapsed < OVERVIEW_REFRESH_INTERVAL { + tokio::select! { + _ = cancel_rx.changed() => break, + _ = cx + .background_executor() + .timer(OVERVIEW_REFRESH_INTERVAL - elapsed) => {} + } + } + } + last_refresh = Some(Instant::now()); + + let applied = this.update(cx, |this, cx| { + if pod_update { + this.pod_snapshot = snapshot; + } else { + this.node_snapshot = snapshot; } + // A hidden tab may still finish one in-flight refresh. + // Keep the snapshot but skip the work until `set_active` + // recomputes on resume. + if this.is_active() { + this.recompute(); + cx.notify(); + } + }); + if applied.is_err() { + break; } } }) @@ -215,6 +257,21 @@ impl ClusterOverviewViewModel { fn recompute(&mut self) { self.stats = ClusterOverviewStats::from_items(self.node_snapshot.items(), self.pod_snapshot.items()); + + let mut unhealthy: Vec> = self + .pod_snapshot + .items() + .iter() + .filter(|pod| is_unhealthy_pod(pod)) + .cloned() + .collect(); + unhealthy.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.name.cmp(&right.name)) + }); + unhealthy.truncate(UNHEALTHY_PREVIEW); + self.unhealthy = Arc::from(unhealthy); } fn cancel_pump(&mut self) { @@ -229,7 +286,7 @@ impl ClusterOverviewViewModel { if let Some(cancel) = self.metrics_cancel.take() { let _ = cancel.send(true); } - self.metrics.clear(); + self.metrics = Arc::new(BTreeMap::new()); self.metrics_error = None; self.metrics_generation = self.metrics_generation.wrapping_add(1); } @@ -242,7 +299,7 @@ impl ClusterOverviewViewModel { if let Some(cancel) = self.metrics_cancel.take() { let _ = cancel.send(true); } - self.metrics.clear(); + self.metrics = Arc::new(BTreeMap::new()); self.metrics_error = None; self.metrics_generation = self.metrics_generation.wrapping_add(1); let generation = self.metrics_generation; @@ -264,11 +321,11 @@ impl ClusterOverviewViewModel { } match update { Ok(metrics) => { - this.metrics = metrics; + this.metrics = Arc::new(metrics); this.metrics_error = None; } Err(error) => { - this.metrics.clear(); + this.metrics = Arc::new(BTreeMap::new()); this.metrics_error = Some(error); } } diff --git a/src/view_models/resources/persistent_volume_claims/mod.rs b/src/view_models/resources/persistent_volume_claims/mod.rs index eef76f3..272fff0 100644 --- a/src/view_models/resources/persistent_volume_claims/mod.rs +++ b/src/view_models/resources/persistent_volume_claims/mod.rs @@ -163,8 +163,9 @@ impl PersistentVolumeClaimsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::persistent_volume_claims::subscribe( + let subscription = match repository::subscribe( cx, + ResourceKind::PersistentVolumeClaims, self.scope.clone(), ) { Ok(s) => s, diff --git a/src/view_models/resources/persistent_volumes/mod.rs b/src/view_models/resources/persistent_volumes/mod.rs index 7e9adeb..fe9084e 100644 --- a/src/view_models/resources/persistent_volumes/mod.rs +++ b/src/view_models/resources/persistent_volumes/mod.rs @@ -163,17 +163,18 @@ impl PersistentVolumesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::persistent_volumes::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::PersistentVolumes, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/pod/exec.rs b/src/view_models/resources/pod/exec.rs index c1b6a58..c492e9b 100644 --- a/src/view_models/resources/pod/exec.rs +++ b/src/view_models/resources/pod/exec.rs @@ -1,11 +1,15 @@ //! ViewModel for an interactive TTY exec session in a pod container. +//! +//! The session itself lives in [`crate::repository::terminal`]. This view model +//! only attaches to it, so recreating the view does not kill the shell. use std::num::NonZeroU16; +use std::sync::Arc; use gpui_kit::Context; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{broadcast, mpsc}; -use crate::repository::resources::pods; +use crate::repository::terminal::{self, TerminalEvent, TerminalSession}; const SCROLLBACK_LINES: usize = 5_000; const DEFAULT_COLS: u16 = 80; @@ -24,8 +28,8 @@ pub struct PodExecViewModel { error: Option, input_tx: Option>>, resize_tx: Option>, + session: Option>, generation: u64, - cancel: Option>, } impl PodExecViewModel { @@ -54,8 +58,8 @@ impl PodExecViewModel { error: None, input_tx: None, resize_tx: None, + session: None, generation: 0, - cancel: None, }; this.start_session(cx); this @@ -151,12 +155,14 @@ impl PodExecViewModel { cx.notify(); } - pub fn shutdown(&mut self) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); + /// Destroy the stored session. Called when the terminal tab is closed. + pub fn shutdown(&mut self, cx: &mut gpui_kit::App) { + if let Some(session) = self.session.take() { + terminal::destroy(cx, session.key()); } self.input_tx = None; self.resize_tx = None; + self.generation = self.generation.wrapping_add(1); } fn restart(&mut self, cx: &mut Context) { @@ -166,8 +172,8 @@ impl PodExecViewModel { } fn start_session(&mut self, cx: &mut Context) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); + if let Some(session) = self.session.take() { + terminal::destroy(cx, session.key()); } self.generation = self.generation.wrapping_add(1); let generation = self.generation; @@ -177,7 +183,7 @@ impl PodExecViewModel { self.status = format!("Starting {} in {}…", self.shell, self.container); cx.notify(); - let session = match pods::exec_shell( + let session = match terminal::open( cx, &self.namespace, &self.name, @@ -193,51 +199,63 @@ impl PodExecViewModel { return; } }; - self.cancel = Some(session.cancel_handle()); + + let attachment = session.attach(); + if !attachment.history.is_empty() { + self.parser.process(&attachment.history); + } self.input_tx = Some(session.input_handle()); let resize = session.resize_handle(); let (rows, cols) = self.parser.screen().size(); let _ = resize.send((cols.get(), rows.get())); self.resize_tx = Some(resize); + self.session = Some(session); self.status = format!("Connecting · {} · {}", self.container, self.shell); + let mut updates = attachment.updates; cx.spawn(async move |this, cx| { - let mut session = session; - while let Some(update) = session.next().await { + loop { + let event = match updates.recv().await { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let current = this.update(cx, |this, cx| { if this.generation != generation { return false; } - match update { - Ok(bytes) => { + match event { + TerminalEvent::Data(bytes) => { this.parser.process(&bytes); this.error = None; this.status = format!("Connected · {} · {}", this.container, this.shell); } - Err(error) => { + TerminalEvent::Error(error) => { this.process_error(&error); this.error = Some(error); this.status = "Exec session error".to_string(); } + TerminalEvent::Ended => { + if this.error.is_none() { + this.parser.process(b"\r\n[session ended]\r\n"); + this.status = "Exec session ended".to_string(); + } + this.input_tx = None; + this.resize_tx = None; + this.session = None; + cx.notify(); + return false; + } } cx.notify(); true }); - match current { - Ok(true) => {} - _ => break, + if !matches!(current, Ok(true)) { + break; } } - let _ = this.update(cx, |this, cx| { - if this.generation == generation { - this.parser.process(b"\r\n[session ended]\r\n"); - this.status = "Exec session ended".to_string(); - this.input_tx = None; - this.resize_tx = None; - cx.notify(); - } - }); }) .detach(); } @@ -250,8 +268,9 @@ impl PodExecViewModel { impl Drop for PodExecViewModel { fn drop(&mut self) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); - } + // Detaching from the view must not destroy the stored session. Closing + // the tab calls `shutdown`, which performs the explicit destroy. + self.input_tx = None; + self.resize_tx = None; } } diff --git a/src/view_models/resources/pod_disruption_budgets/mod.rs b/src/view_models/resources/pod_disruption_budgets/mod.rs index ef52404..261c17e 100644 --- a/src/view_models/resources/pod_disruption_budgets/mod.rs +++ b/src/view_models/resources/pod_disruption_budgets/mod.rs @@ -163,20 +163,19 @@ impl PodDisruptionBudgetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::pod_disruption_budgets::subscribe( - cx, - self.scope.clone(), - ) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::PodDisruptionBudgets, self.scope.clone()) + { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/replica_sets/mod.rs b/src/view_models/resources/replica_sets/mod.rs index 87aeaa7..b3a11ef 100644 --- a/src/view_models/resources/replica_sets/mod.rs +++ b/src/view_models/resources/replica_sets/mod.rs @@ -164,7 +164,7 @@ impl ReplicaSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::replica_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ReplicaSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/replication_controllers/mod.rs b/src/view_models/resources/replication_controllers/mod.rs index 0d46a2c..29bdd4b 100644 --- a/src/view_models/resources/replication_controllers/mod.rs +++ b/src/view_models/resources/replication_controllers/mod.rs @@ -163,19 +163,21 @@ impl ReplicationControllersViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = - match repository::resources::replication_controllers::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = match repository::subscribe( + cx, + ResourceKind::ReplicationControllers, + self.scope.clone(), + ) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/role_bindings/mod.rs b/src/view_models/resources/role_bindings/mod.rs index f86597c..43bbadb 100644 --- a/src/view_models/resources/role_bindings/mod.rs +++ b/src/view_models/resources/role_bindings/mod.rs @@ -164,7 +164,7 @@ impl RoleBindingsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::role_bindings::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::RoleBindings, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/roles/mod.rs b/src/view_models/resources/roles/mod.rs index 07d22cc..521568e 100644 --- a/src/view_models/resources/roles/mod.rs +++ b/src/view_models/resources/roles/mod.rs @@ -158,7 +158,8 @@ impl RolesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::roles::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Roles, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/secrets/mod.rs b/src/view_models/resources/secrets/mod.rs index c5cd142..230d1c4 100644 --- a/src/view_models/resources/secrets/mod.rs +++ b/src/view_models/resources/secrets/mod.rs @@ -158,17 +158,18 @@ impl SecretsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::secrets::subscribe(cx, self.scope.clone()) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Secrets, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/service_accounts/mod.rs b/src/view_models/resources/service_accounts/mod.rs index a7ce13e..290c5b6 100644 --- a/src/view_models/resources/service_accounts/mod.rs +++ b/src/view_models/resources/service_accounts/mod.rs @@ -164,7 +164,7 @@ impl ServiceAccountsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::service_accounts::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ServiceAccounts, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/services/mod.rs b/src/view_models/resources/services/mod.rs index 11a3c52..548e8cc 100644 --- a/src/view_models/resources/services/mod.rs +++ b/src/view_models/resources/services/mod.rs @@ -158,18 +158,18 @@ impl ServicesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::services::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Services, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/stateful_sets/mod.rs b/src/view_models/resources/stateful_sets/mod.rs index 741c40a..816bf3d 100644 --- a/src/view_models/resources/stateful_sets/mod.rs +++ b/src/view_models/resources/stateful_sets/mod.rs @@ -164,7 +164,7 @@ impl StatefulSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::stateful_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::StatefulSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/storage_classes/mod.rs b/src/view_models/resources/storage_classes/mod.rs index 00b188e..8dd7cf5 100644 --- a/src/view_models/resources/storage_classes/mod.rs +++ b/src/view_models/resources/storage_classes/mod.rs @@ -163,17 +163,18 @@ impl StorageClassesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::storage_classes::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::StorageClasses, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); From a14fc00154874d75c089429dbf4cc13a322beb31 Mon Sep 17 00:00:00 2001 From: GT020 Date: Thu, 5 Nov 2026 18:17:50 +0530 Subject: [PATCH 6/8] fix: poll immediately when watch is denied Remember watch denials per cluster epoch, start denied kinds in polling, and let cluster-scoped kinds such as Namespaces bypass the global all-namespaces watch probe. --- src/repository/store/engine.rs | 16 +++++-- src/repository/store/mod.rs | 77 +++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/repository/store/engine.rs b/src/repository/store/engine.rs index de08539..dbb63b6 100644 --- a/src/repository/store/engine.rs +++ b/src/repository/store/engine.rs @@ -254,6 +254,9 @@ where Command::SetPrefs(prefs) => { self.prefs = prefs; self.watch_denied = false; + if prefs.mode == WatchPreference::Watch { + super::clear_watch_denied(self.epoch, self.kind); + } self.mode = match prefs.mode { WatchPreference::Watch => WatchMode::Watch, WatchPreference::Poll => WatchMode::Poll(prefs.rate), @@ -313,6 +316,7 @@ where "store: watch denied, falling back to polling" ); self.watch_denied = true; + super::mark_watch_denied(self.epoch, self.kind); self.mode = WatchMode::Poll(self.prefs.rate); let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); self.restart_watches(plan); @@ -345,9 +349,15 @@ where } fn compute_plan(&self) -> StoreWatchPlan { - let global = match self.mode { - WatchMode::Watch => self.global_watch, - WatchMode::Poll(_) => self.global_list, + let global = if !self.kind.is_namespaced() { + // Cluster-scoped kinds such as Namespaces are inherently global; + // the all-namespace RBAC probe must not block them. + true + } else { + match self.mode { + WatchMode::Watch => self.global_watch, + WatchMode::Poll(_) => self.global_list, + } }; watch_plan( self.scopes.keys().cloned(), diff --git a/src/repository/store/mod.rs b/src/repository/store/mod.rs index 5b6a91b..f42606a 100644 --- a/src/repository/store/mod.rs +++ b/src/repository/store/mod.rs @@ -19,15 +19,54 @@ pub use plan::{ClusterCapabilities, StoreWatchPlan, watch_plan}; pub use record::{KubeObjectRecord, StoredObject}; pub use view::StoreView; -use std::sync::Arc; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, OnceLock}; use gpui_kit::{App, BorrowAppContext as _}; use tokio::sync::watch; use crate::cluster::ClusterState; use crate::models::{NamespaceScope, ObjectKey, ResourceKind}; +use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; use crate::repository::{Repository, StoreEntry, Subscription}; +/// Kinds that must start polling immediately when watch is denied. Namespaces +/// are required by every namespaced view, and Events/Leases are high-churn +/// enough that a failed watch should not delay their first useful poll. +const IMMEDIATE_POLL_ON_DENIAL: &[ResourceKind] = &[ + ResourceKind::Namespaces, + ResourceKind::Events, + ResourceKind::Leases, +]; + +fn requires_immediate_poll(kind: ResourceKind) -> bool { + IMMEDIATE_POLL_ON_DENIAL.contains(&kind) +} + +fn denied_registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) +} + +pub(crate) fn watch_denied(epoch: u64, kind: ResourceKind) -> bool { + denied_registry() + .lock() + .is_ok_and(|set| set.contains(&(epoch, kind))) +} + +pub(crate) fn mark_watch_denied(epoch: u64, kind: ResourceKind) { + if let Ok(mut set) = denied_registry().lock() { + set.retain(|(stale_epoch, _)| *stale_epoch == epoch); + set.insert((epoch, kind)); + } +} + +pub(crate) fn clear_watch_denied(epoch: u64, kind: ResourceKind) { + if let Ok(mut set) = denied_registry().lock() { + set.remove(&(epoch, kind)); + } +} + /// Subscribe a view to the shared store for `kind`. pub(crate) fn subscribe( cx: &mut App, @@ -92,7 +131,14 @@ fn ensure_store( return Ok(entry.store.clone()); } - let prefs = repository.watch_prefs.prefs_for(kind); + let mut prefs: WatchPrefs = repository.watch_prefs.prefs_for(kind); + if prefs.mode == WatchPreference::Watch + && (watch_denied(epoch, kind) || (!global_watch && requires_immediate_poll(kind))) + { + // The first attempt already established that watch is unavailable, + // or this kind is required before namespaced views can start. + prefs.mode = WatchPreference::Poll; + } let Some(store) = engine::spawn( kind, client, @@ -117,3 +163,30 @@ fn ensure_store( Ok((store, tokio)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespaces_are_in_the_immediate_poll_set() { + assert!(requires_immediate_poll(ResourceKind::Namespaces)); + assert!(requires_immediate_poll(ResourceKind::Events)); + assert!(requires_immediate_poll(ResourceKind::Leases)); + assert!(!requires_immediate_poll(ResourceKind::Pods)); + } + + #[test] + fn watch_denial_is_remembered_and_can_be_cleared() { + let epoch = u64::MAX; + clear_watch_denied(epoch, ResourceKind::Namespaces); + assert!(!watch_denied(epoch, ResourceKind::Namespaces)); + + mark_watch_denied(epoch, ResourceKind::Namespaces); + assert!(watch_denied(epoch, ResourceKind::Namespaces)); + assert!(!watch_denied(epoch, ResourceKind::Pods)); + + clear_watch_denied(epoch, ResourceKind::Namespaces); + assert!(!watch_denied(epoch, ResourceKind::Namespaces)); + } +} From c20184de1799050cde9ccde615849fc684e27ae1 Mon Sep 17 00:00:00 2001 From: GT020 Date: Fri, 6 Nov 2026 22:54:36 +0530 Subject: [PATCH 7/8] refactor: remove polling and keep watch-only stores Drop poll preferences, poll-rate UI, and polling fallback. Keep listed data when a watch is denied and retry the watch instead of marking the store failed. --- src/cluster/mod.rs | 27 +-- src/models/mod.rs | 4 +- src/models/resources/mod.rs | 2 +- src/models/resources/store.rs | 26 +-- src/repository/mod.rs | 37 +---- src/repository/store/data.rs | 14 +- src/repository/store/engine.rs | 296 ++++----------------------------- src/repository/store/mod.rs | 87 +--------- src/repository/store/view.rs | 10 +- src/repository/watch_prefs.rs | 261 ----------------------------- src/ui/resources/watch_mode.rs | 78 +-------- 11 files changed, 65 insertions(+), 777 deletions(-) delete mode 100644 src/repository/watch_prefs.rs diff --git a/src/cluster/mod.rs b/src/cluster/mod.rs index edcb5ee..34691c0 100644 --- a/src/cluster/mod.rs +++ b/src/cluster/mod.rs @@ -51,10 +51,6 @@ pub struct ClusterState { /// Whether the current credentials may watch across all namespaces. This /// is the Freelens `isGlobalWatchEnabled` equivalent. global_watch: bool, - /// Whether the current credentials may list across all namespaces. Polling - /// uses this when watch is denied, so a list-capable identity can still - /// keep the all-namespaces store warm. - global_list: bool, context_epoch: u64, namespace_cancel: Option>, /// Kept alive for the app lifetime: the selector's handle on the shared @@ -107,10 +103,6 @@ impl ClusterState { pub fn global_watch(&self) -> bool { self.global_watch } - - pub fn global_list(&self) -> bool { - self.global_list - } } /// Wire the global before the first window opens. @@ -187,7 +179,6 @@ pub fn init(cx: &mut App) { reachable: None, namespaces: Vec::new(), global_watch: true, - global_list: true, context_epoch: 0, namespace_cancel: None, namespace_subscription: None, @@ -205,7 +196,7 @@ pub fn init(cx: &mut App) { /// Start or restart the shared namespace subscription. The selector and the /// Namespaces resource page read from the same repository watcher, so -/// namespaces are fetched once per context and follow the saved poll rate. +/// namespaces are fetched once per context and kept live by the shared store. pub fn refresh_namespaces(cx: &mut App) { if cx.global::().client.is_none() { return; @@ -379,10 +370,8 @@ async fn probe_global_access(client: &Client, verb: &str) -> bool { } } -async fn probe_capabilities(client: &Client) -> (bool, bool) { - let global_watch = probe_global_access(client, "watch").await; - let global_list = probe_global_access(client, "list").await; - (global_watch, global_list) +async fn probe_capabilities(client: &Client) -> bool { + probe_global_access(client, "watch").await } fn review_allows_watch(review: &SelfSubjectAccessReview) -> bool { @@ -411,7 +400,7 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { let building = tokio.spawn(build_client(kubeconfig, context.source, store)); cx.spawn(async move |cx: &mut gpui_kit::AsyncApp| { let built = building.await; - let (global_watch, global_list) = match &built { + let global_watch = match &built { Ok(Ok(client)) => { // The capability probe performs HTTP requests and must run on // the cluster runtime, not on GPUI's executor: hyper's connect @@ -421,14 +410,14 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { .spawn(async move { probe_capabilities(&client).await }) .await { - Ok(capabilities) => capabilities, + Ok(allowed) => allowed, Err(error) => { tracing::warn!(%error, "cluster: capability probe task failed"); - (true, true) + true } } } - _ => (true, true), + _ => true, }; cx.update(|cx| { let state = cx.global::(); @@ -441,14 +430,12 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { tracing::info!( context = %context_id, global_watch, - global_list, elapsed_ms = started.elapsed().as_millis() as u64, "cluster: client ready" ); cx.update_global::(|state, _| { state.client = Some(client); state.global_watch = global_watch; - state.global_list = global_list; state.context_epoch = state.context_epoch.wrapping_add(1); }); refresh_namespaces(cx); diff --git a/src/models/mod.rs b/src/models/mod.rs index f9fa17c..ee92ec7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,6 +9,6 @@ pub use cluster::NamespaceScope; pub use resources::{ ConfigMapsModel, DaemonSetsModel, DeploymentsModel, IngressesModel, Item, JobsModel, NodesModel, ObjectKey, PersistentVolumeClaimsModel, PersistentVolumesModel, PodsModel, - PollRate, ResourceDetail, ResourceDetailState, ResourceDetailUpdate, ResourceKind, - ResourceSnapshot, SecretsModel, ServicesModel, SnapshotPhase, WatchMode, + ResourceDetail, ResourceDetailState, ResourceDetailUpdate, ResourceKind, ResourceSnapshot, + SecretsModel, ServicesModel, SnapshotPhase, WatchMode, }; diff --git a/src/models/resources/mod.rs b/src/models/resources/mod.rs index b818cce..acb13b5 100644 --- a/src/models/resources/mod.rs +++ b/src/models/resources/mod.rs @@ -68,4 +68,4 @@ pub use service_accounts::ServiceAccountsModel; pub use services::ServicesModel; pub use stateful_sets::StatefulSetsModel; pub use storage_classes::StorageClassesModel; -pub use store::{ObjectKey, PollRate, ResourceSnapshot, SnapshotPhase, WatchMode}; +pub use store::{ObjectKey, ResourceSnapshot, SnapshotPhase, WatchMode}; diff --git a/src/models/resources/store.rs b/src/models/resources/store.rs index 96bc2f5..4c7fe74 100644 --- a/src/models/resources/store.rs +++ b/src/models/resources/store.rs @@ -37,34 +37,10 @@ pub enum SnapshotPhase { Error(String), } -/// How the snapshot is kept up to date. `Poll` carries the cadence so the UI -/// can show and adjust it. +/// The store always uses a live watch stream. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WatchMode { - /// Live watch stream. Watch, - /// Watch is not permitted; the store is re-listed on this cadence. - Poll(PollRate), -} - -/// Automatic re-list cadence while polling. `Off` disables the timer; a -/// manual refresh still re-lists once. Persisted per resource kind. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PollRate { - /// No automatic polling. - Off, - /// Poll every this many seconds. - Every(u64), -} - -impl PollRate { - pub const DEFAULT: PollRate = PollRate::Every(5); -} - -impl Default for PollRate { - fn default() -> Self { - Self::DEFAULT - } } impl ResourceSnapshot { diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 738f7cb..7fa60c5 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -6,18 +6,15 @@ pub mod resources; pub mod retry; pub mod store; pub mod terminal; -pub mod watch_prefs; - -pub use watch_prefs::{WatchPreference, WatchPrefs}; use std::any::Any; use std::collections::HashMap; use std::sync::Arc; -use gpui_kit::{App, BorrowAppContext as _, Global}; +use gpui_kit::{App, Global}; use tokio::sync::watch; -use crate::models::{NamespaceScope, PollRate, ResourceKind, ResourceSnapshot}; +use crate::models::{NamespaceScope, ResourceKind, ResourceSnapshot}; pub(crate) struct StoreEntry { pub epoch: u64, @@ -27,7 +24,6 @@ pub(crate) struct StoreEntry { } pub struct Repository { - watch_prefs: watch_prefs::WatchPrefsStore, pub(crate) stores: HashMap, pub(crate) terminal: terminal::TerminalSessionStore, } @@ -36,7 +32,6 @@ impl Global for Repository {} pub fn init(cx: &mut App) { cx.set_global(Repository { - watch_prefs: watch_prefs::WatchPrefsStore::load(), stores: HashMap::new(), terminal: terminal::TerminalSessionStore::default(), }); @@ -147,34 +142,6 @@ pub(crate) fn subscribe_kind( store::subscribe(cx, kind, scope) } -/// Set whether a resource kind is watched live or polled. The saved poll rate -/// is kept. Applies to every running watch task for the kind and is saved. -pub fn set_watch_preference(cx: &mut App, kind: ResourceKind, mode: WatchPreference) { - update_watch_prefs(cx, kind, |prefs| prefs.mode = mode); -} - -/// Set a kind's poll rate and switch it to poll mode. Applies to every -/// running watch task for the kind and is saved. -pub fn set_poll_rate(cx: &mut App, kind: ResourceKind, rate: PollRate) { - update_watch_prefs(cx, kind, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = rate; - }); -} - -fn update_watch_prefs(cx: &mut App, kind: ResourceKind, change: impl FnOnce(&mut WatchPrefs)) { - cx.update_global::(|repository, _| { - repository.watch_prefs.update(kind, change); - if let Err(error) = repository.watch_prefs.save() { - tracing::warn!(%error, "repository: failed to save watch prefs"); - } - let prefs = repository.watch_prefs.prefs_for(kind); - if let Some(entry) = repository.stores.get(&kind) { - entry.store.set_prefs(prefs); - } - }); -} - #[cfg(test)] mod tests { use std::time::Duration; diff --git a/src/repository/store/data.rs b/src/repository/store/data.rs index 9ec5f16..d891823 100644 --- a/src/repository/store/data.rs +++ b/src/repository/store/data.rs @@ -101,6 +101,10 @@ impl StoreData { self.pending } + pub fn is_empty(&self) -> bool { + self.objects.is_empty() + } + #[cfg(test)] pub fn get_by_uid(&self, uid: &str) -> Option<&Arc> { self.by_uid.get(uid).and_then(|key| self.objects.get(key)) @@ -487,15 +491,9 @@ mod tests { fn snapshot_exposes_scope_and_summary() { let mut data = StoreData::new(); data.upsert(record("pod-a", "ns-a", "uid-a", "1")); - let snapshot = data.snapshot( - SnapshotPhase::Error("offline".into()), - WatchMode::Poll(crate::models::PollRate::Every(5)), - ); + let snapshot = data.snapshot(SnapshotPhase::Error("offline".into()), WatchMode::Watch); assert_eq!(snapshot.phase(), &SnapshotPhase::Error("offline".into())); - assert_eq!( - snapshot.mode(), - WatchMode::Poll(crate::models::PollRate::Every(5)) - ); + assert_eq!(snapshot.mode(), WatchMode::Watch); assert_eq!(snapshot.summary_items().len(), 1); } diff --git a/src/repository/store/engine.rs b/src/repository/store/engine.rs index dbb63b6..2e27dba 100644 --- a/src/repository/store/engine.rs +++ b/src/repository/store/engine.rs @@ -30,9 +30,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use tokio::sync::{mpsc, watch}; -use crate::models::{ - Item, NamespaceScope, ObjectKey, PollRate, ResourceKind, SnapshotPhase, WatchMode, -}; +use crate::models::{Item, NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase, WatchMode}; use crate::repository::Subscription; use crate::repository::store::mapping::{ cluster_role_item, config_map_item, cron_job_item, custom_item, daemon_set_item, @@ -46,11 +44,11 @@ use crate::repository::store::{ ClusterCapabilities, KubeObjectRecord, StoreData, StoreSnapshot, StoreView, StoreWatchPlan, watch_plan, }; -use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; const EVENT_FLUSH_INTERVAL: Duration = Duration::from_secs(1); const WATCH_TIMEOUT_SECS: u32 = 280; const RECONNECT_DELAY: Duration = Duration::from_millis(500); +const WATCH_DENIED_RETRY: Duration = Duration::from_secs(5); const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); const STORE_ITEM_CAP: usize = 50_000; @@ -72,10 +70,6 @@ impl KubeObjectStoreHandle { self.snapshots.subscribe() } - pub(crate) fn set_prefs(&self, prefs: WatchPrefs) { - let _ = self.commands.send(Command::SetPrefs(prefs)); - } - pub(crate) fn subscribe( &self, scope: NamespaceScope, @@ -142,13 +136,11 @@ enum Command { Subscribe(NamespaceScope), Unsubscribe(NamespaceScope), Refresh, - SetPrefs(WatchPrefs), } enum InitialStream { Listed { resource_version: String }, Unsupported, - Denied(String), Cancelled, } @@ -157,7 +149,7 @@ enum StoreEvent { Upsert(KubeObjectRecord), Remove(ObjectKey), Error(NamespaceScope, String), - WatchDenied(NamespaceScope, String), + WatchError(NamespaceScope, String), } struct StoreTask @@ -170,7 +162,6 @@ where epoch: u64, kind: ResourceKind, global_watch: bool, - global_list: bool, data: StoreData, scopes: HashMap, plan: Option, @@ -180,9 +171,6 @@ where command_rx: mpsc::UnboundedReceiver, snapshots: watch::Sender>, phase: SnapshotPhase, - mode: WatchMode, - prefs: WatchPrefs, - watch_denied: bool, map: F, make_api: ApiFactory, } @@ -251,24 +239,6 @@ where let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); self.restart_watches(plan); } - Command::SetPrefs(prefs) => { - self.prefs = prefs; - self.watch_denied = false; - if prefs.mode == WatchPreference::Watch { - super::clear_watch_denied(self.epoch, self.kind); - } - self.mode = match prefs.mode { - WatchPreference::Watch => WatchMode::Watch, - WatchPreference::Poll => WatchMode::Poll(prefs.rate), - }; - tracing::info!( - kind = ?self.kind, - mode = ?self.mode, - "store: update mode" - ); - let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); - self.restart_watches(plan); - } } } @@ -303,30 +273,18 @@ where self.flush(false); } StoreEvent::Error(scope, message) => { - tracing::warn!(kind = ?self.kind, ?scope, %message, "store: watch error"); + tracing::warn!(kind = ?self.kind, ?scope, %message, "store: list error"); self.phase = SnapshotPhase::Error(message); self.flush(true); } - StoreEvent::WatchDenied(scope, message) => { - if self.prefs.mode == WatchPreference::Watch && !self.watch_denied { - tracing::warn!( - kind = ?self.kind, - ?scope, - %message, - "store: watch denied, falling back to polling" - ); - self.watch_denied = true; - super::mark_watch_denied(self.epoch, self.kind); - self.mode = WatchMode::Poll(self.prefs.rate); - let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); - self.restart_watches(plan); - } else { - // Even polling cannot read this resource, so surface the - // permission problem instead of silently retaining stale - // data. + StoreEvent::WatchError(scope, message) => { + tracing::warn!(kind = ?self.kind, ?scope, %message, "store: watch error"); + if self.data.is_empty() { self.phase = SnapshotPhase::Error(message); self.flush(true); } + // With data from the initial list, watch failures are + // transient: keep serving the cached rows and retry. } } } @@ -350,14 +308,9 @@ where fn compute_plan(&self) -> StoreWatchPlan { let global = if !self.kind.is_namespaced() { - // Cluster-scoped kinds such as Namespaces are inherently global; - // the all-namespace RBAC probe must not block them. true } else { - match self.mode { - WatchMode::Watch => self.global_watch, - WatchMode::Poll(_) => self.global_list, - } + self.global_watch }; watch_plan( self.scopes.keys().cloned(), @@ -396,17 +349,8 @@ where let client = self.client.clone(); let map = self.map; let make_api = self.make_api.clone(); - match self.mode { - WatchMode::Watch => { - self.tokio - .spawn(run_watch(client, scope, events, cancel_rx, map, make_api)); - } - WatchMode::Poll(rate) => { - self.tokio.spawn(run_poll( - client, scope, events, cancel_rx, map, make_api, rate, - )); - } - } + self.tokio + .spawn(run_watch(client, scope, events, cancel_rx, map, make_api)); self.watches.push(cancel); } @@ -421,7 +365,7 @@ where return; } - let snapshot = Arc::new(self.data.snapshot(self.phase.clone(), self.mode)); + let snapshot = Arc::new(self.data.snapshot(self.phase.clone(), WatchMode::Watch)); tracing::trace!( epoch = self.epoch, kind = ?self.kind, @@ -466,12 +410,7 @@ where }; let mut stream = match watched { Ok(stream) => Box::pin(stream), - Err(error) => { - if is_forbidden(&error) { - return InitialStream::Denied(format!("Watch denied: {error}")); - } - return InitialStream::Unsupported; - } + Err(_) => return InitialStream::Unsupported, }; let mut records: BTreeMap = BTreeMap::new(); @@ -525,15 +464,7 @@ where } return InitialStream::Listed { resource_version }; } - Ok(Some(WatchEvent::Error(status))) => { - if status.code == 403 { - return InitialStream::Denied(format!( - "Watch denied: {}", - status.message - )); - } - return InitialStream::Unsupported; - } + Ok(Some(WatchEvent::Error(_))) => return InitialStream::Unsupported, Ok(None) | Err(_) => return InitialStream::Unsupported, } } @@ -585,10 +516,6 @@ async fn run_watch( ); stream_initial_disabled = true; } - InitialStream::Denied(message) => { - let _ = events.send(StoreEvent::WatchDenied(scope.clone(), message)); - return; - } InitialStream::Cancelled => return, } } @@ -619,16 +546,15 @@ async fn run_watch( need_list = false; } Err(error) => { - if is_forbidden(&error) { - let _ = events.send(StoreEvent::WatchDenied( - scope.clone(), - format!("Watch denied: {error}"), - )); - return; - } + let delay = match &error { + kube::Error::Api(response) if response.code == 403 => { + WATCH_DENIED_RETRY + } + _ => RECONNECT_DELAY, + }; let message = format!("Could not list resources: {error}"); let _ = events.send(StoreEvent::Error(scope.clone(), message)); - if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + if !sleep_or_cancel(&mut cancel, delay).await { return; } continue; @@ -646,17 +572,9 @@ async fn run_watch( let mut stream = match watched { Ok(stream) => Box::pin(stream), Err(error) => { - if is_forbidden(&error) { - let _ = events.send(StoreEvent::WatchDenied( - scope.clone(), - format!("Watch denied: {error}"), - )); - return; - } let message = format!("Could not watch resources: {error}"); - let _ = events.send(StoreEvent::Error(scope.clone(), message)); - need_list = true; - if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + if !sleep_or_cancel(&mut cancel, WATCH_DENIED_RETRY).await { return; } continue; @@ -664,6 +582,7 @@ async fn run_watch( }; let mut relist = false; + let mut denied = false; loop { let event = tokio::select! { _ = cancel.changed() => return, @@ -701,24 +620,18 @@ async fn run_watch( Ok(Some(WatchEvent::Error(status))) => { relist = status.code == 410 || status.reason == "Expired" || status.reason == "Gone"; - if status.code == 403 { - let _ = events.send(StoreEvent::WatchDenied( - scope.clone(), - format!("Watch denied: {}", status.message), - )); - return; - } if relist { tracing::debug!(code = status.code, "store: watch expired, relisting"); } else { + denied = status.code == 403; let message = format!("Watch error ({}): {}", status.code, status.message); - let _ = events.send(StoreEvent::Error(scope.clone(), message)); + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); } break; } Ok(None) => break, Err(error) => { - let _ = events.send(StoreEvent::Error( + let _ = events.send(StoreEvent::WatchError( scope.clone(), format!("Watch stream failed: {error}"), )); @@ -731,89 +644,17 @@ async fn run_watch( need_list = true; } - if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { - return; - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn run_poll( - client: Client, - scope: NamespaceScope, - events: mpsc::UnboundedSender, - mut cancel: watch::Receiver, - map: F, - make_api: ApiFactory, - rate: PollRate, -) where - K: Resource - + ResourceExt - + Clone - + Debug - + DeserializeOwned - + Serialize - + Send - + Sync - + 'static - + crate::repository::store::StoredObject, - F: Fn(&K) -> Item + Copy + Send + Sync + 'static, -{ - loop { - let api = make_api(&client, &scope); - let params = ListParams::default(); - let listed = tokio::select! { - _ = cancel.changed() => return, - result = api.list(¶ms) => result, - }; - - match listed { - Ok(list) => { - let records = list - .items - .iter() - .map(|object| KubeObjectRecord::new(object.clone(), map(object))) - .collect(); - if events - .send(StoreEvent::Replace(scope.clone(), records)) - .is_err() - { - return; - } - } - Err(error) => { - if is_forbidden(&error) { - let _ = events.send(StoreEvent::WatchDenied( - scope.clone(), - format!("List denied: {error}"), - )); - return; - } - let message = format!("Could not list resources: {error}"); - let _ = events.send(StoreEvent::Error(scope.clone(), message)); - } - } - - let delay = match rate { - PollRate::Off => None, - PollRate::Every(seconds) => Some(Duration::from_secs(seconds.max(1))), + let delay = if denied { + WATCH_DENIED_RETRY + } else { + RECONNECT_DELAY }; - tokio::select! { - _ = cancel.changed() => return, - _ = async { - match delay { - Some(delay) => tokio::time::sleep(delay).await, - None => std::future::pending().await, - } - } => {} + if !sleep_or_cancel(&mut cancel, delay).await { + return; } } } -fn is_forbidden(error: &kube::Error) -> bool { - matches!(error, kube::Error::Api(response) if response.code == 403) -} - async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { tokio::select! { _ = cancel.changed() => false, @@ -829,8 +670,6 @@ pub(crate) fn spawn( tokio: tokio::runtime::Handle, epoch: u64, global_watch: bool, - global_list: bool, - prefs: WatchPrefs, ) -> Option> { match kind { ResourceKind::Pods => Some(spawn_typed( @@ -839,8 +678,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, pod_item, namespaced_api::, )), @@ -850,8 +687,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, deployment_item, namespaced_api::, )), @@ -861,8 +696,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, stateful_set_item, namespaced_api::, )), @@ -872,8 +705,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, replica_set_item, namespaced_api::, )), @@ -883,8 +714,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, replication_controller_item, namespaced_api::, )), @@ -894,8 +723,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, daemon_set_item, namespaced_api::, )), @@ -905,8 +732,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, job_item, namespaced_api::, )), @@ -916,8 +741,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, cron_job_item, namespaced_api::, )), @@ -927,8 +750,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, horizontal_pod_autoscaler_item, namespaced_api::, )), @@ -938,8 +759,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, pod_disruption_budget_item, namespaced_api::, )), @@ -949,8 +768,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, service_item, namespaced_api::, )), @@ -960,8 +777,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, ingress_item, namespaced_api::, )), @@ -971,8 +786,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, endpoints_item, namespaced_api::, )), @@ -982,8 +795,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, endpoint_slice_item, namespaced_api::, )), @@ -993,8 +804,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, network_policy_item, namespaced_api::, )), @@ -1004,8 +813,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, pvc_item, namespaced_api::, )), @@ -1015,8 +822,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, storage_class_item, cluster_api::, )), @@ -1026,8 +831,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, config_map_item, namespaced_api::, )), @@ -1037,8 +840,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, secret_item, namespaced_api::, )), @@ -1048,8 +849,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, service_account_item, namespaced_api::, )), @@ -1059,8 +858,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, pv_item, cluster_api::, )), @@ -1070,8 +867,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, namespace_item, cluster_api::, )), @@ -1081,8 +876,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, node_item, cluster_api::, )), @@ -1092,8 +885,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, lease_item, namespaced_api::, )), @@ -1103,8 +894,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, event_item, namespaced_api::, )), @@ -1114,8 +903,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, cluster_role_item, cluster_api::, )), @@ -1125,8 +912,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, role_item, namespaced_api::, )), @@ -1136,8 +921,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, role_binding_item, namespaced_api::, )), @@ -1159,8 +942,6 @@ pub(crate) fn spawn( tokio, epoch, global_watch, - global_list, - prefs, custom_item, make_api, )) @@ -1175,8 +956,6 @@ fn spawn_typed( tokio: tokio::runtime::Handle, epoch: u64, global_watch: bool, - global_list: bool, - prefs: WatchPrefs, map: F, make_api: M, ) -> Arc @@ -1211,7 +990,6 @@ where epoch, kind, global_watch, - global_list, data, scopes: HashMap::new(), plan: None, @@ -1221,12 +999,6 @@ where command_rx, snapshots, phase: SnapshotPhase::Loading, - mode: match prefs.mode { - WatchPreference::Watch => WatchMode::Watch, - WatchPreference::Poll => WatchMode::Poll(prefs.rate), - }, - prefs, - watch_denied: false, map, make_api: Arc::new(make_api), }; diff --git a/src/repository/store/mod.rs b/src/repository/store/mod.rs index f42606a..5ef72c8 100644 --- a/src/repository/store/mod.rs +++ b/src/repository/store/mod.rs @@ -19,54 +19,15 @@ pub use plan::{ClusterCapabilities, StoreWatchPlan, watch_plan}; pub use record::{KubeObjectRecord, StoredObject}; pub use view::StoreView; -use std::collections::HashSet; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; use gpui_kit::{App, BorrowAppContext as _}; use tokio::sync::watch; use crate::cluster::ClusterState; use crate::models::{NamespaceScope, ObjectKey, ResourceKind}; -use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; use crate::repository::{Repository, StoreEntry, Subscription}; -/// Kinds that must start polling immediately when watch is denied. Namespaces -/// are required by every namespaced view, and Events/Leases are high-churn -/// enough that a failed watch should not delay their first useful poll. -const IMMEDIATE_POLL_ON_DENIAL: &[ResourceKind] = &[ - ResourceKind::Namespaces, - ResourceKind::Events, - ResourceKind::Leases, -]; - -fn requires_immediate_poll(kind: ResourceKind) -> bool { - IMMEDIATE_POLL_ON_DENIAL.contains(&kind) -} - -fn denied_registry() -> &'static Mutex> { - static REGISTRY: OnceLock>> = OnceLock::new(); - REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) -} - -pub(crate) fn watch_denied(epoch: u64, kind: ResourceKind) -> bool { - denied_registry() - .lock() - .is_ok_and(|set| set.contains(&(epoch, kind))) -} - -pub(crate) fn mark_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.retain(|(stale_epoch, _)| *stale_epoch == epoch); - set.insert((epoch, kind)); - } -} - -pub(crate) fn clear_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.remove(&(epoch, kind)); - } -} - /// Subscribe a view to the shared store for `kind`. pub(crate) fn subscribe( cx: &mut App, @@ -122,7 +83,6 @@ fn ensure_store( let tokio = state.tokio(); let epoch = state.context_epoch(); let global_watch = state.global_watch(); - let global_list = state.global_list(); let store = cx.update_global::(|repository, _| { if let Some(entry) = repository.stores.get(&kind) @@ -131,23 +91,7 @@ fn ensure_store( return Ok(entry.store.clone()); } - let mut prefs: WatchPrefs = repository.watch_prefs.prefs_for(kind); - if prefs.mode == WatchPreference::Watch - && (watch_denied(epoch, kind) || (!global_watch && requires_immediate_poll(kind))) - { - // The first attempt already established that watch is unavailable, - // or this kind is required before namespaced views can start. - prefs.mode = WatchPreference::Poll; - } - let Some(store) = engine::spawn( - kind, - client, - tokio.clone(), - epoch, - global_watch, - global_list, - prefs, - ) else { + let Some(store) = engine::spawn(kind, client, tokio.clone(), epoch, global_watch) else { return Err(format!("{} is not available in this build.", kind.label())); }; repository.stores.insert( @@ -163,30 +107,3 @@ fn ensure_store( Ok((store, tokio)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn namespaces_are_in_the_immediate_poll_set() { - assert!(requires_immediate_poll(ResourceKind::Namespaces)); - assert!(requires_immediate_poll(ResourceKind::Events)); - assert!(requires_immediate_poll(ResourceKind::Leases)); - assert!(!requires_immediate_poll(ResourceKind::Pods)); - } - - #[test] - fn watch_denial_is_remembered_and_can_be_cleared() { - let epoch = u64::MAX; - clear_watch_denied(epoch, ResourceKind::Namespaces); - assert!(!watch_denied(epoch, ResourceKind::Namespaces)); - - mark_watch_denied(epoch, ResourceKind::Namespaces); - assert!(watch_denied(epoch, ResourceKind::Namespaces)); - assert!(!watch_denied(epoch, ResourceKind::Pods)); - - clear_watch_denied(epoch, ResourceKind::Namespaces); - assert!(!watch_denied(epoch, ResourceKind::Namespaces)); - } -} diff --git a/src/repository/store/view.rs b/src/repository/store/view.rs index 9a9195e..54701ea 100644 --- a/src/repository/store/view.rs +++ b/src/repository/store/view.rs @@ -109,7 +109,7 @@ fn visible_objects<'a>( #[cfg(test)] mod tests { use super::*; - use crate::models::{NamespaceScope, PollRate, ResourceSnapshot, SnapshotPhase, WatchMode}; + use crate::models::{NamespaceScope, ResourceSnapshot, SnapshotPhase, WatchMode}; fn view_for(scope: NamespaceScope, snapshot: &StoreSnapshot) -> StoreView { let mut view = StoreView::new(scope); @@ -181,19 +181,13 @@ mod tests { } #[test] - fn phase_and_mode_changes_always_emit() { + fn phase_changes_always_emit() { let mut data = super::super::data::StoreData::new(); let mut view = StoreView::new(NamespaceScope::All); let ready = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); let first = view.apply(&ready).expect("initial emit"); assert_eq!(first.mode(), WatchMode::Watch); - let mut data = super::super::data::StoreData::new(); - data.upsert(test_record("a", "ns-1", "uid-a", "1")); - let polled = data.snapshot(SnapshotPhase::Ready, WatchMode::Poll(PollRate::Every(5))); - let emitted = view.apply(&polled).expect("mode change emits"); - assert_eq!(emitted.mode(), WatchMode::Poll(PollRate::Every(5))); - let mut errored_data = super::super::data::StoreData::new(); errored_data.upsert(test_record("a", "ns-1", "uid-a", "1")); let errored = diff --git a/src/repository/watch_prefs.rs b/src/repository/watch_prefs.rs deleted file mode 100644 index d5d4029..0000000 --- a/src/repository/watch_prefs.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Persisted per-resource-kind update preferences: watch vs poll, and the -//! poll rate. -//! -//! Lives under the app config folder, e.g. `~/.config/krusty/poll-rates.json`. -//! Kinds without an entry use [`WatchPrefs::default`]: live watch with a -//! five-second poll fallback. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::models::{PollRate, ResourceKind}; - -/// How the user wants a resource kind kept up to date. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum WatchPreference { - /// Live watch stream; falls back to polling when the API denies watch. - #[default] - Watch, - /// Never watch; re-list on the configured poll rate. - Poll, -} - -/// The full per-kind update preference. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct WatchPrefs { - #[serde(default)] - pub mode: WatchPreference, - #[serde(default)] - pub rate: PollRate, -} - -#[derive(Serialize, Deserialize, Debug, Default)] -struct Store { - version: u32, - #[serde(default)] - kinds: HashMap, -} - -#[derive(Debug, Default)] -pub struct WatchPrefsStore { - kinds: HashMap, -} - -/// The factory preference for a kind. Namespaces, Events, and Leases default -/// to polling: many clusters deny `watch` on them and they are high-churn or -/// rarely change. Picking "Live watch" in the UI saves and sticks across -/// launches. -fn default_prefs_for(kind: ResourceKind) -> WatchPrefs { - match kind { - ResourceKind::Namespaces | ResourceKind::Events | ResourceKind::Leases => WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::DEFAULT, - }, - _ => WatchPrefs::default(), - } -} - -impl WatchPrefsStore { - /// Path under the OS app config dir, e.g. ~/.config/krusty/poll-rates.json. - pub fn path() -> Option { - dirs::config_dir().map(|d| d.join("krusty").join("poll-rates.json")) - } - - pub fn load() -> Self { - let Some(path) = Self::path() else { - tracing::debug!("watch prefs: no config directory"); - return Self::default(); - }; - match std::fs::read(&path) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(store) => { - tracing::debug!( - path = %path.display(), - kinds = store.kinds.len(), - "watch prefs: loaded" - ); - Self { kinds: store.kinds } - } - Err(error) => { - tracing::warn!( - path = %path.display(), - %error, - "watch prefs: invalid config file, ignoring" - ); - Self::default() - } - }, - Err(error) => { - tracing::debug!( - path = %path.display(), - %error, - "watch prefs: no config file yet" - ); - Self::default() - } - } - } - - pub fn save(&self) -> Result<(), &'static str> { - let Some(path) = Self::path() else { - tracing::warn!("watch prefs: save failed, no config directory"); - return Err("no config directory"); - }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|_| "failed to create config directory")?; - } - let store = Store { - version: 2, - kinds: self.kinds.clone(), - }; - let bytes = - serde_json::to_vec_pretty(&store).map_err(|_| "failed to serialize watch prefs")?; - std::fs::write(&path, bytes).map_err(|_| "failed to write watch prefs")?; - tracing::debug!(path = %path.display(), kinds = self.kinds.len(), "watch prefs: saved"); - Ok(()) - } - - /// The saved preference for a kind, or its factory default. - pub fn prefs_for(&self, kind: ResourceKind) -> WatchPrefs { - self.kinds - .get(kind.label()) - .copied() - .unwrap_or_else(|| default_prefs_for(kind)) - } - - /// Change a kind's preference. Entries equal to the kind's factory - /// default are dropped. - pub fn update(&mut self, kind: ResourceKind, change: impl FnOnce(&mut WatchPrefs)) { - let mut prefs = self.prefs_for(kind); - change(&mut prefs); - if prefs == default_prefs_for(kind) { - self.kinds.remove(kind.label()); - } else { - self.kinds.insert(kind.label().to_string(), prefs); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_to_live_watch_with_five_second_fallback() { - let store = WatchPrefsStore::default(); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Watch, - rate: PollRate::Every(5), - } - ); - } - - #[test] - fn high_churn_kinds_default_to_polling_until_watch_is_chosen() { - let mut store = WatchPrefsStore::default(); - for kind in [ - ResourceKind::Namespaces, - ResourceKind::Events, - ResourceKind::Leases, - ] { - assert_eq!( - store.prefs_for(kind), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(5), - } - ); - } - - // Choosing live watch is saved and sticks. - store.update(ResourceKind::Namespaces, |prefs| { - prefs.mode = WatchPreference::Watch; - }); - assert_eq!( - store.prefs_for(ResourceKind::Namespaces).mode, - WatchPreference::Watch - ); - assert!(store.kinds.contains_key("Namespaces")); - - // Switching back to the factory default drops the entry. - store.update(ResourceKind::Namespaces, |prefs| { - prefs.mode = WatchPreference::Poll; - }); - assert!(!store.kinds.contains_key("Namespaces")); - } - - #[test] - fn remembers_mode_and_rate_independently() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::Every(30)); - store.update(ResourceKind::Events, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = PollRate::Off; - }); - - // A custom rate survives switching between watch and poll. - store.update(ResourceKind::Pods, |prefs| { - prefs.mode = WatchPreference::Poll - }); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(30), - } - ); - store.update(ResourceKind::Pods, |prefs| { - prefs.mode = WatchPreference::Watch; - }); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Watch, - rate: PollRate::Every(30), - } - ); - - assert_eq!( - store.prefs_for(ResourceKind::Events), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Off, - } - ); - assert_eq!(store.prefs_for(ResourceKind::Nodes), WatchPrefs::default()); - } - - #[test] - fn resetting_to_default_drops_the_entry() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::Every(30)); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::DEFAULT); - assert!(!store.kinds.contains_key("Pods")); - } - - #[test] - fn serializes_as_kind_labels() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Deployments, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = PollRate::Every(10); - }); - let json = serde_json::to_string(&Store { - version: 2, - kinds: store.kinds.clone(), - }) - .expect("serialize"); - assert!(json.contains("\"Deployments\"")); - let parsed: Store = serde_json::from_str(&json).expect("deserialize"); - assert_eq!( - parsed.kinds["Deployments"], - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(10), - } - ); - } -} diff --git a/src/ui/resources/watch_mode.rs b/src/ui/resources/watch_mode.rs index 22490fe..9173b2d 100644 --- a/src/ui/resources/watch_mode.rs +++ b/src/ui/resources/watch_mode.rs @@ -1,41 +1,17 @@ -//! Update-mode indicator for resource list headers. -//! -//! Shows a small "Live" badge while the watch stream is healthy and "Polling" -//! when the store is re-listed on a timer — either because the API server -//! denied `watch` (RBAC) or because the user prefers polling. The dropdown -//! switches the kind between live watch and a poll rate (or off), and the -//! choice is saved per resource kind. +//! Live-watch indicator for resource list headers. use gpui_kit::base::h_flex; -use gpui_kit::component::{ - ActiveTheme as _, IconName, Sizable as _, - button::{Button, ButtonVariants as _}, - menu::{DropdownMenu as _, PopupMenuItem}, -}; +use gpui_kit::component::ActiveTheme as _; use gpui_kit::{AnyElement, App, IntoElement, ParentElement as _, Styled as _, div, px}; -use crate::models::{PollRate, ResourceKind, WatchMode}; -use crate::repository::WatchPreference; +use crate::models::{ResourceKind, WatchMode}; -/// Poll intervals offered in the rate menu, in seconds. -const POLL_RATES: [u64; 5] = [2, 5, 10, 30, 60]; - -pub(crate) fn render(mode: WatchMode, kind: ResourceKind, cx: &App) -> AnyElement { - let (color, label, button_label) = match mode { - WatchMode::Watch => (cx.theme().success, "Live".to_string(), "Live".to_string()), - WatchMode::Poll(PollRate::Every(secs)) => ( - cx.theme().warning, - format!("Polling · {secs}s"), - format!("{secs}s"), - ), - WatchMode::Poll(PollRate::Off) => ( - cx.theme().warning, - "Polling off".to_string(), - "Off".to_string(), - ), +pub(crate) fn render(mode: WatchMode, _kind: ResourceKind, cx: &App) -> AnyElement { + let (color, label) = match mode { + WatchMode::Watch => (cx.theme().success, "Live"), }; - let badge = h_flex() + h_flex() .gap_1() .items_center() .child(div().w(px(6.)).h(px(6.)).rounded_full().bg(color)) @@ -44,44 +20,6 @@ pub(crate) fn render(mode: WatchMode, kind: ResourceKind, cx: &App) -> AnyElemen .text_xs() .text_color(cx.theme().muted_foreground) .child(label), - ); - - let mode_button = Button::new("watch-mode") - .ghost() - .small() - .label(button_label) - .icon(IconName::ChevronsUpDown) - .tooltip( - "How this resource type stays up to date. \ - Watch needs the `watch` permission; otherwise krusty falls back \ - to polling. Saved per resource type.", ) - .dropdown_menu(move |mut menu, _, _| { - menu = menu.item( - PopupMenuItem::new("Live watch") - .checked(mode == WatchMode::Watch) - .on_click(move |_, _, cx| { - crate::repository::set_watch_preference(cx, kind, WatchPreference::Watch); - }), - ); - menu = menu.separator(); - for secs in POLL_RATES { - menu = menu.item( - PopupMenuItem::new(format!("Poll every {secs}s")) - .checked(mode == WatchMode::Poll(PollRate::Every(secs))) - .on_click(move |_, _, cx| { - crate::repository::set_poll_rate(cx, kind, PollRate::Every(secs)); - }), - ); - } - menu.separator().item( - PopupMenuItem::new("Polling off") - .checked(mode == WatchMode::Poll(PollRate::Off)) - .on_click(move |_, _, cx| { - crate::repository::set_poll_rate(cx, kind, PollRate::Off); - }), - ) - }); - - badge.child(mode_button).into_any_element() + .into_any_element() } From 1c378f12fd66bbe0fa5a1d3b5e85fce14d01f952 Mon Sep 17 00:00:00 2001 From: GT020 Date: Fri, 6 Nov 2026 23:02:25 +0530 Subject: [PATCH 8/8] fix: stop retrying forbidden resource watches Treat watch and list 403 responses as permanent for the current task so forbidden resources keep their listed data without an automatic retry loop. --- src/repository/store/engine.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/repository/store/engine.rs b/src/repository/store/engine.rs index 2e27dba..2f474b7 100644 --- a/src/repository/store/engine.rs +++ b/src/repository/store/engine.rs @@ -546,15 +546,12 @@ async fn run_watch( need_list = false; } Err(error) => { - let delay = match &error { - kube::Error::Api(response) if response.code == 403 => { - WATCH_DENIED_RETRY - } - _ => RECONNECT_DELAY, - }; let message = format!("Could not list resources: {error}"); let _ = events.send(StoreEvent::Error(scope.clone(), message)); - if !sleep_or_cancel(&mut cancel, delay).await { + if is_forbidden(&error) { + return; + } + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { return; } continue; @@ -574,6 +571,9 @@ async fn run_watch( Err(error) => { let message = format!("Could not watch resources: {error}"); let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + if is_forbidden(&error) { + return; + } if !sleep_or_cancel(&mut cancel, WATCH_DENIED_RETRY).await { return; } @@ -582,7 +582,6 @@ async fn run_watch( }; let mut relist = false; - let mut denied = false; loop { let event = tokio::select! { _ = cancel.changed() => return, @@ -622,8 +621,11 @@ async fn run_watch( status.code == 410 || status.reason == "Expired" || status.reason == "Gone"; if relist { tracing::debug!(code = status.code, "store: watch expired, relisting"); + } else if status.code == 403 { + let message = format!("Watch error (403): {}", status.message); + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + return; } else { - denied = status.code == 403; let message = format!("Watch error ({}): {}", status.code, status.message); let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); } @@ -644,17 +646,16 @@ async fn run_watch( need_list = true; } - let delay = if denied { - WATCH_DENIED_RETRY - } else { - RECONNECT_DELAY - }; - if !sleep_or_cancel(&mut cancel, delay).await { + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { return; } } } +fn is_forbidden(error: &kube::Error) -> bool { + matches!(error, kube::Error::Api(response) if response.code == 403) +} + async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { tokio::select! { _ = cancel.changed() => false,