@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,582 @@
1
+ // Timeline data-source abstraction.
2
+ //
3
+ // Radar's timeline can be backed by two stores:
4
+ // - 'local' — the in-process event store the Radar binary keeps (default,
5
+ // OSS standalone). Fetched via GET {apiBase}/changes.
6
+ // - 'retained' — a longer-horizon history store answered upstream of Radar
7
+ // (relative to apiBase) as GET {apiBase}/timeline/events and
8
+ // GET {apiBase}/timeline/overview. This is an extension point:
9
+ // the standalone binary never selects it; a host that embeds
10
+ // RadarApp behind a proxy that serves retained history opts in
11
+ // via the `timelineSource` prop.
12
+ //
13
+ // Both sources expose the same `useEvents(query)` hook shape so the timeline
14
+ // wrappers stay source-agnostic: pick the source from context, call useEvents.
15
+ import { useMemo } from 'react'
16
+ import { useQuery, keepPreviousData } from '@tanstack/react-query'
17
+ import { quantizeBaseWindow } from '@skyhook-io/k8s-ui'
18
+ import { useChanges, apiFetch, ApiError, type UseChangesOptions } from './client'
19
+ import { apiUrl, getApiBase } from './config'
20
+ import type { TimelineEvent, TimeRange } from '../types'
21
+
22
+ // The query the wrappers pass. Superset of the local store's params so most
23
+ // call sites don't change shape when switching sources.
24
+ export type TimelineQuery = UseChangesOptions & {
25
+ // Explicit [from,to] window in epoch-ms. When both are set the retained
26
+ // source loads exactly this window (the scrubber's brush selection) instead
27
+ // of deriving one from `timeRange`. The local source can't express a frozen
28
+ // past window server-side, so it loads the whole ring and bounds it to
29
+ // [from,to] client-side.
30
+ fromMs?: number
31
+ toMs?: number
32
+ // LIVE mode: the [from,to] window slides every tick. Quantize the BASE fetch
33
+ // window to fixed steps so the react-query key only changes every few minutes;
34
+ // the precise sliding window is still applied by the client-side filter, and
35
+ // the trailing seam is covered by the live poll. Ignored by the local source.
36
+ sliding?: boolean
37
+ }
38
+
39
+ export interface TimelineSourceCapabilities {
40
+ mode: 'local' | 'retained'
41
+ // Only meaningful for 'retained': the maximum lookback the retained backend
42
+ // serves. Clamps EVERY derived range (rangeSpanMs, not just 'all'), the
43
+ // from-edge of an explicit [from,to] window, and the scrubber's selectable
44
+ // domain — nothing can reach further back than this. Defaults to
45
+ // DEFAULT_RETAINED_MAX_RANGE_DAYS when unset.
46
+ maxRangeDays?: number
47
+ }
48
+
49
+ // A single window [from,to] in epoch-ms. Cluster identity is implicit in the
50
+ // configured apiBase path, so no cluster id is carried here.
51
+ export interface TimelineRange {
52
+ from: number
53
+ to: number
54
+ }
55
+
56
+ // Coverage lines emitted inline in the retained events stream (gaps / retention
57
+ // boundaries). Shape is owned by the retained backend; kept opaque here and
58
+ // surfaced on the result so a future UI can render coverage without another
59
+ // round-trip. Not consumed by the timeline wrappers today.
60
+ export interface TimelineCoverageRecord {
61
+ type: 'coverage'
62
+ [key: string]: unknown
63
+ }
64
+
65
+ // A recording-coverage span within an overview bucket. Event-time bounds are
66
+ // owned by the retained (hub) backend; on the wire a bucket may carry one span
67
+ // or several — fetchRetainedOverview normalizes both to an array so consumers
68
+ // only ever see TimelineCoverageSpan[].
69
+ export interface TimelineCoverageSpan {
70
+ eventTimeStartMs?: number
71
+ eventTimeEndMs?: number
72
+ }
73
+
74
+ export interface TimelineOverviewBucket {
75
+ // Bucket start in epoch-ms — hourly for the retained rollup, sub-hour when the
76
+ // local overview rebuckets finer. Named for what it is, not its granularity.
77
+ startMs: number
78
+ summary: {
79
+ total: number
80
+ adds: number
81
+ updates: number
82
+ deletes: number
83
+ warnings: number
84
+ // Server-owned health rollup. localOverviewFromEvents only emits
85
+ // 'healthy'/'unhealthy', but the retained (hub) rollup can carry the full
86
+ // HealthLevel vocabulary, so this stays an open string.
87
+ worstHealth: string
88
+ namespaces: string[]
89
+ }
90
+ coverage?: TimelineCoverageSpan[]
91
+ }
92
+
93
+ // Overview response envelope: the hourly `buckets` plus `availableFromMs` (the
94
+ // oldest event-time the server holds for this cluster) so the scrubber domain
95
+ // reflects real retention.
96
+ export interface TimelineOverviewResult {
97
+ buckets: TimelineOverviewBucket[]
98
+ availableFromMs?: number
99
+ }
100
+
101
+ export interface TimelineEventsResult {
102
+ data: TimelineEvent[] | undefined
103
+ isLoading: boolean
104
+ isFetching: boolean
105
+ isError: boolean
106
+ refetch: () => void
107
+ // Present only for sources that report coverage (retained).
108
+ coverage?: TimelineCoverageRecord[]
109
+ }
110
+
111
+ export interface TimelineSource {
112
+ capabilities: TimelineSourceCapabilities
113
+ useEvents: (query: TimelineQuery) => TimelineEventsResult
114
+ // Optional hourly rollup. Only the retained source implements it.
115
+ fetchOverview?: (range: TimelineRange) => Promise<TimelineOverviewResult>
116
+ }
117
+
118
+ // Config carried by the RadarApp `timelineSource` prop. Absent = local.
119
+ export interface TimelineSourceConfig {
120
+ mode: 'retained'
121
+ maxRangeDays?: number
122
+ }
123
+
124
+ // ============================================================================
125
+ // Local source — thin wrapper over the existing useChanges behavior. Zero
126
+ // behavioral change for OSS standalone.
127
+ // ============================================================================
128
+
129
+ // Full ring size to pull when the host bounds the local list by the shared
130
+ // scrubber selection — matches the swimlane's ring fetch so both views cover the
131
+ // same window.
132
+ const LOCAL_RING_LIMIT = 10000
133
+
134
+ function useLocalEvents(query: TimelineQuery): TimelineEventsResult {
135
+ // Without a window the dropdown-driven `since` fetch is untouched. When the
136
+ // host drives an explicit [from,to] window (the shared scrubber selection),
137
+ // the private range dropdown is bypassed: the local /changes endpoint is
138
+ // `since`-based and can't express a frozen past window, so load the whole ring
139
+ // and bound it to the selection client-side (applyClientFilters), exactly as
140
+ // the swimlane derives its view from the loaded ring.
141
+ const windowed = query.fromMs != null || query.toMs != null
142
+ // deltaSync on every full-ring pull (timeRange 'all' — the swimlane's direct
143
+ // query and the list's windowed one): SSE-driven refetches then transfer only
144
+ // what arrived since the last full load. Dropdown-ranged (`since`) queries
145
+ // stay plain — they're small and their range moves with the clock.
146
+ const { data, isLoading, isFetching, isError, refetch } = useChanges(
147
+ windowed
148
+ ? { ...query, timeRange: 'all', limit: LOCAL_RING_LIMIT, deltaSync: true }
149
+ : { ...query, deltaSync: query.timeRange === 'all' },
150
+ )
151
+ // applyClientFilters runs on BOTH paths: a multi-kind selection is a CLIENT-side
152
+ // filter (only a single kind rides the /changes server query key), so a 2+ kind
153
+ // pick is narrowed here whether or not a window is set. A non-windowed query has
154
+ // null from/to, so the [from,to] bounding inside is a no-op there; the windowed
155
+ // path additionally bounds the loaded ring. The memo watches kindsKey itself —
156
+ // `data` identity won't change when only the kind set does.
157
+ const kindsKey = query.kinds?.join(',')
158
+ const events = useMemo(
159
+ () => (data ? applyClientFilters(data, query) : data),
160
+ // `data` identity captures every server-side filter change (namespaces,
161
+ // k8s-events, deleted — all in the useChanges query key); the client-only
162
+ // window + cap + kind set are added here, plus includeManaged, which is
163
+ // client-enforced and must not depend on staying in the server key. The
164
+ // live tick advances query.toMs, re-filtering to the sliding edge with no
165
+ // refetch.
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ [data, query.fromMs, query.toMs, query.limit, kindsKey, query.includeManaged],
168
+ )
169
+ return { data: events, isLoading, isFetching, isError, refetch }
170
+ }
171
+
172
+ export const localSource: TimelineSource = {
173
+ capabilities: { mode: 'local' },
174
+ useEvents: useLocalEvents,
175
+ }
176
+
177
+ const LOCAL_HOUR_MS = 60 * 60 * 1000
178
+
179
+ interface LocalHourSlot {
180
+ total: number
181
+ adds: number
182
+ updates: number
183
+ deletes: number
184
+ warnings: number
185
+ namespaces: Set<string>
186
+ }
187
+
188
+ // Client-side overview for the local source. The Radar binary loads the whole
189
+ // event ring into the browser (the swimlane's 10k fetch), so the scrubber
190
+ // histogram is derived here instead of from a server rollup — the local store
191
+ // has no /timeline/overview endpoint. Buckets by hour to match the retained
192
+ // overview shape the scrubber host already groups.
193
+ //
194
+ // `availableFromMs` is the oldest event time held in the ring: the scrubber
195
+ // domain floor comes from whoever holds the data, exactly as the retained
196
+ // source reports its own retention floor.
197
+ //
198
+ // `bucketSizeMs` defaults to the retained rollup's hourly granularity; a
199
+ // tightly framed strip passes a sub-hour size to rebucket the same events
200
+ // finer. The bucket's `startMs` is its start regardless of size — hourly or not.
201
+ //
202
+ // No coverage/gap field is emitted. Coverage is a retention concept — the hub
203
+ // records what it missed while not watching. Locally we cannot know what
204
+ // happened during downtime, so claiming a gap would be dishonest; we omit it.
205
+ export function localOverviewFromEvents(events: TimelineEvent[], bucketSizeMs = LOCAL_HOUR_MS): TimelineOverviewResult {
206
+ const slots = new Map<number, LocalHourSlot>()
207
+ let oldest = Number.POSITIVE_INFINITY
208
+
209
+ for (const e of events) {
210
+ const t = new Date(e.timestamp).getTime()
211
+ if (!Number.isFinite(t)) continue
212
+ if (t < oldest) oldest = t
213
+ const hour = Math.floor(t / bucketSizeMs) * bucketSizeMs
214
+ let slot = slots.get(hour)
215
+ if (!slot) {
216
+ slot = { total: 0, adds: 0, updates: 0, deletes: 0, warnings: 0, namespaces: new Set() }
217
+ slots.set(hour, slot)
218
+ }
219
+ slot.total++
220
+ if (e.eventType === 'add') slot.adds++
221
+ else if (e.eventType === 'update') slot.updates++
222
+ else if (e.eventType === 'delete') slot.deletes++
223
+ if (e.eventType === 'Warning') slot.warnings++
224
+ if (e.namespace) slot.namespaces.add(e.namespace)
225
+ }
226
+
227
+ const buckets: TimelineOverviewBucket[] = [...slots.entries()]
228
+ .map(([startMs, s]) => ({
229
+ startMs,
230
+ summary: {
231
+ total: s.total,
232
+ adds: s.adds,
233
+ updates: s.updates,
234
+ deletes: s.deletes,
235
+ warnings: s.warnings,
236
+ worstHealth: s.warnings > 0 ? 'unhealthy' : 'healthy',
237
+ namespaces: [...s.namespaces],
238
+ },
239
+ }))
240
+ .sort((a, b) => a.startMs - b.startMs)
241
+
242
+ return { buckets, availableFromMs: Number.isFinite(oldest) ? oldest : undefined }
243
+ }
244
+
245
+ // ============================================================================
246
+ // Retained source — streams NDJSON from {apiBase}/timeline/events.
247
+ // ============================================================================
248
+
249
+ const HOUR_MS = 60 * 60 * 1000
250
+ const DAY_MS = 24 * HOUR_MS
251
+
252
+ // Bound for the 'all' range when the host doesn't specify maxRangeDays.
253
+ const DEFAULT_RETAINED_MAX_RANGE_DAYS = 7
254
+
255
+ // Recent window the live poll re-fetches and merges over the loaded range. Wide
256
+ // enough that a missed 10s tick can't open a gap. Exported so a unit test can pin
257
+ // it against the base-window quantization step (quantization lag < poll window).
258
+ export const LIVE_WINDOW_MS = 10 * 60 * 1000
259
+ const LIVE_REFETCH_MS = 10_000
260
+
261
+ function rangeSpanMs(range: TimeRange | undefined, maxRangeDays?: number): number {
262
+ const cap = (maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
263
+ let span: number
264
+ switch (range) {
265
+ case '5m':
266
+ span = 5 * 60 * 1000
267
+ break
268
+ case '30m':
269
+ span = 30 * 60 * 1000
270
+ break
271
+ case '1h':
272
+ span = HOUR_MS
273
+ break
274
+ case '6h':
275
+ span = 6 * HOUR_MS
276
+ break
277
+ case '24h':
278
+ span = 24 * HOUR_MS
279
+ break
280
+ case '7d':
281
+ span = 7 * DAY_MS
282
+ break
283
+ case '30d':
284
+ span = 30 * DAY_MS
285
+ break
286
+ case 'all':
287
+ case undefined:
288
+ span = cap
289
+ break
290
+ default:
291
+ span = HOUR_MS
292
+ }
293
+ return Math.min(span, cap)
294
+ }
295
+
296
+ interface RetainedWindowResult {
297
+ events: TimelineEvent[]
298
+ coverage: TimelineCoverageRecord[]
299
+ }
300
+
301
+ type TerminalRecord = { type: 'end' } | { type: 'error'; message?: string }
302
+
303
+ // De-dupe by id keeping the LAST occurrence — a later revision of an event
304
+ // replaces the earlier one.
305
+ function dedupeById(events: TimelineEvent[]): TimelineEvent[] {
306
+ const byId = new Map<string, TimelineEvent>()
307
+ for (const e of events) byId.set(e.id, e)
308
+ return Array.from(byId.values())
309
+ }
310
+
311
+ // Exported for unit tests (the NDJSON stream parser); not re-exported publicly.
312
+ export async function fetchRetainedWindow(
313
+ from: number,
314
+ to: number,
315
+ signal?: AbortSignal,
316
+ ): Promise<RetainedWindowResult> {
317
+ const res = await apiFetch(
318
+ apiUrl(`/timeline/events?from=${Math.round(from)}&to=${Math.round(to)}`),
319
+ signal ? { signal } : undefined,
320
+ )
321
+ if (!res.ok) {
322
+ const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
323
+ throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
324
+ }
325
+ if (!res.body) {
326
+ throw new Error('timeline stream has no body')
327
+ }
328
+
329
+ const reader = res.body.getReader()
330
+ const decoder = new TextDecoder()
331
+ const events: TimelineEvent[] = []
332
+ const coverage: TimelineCoverageRecord[] = []
333
+ let terminal: TerminalRecord | null = null
334
+ let buf = ''
335
+
336
+ const handleLine = (line: string): void => {
337
+ const trimmed = line.trim()
338
+ if (!trimmed) return
339
+ const rec = JSON.parse(trimmed) as { type?: string; message?: string }
340
+ if (rec.type === 'end') {
341
+ terminal = { type: 'end' }
342
+ } else if (rec.type === 'error') {
343
+ terminal = { type: 'error', message: rec.message }
344
+ } else if (rec.type === 'coverage') {
345
+ coverage.push(rec as TimelineCoverageRecord)
346
+ } else {
347
+ events.push(rec as unknown as TimelineEvent)
348
+ }
349
+ }
350
+
351
+ for (;;) {
352
+ const { done, value } = await reader.read()
353
+ if (done) break
354
+ buf += decoder.decode(value, { stream: true })
355
+ let idx: number
356
+ while ((idx = buf.indexOf('\n')) >= 0) {
357
+ handleLine(buf.slice(0, idx))
358
+ buf = buf.slice(idx + 1)
359
+ }
360
+ }
361
+ handleLine(buf)
362
+
363
+ // Absence of a terminal record means the response was truncated — treat as a
364
+ // failure so the UI doesn't render a partial window as complete.
365
+ if (!terminal) {
366
+ throw new Error('timeline stream truncated (missing terminal record)')
367
+ }
368
+ if ((terminal as TerminalRecord).type === 'error') {
369
+ throw new Error((terminal as { message?: string }).message || 'timeline stream error')
370
+ }
371
+
372
+ return { events: dedupeById(events), coverage }
373
+ }
374
+
375
+ // Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
376
+ // a resource managed by another — owned, or one of the churn kinds. Keep the
377
+ // two predicates in lockstep.
378
+ function isManagedTimelineEvent(e: TimelineEvent): boolean {
379
+ return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
380
+ }
381
+
382
+ // The retained endpoint scopes only by [from,to] (cluster is implicit in the
383
+ // apiBase path), so the store-side query params the local endpoint honors are
384
+ // applied client-side over the loaded window. Exported for unit tests; not part
385
+ // of the package's public surface.
386
+ export function applyClientFilters(events: TimelineEvent[], query: TimelineQuery): TimelineEvent[] {
387
+ let out = events
388
+ if (query.namespaces && query.namespaces.length > 0) {
389
+ const set = new Set(query.namespaces)
390
+ out = out.filter((e) => set.has(e.namespace))
391
+ }
392
+ if (query.kinds && query.kinds.length > 0) {
393
+ const set = new Set(query.kinds)
394
+ out = out.filter((e) => set.has(e.kind))
395
+ }
396
+ if (query.includeK8sEvents === false) {
397
+ out = out.filter((e) => e.source !== 'k8s_event')
398
+ }
399
+ if (query.includeDeleted === false) {
400
+ out = out.filter((e) => e.eventType !== 'delete')
401
+ }
402
+ // Enforced here — not only server-side — so both sources honor it
403
+ // identically: the retained endpoint has no include_managed param, and the
404
+ // local path's filter presets can override the server-side flag (the 'all'
405
+ // preset re-includes managed). Only an explicit false filters; the default
406
+ // keeps machinery rows, which the swimlane's pod/RS child lanes require.
407
+ if (query.includeManaged === false) {
408
+ out = out.filter((e) => !isManagedTimelineEvent(e))
409
+ }
410
+ // An explicit brush window bounds the result by event time so the live poll's
411
+ // recent-window merge can't leak events past the selected [from,to].
412
+ if (query.fromMs != null || query.toMs != null) {
413
+ out = out.filter((e) => {
414
+ const t = new Date(e.timestamp).getTime()
415
+ if (query.fromMs != null && t < query.fromMs) return false
416
+ if (query.toMs != null && t > query.toMs) return false
417
+ return true
418
+ })
419
+ }
420
+ out = [...out].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
421
+ if (query.limit && out.length > query.limit) {
422
+ out = out.slice(0, query.limit)
423
+ }
424
+ return out
425
+ }
426
+
427
+ function mergeWindows(
428
+ base: RetainedWindowResult | undefined,
429
+ live: RetainedWindowResult | undefined,
430
+ ): RetainedWindowResult {
431
+ // live is newer, so it overwrites base for shared ids.
432
+ const events = dedupeById([...(base?.events ?? []), ...(live?.events ?? [])])
433
+ const coverage = [...(base?.coverage ?? []), ...(live?.coverage ?? [])]
434
+ return { events, coverage }
435
+ }
436
+
437
+ function createRetainedEventsHook(
438
+ capabilities: TimelineSourceCapabilities,
439
+ ): (query: TimelineQuery) => TimelineEventsResult {
440
+ return function useRetainedEvents(query: TimelineQuery): TimelineEventsResult {
441
+ const enabled = query.enabled ?? true
442
+
443
+ // Pin the base window when the range (or cap) changes — not on every render —
444
+ // so the query key is stable and react-query can cache it. Recency is the
445
+ // live poll's job.
446
+ const window = useMemo<TimelineRange>(() => {
447
+ if (query.fromMs != null && query.toMs != null) {
448
+ // An explicit window (frozen brush, or a hand-entered ?from&to) must not
449
+ // reach further back than maxRangeDays — the same cap rangeSpanMs applies
450
+ // to the range-derived branch below. Anchor at the recent edge.
451
+ const capMs = (capabilities.maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
452
+ const from = Math.max(query.fromMs, query.toMs - capMs)
453
+ // Sliding: quantize so two ticks inside one step share a query key (no
454
+ // refetch). The precise [from,to] is still enforced by applyClientFilters.
455
+ if (query.sliding) {
456
+ const q = quantizeBaseWindow(from, query.toMs)
457
+ return { from: q.fromMs, to: q.toMs }
458
+ }
459
+ return { from, to: query.toMs }
460
+ }
461
+ const to = Date.now()
462
+ return { from: to - rangeSpanMs(query.timeRange, capabilities.maxRangeDays), to }
463
+ // eslint-disable-next-line react-hooks/exhaustive-deps
464
+ }, [query.fromMs, query.toMs, query.timeRange, query.sliding, capabilities.maxRangeDays])
465
+
466
+ const base = useQuery<RetainedWindowResult>({
467
+ // apiBase scopes the key: a host that swaps clusters (mutable module global)
468
+ // must not serve the previous cluster's cached window.
469
+ queryKey: ['timeline-retained', getApiBase(), 'base', window.from, window.to],
470
+ queryFn: ({ signal }) => fetchRetainedWindow(window.from, window.to, signal),
471
+ enabled,
472
+ staleTime: LIVE_REFETCH_MS,
473
+ // The base window quantizes to fixed steps, so its key rotates every few
474
+ // minutes even while live. Hold the previous window's events through the
475
+ // refetch instead of blanking the range on each rotation.
476
+ placeholderData: keepPreviousData,
477
+ })
478
+
479
+ // Fixed recent-window re-poll. Key is stable; queryFn reads the clock fresh
480
+ // each tick so the window slides without churning the cache key. Skipped for
481
+ // a purely historical brush (its right edge is older than the live window),
482
+ // where recency merging would only add events the time filter drops.
483
+ const liveEnabled = enabled && (query.toMs == null || query.toMs >= Date.now() - LIVE_WINDOW_MS)
484
+ const live = useQuery<RetainedWindowResult>({
485
+ queryKey: ['timeline-retained', getApiBase(), 'live'],
486
+ queryFn: ({ signal }) => {
487
+ const to = Date.now()
488
+ return fetchRetainedWindow(to - LIVE_WINDOW_MS, to, signal)
489
+ },
490
+ enabled: liveEnabled,
491
+ refetchInterval: LIVE_REFETCH_MS,
492
+ })
493
+
494
+ const merged = useMemo(
495
+ () => mergeWindows(base.data, live.data),
496
+ [base.data, live.data],
497
+ )
498
+
499
+ const kindsKey = query.kinds?.join(',')
500
+ const data = useMemo(() => {
501
+ if (base.data === undefined && live.data === undefined) return undefined
502
+ return applyClientFilters(merged.events, query)
503
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
+ }, [
505
+ merged,
506
+ base.data,
507
+ live.data,
508
+ query.namespaces,
509
+ kindsKey,
510
+ query.includeK8sEvents,
511
+ query.includeDeleted,
512
+ query.includeManaged,
513
+ query.limit,
514
+ query.fromMs,
515
+ query.toMs,
516
+ ])
517
+
518
+ return {
519
+ data,
520
+ isLoading: base.isLoading,
521
+ isFetching: base.isFetching || live.isFetching,
522
+ // Base failure is a real error; a failing live poll must not blank an
523
+ // already-loaded range.
524
+ isError: base.isError,
525
+ refetch: () => {
526
+ base.refetch()
527
+ live.refetch()
528
+ },
529
+ coverage: merged.coverage,
530
+ }
531
+ }
532
+ }
533
+
534
+ // The retained (hub) wire shape: the bucket start ships as `hourStartMs` and
535
+ // coverage may be a single span or an array. fetchRetainedOverview remaps this
536
+ // to the client `TimelineOverviewBucket` (startMs + a normalized coverage array)
537
+ // at this one parse boundary so nothing downstream sees the wire quirks.
538
+ interface RawOverviewBucket {
539
+ hourStartMs: number
540
+ summary: TimelineOverviewBucket['summary']
541
+ coverage?: TimelineCoverageSpan | TimelineCoverageSpan[]
542
+ }
543
+
544
+ function normalizeCoverage(
545
+ cov: TimelineCoverageSpan | TimelineCoverageSpan[] | undefined,
546
+ ): TimelineCoverageSpan[] | undefined {
547
+ if (cov == null) return undefined
548
+ return Array.isArray(cov) ? cov : [cov]
549
+ }
550
+
551
+ async function fetchRetainedOverview(range: TimelineRange): Promise<TimelineOverviewResult> {
552
+ const res = await apiFetch(apiUrl(`/timeline/overview?from=${Math.round(range.from)}&to=${Math.round(range.to)}`))
553
+ if (!res.ok) {
554
+ const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
555
+ throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
556
+ }
557
+ const body: unknown = await res.json()
558
+ const env = body as { buckets?: RawOverviewBucket[]; availableFromMs?: number }
559
+ const buckets: TimelineOverviewBucket[] = (env.buckets ?? []).map((b) => ({
560
+ startMs: b.hourStartMs,
561
+ summary: b.summary,
562
+ coverage: normalizeCoverage(b.coverage),
563
+ }))
564
+ return { buckets, availableFromMs: env.availableFromMs }
565
+ }
566
+
567
+ export function createRetainedSource(config: TimelineSourceConfig): TimelineSource {
568
+ const capabilities: TimelineSourceCapabilities = {
569
+ mode: 'retained',
570
+ maxRangeDays: config.maxRangeDays,
571
+ }
572
+ return {
573
+ capabilities,
574
+ useEvents: createRetainedEventsHook(capabilities),
575
+ fetchOverview: fetchRetainedOverview,
576
+ }
577
+ }
578
+
579
+ export function resolveTimelineSource(config?: TimelineSourceConfig): TimelineSource {
580
+ if (config?.mode === 'retained') return createRetainedSource(config)
581
+ return localSource
582
+ }