@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
package/src/api/client.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type {
3
+ AppHistory,
4
+ AppRow,
5
+ ArgoSyncOpts,
6
+ YamlDocumentIdentity,
7
+ YamlSchemaLoadResult,
8
+ } from '@skyhook-io/k8s-ui'
1
9
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
2
10
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
3
11
  import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
@@ -14,8 +22,12 @@ import type {
14
22
  HelmReleaseDetail,
15
23
  HelmValues,
16
24
  ManifestDiff,
25
+ NotesDiff,
26
+ HooksDiff,
27
+ ResourceDiff,
17
28
  UpgradeInfo,
18
29
  BatchUpgradeInfo,
30
+ ValuesDiff,
19
31
  ValuesPreviewResponse,
20
32
  HelmRepository,
21
33
  ChartSearchResult,
@@ -23,26 +35,54 @@ import type {
23
35
  InstallChartRequest,
24
36
  ArtifactHubSearchResult,
25
37
  ArtifactHubChartDetail,
38
+ GitOpsResourceTree,
39
+ GitOpsInsight,
40
+ GitOpsInsightRef,
41
+ GitOpsResourceDiff,
42
+ ArgoRevisionMetadata,
26
43
  } from '../types'
27
44
  import type { GitOpsOperationResponse } from '../types/gitops'
28
45
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
29
46
  import { pluralToKind } from '../utils/navigation'
30
47
 
48
+ // Auto-refresh cadences (ms) — named constants for each polled hook's
49
+ // refetchInterval below, so the poll rate reads clearly at each call site.
50
+ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
51
+ const AUDIT_REFRESH_INTERVAL_MS = 60_000
52
+ const ISSUES_REFRESH_INTERVAL_MS = 30_000
53
+ const COST_REFRESH_INTERVAL_MS = 60_000
54
+ const COST_DISCOVERY_RETRY_INTERVAL_MS = 5_000
55
+ export const COST_DISCOVERY_GRACE_MS = 30_000
56
+ const COST_TREND_REFRESH_INTERVAL_MS = 120_000
57
+ const CHANGES_REFRESH_INTERVAL_MS = 60_000
58
+ const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
59
+
31
60
  // Wrapper around fetch that always includes credentials (for session cookies)
32
61
  // and handles 401 responses globally. Merges caller-provided headers with
33
62
  // auth headers from the config module so library consumers (Radar Hub) can
34
63
  // inject Authorization bearer tokens without each call site knowing.
35
- function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
64
+ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
36
65
  const headers = new Headers(init?.headers)
37
66
  for (const [k, v] of Object.entries(getAuthHeaders())) {
38
67
  if (!headers.has(k)) headers.set(k, v)
39
68
  }
40
- return fetch(input, { credentials: getCredentialsMode(), ...init, headers }).then(async response => {
69
+ return fetch(input, {
70
+ credentials: getCredentialsMode(),
71
+ ...init,
72
+ headers,
73
+ }).then(async (response) => {
41
74
  const authPrefix = `${getBasename()}/auth`
42
75
  if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
43
76
  // Save current location so user returns to where they were after re-auth.
44
77
  // Editor draft is auto-saved by EditableYamlView via sessionStorage.
45
- try { sessionStorage.setItem('radar_return_path', window.location.pathname + window.location.search) } catch { /* best-effort */ }
78
+ try {
79
+ sessionStorage.setItem(
80
+ 'radar_return_path',
81
+ window.location.pathname + window.location.search,
82
+ )
83
+ } catch {
84
+ /* best-effort */
85
+ }
46
86
 
47
87
  let authMode: string | undefined
48
88
  try {
@@ -61,7 +101,11 @@ function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respons
61
101
  const lastReload = sessionStorage.getItem('radar_proxy_reload')
62
102
  const now = Date.now()
63
103
  if (!lastReload || now - parseInt(lastReload) > 5000) {
64
- try { sessionStorage.setItem('radar_proxy_reload', String(now)) } catch { /* best-effort */ }
104
+ try {
105
+ sessionStorage.setItem('radar_proxy_reload', String(now))
106
+ } catch {
107
+ /* best-effort */
108
+ }
65
109
  window.location.reload()
66
110
  }
67
111
  }
@@ -86,8 +130,50 @@ export function isForbiddenError(error: unknown): boolean {
86
130
  return error instanceof ApiError && error.status === 403
87
131
  }
88
132
 
89
- export async function fetchJSON<T>(path: string): Promise<T> {
90
- const response = await apiFetch(`${getApiBase()}${path}`)
133
+ const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
134
+
135
+ function mentionsMetricsAPIGroup(message: string): boolean {
136
+ const tokens = message.split(/[^a-z0-9]+/).filter(Boolean)
137
+ return tokens.some(
138
+ (token, index) =>
139
+ token === METRICS_API_GROUP_TOKENS[0] &&
140
+ tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
141
+ tokens[index + 2] === METRICS_API_GROUP_TOKENS[2],
142
+ )
143
+ }
144
+
145
+ function hasMetricsUnavailablePhrase(message: string): boolean {
146
+ return (
147
+ message.includes('may not be installed') ||
148
+ message.includes('not found') ||
149
+ message.includes('could not find the requested resource') ||
150
+ message.includes('no matches for kind') ||
151
+ message.includes('no resource matches') ||
152
+ message.includes('no metrics known') ||
153
+ message.includes('not available') ||
154
+ message.includes('unable to fetch metrics') ||
155
+ message.includes('currently unable to handle the request')
156
+ )
157
+ }
158
+
159
+ export function isMetricsUnavailableError(error: unknown): boolean {
160
+ if (!(error instanceof ApiError)) return false
161
+ if (error.status !== 404 && error.status !== 500) return false
162
+ return [error.message, error.data?.error].some((message) => {
163
+ if (typeof message !== 'string') return false
164
+ const normalized = message.toLowerCase()
165
+ const hasMetricsSignal =
166
+ normalized.includes('metrics-server') ||
167
+ mentionsMetricsAPIGroup(normalized) ||
168
+ normalized.includes('pod metrics') ||
169
+ normalized.includes('node metrics')
170
+ return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
171
+ })
172
+ }
173
+
174
+ export async function fetchJSON<T>(path: string, init?: RequestInit | AbortSignal): Promise<T> {
175
+ const requestInit = init instanceof AbortSignal ? { signal: init } : init
176
+ const response = await apiFetch(`${getApiBase()}${path}`, requestInit)
91
177
  if (!response.ok) {
92
178
  const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
93
179
  throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
@@ -118,7 +204,7 @@ export interface DashboardProblem {
118
204
  namespace: string
119
205
  name: string
120
206
  group?: string
121
- severity: 'critical' | 'high' | 'medium'
207
+ severity: 'critical' | 'high' | 'medium' | 'warning' | 'info'
122
208
  reason: string
123
209
  message: string
124
210
  age: string
@@ -137,6 +223,9 @@ export interface WorkloadCount {
137
223
  export interface DashboardMetrics {
138
224
  cpu?: MetricSummary
139
225
  memory?: MetricSummary
226
+ // When false, only requests/capacity are meaningful — live usage (from
227
+ // metrics-server) is unavailable and usage fields are zero.
228
+ usageAvailable: boolean
140
229
  }
141
230
 
142
231
  export interface MetricSummary {
@@ -148,7 +237,13 @@ export interface MetricSummary {
148
237
  }
149
238
 
150
239
  export interface DashboardResourceCounts {
151
- pods: { total: number; running: number; pending: number; failed: number; succeeded: number }
240
+ pods: {
241
+ total: number
242
+ running: number
243
+ pending: number
244
+ failed: number
245
+ succeeded: number
246
+ }
152
247
  deployments: { total: number; available: number; unavailable: number }
153
248
  statefulSets: WorkloadCount
154
249
  daemonSets: WorkloadCount
@@ -166,15 +261,6 @@ export interface DashboardResourceCounts {
166
261
  restricted?: string[] // Resource kinds the user cannot list due to RBAC
167
262
  }
168
263
 
169
- export interface DashboardEvent {
170
- type: string
171
- reason: string
172
- message: string
173
- involvedObject: string
174
- namespace: string
175
- timestamp: string
176
- }
177
-
178
264
  export interface DashboardChange {
179
265
  kind: string
180
266
  namespace: string
@@ -231,15 +317,27 @@ export interface DashboardCRDCount {
231
317
  }
232
318
 
233
319
  // Re-export shared types from k8s-ui — single source of truth
234
- import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta } from '@skyhook-io/k8s-ui'
320
+ import type {
321
+ AuditCardData,
322
+ AuditFinding,
323
+ ResourceGroup,
324
+ CheckMeta,
325
+ Check,
326
+ Issue,
327
+ IssueRecentChange,
328
+ } from '@skyhook-io/k8s-ui'
235
329
  export type DashboardAudit = AuditCardData
236
- export type { AuditFinding, ResourceGroup, CheckMeta }
330
+ export type { AuditFinding, ResourceGroup, CheckMeta, Check }
237
331
 
238
332
  export interface AuditResponse {
239
333
  summary: DashboardAudit
240
334
  findings: AuditFinding[]
241
335
  groups: ResourceGroup[]
242
336
  checks: Record<string, CheckMeta>
337
+ // Remediation-queue rollup: findings grouped by check, prioritized. Present
338
+ // on the non-raw scan (standalone + embedded per-cluster views); the Checks
339
+ // queue renders this.
340
+ groupedChecks?: Check[]
243
341
  }
244
342
 
245
343
  export interface DashboardCertificateHealth {
@@ -256,12 +354,36 @@ export interface DashboardNetworkPolicyCoverage {
256
354
  totalWorkloads: number
257
355
  }
258
356
 
357
+ export interface DashboardGitOpsControllers {
358
+ // Aggregate roll-up across all detected controllers.
359
+ status: 'healthy' | 'degraded' | 'crashing'
360
+ controllers: DashboardGitOpsController[]
361
+ }
362
+
363
+ export interface DashboardGitOpsController {
364
+ name: string
365
+ // Tool vocabulary aligns with the backend `ctrlTool*` constants in
366
+ // internal/server/dashboard_gitops.go and the GitOps tree-builder
367
+ // tags in pkg/gitops/tree/graph.go (`gitopsTool`). Keep these three
368
+ // surfaces in sync — diverging vocabulary across surfaces was a real
369
+ // source of confusion until consolidated.
370
+ tool: 'argocd' | 'fluxcd'
371
+ namespace: string
372
+ ready: number
373
+ total: number
374
+ // Per-controller status (aggregate is in the parent and excludes
375
+ // 'pending' — it normalizes into 'degraded' there).
376
+ status: 'healthy' | 'degraded' | 'crashing' | 'pending'
377
+ // Reason for the crash when status === 'crashing'. Common values:
378
+ // "CrashLoopBackOff", "Error". Empty for non-crashing states.
379
+ crashReason?: string
380
+ }
381
+
259
382
  export interface DashboardResponse {
260
383
  cluster: DashboardCluster
261
384
  health: DashboardHealth
262
385
  problems: DashboardProblem[]
263
386
  resourceCounts: DashboardResourceCounts
264
- recentEvents: DashboardEvent[]
265
387
  recentChanges: DashboardChange[]
266
388
  topologySummary: DashboardTopologySummary
267
389
  trafficSummary: DashboardTrafficSummary | null
@@ -270,9 +392,14 @@ export interface DashboardResponse {
270
392
  certificateHealth: DashboardCertificateHealth | null
271
393
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
272
394
  audit: DashboardAudit | null
273
- nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
395
+ gitopsControllers: DashboardGitOpsControllers | null
396
+ nodeVersionSkew: {
397
+ versions: Record<string, string[]>
398
+ minVersion: string
399
+ maxVersion: string
400
+ } | null
274
401
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
- partialData?: string[] // Resource kinds still loading after first paint (slow-cluster fallback)
402
+ partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
276
403
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
277
404
  }
278
405
 
@@ -280,13 +407,14 @@ export interface DashboardCRDsResponse {
280
407
  topCRDs: DashboardCRDCount[]
281
408
  }
282
409
 
283
- export function useDashboard(namespaces: string[] = []) {
410
+ export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
284
411
  const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
285
412
  return useQuery<DashboardResponse>({
286
413
  queryKey: ['dashboard', namespaces],
287
414
  queryFn: () => fetchJSON(`/dashboard${params}`),
415
+ enabled: options?.enabled ?? true,
288
416
  staleTime: 15000, // 15 seconds
289
- refetchInterval: 30000, // Refresh every 30 seconds
417
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
290
418
  })
291
419
  }
292
420
 
@@ -297,7 +425,40 @@ export function useAudit(namespaces: string[] = []) {
297
425
  queryKey: ['audit', namespaces],
298
426
  queryFn: () => fetchJSON(`/audit${params}`),
299
427
  staleTime: 30000,
300
- refetchInterval: 60000,
428
+ refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
429
+ placeholderData: (prev) => prev,
430
+ })
431
+ }
432
+
433
+ // Live cluster Issues — the grouped triage queue (radar's /api/issues =
434
+ // internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
435
+ // view fans the same shape across clusters. Do not carry old data across query
436
+ // keys: issues are scope-sensitive, so namespace changes must not show the
437
+ // previous scope's rows while the new scope fetches.
438
+ // total = rows returned (after the cap); total_matched = rows that matched
439
+ // before the cap. total_matched > total means the queue was truncated — surface
440
+ // that honestly rather than presenting a capped list as if it were complete.
441
+ export interface IssuesResponse {
442
+ issues: Issue[]
443
+ total?: number
444
+ total_matched?: number
445
+ recent_changes?: IssueRecentChange[]
446
+ recent_changes_reason?: string
447
+ recent_changes_guidance?: string
448
+ recent_changes_truncated?: boolean
449
+ // Present only when RBAC visibility is incomplete (absent = full access).
450
+ // state 'degraded' means core workload reads are denied, so an empty list may
451
+ // mean "can't see" rather than "nothing broken" — the UI must say so.
452
+ visibility?: { state?: string; impact?: string }
453
+ }
454
+
455
+ export function useIssues(namespaces: string[] = []) {
456
+ const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
457
+ return useQuery<IssuesResponse>({
458
+ queryKey: ['issues', namespaces],
459
+ queryFn: () => fetchJSON(`/issues${params}`),
460
+ staleTime: 30000,
461
+ refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
301
462
  })
302
463
  }
303
464
 
@@ -309,6 +470,34 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
309
470
  })
310
471
  }
311
472
 
473
+ // Live Issues that touch ONE resource — its own issues plus, for a workload, its
474
+ // owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
475
+ // the "Operational Issues" section in the resource detail. Cluster-scoped
476
+ // resources pass "_" for namespace; namespaced ones also scope the scan via
477
+ // ?namespaces= for a cheap, bounded Compose.
478
+ export function useResourceIssues(
479
+ kind: string,
480
+ group: string | undefined,
481
+ namespace: string,
482
+ name: string,
483
+ enabled = true,
484
+ ) {
485
+ const clusterScoped = !namespace
486
+ const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
487
+ const params = new URLSearchParams()
488
+ if (group) params.set('group', group)
489
+ const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
490
+ const qs = params.toString()
491
+ return useQuery<Issue[]>({
492
+ queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
493
+ queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
494
+ // No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
495
+ // on reopen without re-running an uncapped Compose every 30s.
496
+ staleTime: 30000,
497
+ enabled: enabled && !!kind && !!name,
498
+ })
499
+ }
500
+
312
501
  // Audit settings
313
502
  export interface AuditSettings {
314
503
  ignoredNamespaces: string[]
@@ -405,7 +594,8 @@ export interface OpenCostNamespaceCost {
405
594
  idleCost?: number
406
595
  }
407
596
 
408
- export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error'
597
+ export type CostUnavailableReason =
598
+ 'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
409
599
 
410
600
  export interface OpenCostSummary {
411
601
  available: boolean
@@ -419,11 +609,41 @@ export interface OpenCostSummary {
419
609
  namespaces?: OpenCostNamespaceCost[]
420
610
  }
421
611
 
612
+ const noPrometheusFirstSeenAt = new Map<string, number>()
613
+
614
+ function costRefetchInterval(
615
+ defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
616
+ contextName?: string,
617
+ ) {
618
+ return (query: {
619
+ queryHash?: string
620
+ queryKey?: unknown
621
+ state: {
622
+ data?: { available?: boolean; reason?: CostUnavailableReason }
623
+ dataUpdatedAt?: number
624
+ }
625
+ }) => {
626
+ const data = query.state.data
627
+ const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
628
+ if (data?.available === false && data.reason === 'no_prometheus') {
629
+ const now = Date.now()
630
+ const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
631
+ noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
632
+ return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
633
+ ? COST_DISCOVERY_RETRY_INTERVAL_MS
634
+ : defaultInterval
635
+ }
636
+ noPrometheusFirstSeenAt.delete(queryID)
637
+ return defaultInterval
638
+ }
639
+ }
640
+
422
641
  export function useOpenCostSummary() {
642
+ const clusterInfo = useClusterInfo()
423
643
  return useQuery<OpenCostSummary>({
424
644
  queryKey: ['opencost-summary'],
425
645
  queryFn: () => fetchJSON('/opencost/summary'),
426
- refetchInterval: 60000, // Refresh every minute
646
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
427
647
  staleTime: 30000,
428
648
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
429
649
  })
@@ -439,6 +659,10 @@ export interface OpenCostWorkloadCost {
439
659
  replicas: number
440
660
  cpuUsageCost?: number
441
661
  memoryUsageCost?: number
662
+ cpuUsageAvailable: boolean
663
+ memoryUsageAvailable: boolean
664
+ cpuAllocationUse: number
665
+ memoryAllocationUse: number
442
666
  efficiency?: number
443
667
  idleCost?: number
444
668
  }
@@ -451,11 +675,41 @@ export interface OpenCostWorkloadResponse {
451
675
  }
452
676
 
453
677
  export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
678
+ const clusterInfo = useClusterInfo()
454
679
  return useQuery<OpenCostWorkloadResponse>({
455
680
  queryKey: ['opencost-workloads', namespace],
456
681
  queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
457
682
  enabled: (options?.enabled ?? true) && Boolean(namespace),
683
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
684
+ staleTime: 30000,
685
+ })
686
+ }
687
+
688
+ export interface OpenCostWorkloadDetailResponse {
689
+ available: boolean
690
+ reason?: CostUnavailableReason
691
+ namespace: string
692
+ kind: string
693
+ name: string
694
+ current?: OpenCostWorkloadCost
695
+ }
696
+
697
+ export function useOpenCostWorkload(
698
+ kind: string,
699
+ namespace: string,
700
+ name: string,
701
+ options?: { enabled?: boolean },
702
+ ) {
703
+ const clusterInfo = useClusterInfo()
704
+ return useQuery<OpenCostWorkloadDetailResponse>({
705
+ queryKey: ['opencost-workload', kind, namespace, name],
706
+ queryFn: () =>
707
+ fetchJSON(
708
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
709
+ ),
710
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
458
711
  staleTime: 30000,
712
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
459
713
  })
460
714
  }
461
715
 
@@ -480,18 +734,175 @@ export interface OpenCostTrendResponse {
480
734
  }
481
735
 
482
736
  export function useOpenCostTrend(range_: CostTimeRange = '24h') {
737
+ const clusterInfo = useClusterInfo()
483
738
  return useQuery<OpenCostTrendResponse>({
484
739
  queryKey: ['opencost-trend', range_],
485
740
  queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
486
741
  staleTime: 60000,
487
- refetchInterval: 120000, // Refresh every 2 minutes
742
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
488
743
  placeholderData: (prev) => prev,
489
744
  })
490
745
  }
491
746
 
747
+ export interface OpenCostWorkloadTrendResponse {
748
+ available: boolean
749
+ reason?: CostUnavailableReason
750
+ namespace: string
751
+ kind: string
752
+ name: string
753
+ range: string
754
+ windowTotalCost?: number
755
+ dataPoints?: OpenCostTrendDataPoint[]
756
+ }
757
+
758
+ export function useOpenCostWorkloadTrend(
759
+ kind: string,
760
+ namespace: string,
761
+ name: string,
762
+ range_: CostTimeRange = '24h',
763
+ options?: { enabled?: boolean },
764
+ ) {
765
+ const clusterInfo = useClusterInfo()
766
+ return useQuery<OpenCostWorkloadTrendResponse>({
767
+ queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
768
+ queryFn: () =>
769
+ fetchJSON(
770
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
771
+ ),
772
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
773
+ staleTime: 60000,
774
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
775
+ })
776
+ }
777
+
778
+ export interface OpenCostApplicationWorkloadRef {
779
+ kind: string
780
+ namespace: string
781
+ name: string
782
+ }
783
+
784
+ export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
785
+ reason: CostUnavailableReason
786
+ scaledToZero?: boolean
787
+ }
788
+
789
+ export interface OpenCostApplicationCostCoverage {
790
+ total: number
791
+ included: number
792
+ unavailable?: OpenCostApplicationWorkloadStatus[]
793
+ unsupported?: OpenCostApplicationWorkloadRef[]
794
+ }
795
+
796
+ export interface OpenCostApplicationCostTotals {
797
+ hourlyCost: number
798
+ cpuCost: number
799
+ memoryCost: number
800
+ replicas: number
801
+ cpuUsageCost?: number
802
+ memoryUsageCost?: number
803
+ cpuUsageAvailable: boolean
804
+ memoryUsageAvailable: boolean
805
+ cpuAllocationUse: number
806
+ memoryAllocationUse: number
807
+ }
808
+
809
+ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
810
+ available: boolean
811
+ reason?: CostUnavailableReason
812
+ scaledToZero?: boolean
813
+ current?: OpenCostWorkloadCost
814
+ }
815
+
816
+ export interface OpenCostApplicationCostResponse {
817
+ available: boolean
818
+ reason?: CostUnavailableReason
819
+ partial?: boolean
820
+ totals: OpenCostApplicationCostTotals
821
+ coverage: OpenCostApplicationCostCoverage
822
+ workloads?: OpenCostApplicationWorkloadCost[]
823
+ }
824
+
825
+ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
826
+ windowTotalCost?: number
827
+ dataPoints?: OpenCostTrendDataPoint[]
828
+ }
829
+
830
+ export interface OpenCostApplicationCostTrendResponse {
831
+ available: boolean
832
+ reason?: CostUnavailableReason
833
+ range: string
834
+ partial?: boolean
835
+ windowTotalCost?: number
836
+ dataPoints?: OpenCostTrendDataPoint[]
837
+ series?: OpenCostApplicationCostTrendSeries[]
838
+ coverage: OpenCostApplicationCostCoverage
839
+ }
840
+
841
+ function stableOpenCostWorkloadRefs(
842
+ workloads: OpenCostApplicationWorkloadRef[],
843
+ ): OpenCostApplicationWorkloadRef[] {
844
+ const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
845
+ for (const workload of workloads) {
846
+ if (!workload.kind || !workload.namespace || !workload.name) continue
847
+ const ref = {
848
+ kind: workload.kind,
849
+ namespace: workload.namespace,
850
+ name: workload.name,
851
+ }
852
+ byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
853
+ }
854
+ return [...byKey.values()].sort((a, b) =>
855
+ `${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
856
+ )
857
+ }
858
+
859
+ export function useOpenCostApplicationCost(
860
+ workloads: OpenCostApplicationWorkloadRef[],
861
+ options?: { enabled?: boolean },
862
+ ) {
863
+ const clusterInfo = useClusterInfo()
864
+ const refs = stableOpenCostWorkloadRefs(workloads)
865
+ return useQuery<OpenCostApplicationCostResponse>({
866
+ queryKey: ['opencost-application', refs],
867
+ queryFn: ({ signal }) =>
868
+ fetchJSON('/opencost/application', {
869
+ method: 'POST',
870
+ headers: { 'Content-Type': 'application/json' },
871
+ body: JSON.stringify({ workloads: refs }),
872
+ signal,
873
+ }),
874
+ enabled: (options?.enabled ?? true) && refs.length > 0,
875
+ staleTime: 30000,
876
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
877
+ })
878
+ }
879
+
880
+ export function useOpenCostApplicationCostTrend(
881
+ workloads: OpenCostApplicationWorkloadRef[],
882
+ range_: CostTimeRange = '24h',
883
+ options?: { enabled?: boolean },
884
+ ) {
885
+ const clusterInfo = useClusterInfo()
886
+ const refs = stableOpenCostWorkloadRefs(workloads)
887
+ return useQuery<OpenCostApplicationCostTrendResponse>({
888
+ queryKey: ['opencost-application-trend', refs, range_],
889
+ queryFn: ({ signal }) =>
890
+ fetchJSON('/opencost/application/trend', {
891
+ method: 'POST',
892
+ headers: { 'Content-Type': 'application/json' },
893
+ body: JSON.stringify({ workloads: refs, range: range_ }),
894
+ signal,
895
+ }),
896
+ enabled: (options?.enabled ?? true) && refs.length > 0,
897
+ staleTime: 60000,
898
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
899
+ })
900
+ }
901
+
492
902
  // Node cost breakdown
493
903
  export interface OpenCostNodeCost {
494
904
  name: string
905
+ providerID?: string
495
906
  instanceType?: string
496
907
  region?: string
497
908
  hourlyCost: number
@@ -506,11 +917,12 @@ export interface OpenCostNodeResponse {
506
917
  }
507
918
 
508
919
  export function useOpenCostNodes() {
920
+ const clusterInfo = useClusterInfo()
509
921
  return useQuery<OpenCostNodeResponse>({
510
922
  queryKey: ['opencost-nodes'],
511
923
  queryFn: () => fetchJSON('/opencost/nodes'),
512
924
  staleTime: 60000,
513
- refetchInterval: 120000,
925
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
514
926
  placeholderData: (prev) => prev,
515
927
  })
516
928
  }
@@ -623,6 +1035,84 @@ export interface RuntimeStats {
623
1035
  dynamicInformers?: number
624
1036
  }
625
1037
 
1038
+ // ============================================================================
1039
+ // Resource search (GET /api/search) — the existing search engine, RBAC-filtered
1040
+ // and ranked server-side. Mirrors internal/search.Hit / .Result.
1041
+ // ============================================================================
1042
+
1043
+ export interface SearchMatchedField {
1044
+ token: string
1045
+ /** "name" | "namespace" | "label:k" | "annotation:k" | "image" | "kind" | "content:path" */
1046
+ site: string
1047
+ score: number
1048
+ }
1049
+
1050
+ export interface SearchSummaryContext {
1051
+ health?: string
1052
+ issueCount?: number
1053
+ managedBy?: { kind?: string; name?: string } | null
1054
+ }
1055
+
1056
+ export interface SearchHit {
1057
+ score: number
1058
+ kind: string
1059
+ group?: string
1060
+ namespace?: string
1061
+ name: string
1062
+ matched?: SearchMatchedField[]
1063
+ summaryContext?: SearchSummaryContext
1064
+ /** Embedder (Radar Hub) only: the cluster this hit belongs to, for
1065
+ * cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
1066
+ * unset — the omnibar keys + displays the cluster only when present. */
1067
+ cluster?: string
1068
+ clusterName?: string
1069
+ }
1070
+
1071
+ export interface SearchResult {
1072
+ hits: SearchHit[]
1073
+ total: number
1074
+ searched: number
1075
+ total_matched: number
1076
+ }
1077
+
1078
+ const SEARCH_MIN_QUERY = 2
1079
+
1080
+ // useSearch hits the resource-search engine. The caller supplies the (already
1081
+ // debounced) query; the hook is enabled only past the min length. include=none
1082
+ // keeps the per-hit payload identity-only; context=summary attaches
1083
+ // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
1084
+ // overlapping scans on a new query. keepPreviousData avoids flicker while the
1085
+ // next query resolves.
1086
+ export function useSearch(
1087
+ query: string,
1088
+ opts?: {
1089
+ limit?: number
1090
+ context?: 'summary' | 'none'
1091
+ enabled?: boolean
1092
+ globalNs?: boolean
1093
+ },
1094
+ ) {
1095
+ const trimmed = query.trim()
1096
+ const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
1097
+ const limit = opts?.limit ?? 20
1098
+ const context = opts?.context ?? 'summary'
1099
+ // globalNs makes search ignore the per-user namespace-switcher pick and scan
1100
+ // the user's full RBAC ceiling (scope then comes only from the query's `ns:`
1101
+ // tokens). The omnibar opts in so ⌘K is a genuinely global lookup.
1102
+ const globalNs = opts?.globalNs ?? false
1103
+ return useQuery<SearchResult>({
1104
+ queryKey: ['search', trimmed, limit, context, globalNs],
1105
+ queryFn: ({ signal }) =>
1106
+ fetchJSON<SearchResult>(
1107
+ `/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
1108
+ signal,
1109
+ ),
1110
+ enabled,
1111
+ staleTime: 2000,
1112
+ placeholderData: (prev) => prev, // keepPreviousData
1113
+ })
1114
+ }
1115
+
626
1116
  export interface HealthResponse {
627
1117
  status: string
628
1118
  resourceCount: number
@@ -649,11 +1139,13 @@ export function useCapabilities() {
649
1139
  })
650
1140
  }
651
1141
 
652
- // Namespace-scoped capabilities: lazy re-check for exec/logs/portForward when
653
- // global RBAC checks denied them. Users with namespace-scoped RoleBindings may
1142
+ // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
654
1143
  // have these permissions in specific namespaces.
655
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities) {
656
- const needsCheck = namespace && (!globalCaps.exec || !globalCaps.logs || !globalCaps.portForward)
1144
+ export function useNamespaceCapabilities(
1145
+ namespace: string | undefined,
1146
+ globalCaps: Capabilities | undefined,
1147
+ ) {
1148
+ const needsCheck = namespace && globalCaps
657
1149
  return useQuery<Capabilities>({
658
1150
  queryKey: ['capabilities', namespace],
659
1151
  queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`),
@@ -673,6 +1165,10 @@ export interface AuthMe {
673
1165
  /** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
674
1166
  * Absent when not running under Cloud (OSS, OIDC, no role group). */
675
1167
  cloudRole?: CloudRole
1168
+ /** Proxy mode only: whether an upstream sign-out URL is configured.
1169
+ * When false, logout clears Radar's cookie but the proxy may re-auth
1170
+ * the same user on the next request. */
1171
+ proxyLogoutConfigured?: boolean
676
1172
  }
677
1173
 
678
1174
  export function useAuthMe() {
@@ -684,10 +1180,14 @@ export function useAuthMe() {
684
1180
  }
685
1181
 
686
1182
  // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
687
- // CloudRole.AtLeast — the SPA must agree with the backend on what
1183
+ // CloudRole.AtLeast — the frontend must agree with the backend on what
688
1184
  // "member-or-higher" means; otherwise we'd hide a button the
689
1185
  // backend would happily honor (or vice versa).
690
- const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
1186
+ const CLOUD_ROLE_RANK: Record<string, number> = {
1187
+ viewer: 1,
1188
+ member: 2,
1189
+ owner: 3,
1190
+ }
691
1191
 
692
1192
  /**
693
1193
  * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
@@ -768,23 +1268,175 @@ export function useNamespaces() {
768
1268
  }
769
1269
 
770
1270
  // Topology (for manual refresh)
771
- export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean }) {
1271
+ export function useTopology(
1272
+ namespaces: string[],
1273
+ viewMode: string = 'resources',
1274
+ options?: {
1275
+ enabled?: boolean
1276
+ includeReplicaSets?: boolean
1277
+ refetchInterval?: number | false
1278
+ },
1279
+ ) {
772
1280
  const params = new URLSearchParams()
773
1281
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
774
1282
  if (viewMode) params.set('view', viewMode)
1283
+ if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
775
1284
  const queryString = params.toString()
776
1285
 
777
1286
  return useQuery<Topology>({
778
- queryKey: ['topology', namespaces, viewMode],
1287
+ queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
779
1288
  queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
780
1289
  staleTime: 5000, // 5 seconds
781
1290
  enabled: options?.enabled !== false,
1291
+ refetchInterval: options?.refetchInterval,
1292
+ })
1293
+ }
1294
+
1295
+ export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
1296
+ const params = new URLSearchParams()
1297
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1298
+ const queryString = params.toString()
1299
+
1300
+ const enabled = options?.enabled !== false
1301
+ return useQuery<{ applications: AppRow[] }>({
1302
+ queryKey: ['applications', namespaces],
1303
+ queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
1304
+ staleTime: 30_000,
1305
+ // Only poll while a consumer needs the index; gated off it must not keep the
1306
+ // background refetch alive.
1307
+ enabled,
1308
+ refetchInterval: enabled ? APPLICATIONS_REFRESH_INTERVAL_MS : false,
1309
+ })
1310
+ }
1311
+
1312
+ export function useApplicationHistory(
1313
+ appKey: string | undefined,
1314
+ namespaces: string[],
1315
+ options?: { enabled?: boolean },
1316
+ ) {
1317
+ const params = new URLSearchParams()
1318
+ if (appKey) params.set('app', appKey)
1319
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1320
+ const queryString = params.toString()
1321
+
1322
+ return useQuery<AppHistory>({
1323
+ queryKey: ['application-history', appKey, namespaces],
1324
+ queryFn: appKey ? () => fetchJSON(`/applications/history?${queryString}`) : skipToken,
1325
+ enabled: Boolean(appKey) && (options?.enabled ?? true),
1326
+ staleTime: 15_000,
1327
+ refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
1328
+ })
1329
+ }
1330
+
1331
+ export function useGitOpsTree(
1332
+ kind: string,
1333
+ namespace: string,
1334
+ name: string,
1335
+ group?: string,
1336
+ namespaces: string[] = [],
1337
+ options?: { enabled?: boolean },
1338
+ ) {
1339
+ const ns = namespace || '_'
1340
+ const params = new URLSearchParams()
1341
+ if (group) params.set('group', group)
1342
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1343
+ const queryString = params.toString()
1344
+
1345
+ return useQuery<GitOpsResourceTree>({
1346
+ queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
1347
+ queryFn: () =>
1348
+ fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1349
+ enabled: Boolean(kind && name) && (options?.enabled ?? true),
1350
+ staleTime: 5000,
1351
+ })
1352
+ }
1353
+
1354
+ // Poll fast (2s) while a sync/rollback is in flight so the user sees the
1355
+ // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
1356
+ // operationState.phase from Running/Terminating to a terminal phase, so this
1357
+ // auto-quiesces on completion.
1358
+ const INSIGHTS_RUNNING_POLL_MS = 2000
1359
+
1360
+ export function useGitOpsInsights(
1361
+ kind: string,
1362
+ namespace: string,
1363
+ name: string,
1364
+ group?: string,
1365
+ namespaces: string[] = [],
1366
+ ) {
1367
+ const ns = namespace || '_'
1368
+ const params = new URLSearchParams()
1369
+ if (group) params.set('group', group)
1370
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1371
+ const queryString = params.toString()
1372
+
1373
+ return useQuery<GitOpsInsight>({
1374
+ queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
1375
+ queryFn: () =>
1376
+ fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1377
+ enabled: Boolean(kind && name),
1378
+ staleTime: 5000,
1379
+ refetchInterval: (query) => {
1380
+ const phase = query.state.data?.summary?.operationPhase
1381
+ return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
1382
+ },
1383
+ })
1384
+ }
1385
+
1386
+ // Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
1387
+ // ns/name identify the Application; the ref identifies the managed resource.
1388
+ // Fetched on demand — the caller mounts this only when the user opens "Full
1389
+ // diff", so it's enabled whenever the ref is resolvable. Errors surface via
1390
+ // fetchJSON's ApiError (server {"error"} string as .message).
1391
+ export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
1392
+ const ns = appNamespace || '_'
1393
+ const params = new URLSearchParams()
1394
+ if (ref.group) params.set('group', ref.group)
1395
+ params.set('kind', ref.kind)
1396
+ if (ref.namespace) params.set('resourceNamespace', ref.namespace)
1397
+ params.set('resourceName', ref.name)
1398
+
1399
+ return useQuery<GitOpsResourceDiff>({
1400
+ queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
1401
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
1402
+ enabled: Boolean(appName && ref.kind && ref.name),
1403
+ staleTime: 15_000,
1404
+ })
1405
+ }
1406
+
1407
+ // Git commit metadata for one deployed revision of an Argo CD Application.
1408
+ // Enabled only when a revision is known and the caller passes `enabled` (gated
1409
+ // on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
1410
+ // metadata is effectively immutable.
1411
+ export function useArgoRevisionMetadata(
1412
+ appNamespace: string,
1413
+ appName: string,
1414
+ revision: string | undefined,
1415
+ opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
1416
+ ) {
1417
+ const ns = appNamespace || '_'
1418
+ const params = new URLSearchParams()
1419
+ if (revision) params.set('revision', revision)
1420
+ if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
1421
+ if (opts?.project) params.set('project', opts.project)
1422
+
1423
+ return useQuery<ArgoRevisionMetadata>({
1424
+ queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
1425
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
1426
+ enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
1427
+ staleTime: 5 * 60_000,
782
1428
  })
783
1429
  }
784
1430
 
785
1431
  // Generic resource fetching - returns resource with relationships
786
1432
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
787
- export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
1433
+ export function useResource<T>(
1434
+ kind: string,
1435
+ namespace: string,
1436
+ name: string,
1437
+ group?: string,
1438
+ options?: { enabled?: boolean; refetchInterval?: number | false },
1439
+ ) {
788
1440
  // For cluster-scoped resources, use '_' as namespace placeholder
789
1441
  const ns = namespace || '_'
790
1442
  const params = new URLSearchParams()
@@ -793,8 +1445,10 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
793
1445
 
794
1446
  const query = useQuery<ResourceWithRelationships<T>>({
795
1447
  queryKey: ['resource', kind, namespace, name, group],
796
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
797
- enabled: Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1448
+ queryFn: () =>
1449
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1450
+ enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1451
+ refetchInterval: options?.refetchInterval,
798
1452
  })
799
1453
 
800
1454
  // Extract resource and relationships from the response
@@ -803,11 +1457,17 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
803
1457
  data: query.data?.resource,
804
1458
  relationships: query.data?.relationships,
805
1459
  certificateInfo: query.data?.certificateInfo,
1460
+ hpaDiagnosis: query.data?.hpaDiagnosis,
806
1461
  }
807
1462
  }
808
1463
 
809
1464
  // Hook that returns full response with relationships explicitly
810
- export function useResourceWithRelationships<T>(kind: string, namespace: string, name: string, group?: string) {
1465
+ export function useResourceWithRelationships<T>(
1466
+ kind: string,
1467
+ namespace: string,
1468
+ name: string,
1469
+ group?: string,
1470
+ ) {
811
1471
  const ns = namespace || '_'
812
1472
  const params = new URLSearchParams()
813
1473
  if (group) params.set('group', group)
@@ -815,13 +1475,19 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
815
1475
 
816
1476
  return useQuery<ResourceWithRelationships<T>>({
817
1477
  queryKey: ['resource', kind, namespace, name, group],
818
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1478
+ queryFn: () =>
1479
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
819
1480
  enabled: Boolean(kind && name),
820
1481
  })
821
1482
  }
822
1483
 
823
1484
  // List resources - queryKey includes group for cache sharing with ResourcesView
824
- export function useResources<T>(kind: string, namespace?: string, group?: string) {
1485
+ export function useResources<T>(
1486
+ kind: string,
1487
+ namespace?: string,
1488
+ group?: string,
1489
+ options?: { enabled?: boolean; refetchInterval?: number | false },
1490
+ ) {
825
1491
  const params = new URLSearchParams()
826
1492
  if (namespace) params.set('namespace', namespace)
827
1493
  if (group) params.set('group', group)
@@ -830,20 +1496,153 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
830
1496
  return useQuery<T[]>({
831
1497
  queryKey: ['resources', kind, group, namespace],
832
1498
  queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
1499
+ enabled: (options?.enabled ?? true) && Boolean(kind),
833
1500
  staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
1501
+ refetchInterval: options?.refetchInterval,
834
1502
  })
835
1503
  }
836
1504
 
837
1505
  // Timeline changes (unified view of changes + K8s events)
838
1506
  export interface UseChangesOptions {
839
1507
  namespaces?: string[]
840
- kind?: string
1508
+ // Kind filter. The server narrows to a single kind (tighter result caps), so
1509
+ // exactly one selected kind is pushed server-side; a multi-kind selection
1510
+ // fetches unfiltered and is narrowed client-side by the caller.
1511
+ kinds?: string[]
841
1512
  timeRange?: TimeRange
842
1513
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
843
1514
  includeK8sEvents?: boolean
844
1515
  includeManaged?: boolean
1516
+ includeDeleted?: boolean
845
1517
  limit?: number
846
1518
  enabled?: boolean
1519
+ // Cursor-aware refetches: after the first full load, refetches ask the
1520
+ // server only for events that arrived after the highest seq already cached
1521
+ // and merge them in, instead of re-pulling the whole ring. Intended for the
1522
+ // timeline's full-ring (10k) query, where every SSE nudge would otherwise
1523
+ // re-transfer megabytes for a handful of new events.
1524
+ deltaSync?: boolean
1525
+ }
1526
+
1527
+ // The store epoch guards delta cursors: a restarted store restarts seq
1528
+ // numbering, so an epoch change forces a full resync. A periodic full resync
1529
+ // also runs as anti-entropy for anything a dropped SSE connection or a
1530
+ // server-side eviction could leave behind in the cached copy.
1531
+ const FULL_RESYNC_MS = 5 * 60_000
1532
+
1533
+ export interface ChangesDeltaMeta {
1534
+ epoch: string
1535
+ lastFullMs: number
1536
+ // Highest seq observed in ANY response for this query — not just what
1537
+ // survived the cap. A delta event older than everything cached gets capped
1538
+ // out of the merge; deriving the cursor from cached rows alone would then
1539
+ // re-request that same event on every refetch until the next full resync.
1540
+ highWaterSeq: number
1541
+ }
1542
+ const changesDeltaMeta = new Map<string, ChangesDeltaMeta>()
1543
+
1544
+ // The since_seq cursor for the next refetch, or 0 for a full fetch. Delta
1545
+ // requires an epoch-stamped prior full load, a cached page to merge into, and
1546
+ // the anti-entropy full resync not being due.
1547
+ export function deltaFetchCursor(
1548
+ meta: ChangesDeltaMeta | undefined,
1549
+ cached: TimelineEvent[] | undefined,
1550
+ nowMs: number,
1551
+ ): number {
1552
+ if (!meta?.epoch || !cached) return 0
1553
+ if (nowMs - meta.lastFullMs > FULL_RESYNC_MS) return 0
1554
+ return Math.max(meta.highWaterSeq, maxEventSeq(cached))
1555
+ }
1556
+
1557
+ async function fetchChangesPage(
1558
+ path: string,
1559
+ signal?: AbortSignal,
1560
+ ): Promise<{ events: TimelineEvent[]; epoch: string; maxSeq: number }> {
1561
+ const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
1562
+ if (!response.ok) {
1563
+ const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
1564
+ throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
1565
+ }
1566
+ const events = (await response.json()) as TimelineEvent[]
1567
+ // maxSeq is the page's frontier computed before the server's
1568
+ // cluster-scoped-RBAC filter — rows dropped THERE still advance the cursor.
1569
+ // (Rows dropped by content filters inside the store query do not; see the
1570
+ // known limitation on the server's handleChanges.)
1571
+ const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
1572
+ return {
1573
+ events,
1574
+ epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
1575
+ maxSeq,
1576
+ }
1577
+ }
1578
+
1579
+ // Highest store-assigned arrival number in the cached page — the delta cursor.
1580
+ export function maxEventSeq(events: TimelineEvent[]): number {
1581
+ let max = 0
1582
+ for (const event of events) {
1583
+ if (event.seq && event.seq > max) max = event.seq
1584
+ }
1585
+ return max
1586
+ }
1587
+
1588
+ // Merge a delta page into the cached page: a delta row replaces its cached id
1589
+ // (a K8s Event count bump re-arrives under the same id), new ids are added,
1590
+ // order stays newest-first (arrival number breaks timestamp ties), and the
1591
+ // result is capped to the query's limit by dropping the oldest.
1592
+ export function mergeDeltaEvents(
1593
+ prev: TimelineEvent[],
1594
+ delta: TimelineEvent[],
1595
+ cap: number,
1596
+ ): TimelineEvent[] {
1597
+ if (delta.length === 0) return prev
1598
+ const replaced = new Set(delta.map((event) => event.id))
1599
+ const merged = [...delta, ...prev.filter((event) => !replaced.has(event.id))]
1600
+ merged.sort((a, b) => {
1601
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1602
+ if (byTime !== 0) return byTime
1603
+ return (b.seq ?? 0) - (a.seq ?? 0)
1604
+ })
1605
+ return merged.length > cap ? merged.slice(0, cap) : merged
1606
+ }
1607
+
1608
+ // Delta-sync orchestration for useChanges, extracted so the
1609
+ // full-fetch → delta-poll → epoch-mismatch-resync contract is exercisable
1610
+ // without a React render. State is passed in explicitly — the cached page and
1611
+ // the shared meta store — rather than closed over from module scope, so a
1612
+ // caller (and a test) drives it with fresh state each invocation.
1613
+ export async function runDeltaSyncFetch(deps: {
1614
+ path: string
1615
+ queryString: string
1616
+ limit: number
1617
+ metaKey: string
1618
+ cached: TimelineEvent[] | undefined
1619
+ metaStore: Map<string, ChangesDeltaMeta>
1620
+ now: number
1621
+ signal?: AbortSignal
1622
+ }): Promise<TimelineEvent[]> {
1623
+ const { path, queryString, limit, metaKey, cached, metaStore, now, signal } = deps
1624
+ const meta = metaStore.get(metaKey)
1625
+ const cursor = deltaFetchCursor(meta, cached, now)
1626
+ if (cursor > 0) {
1627
+ const delta = await fetchChangesPage(
1628
+ `${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
1629
+ signal,
1630
+ )
1631
+ if (delta.epoch && delta.epoch === meta!.epoch) {
1632
+ meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
1633
+ // Returning the cached reference on an empty delta skips re-renders.
1634
+ return delta.events.length ? mergeDeltaEvents(cached!, delta.events, limit) : cached!
1635
+ }
1636
+ // Epoch changed — the store restarted and seq numbering reset, so the
1637
+ // cursor is meaningless. Fall through to a full resync.
1638
+ }
1639
+ const full = await fetchChangesPage(path, signal)
1640
+ metaStore.set(metaKey, {
1641
+ epoch: full.epoch,
1642
+ lastFullMs: now,
1643
+ highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
1644
+ })
1645
+ return full.events
847
1646
  }
848
1647
 
849
1648
  function getTimeRangeDate(range: TimeRange): Date | null {
@@ -860,20 +1659,41 @@ function getTimeRangeDate(range: TimeRange): Date | null {
860
1659
  return new Date(now.getTime() - 6 * 60 * 60 * 1000)
861
1660
  case '24h':
862
1661
  return new Date(now.getTime() - 24 * 60 * 60 * 1000)
1662
+ case '7d':
1663
+ return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
1664
+ case '30d':
1665
+ return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
863
1666
  default:
864
1667
  return null
865
1668
  }
866
1669
  }
867
1670
 
868
1671
  export function useChanges(options: UseChangesOptions = {}) {
869
- const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
1672
+ const {
1673
+ namespaces = [],
1674
+ kinds,
1675
+ timeRange = '1h',
1676
+ filter = 'all',
1677
+ includeK8sEvents = true,
1678
+ includeManaged = false,
1679
+ includeDeleted = true,
1680
+ limit = 200,
1681
+ enabled = true,
1682
+ deltaSync = false,
1683
+ } = options
1684
+ const queryClient = useQueryClient()
1685
+
1686
+ // Only a single-kind selection narrows the server query; a multi-kind
1687
+ // selection is filtered client-side so the server cap isn't spent on one kind.
1688
+ const serverKind = kinds && kinds.length === 1 ? kinds[0] : undefined
870
1689
 
871
1690
  const params = new URLSearchParams()
872
1691
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
873
- if (kind) params.set('kind', kind)
1692
+ if (serverKind) params.set('kind', serverKind)
874
1693
  if (filter) params.set('filter', filter)
875
1694
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
876
1695
  if (includeManaged) params.set('include_managed', 'true')
1696
+ if (!includeDeleted) params.set('include_deleted', 'false')
877
1697
  params.set('limit', String(limit))
878
1698
 
879
1699
  const sinceDate = getTimeRangeDate(timeRange)
@@ -882,18 +1702,50 @@ export function useChanges(options: UseChangesOptions = {}) {
882
1702
  }
883
1703
 
884
1704
  const queryString = params.toString()
1705
+ const path = `/changes${queryString ? `?${queryString}` : ''}`
1706
+ const queryKey = [
1707
+ 'changes',
1708
+ namespaces,
1709
+ serverKind,
1710
+ timeRange,
1711
+ filter,
1712
+ includeK8sEvents,
1713
+ includeManaged,
1714
+ includeDeleted,
1715
+ limit,
1716
+ ]
885
1717
 
886
1718
  return useQuery<TimelineEvent[]>({
887
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
888
- queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
889
- staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
890
- refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
1719
+ queryKey,
1720
+ queryFn: async ({ signal }) => {
1721
+ if (!deltaSync) return fetchJSON(path, signal)
1722
+
1723
+ const metaKey = JSON.stringify(queryKey)
1724
+ const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
1725
+ return runDeltaSyncFetch({
1726
+ path,
1727
+ queryString,
1728
+ limit,
1729
+ metaKey,
1730
+ cached,
1731
+ metaStore: changesDeltaMeta,
1732
+ now: Date.now(),
1733
+ signal,
1734
+ })
1735
+ },
1736
+ staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1737
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
891
1738
  enabled,
892
1739
  })
893
1740
  }
894
1741
 
895
1742
  // Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
896
- export function useResourceChildren(kind: string, namespace: string, name: string, timeRange: TimeRange = '1h') {
1743
+ export function useResourceChildren(
1744
+ kind: string,
1745
+ namespace: string,
1746
+ name: string,
1747
+ timeRange: TimeRange = '1h',
1748
+ ) {
897
1749
  const sinceDate = getTimeRangeDate(timeRange)
898
1750
  const params = new URLSearchParams()
899
1751
  if (sinceDate) {
@@ -922,7 +1774,11 @@ export interface ResourceEventsResult {
922
1774
  // K8s events and resource updates are fetched separately so a high-frequency
923
1775
  // informer update stream (e.g. a CrashLoop status field flapping every few
924
1776
  // seconds) can never starve out user-meaningful K8s events under a shared limit.
925
- export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
1777
+ export function useResourceEvents(
1778
+ kind: string,
1779
+ namespace: string,
1780
+ name: string,
1781
+ ): ResourceEventsResult {
926
1782
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
927
1783
  // but callers pass the URL-form kind ("pods").
928
1784
  const singularKind = pluralToKind(kind)
@@ -935,6 +1791,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
935
1791
  const p = new URLSearchParams()
936
1792
  p.set('namespace', namespace)
937
1793
  p.set('kind', singularKind)
1794
+ p.set('name', name)
938
1795
  p.set('include_managed', 'true')
939
1796
  p.set('since', since)
940
1797
  return p
@@ -951,8 +1808,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
951
1808
  const params = baseParams()
952
1809
  params.set('sources', 'k8s_event')
953
1810
  params.set('limit', '500')
954
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
955
- return events.filter(e => e.name === name)
1811
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
956
1812
  },
957
1813
  enabled,
958
1814
  refetchInterval: 15000,
@@ -966,8 +1822,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
966
1822
  const params = baseParams()
967
1823
  params.set('sources', 'informer,historical')
968
1824
  params.set('limit', '50')
969
- const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
970
- return events.filter(e => e.name === name)
1825
+ return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
971
1826
  },
972
1827
  enabled,
973
1828
  refetchInterval: 15000,
@@ -989,8 +1844,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
989
1844
  export interface ContainerMetrics {
990
1845
  name: string
991
1846
  usage: {
992
- cpu: string // e.g., "10m" (millicores)
993
- memory: string // e.g., "128Mi"
1847
+ cpu: string // e.g., "10m" (millicores)
1848
+ memory: string // e.g., "128Mi"
994
1849
  }
995
1850
  }
996
1851
 
@@ -1018,25 +1873,44 @@ export interface NodeMetrics {
1018
1873
  }
1019
1874
  }
1020
1875
 
1876
+ async function fetchMetricsOrNull<T>(path: string): Promise<T | null> {
1877
+ try {
1878
+ return await fetchJSON<T>(path)
1879
+ } catch (error) {
1880
+ if (isMetricsUnavailableError(error)) return null
1881
+ throw error
1882
+ }
1883
+ }
1884
+
1885
+ function retryMetricsQuery(failureCount: number, error: unknown): boolean {
1886
+ return !isMetricsUnavailableError(error) && failureCount < 1
1887
+ }
1888
+
1021
1889
  // Fetch metrics for a specific pod
1022
- export function usePodMetrics(namespace: string, podName: string) {
1023
- return useQuery<PodMetrics>({
1890
+ export function usePodMetrics(namespace: string, podName: string, options?: { enabled?: boolean }) {
1891
+ return useQuery<PodMetrics | null>({
1024
1892
  queryKey: ['pod-metrics', namespace, podName],
1025
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}`),
1026
- enabled: Boolean(namespace && podName),
1027
- staleTime: 15000, // Metrics are fresh for 15 seconds
1028
- refetchInterval: 30000, // Refresh every 30 seconds
1893
+ queryFn: () => fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
1894
+ enabled: Boolean(namespace && podName) && (options?.enabled ?? true),
1895
+ staleTime: 15000,
1896
+ refetchInterval: 30000,
1897
+ refetchOnMount: 'always',
1898
+ refetchOnReconnect: 'always',
1899
+ retry: retryMetricsQuery,
1029
1900
  })
1030
1901
  }
1031
1902
 
1032
1903
  // Fetch metrics for a specific node
1033
- export function useNodeMetrics(nodeName: string) {
1034
- return useQuery<NodeMetrics>({
1904
+ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }) {
1905
+ return useQuery<NodeMetrics | null>({
1035
1906
  queryKey: ['node-metrics', nodeName],
1036
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}`),
1037
- enabled: Boolean(nodeName),
1907
+ queryFn: () => fetchMetricsOrNull<NodeMetrics>(`/metrics/nodes/${nodeName}`),
1908
+ enabled: Boolean(nodeName) && (options?.enabled ?? true),
1038
1909
  staleTime: 15000,
1039
1910
  refetchInterval: 30000,
1911
+ refetchOnMount: 'always',
1912
+ refetchOnReconnect: 'always',
1913
+ retry: retryMetricsQuery,
1040
1914
  })
1041
1915
  }
1042
1916
 
@@ -1046,8 +1920,8 @@ export function useNodeMetrics(nodeName: string) {
1046
1920
 
1047
1921
  export interface MetricsDataPoint {
1048
1922
  timestamp: string
1049
- cpu: number // CPU in nanocores
1050
- memory: number // Memory in bytes
1923
+ cpu: number // CPU in nanocores
1924
+ memory: number // Memory in bytes
1051
1925
  }
1052
1926
 
1053
1927
  export interface ContainerMetricsHistory {
@@ -1060,19 +1934,77 @@ export interface PodMetricsHistory {
1060
1934
  name: string
1061
1935
  containers: ContainerMetricsHistory[]
1062
1936
  collectionError?: string
1937
+ rawCollectionError?: string
1938
+ metricsUnavailableDiagnosis?: string
1939
+ metricsUnavailable?: boolean
1940
+ metricsUnavailableReason?: string
1063
1941
  }
1064
1942
 
1065
1943
  export interface NodeMetricsHistory {
1066
1944
  name: string
1067
1945
  dataPoints: MetricsDataPoint[]
1068
1946
  collectionError?: string
1947
+ rawCollectionError?: string
1948
+ metricsUnavailableDiagnosis?: string
1949
+ metricsUnavailable?: boolean
1950
+ metricsUnavailableReason?: string
1951
+ }
1952
+
1953
+ function withoutCollectionError<
1954
+ T extends { collectionError?: string; rawCollectionError?: string },
1955
+ >(history: T): T {
1956
+ const next = { ...history }
1957
+ delete next.collectionError
1958
+ delete next.rawCollectionError
1959
+ return next
1960
+ }
1961
+
1962
+ export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1963
+ if (history.metricsUnavailable !== true) return history
1964
+ return {
1965
+ ...withoutCollectionError(history),
1966
+ metricsUnavailable: true,
1967
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1968
+ }
1969
+ }
1970
+
1971
+ export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1972
+ if (history.metricsUnavailable !== true) return history
1973
+ return {
1974
+ ...withoutCollectionError(history),
1975
+ metricsUnavailable: true,
1976
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1977
+ }
1978
+ }
1979
+
1980
+ export function shouldFetchLiveMetrics(
1981
+ historySettled: boolean,
1982
+ metricsUnavailable: boolean,
1983
+ ): boolean {
1984
+ return historySettled && !metricsUnavailable
1985
+ }
1986
+
1987
+ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: unknown): boolean {
1988
+ return liveMetricsEnabled && metrics === null
1989
+ }
1990
+
1991
+ export function getVisibleLiveMetrics<T>(
1992
+ liveMetricsEnabled: boolean,
1993
+ metricsUnavailable: boolean,
1994
+ metrics: T | null | undefined,
1995
+ ): T | undefined {
1996
+ if (!liveMetricsEnabled || metricsUnavailable) return undefined
1997
+ return metrics ?? undefined
1069
1998
  }
1070
1999
 
1071
2000
  // Fetch historical metrics for a pod (last ~1 hour)
1072
2001
  export function usePodMetricsHistory(namespace: string, podName: string) {
1073
2002
  return useQuery<PodMetricsHistory>({
1074
2003
  queryKey: ['pod-metrics-history', namespace, podName],
1075
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}/history`),
2004
+ queryFn: async () =>
2005
+ normalizePodMetricsHistory(
2006
+ await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
2007
+ ),
1076
2008
  enabled: Boolean(namespace && podName),
1077
2009
  staleTime: 25000, // Slightly less than poll interval
1078
2010
  refetchInterval: 30000, // Match the backend poll interval
@@ -1083,7 +2015,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1083
2015
  export function useNodeMetricsHistory(nodeName: string) {
1084
2016
  return useQuery<NodeMetricsHistory>({
1085
2017
  queryKey: ['node-metrics-history', nodeName],
1086
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}/history`),
2018
+ queryFn: async () =>
2019
+ normalizeNodeMetricsHistory(
2020
+ await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
2021
+ ),
1087
2022
  enabled: Boolean(nodeName),
1088
2023
  staleTime: 25000,
1089
2024
  refetchInterval: 30000,
@@ -1094,38 +2029,45 @@ export function useNodeMetricsHistory(nodeName: string) {
1094
2029
  export interface TopPodMetrics {
1095
2030
  namespace: string
1096
2031
  name: string
1097
- cpu: number // nanocores (usage)
1098
- memory: number // bytes (usage)
1099
- cpuRequest: number // nanocores (sum across containers)
1100
- cpuLimit: number // nanocores (sum across containers)
2032
+ cpu: number // nanocores (usage)
2033
+ memory: number // bytes (usage)
2034
+ cpuRequest: number // nanocores (sum across containers)
2035
+ cpuLimit: number // nanocores (sum across containers)
1101
2036
  memoryRequest: number // bytes (sum across containers)
1102
- memoryLimit: number // bytes (sum across containers)
2037
+ memoryLimit: number // bytes (sum across containers)
1103
2038
  }
1104
2039
 
1105
2040
  export interface TopNodeMetrics {
1106
2041
  name: string
1107
- cpu: number // nanocores (usage)
1108
- memory: number // bytes (usage)
1109
- podCount: number // pods scheduled on this node
1110
- cpuAllocatable: number // nanocores
2042
+ cpu: number // nanocores (usage)
2043
+ memory: number // bytes (usage)
2044
+ podCount: number // pods scheduled on this node
2045
+ cpuAllocatable: number // nanocores
1111
2046
  memoryAllocatable: number // bytes
1112
2047
  }
1113
2048
 
1114
- // Fetch bulk metrics for all pods (for CPU/Memory columns in resource table)
1115
- export function useTopPodMetrics() {
2049
+ // Fetch bulk metrics for pods (for CPU/Memory columns in resource table)
2050
+ export function useTopPodMetrics(options?: { enabled?: boolean; namespaces?: string[] }) {
2051
+ const namespacesParam = options?.namespaces?.join(',') ?? ''
2052
+ const params = new URLSearchParams()
2053
+ if (namespacesParam) params.set('namespaces', namespacesParam)
2054
+ const queryString = params.toString()
2055
+
1116
2056
  return useQuery<TopPodMetrics[]>({
1117
- queryKey: ['top-pod-metrics'],
1118
- queryFn: () => fetchJSON('/metrics/top/pods'),
2057
+ queryKey: ['top-pod-metrics', namespacesParam],
2058
+ queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
2059
+ enabled: options?.enabled ?? true,
1119
2060
  staleTime: 25000,
1120
2061
  refetchInterval: 30000,
1121
2062
  })
1122
2063
  }
1123
2064
 
1124
2065
  // Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
1125
- export function useTopNodeMetrics() {
2066
+ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
1126
2067
  return useQuery<TopNodeMetrics[]>({
1127
2068
  queryKey: ['top-node-metrics'],
1128
2069
  queryFn: () => fetchJSON('/metrics/top/nodes'),
2070
+ enabled: options?.enabled ?? true,
1129
2071
  staleTime: 25000,
1130
2072
  refetchInterval: 30000,
1131
2073
  })
@@ -1150,19 +2092,21 @@ export interface PrometheusStatus {
1150
2092
  error?: string
1151
2093
  }
1152
2094
 
1153
- export interface PrometheusDataPoint {
1154
- timestamp: number
1155
- value: number
1156
- }
2095
+ // Time-series sample types live in @skyhook-io/k8s-ui (shared with library
2096
+ // consumers). Re-export here so radar-app callers keep their existing import
2097
+ // paths; the Prom-prefixed names are deprecated aliases.
2098
+ export type {
2099
+ TimeSeriesPoint,
2100
+ TimeSeries,
2101
+ PrometheusDataPoint,
2102
+ PrometheusSeries,
2103
+ } from '@skyhook-io/k8s-ui/components/charts'
1157
2104
 
1158
- export interface PrometheusSeries {
1159
- labels: Record<string, string>
1160
- dataPoints: PrometheusDataPoint[]
1161
- }
2105
+ import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
1162
2106
 
1163
2107
  export interface PrometheusQueryResult {
1164
2108
  resultType: string
1165
- series: PrometheusSeries[]
2109
+ series: ChartTimeSeries[]
1166
2110
  }
1167
2111
 
1168
2112
  export interface PrometheusResourceMetrics {
@@ -1174,11 +2118,115 @@ export interface PrometheusResourceMetrics {
1174
2118
  range: string
1175
2119
  result: PrometheusQueryResult
1176
2120
  query?: string // PromQL query (included when result is empty, for diagnostics)
1177
- hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
2121
+ hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
2122
+ }
2123
+
2124
+ export type PrometheusMetricCategory =
2125
+ 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
2126
+ export type PrometheusTimeRange =
2127
+ '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
2128
+
2129
+ // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
2130
+ // HasData=false silently indicates the CSI driver doesn't report or Prom
2131
+ // isn't scraping kubelet endpoints — UI should hide the gauge in that case.
2132
+ export interface PrometheusPVCUsage {
2133
+ namespace: string
2134
+ name: string
2135
+ used: number
2136
+ capacity: number
2137
+ ratio: number
2138
+ hasData: boolean
2139
+ }
2140
+
2141
+ export type RightsizingFit =
2142
+ 'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
2143
+ export type RightsizingConfidence = 'low' | 'medium' | 'high'
2144
+ export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
2145
+
2146
+ export interface RightsizingRow {
2147
+ container: string
2148
+ resource: 'cpu' | 'memory'
2149
+ fit: RightsizingFit
2150
+ confidence: RightsizingConfidence
2151
+ currentRequest?: string
2152
+ currentRequestValue?: number
2153
+ currentLimit?: string
2154
+ currentLimitValue?: number
2155
+ observed?: {
2156
+ name: 'P95' | 'P99' | 'Max'
2157
+ value: number
2158
+ formatted: string
2159
+ }
2160
+ peak?: {
2161
+ name: 'P99'
2162
+ value: number
2163
+ formatted: string
2164
+ }
2165
+ calculatedRequest?: string
2166
+ calculatedRequestValue?: number
2167
+ recommendedRequest?: string
2168
+ recommendedRequestValue?: number
2169
+ reductionLimited?: boolean
2170
+ bursty?: boolean
2171
+ recommendationReason?: string
2172
+ sampleCount: number
2173
+ expectedSamples: number
2174
+ coverage: number
2175
+ hpaManaged: boolean
2176
+ hpaEvidenceAvailable: boolean
2177
+ throttleAvailable?: boolean
2178
+ throttleRatio?: number
2179
+ currentPodOOM?: boolean
2180
+ windowOomEvidence?: boolean
2181
+ oomEvidenceAvailable: boolean
2182
+ limitConflict?: boolean
2183
+ queryError?: string
2184
+ }
2185
+
2186
+ export interface PrometheusRightsizing {
2187
+ kind: string
2188
+ namespace: string
2189
+ name: string
2190
+ window: string
2191
+ source: 'radar'
2192
+ ownerCoverage: RightsizingOwnerCoverage
2193
+ scaledToZero: boolean
2194
+ sampleAvailable: boolean
2195
+ rows: RightsizingRow[]
2196
+ reason?: string
2197
+ }
2198
+
2199
+ export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
2200
+
2201
+ export interface RightsizingScanWorkload {
2202
+ kind: string
2203
+ namespace: string
2204
+ name: string
2205
+ replicas: number
2206
+ scaledToZero: boolean
2207
+ rows: RightsizingRow[]
1178
2208
  }
1179
2209
 
1180
- export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
1181
- export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
2210
+ export interface RightsizingScanCoverage {
2211
+ workloadsDiscovered: number
2212
+ workloadsEvaluated: number
2213
+ workloadsWithData: number
2214
+ batches: number
2215
+ completedBatches: number
2216
+ restrictedKinds?: string[]
2217
+ unavailableKinds?: string[]
2218
+ }
2219
+
2220
+ export interface RightsizingScanResponse {
2221
+ state: RightsizingScanState
2222
+ scannedAt: string
2223
+ window: string
2224
+ source: 'radar'
2225
+ coverage: RightsizingScanCoverage
2226
+ workloads: RightsizingScanWorkload[]
2227
+ warnings?: { code: string; message: string }[]
2228
+ reason?: string
2229
+ }
1182
2230
 
1183
2231
  // Check Prometheus availability
1184
2232
  export function usePrometheusStatus() {
@@ -1190,12 +2238,32 @@ export function usePrometheusStatus() {
1190
2238
  })
1191
2239
  }
1192
2240
 
2241
+ export interface ArgoStatus {
2242
+ // configured = a URL or token is set; connected = a probe has landed and the
2243
+ // client is live. The two differ right after a restart (configured, reconnecting).
2244
+ configured: boolean
2245
+ connected: boolean
2246
+ address?: string
2247
+ }
2248
+
2249
+ export function useArgoStatus(enabled = true) {
2250
+ return useQuery<ArgoStatus>({
2251
+ queryKey: ['argocd-status'],
2252
+ queryFn: () => fetchJSON('/integrations/argocd/status'),
2253
+ enabled,
2254
+ staleTime: 30000,
2255
+ refetchInterval: 60000,
2256
+ })
2257
+ }
2258
+
1193
2259
  // Connect to Prometheus (trigger discovery)
1194
2260
  export function usePrometheusConnect() {
1195
2261
  const queryClient = useQueryClient()
1196
2262
  return useMutation({
1197
2263
  mutationFn: async () => {
1198
- const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
2264
+ const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
2265
+ method: 'POST',
2266
+ })
1199
2267
  if (!resp.ok) {
1200
2268
  const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
1201
2269
  throw new Error(body.error || `HTTP ${resp.status}`)
@@ -1212,6 +2280,97 @@ export function usePrometheusConnect() {
1212
2280
  })
1213
2281
  }
1214
2282
 
2283
+ // Auto-discover Prometheus on first mount of any Prom-backed view, and
2284
+ // auto-reconnect across radar restarts on subsequent mounts.
2285
+ //
2286
+ // Two paths through this hook, both running once per cluster context per
2287
+ // component-instance:
2288
+ // 1. Cached path — localStorage flag means "Prom was discovered before on
2289
+ // this context". Probe fires immediately on mount; the user sees
2290
+ // charts populate without manual interaction.
2291
+ // 2. First-time path — no flag yet. Probe fires after a small delay so the
2292
+ // initial workload-view render lands before we hit the cluster network.
2293
+ // Behavior matches Lens / Headlamp defaults; the trade-off is one
2294
+ // cluster probe per session per fresh kubeconfig context.
2295
+ //
2296
+ // On success either way we set the flag, so subsequent mounts take path 1.
2297
+ // On failure we clear the flag (path 1) or leave it cleared (path 2) and
2298
+ // reset attemptedRef, so the existing "Discover Prometheus" CTA renders
2299
+ // once status refreshes. Manual interaction stays available as the fallback.
2300
+ //
2301
+ // localStorage is the right surface: connection intent is browser-local,
2302
+ // not a server-side preference, and we want it to persist across radar
2303
+ // restarts on the same port.
2304
+ const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
2305
+ // First-mount delay before probing the cluster. Chosen short enough that the
2306
+ // CTA → charts transition feels prompt, long enough that the probe doesn't
2307
+ // race the initial workload-view render.
2308
+ const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
2309
+
2310
+ function promAutoConnectKey(contextName: string): string {
2311
+ return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
2312
+ }
2313
+
2314
+ export function useAutoPromConnect(): void {
2315
+ const queryClient = useQueryClient()
2316
+ const { data: clusterInfo } = useClusterInfo()
2317
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
2318
+ const attemptedRef = useRef<string | null>(null)
2319
+
2320
+ useEffect(() => {
2321
+ if (typeof window === 'undefined') return
2322
+ const context = clusterInfo?.context
2323
+ if (!context || statusLoading) return
2324
+
2325
+ // Persist the "we've connected here before" signal once a connection lands.
2326
+ if (status?.connected) {
2327
+ try {
2328
+ window.localStorage.setItem(promAutoConnectKey(context), '1')
2329
+ } catch {
2330
+ // localStorage can throw in some restricted browser modes — fail open.
2331
+ }
2332
+ return
2333
+ }
2334
+
2335
+ if (attemptedRef.current === context) return
2336
+ let cached: string | null = null
2337
+ try {
2338
+ cached = window.localStorage.getItem(promAutoConnectKey(context))
2339
+ } catch {
2340
+ // keep the null fallback
2341
+ }
2342
+
2343
+ attemptedRef.current = context
2344
+
2345
+ // Cached path probes immediately; first-time path defers briefly so the
2346
+ // initial UI render isn't competing with the cluster network call.
2347
+ const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
2348
+ const timeout = window.setTimeout(() => {
2349
+ // Direct apiFetch (not via the usePrometheusConnect mutation) so the
2350
+ // meta-driven toast handler stays silent — the user didn't click anything.
2351
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
2352
+ method: 'POST',
2353
+ })
2354
+ .then(async (resp) => {
2355
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
2356
+ const nextStatus = (await resp.json()) as PrometheusStatus
2357
+ queryClient.setQueryData(['prometheus-status'], nextStatus)
2358
+ if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
2359
+ queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
2360
+ })
2361
+ .catch(() => {
2362
+ try {
2363
+ window.localStorage.removeItem(promAutoConnectKey(context))
2364
+ } catch {
2365
+ // ignore — manual CTA will render once status refreshes
2366
+ }
2367
+ attemptedRef.current = null
2368
+ })
2369
+ }, delay)
2370
+ return () => window.clearTimeout(timeout)
2371
+ }, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
2372
+ }
2373
+
1215
2374
  // Fetch Prometheus metrics for a resource
1216
2375
  export function usePrometheusResourceMetrics(
1217
2376
  kind: string,
@@ -1260,14 +2419,100 @@ export function usePrometheusClusterMetrics(
1260
2419
  ) {
1261
2420
  return useQuery<PrometheusResourceMetrics>({
1262
2421
  queryKey: ['prometheus-cluster-metrics', category, range],
1263
- queryFn: () =>
1264
- fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
2422
+ queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
1265
2423
  enabled,
1266
2424
  staleTime: 30000,
1267
2425
  refetchInterval: 60000,
1268
2426
  })
1269
2427
  }
1270
2428
 
2429
+ // Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
2430
+ export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
2431
+ return useQuery<PrometheusPVCUsage>({
2432
+ queryKey: ['prometheus-pvc-usage', namespace, name],
2433
+ queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
2434
+ enabled: enabled && Boolean(namespace && name),
2435
+ staleTime: 60000,
2436
+ refetchInterval: 120000,
2437
+ })
2438
+ }
2439
+
2440
+ // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
2441
+ export function usePrometheusRightsizing(
2442
+ kind: string,
2443
+ namespace: string,
2444
+ name: string,
2445
+ enabled = true,
2446
+ ) {
2447
+ return useQuery<PrometheusRightsizing>({
2448
+ queryKey: ['prometheus-rightsizing', kind, namespace, name],
2449
+ queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
2450
+ enabled: enabled && Boolean(kind && namespace && name),
2451
+ staleTime: 5 * 60 * 1000,
2452
+ refetchInterval: 10 * 60 * 1000,
2453
+ })
2454
+ }
2455
+
2456
+ const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
2457
+
2458
+ export function getRightsizingScanCacheConfig(
2459
+ namespaces: string[],
2460
+ context = '',
2461
+ ): {
2462
+ namespaceKey: string
2463
+ queryKey: readonly ['prometheus-rightsizing-scan', string, string]
2464
+ queryFn: typeof skipToken
2465
+ gcTime: number
2466
+ } {
2467
+ const namespaceKey = [...namespaces].sort().join(',')
2468
+ return {
2469
+ namespaceKey,
2470
+ queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
2471
+ queryFn: skipToken,
2472
+ gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
2473
+ }
2474
+ }
2475
+
2476
+ // A fleet rightsizing scan is intentionally manual. It can query seven days of
2477
+ // Prometheus history for many containers, so navigation alone must never run it.
2478
+ export function useRightsizingScan(namespaces: string[], context = '') {
2479
+ const queryClient = useQueryClient()
2480
+ const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
2481
+ const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
2482
+ const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
2483
+ const mutation = useMutation({
2484
+ mutationFn: async (startedScope: typeof scanScope) => {
2485
+ const params = new URLSearchParams()
2486
+ if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
2487
+ const query = params.toString()
2488
+ return fetchJSON<RightsizingScanResponse>(
2489
+ `/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
2490
+ {
2491
+ method: 'POST',
2492
+ },
2493
+ )
2494
+ },
2495
+ onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
2496
+ })
2497
+ return {
2498
+ ...mutation,
2499
+ data: snapshot.data,
2500
+ mutate: () => mutation.mutate(scanScope),
2501
+ mutateAsync: () => mutation.mutateAsync(scanScope),
2502
+ }
2503
+ }
2504
+
2505
+ // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
2506
+ export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
2507
+ return useQuery<PrometheusQueryResult>({
2508
+ queryKey: ['promql-range', query, range],
2509
+ queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
2510
+ enabled: enabled && Boolean(query),
2511
+ staleTime: 30000,
2512
+ refetchInterval: 60000,
2513
+ })
2514
+ }
2515
+
1271
2516
  // ============================================================================
1272
2517
  // Pod Logs
1273
2518
  // ============================================================================
@@ -1294,12 +2539,16 @@ export interface LogStreamEvent {
1294
2539
  }
1295
2540
 
1296
2541
  // Fetch pod logs (non-streaming)
1297
- export function usePodLogs(namespace: string, podName: string, options?: {
1298
- container?: string
1299
- tailLines?: number
1300
- previous?: boolean
1301
- sinceSeconds?: number
1302
- }) {
2542
+ export function usePodLogs(
2543
+ namespace: string,
2544
+ podName: string,
2545
+ options?: {
2546
+ container?: string
2547
+ tailLines?: number
2548
+ previous?: boolean
2549
+ sinceSeconds?: number
2550
+ },
2551
+ ) {
1303
2552
  const params = new URLSearchParams()
1304
2553
  if (options?.container) params.set('container', options.container)
1305
2554
  if (options?.tailLines) params.set('tailLines', String(options.tailLines))
@@ -1308,8 +2557,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
1308
2557
  const queryString = params.toString()
1309
2558
 
1310
2559
  return useQuery<LogsResponse>({
1311
- queryKey: ['pod-logs', namespace, podName, options?.container, options?.tailLines, options?.previous, options?.sinceSeconds],
1312
- queryFn: () => fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
2560
+ queryKey: [
2561
+ 'pod-logs',
2562
+ namespace,
2563
+ podName,
2564
+ options?.container,
2565
+ options?.tailLines,
2566
+ options?.previous,
2567
+ options?.sinceSeconds,
2568
+ ],
2569
+ queryFn: () =>
2570
+ fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
1313
2571
  enabled: Boolean(namespace && podName),
1314
2572
  staleTime: 5000, // Allow refetch after 5 seconds
1315
2573
  })
@@ -1324,7 +2582,7 @@ export function createLogStream(
1324
2582
  tailLines?: number
1325
2583
  previous?: boolean
1326
2584
  sinceSeconds?: number
1327
- }
2585
+ },
1328
2586
  ): EventSource {
1329
2587
  const params = new URLSearchParams()
1330
2588
  if (options?.container) params.set('container', options.container)
@@ -1333,9 +2591,12 @@ export function createLogStream(
1333
2591
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
1334
2592
  const queryString = params.toString()
1335
2593
 
1336
- return new EventSource(`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`, {
1337
- withCredentials: getCredentialsMode() === 'include',
1338
- })
2594
+ return new EventSource(
2595
+ `${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
2596
+ {
2597
+ withCredentials: getCredentialsMode() === 'include',
2598
+ },
2599
+ )
1339
2600
  }
1340
2601
 
1341
2602
  // ============================================================================
@@ -1347,6 +2608,7 @@ export interface AvailablePort {
1347
2608
  protocol: string
1348
2609
  containerName?: string
1349
2610
  name?: string
2611
+ scheme?: 'http' | 'https'
1350
2612
  }
1351
2613
 
1352
2614
  export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
@@ -1367,8 +2629,37 @@ export function useUpdateResource() {
1367
2629
  const queryClient = useQueryClient()
1368
2630
 
1369
2631
  return useMutation({
1370
- mutationFn: async ({ kind, namespace, name, yaml }: { kind: string; namespace: string; name: string; yaml: string }) => {
1371
- const response = await apiFetch(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, {
2632
+ mutationFn: async ({
2633
+ kind,
2634
+ namespace,
2635
+ name,
2636
+ yaml,
2637
+ force = true,
2638
+ reviewedResourceVersion,
2639
+ reviewedContext,
2640
+ }: {
2641
+ kind: string
2642
+ namespace: string
2643
+ name: string
2644
+ yaml: string
2645
+ force?: boolean
2646
+ reviewedResourceVersion?: string
2647
+ reviewedContext?: string
2648
+ }) => {
2649
+ const url = new URL(
2650
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2651
+ window.location.origin,
2652
+ )
2653
+ if (!force) {
2654
+ url.searchParams.set('force', 'false')
2655
+ }
2656
+ if (reviewedResourceVersion) {
2657
+ url.searchParams.set('resourceVersion', reviewedResourceVersion)
2658
+ }
2659
+ if (reviewedContext) {
2660
+ url.searchParams.set('reviewedContext', reviewedContext)
2661
+ }
2662
+ const response = await apiFetch(url.toString(), {
1372
2663
  method: 'PUT',
1373
2664
  headers: { 'Content-Type': 'text/plain' },
1374
2665
  body: yaml,
@@ -1383,9 +2674,31 @@ export function useUpdateResource() {
1383
2674
  errorMessage: 'Failed to update resource',
1384
2675
  successMessage: 'Resource updated',
1385
2676
  },
1386
- onSuccess: (_, variables) => {
1387
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
1388
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2677
+ onSuccess: (updated: any, variables) => {
2678
+ // The PUT goes straight to the apiserver and returns the authoritative
2679
+ // object, but the GET behind this query reads Radar's informer cache,
2680
+ // which lags a write by one watch round-trip. Seed the detail cache with
2681
+ // the PUT response so the edit shows immediately. Invalidating here
2682
+ // instead would trigger a refetch that races the seed and re-reads the
2683
+ // lagging cache — the change appears not to have taken effect.
2684
+ if (updated && typeof updated === 'object' && updated.metadata) {
2685
+ queryClient.setQueriesData(
2686
+ {
2687
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2688
+ },
2689
+ (old: any) =>
2690
+ old && typeof old === 'object' && 'resource' in old
2691
+ ? { ...old, resource: updated }
2692
+ : { resource: updated },
2693
+ )
2694
+ } else {
2695
+ queryClient.invalidateQueries({
2696
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2697
+ })
2698
+ }
2699
+ queryClient.invalidateQueries({
2700
+ queryKey: ['resources', variables.kind],
2701
+ })
1389
2702
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1390
2703
  },
1391
2704
  })
@@ -1394,13 +2707,24 @@ export function useUpdateResource() {
1394
2707
  // Cascade delete preview — shows resources that will be garbage-collected
1395
2708
  export interface CascadeDeletePreview {
1396
2709
  root: { kind: string; namespace: string; name: string; group?: string }
1397
- dependents: { kind: string; namespace: string; name: string; group?: string }[]
2710
+ dependents: {
2711
+ kind: string
2712
+ namespace: string
2713
+ name: string
2714
+ group?: string
2715
+ }[]
1398
2716
  }
1399
2717
 
1400
- export function useCascadeDeletePreview(kind: string, namespace: string, name: string, enabled: boolean) {
2718
+ export function useCascadeDeletePreview(
2719
+ kind: string,
2720
+ namespace: string,
2721
+ name: string,
2722
+ enabled: boolean,
2723
+ ) {
1401
2724
  return useQuery<CascadeDeletePreview>({
1402
2725
  queryKey: ['cascade-preview', kind, namespace, name],
1403
- queryFn: () => fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2726
+ queryFn: () =>
2727
+ fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
1404
2728
  enabled,
1405
2729
  staleTime: 30_000,
1406
2730
  })
@@ -1411,8 +2735,26 @@ export function useDeleteResource() {
1411
2735
  const queryClient = useQueryClient()
1412
2736
 
1413
2737
  return useMutation({
1414
- mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
1415
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2738
+ mutationFn: async ({
2739
+ kind,
2740
+ group,
2741
+ namespace,
2742
+ name,
2743
+ force,
2744
+ }: {
2745
+ kind: string
2746
+ group?: string
2747
+ namespace: string
2748
+ name: string
2749
+ force?: boolean
2750
+ }) => {
2751
+ const url = new URL(
2752
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2753
+ window.location.origin,
2754
+ )
2755
+ if (group) {
2756
+ url.searchParams.set('group', group)
2757
+ }
1416
2758
  if (force) {
1417
2759
  url.searchParams.set('force', 'true')
1418
2760
  }
@@ -1431,7 +2773,217 @@ export function useDeleteResource() {
1431
2773
  successMessage: 'Resource deleted',
1432
2774
  },
1433
2775
  onSuccess: (_, variables) => {
1434
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2776
+ queryClient.invalidateQueries({
2777
+ queryKey: ['resources', variables.kind],
2778
+ })
2779
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
2780
+ },
2781
+ })
2782
+ }
2783
+
2784
+ export function useBulkDeleteResources() {
2785
+ const queryClient = useQueryClient()
2786
+
2787
+ return useMutation({
2788
+ mutationFn: async ({
2789
+ items,
2790
+ force,
2791
+ }: {
2792
+ items: Array<{
2793
+ kind: string
2794
+ group?: string
2795
+ namespace: string
2796
+ name: string
2797
+ }>
2798
+ force?: boolean
2799
+ }) => {
2800
+ const results = await Promise.allSettled(
2801
+ items.map(async ({ kind, group, namespace, name }) => {
2802
+ const url = new URL(
2803
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2804
+ window.location.origin,
2805
+ )
2806
+ if (group) url.searchParams.set('group', group)
2807
+ if (force) url.searchParams.set('force', 'true')
2808
+ const response = await apiFetch(url.toString(), { method: 'DELETE' })
2809
+ if (!response.ok) {
2810
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2811
+ throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
2812
+ }
2813
+ return { kind, namespace, name }
2814
+ }),
2815
+ )
2816
+ const failed = results.filter((r) => r.status === 'rejected')
2817
+ if (failed.length > 0) {
2818
+ throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
2819
+ }
2820
+ return { deleted: items.length }
2821
+ },
2822
+ meta: {
2823
+ errorMessage: 'Failed to delete some resources',
2824
+ successMessage: 'Resources deleted',
2825
+ },
2826
+ // onSettled, not onSuccess — a partial failure still deleted some
2827
+ // resources, and the table must refetch to drop them.
2828
+ onSettled: () => {
2829
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
2830
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
2831
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
2832
+ },
2833
+ })
2834
+ }
2835
+
2836
+ interface BulkWorkloadItem {
2837
+ kind: string
2838
+ namespace: string
2839
+ name: string
2840
+ }
2841
+
2842
+ interface BulkWorkloadMutationResult {
2843
+ requested: number
2844
+ succeeded: number
2845
+ failedMessages: string[]
2846
+ }
2847
+
2848
+ function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
2849
+ return results.flatMap((r) =>
2850
+ r.status === 'rejected'
2851
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2852
+ : [],
2853
+ )
2854
+ }
2855
+
2856
+ function bulkWorkloadFailureMessage(
2857
+ action: string,
2858
+ failed: number,
2859
+ total: number,
2860
+ messages: string[],
2861
+ ): string {
2862
+ return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
2863
+ }
2864
+
2865
+ export function useBulkRestartWorkloads() {
2866
+ const queryClient = useQueryClient()
2867
+
2868
+ return useMutation({
2869
+ mutationFn: async ({
2870
+ items,
2871
+ }: {
2872
+ items: BulkWorkloadItem[]
2873
+ }): Promise<BulkWorkloadMutationResult> => {
2874
+ if (items.length === 0) {
2875
+ return { requested: 0, succeeded: 0, failedMessages: [] }
2876
+ }
2877
+ const results = await Promise.allSettled(
2878
+ items.map(async ({ kind, namespace, name }) => {
2879
+ const response = await apiFetch(
2880
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
2881
+ {
2882
+ method: 'POST',
2883
+ },
2884
+ )
2885
+ if (!response.ok) {
2886
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2887
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2888
+ }
2889
+ return { kind, namespace, name }
2890
+ }),
2891
+ )
2892
+ const failedMessages = failedBulkWorkloadMessages(results)
2893
+ if (failedMessages.length === items.length) {
2894
+ throw new Error(
2895
+ bulkWorkloadFailureMessage(
2896
+ 'restart',
2897
+ failedMessages.length,
2898
+ items.length,
2899
+ failedMessages,
2900
+ ),
2901
+ )
2902
+ }
2903
+ return {
2904
+ requested: items.length,
2905
+ succeeded: items.length - failedMessages.length,
2906
+ failedMessages,
2907
+ }
2908
+ },
2909
+ meta: {
2910
+ errorMessage: 'Failed to restart some workloads',
2911
+ },
2912
+ onSuccess: (result) => {
2913
+ if (result.failedMessages.length > 0) {
2914
+ showApiError(
2915
+ `Restarted ${result.succeeded} of ${result.requested} workloads`,
2916
+ result.failedMessages.join('\n'),
2917
+ )
2918
+ } else {
2919
+ showApiSuccess('Workloads restarting')
2920
+ }
2921
+ },
2922
+ onSettled: () => {
2923
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
2924
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
2925
+ },
2926
+ })
2927
+ }
2928
+
2929
+ export function useBulkScaleWorkloads() {
2930
+ const queryClient = useQueryClient()
2931
+
2932
+ return useMutation({
2933
+ mutationFn: async ({
2934
+ items,
2935
+ replicas,
2936
+ }: {
2937
+ items: BulkWorkloadItem[]
2938
+ replicas: number
2939
+ }): Promise<BulkWorkloadMutationResult> => {
2940
+ if (items.length === 0) {
2941
+ return { requested: 0, succeeded: 0, failedMessages: [] }
2942
+ }
2943
+ const results = await Promise.allSettled(
2944
+ items.map(async ({ kind, namespace, name }) => {
2945
+ const response = await apiFetch(
2946
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
2947
+ {
2948
+ method: 'POST',
2949
+ headers: { 'Content-Type': 'application/json' },
2950
+ body: JSON.stringify({ replicas }),
2951
+ },
2952
+ )
2953
+ if (!response.ok) {
2954
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2955
+ throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2956
+ }
2957
+ return { kind, namespace, name }
2958
+ }),
2959
+ )
2960
+ const failedMessages = failedBulkWorkloadMessages(results)
2961
+ if (failedMessages.length === items.length) {
2962
+ throw new Error(
2963
+ bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
2964
+ )
2965
+ }
2966
+ return {
2967
+ requested: items.length,
2968
+ succeeded: items.length - failedMessages.length,
2969
+ failedMessages,
2970
+ }
2971
+ },
2972
+ meta: {
2973
+ errorMessage: 'Failed to scale some workloads',
2974
+ },
2975
+ onSuccess: (result) => {
2976
+ if (result.failedMessages.length > 0) {
2977
+ showApiError(
2978
+ `Scaled ${result.succeeded} of ${result.requested} workloads`,
2979
+ result.failedMessages.join('\n'),
2980
+ )
2981
+ } else {
2982
+ showApiSuccess('Workloads scaled')
2983
+ }
2984
+ },
2985
+ onSettled: () => {
2986
+ queryClient.invalidateQueries({ queryKey: ['resources'] })
1435
2987
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1436
2988
  },
1437
2989
  })
@@ -1445,30 +2997,193 @@ export interface ApplyResourceResult {
1445
2997
  created: boolean
1446
2998
  }
1447
2999
 
3000
+ interface ApplyResourceErrorResponse {
3001
+ error?: string
3002
+ results?: ApplyResourceResult[]
3003
+ failedIndex?: number
3004
+ total?: number
3005
+ }
3006
+
3007
+ export class ApplyResourceError extends Error {
3008
+ readonly appliedResults: ApplyResourceResult[]
3009
+ readonly failedIndex?: number
3010
+ readonly total?: number
3011
+
3012
+ constructor(payload: ApplyResourceErrorResponse, status: number) {
3013
+ super(formatApplyResourceError(payload, status))
3014
+ this.name = 'ApplyResourceError'
3015
+ this.appliedResults = payload.results ?? []
3016
+ this.failedIndex = payload.failedIndex
3017
+ this.total = payload.total
3018
+ }
3019
+ }
3020
+
3021
+ export function formatApplyResourceError(
3022
+ payload: ApplyResourceErrorResponse,
3023
+ status: number,
3024
+ ): string {
3025
+ const message = payload.error || `HTTP ${status}`
3026
+ const applied = payload.results ?? []
3027
+ if (applied.length === 0 || payload.failedIndex === undefined) return message
3028
+
3029
+ const total = payload.total ?? applied.length + 1
3030
+ const appliedLabel = applied.length === 1 ? 'resource was' : 'resources were'
3031
+ const names = applied
3032
+ .slice(0, 3)
3033
+ .map(({ kind, namespace, name }) => `${kind} ${namespace ? `${namespace}/` : ''}${name}`)
3034
+ .join(', ')
3035
+ const more = applied.length > 3 ? ` and ${applied.length - 3} more` : ''
3036
+ const cause = message.replace(/^document \d+:\s*/i, '')
3037
+ return `${applied.length} of ${total} ${appliedLabel} applied before document ${payload.failedIndex + 1} failed. Applied: ${names}${more}. ${cause}`
3038
+ }
3039
+
3040
+ interface YamlSchemaResponse {
3041
+ documents: Array<{
3042
+ index: number
3043
+ status: 'available' | 'unavailable'
3044
+ bundleKey?: string
3045
+ schemaRef?: string
3046
+ error?: string
3047
+ }>
3048
+ bundles: Record<string, { definitions: Record<string, unknown> }>
3049
+ }
3050
+
3051
+ export async function fetchYamlSchemas(
3052
+ documents: YamlDocumentIdentity[],
3053
+ ): Promise<YamlSchemaLoadResult> {
3054
+ const response = await apiFetch(`${getApiBase()}/resources/schemas`, {
3055
+ method: 'POST',
3056
+ headers: { 'Content-Type': 'application/json' },
3057
+ body: JSON.stringify({
3058
+ documents: documents.map(({ index, apiVersion, kind }) => ({
3059
+ index,
3060
+ apiVersion,
3061
+ kind,
3062
+ })),
3063
+ }),
3064
+ })
3065
+ if (!response.ok) {
3066
+ const error = await response.json().catch(() => ({ error: 'Cluster schemas are unavailable' }))
3067
+ throw new Error(error.error || `HTTP ${response.status}`)
3068
+ }
3069
+ const result = (await response.json()) as YamlSchemaResponse
3070
+ const schemas: Array<Record<string, unknown> | null> = documents.map(() => null)
3071
+ const unavailable: Array<{ index: number; reason: string }> = []
3072
+ for (const document of result.documents) {
3073
+ const position = documents.findIndex(({ index }) => index === document.index)
3074
+ if (position < 0) continue
3075
+ const bundle = document.bundleKey ? result.bundles[document.bundleKey] : undefined
3076
+ if (document.status === 'available' && document.schemaRef && bundle) {
3077
+ schemas[position] = {
3078
+ $ref: document.schemaRef,
3079
+ definitions: bundle.definitions,
3080
+ }
3081
+ } else {
3082
+ unavailable.push({
3083
+ index: document.index,
3084
+ reason: document.error || 'Schema unavailable',
3085
+ })
3086
+ }
3087
+ }
3088
+ return { schemas, unavailable }
3089
+ }
3090
+
3091
+ export interface YamlPreviewDocument {
3092
+ index: number
3093
+ status: 'accepted' | 'rejected' | 'unavailable'
3094
+ apiVersion?: string
3095
+ kind?: string
3096
+ namespace?: string
3097
+ name?: string
3098
+ action?: 'create' | 'update' | 'unknown'
3099
+ submittedYaml?: string
3100
+ baselineYaml?: string
3101
+ predictedYaml?: string
3102
+ warnings?: string[]
3103
+ error?: string
3104
+ reviewedResourceVersion?: string
3105
+ redacted?: boolean
3106
+ }
3107
+
3108
+ export interface YamlPreviewResponse {
3109
+ documents: YamlPreviewDocument[]
3110
+ nonAtomic: boolean
3111
+ context: string
3112
+ }
3113
+
3114
+ export interface YamlPreviewRequest {
3115
+ yaml: string
3116
+ mode: 'apply' | 'create' | 'update'
3117
+ force: boolean
3118
+ target?: { kind: string; namespace: string; name: string }
3119
+ }
3120
+
3121
+ export function usePreviewResources() {
3122
+ return useMutation({
3123
+ mutationFn: async (request: YamlPreviewRequest) => {
3124
+ const response = await apiFetch(`${getApiBase()}/resources/preview`, {
3125
+ method: 'POST',
3126
+ headers: { 'Content-Type': 'application/json' },
3127
+ body: JSON.stringify(request),
3128
+ })
3129
+ if (!response.ok) {
3130
+ const error = await response.json().catch(() => ({ error: 'Preview failed' }))
3131
+ throw new Error(error.error || `HTTP ${response.status}`)
3132
+ }
3133
+ return response.json() as Promise<YamlPreviewResponse>
3134
+ },
3135
+ })
3136
+ }
3137
+
1448
3138
  export function useApplyResource() {
1449
3139
  const queryClient = useQueryClient()
1450
3140
 
1451
3141
  return useMutation({
1452
- mutationFn: async ({ yaml, mode = 'apply', dryRun = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean }) => {
3142
+ mutationFn: async ({
3143
+ yaml,
3144
+ mode = 'apply',
3145
+ dryRun = false,
3146
+ force = false,
3147
+ reviewedResourceVersions,
3148
+ reviewedContext,
3149
+ }: {
3150
+ yaml: string
3151
+ mode?: 'apply' | 'create'
3152
+ dryRun?: boolean
3153
+ force?: boolean
3154
+ reviewedResourceVersions?: Record<number, string>
3155
+ reviewedContext?: string
3156
+ }) => {
1453
3157
  const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
1454
3158
  url.searchParams.set('mode', mode)
1455
3159
  if (dryRun) {
1456
3160
  url.searchParams.set('dryRun', 'true')
1457
3161
  }
3162
+ if (force) {
3163
+ url.searchParams.set('force', 'true')
3164
+ }
3165
+ if (reviewedResourceVersions && Object.keys(reviewedResourceVersions).length > 0) {
3166
+ url.searchParams.set('reviewedVersions', JSON.stringify(reviewedResourceVersions))
3167
+ }
3168
+ if (reviewedContext) {
3169
+ url.searchParams.set('reviewedContext', reviewedContext)
3170
+ }
1458
3171
  const response = await apiFetch(url.toString(), {
1459
3172
  method: 'POST',
1460
3173
  headers: { 'Content-Type': 'text/plain' },
1461
3174
  body: yaml,
1462
3175
  })
1463
3176
  if (!response.ok) {
1464
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1465
- throw new Error(error.error || `HTTP ${response.status}`)
3177
+ const error = (await response
3178
+ .json()
3179
+ .catch(() => ({ error: 'Unknown error' }))) as ApplyResourceErrorResponse
3180
+ throw new ApplyResourceError(error, response.status)
1466
3181
  }
1467
3182
  return response.json() as Promise<ApplyResourceResult[]>
1468
3183
  },
1469
3184
  // No meta errorMessage/successMessage — the CreateResourceDialog
1470
3185
  // handles all feedback inline to avoid duplicate toasts.
1471
- onSuccess: () => {
3186
+ onSettled: () => {
1472
3187
  queryClient.invalidateQueries({ queryKey: ['resources'] })
1473
3188
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1474
3189
  },
@@ -1479,6 +3194,25 @@ export function useApplyResource() {
1479
3194
  // CronJob operations
1480
3195
  // ============================================================================
1481
3196
 
3197
+ function invalidateCronJobOperationQueries(
3198
+ queryClient: ReturnType<typeof useQueryClient>,
3199
+ namespace: string,
3200
+ name: string,
3201
+ ) {
3202
+ queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
3203
+ queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
3204
+ queryClient.invalidateQueries({
3205
+ queryKey: ['resource', 'cronjobs', namespace, name],
3206
+ })
3207
+ queryClient.invalidateQueries({
3208
+ queryKey: ['workload-runs', 'cronjobs', namespace, name],
3209
+ })
3210
+ queryClient.invalidateQueries({ queryKey: ['applications'] })
3211
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] })
3212
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
3213
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
3214
+ }
3215
+
1482
3216
  // Trigger a CronJob (create a Job from it)
1483
3217
  export function useTriggerCronJob() {
1484
3218
  const queryClient = useQueryClient()
@@ -1498,10 +3232,8 @@ export function useTriggerCronJob() {
1498
3232
  errorMessage: 'Failed to trigger CronJob',
1499
3233
  successMessage: 'CronJob triggered',
1500
3234
  },
1501
- onSuccess: () => {
1502
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
1503
- queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
1504
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3235
+ onSuccess: (_, variables) => {
3236
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
1505
3237
  },
1506
3238
  })
1507
3239
  }
@@ -1525,9 +3257,8 @@ export function useSuspendCronJob() {
1525
3257
  errorMessage: 'Failed to suspend CronJob',
1526
3258
  successMessage: 'CronJob suspended',
1527
3259
  },
1528
- onSuccess: () => {
1529
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
1530
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3260
+ onSuccess: (_, variables) => {
3261
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
1531
3262
  },
1532
3263
  })
1533
3264
  }
@@ -1551,9 +3282,8 @@ export function useResumeCronJob() {
1551
3282
  errorMessage: 'Failed to resume CronJob',
1552
3283
  successMessage: 'CronJob resumed',
1553
3284
  },
1554
- onSuccess: () => {
1555
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
1556
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3285
+ onSuccess: (_, variables) => {
3286
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
1557
3287
  },
1558
3288
  })
1559
3289
  }
@@ -1567,10 +3297,21 @@ export function useRestartWorkload() {
1567
3297
  const queryClient = useQueryClient()
1568
3298
 
1569
3299
  return useMutation({
1570
- mutationFn: async ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => {
1571
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
1572
- method: 'POST',
1573
- })
3300
+ mutationFn: async ({
3301
+ kind,
3302
+ namespace,
3303
+ name,
3304
+ }: {
3305
+ kind: string
3306
+ namespace: string
3307
+ name: string
3308
+ }) => {
3309
+ const response = await apiFetch(
3310
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
3311
+ {
3312
+ method: 'POST',
3313
+ },
3314
+ )
1574
3315
  if (!response.ok) {
1575
3316
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1576
3317
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -1582,7 +3323,9 @@ export function useRestartWorkload() {
1582
3323
  successMessage: 'Workload restarting',
1583
3324
  },
1584
3325
  onSuccess: (_, variables) => {
1585
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
3326
+ queryClient.invalidateQueries({
3327
+ queryKey: ['resources', variables.kind],
3328
+ })
1586
3329
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1587
3330
  },
1588
3331
  })
@@ -1593,12 +3336,25 @@ export function useScaleWorkload() {
1593
3336
  const queryClient = useQueryClient()
1594
3337
 
1595
3338
  return useMutation({
1596
- mutationFn: async ({ kind, namespace, name, replicas }: { kind: string; namespace: string; name: string; replicas: number }) => {
1597
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
1598
- method: 'POST',
1599
- headers: { 'Content-Type': 'application/json' },
1600
- body: JSON.stringify({ replicas }),
1601
- })
3339
+ mutationFn: async ({
3340
+ kind,
3341
+ namespace,
3342
+ name,
3343
+ replicas,
3344
+ }: {
3345
+ kind: string
3346
+ namespace: string
3347
+ name: string
3348
+ replicas: number
3349
+ }) => {
3350
+ const response = await apiFetch(
3351
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
3352
+ {
3353
+ method: 'POST',
3354
+ headers: { 'Content-Type': 'application/json' },
3355
+ body: JSON.stringify({ replicas }),
3356
+ },
3357
+ )
1602
3358
  if (!response.ok) {
1603
3359
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1604
3360
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -1610,8 +3366,12 @@ export function useScaleWorkload() {
1610
3366
  successMessage: 'Workload scaled',
1611
3367
  },
1612
3368
  onSuccess: (_, variables) => {
1613
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
1614
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
3369
+ queryClient.invalidateQueries({
3370
+ queryKey: ['resources', variables.kind],
3371
+ })
3372
+ queryClient.invalidateQueries({
3373
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3374
+ })
1615
3375
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1616
3376
  },
1617
3377
  })
@@ -1631,7 +3391,12 @@ export interface WorkloadRevision {
1631
3391
  template?: string // Pod template spec as YAML (for revision diff)
1632
3392
  }
1633
3393
 
1634
- export function useWorkloadRevisions(kind: string, namespace: string, name: string, enabled = true) {
3394
+ export function useWorkloadRevisions(
3395
+ kind: string,
3396
+ namespace: string,
3397
+ name: string,
3398
+ enabled = true,
3399
+ ) {
1635
3400
  return useQuery<WorkloadRevision[]>({
1636
3401
  queryKey: ['workload-revisions', kind, namespace, name],
1637
3402
  queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
@@ -1642,12 +3407,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
1642
3407
  export function useRollbackWorkload() {
1643
3408
  const queryClient = useQueryClient()
1644
3409
  return useMutation({
1645
- mutationFn: async ({ kind, namespace, name, revision }: { kind: string; namespace: string; name: string; revision: number }) => {
1646
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`, {
1647
- method: 'POST',
1648
- headers: { 'Content-Type': 'application/json' },
1649
- body: JSON.stringify({ revision }),
1650
- })
3410
+ mutationFn: async ({
3411
+ kind,
3412
+ namespace,
3413
+ name,
3414
+ revision,
3415
+ }: {
3416
+ kind: string
3417
+ namespace: string
3418
+ name: string
3419
+ revision: number
3420
+ }) => {
3421
+ const response = await apiFetch(
3422
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
3423
+ {
3424
+ method: 'POST',
3425
+ headers: { 'Content-Type': 'application/json' },
3426
+ body: JSON.stringify({ revision }),
3427
+ },
3428
+ )
1651
3429
  if (!response.ok) {
1652
3430
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1653
3431
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -1659,9 +3437,15 @@ export function useRollbackWorkload() {
1659
3437
  successMessage: 'Rollback initiated',
1660
3438
  },
1661
3439
  onSuccess: (_, variables) => {
1662
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
1663
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
1664
- queryClient.invalidateQueries({ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name] })
3440
+ queryClient.invalidateQueries({
3441
+ queryKey: ['resources', variables.kind],
3442
+ })
3443
+ queryClient.invalidateQueries({
3444
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3445
+ })
3446
+ queryClient.invalidateQueries({
3447
+ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
3448
+ })
1665
3449
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1666
3450
  },
1667
3451
  })
@@ -1691,7 +3475,9 @@ export function useCordonNode() {
1691
3475
  },
1692
3476
  onSuccess: (_, variables) => {
1693
3477
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
1694
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3478
+ queryClient.invalidateQueries({
3479
+ queryKey: ['resource', 'nodes', '', variables.name],
3480
+ })
1695
3481
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1696
3482
  },
1697
3483
  })
@@ -1717,7 +3503,9 @@ export function useUncordonNode() {
1717
3503
  },
1718
3504
  onSuccess: (_, variables) => {
1719
3505
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
1720
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3506
+ queryClient.invalidateQueries({
3507
+ queryKey: ['resource', 'nodes', '', variables.name],
3508
+ })
1721
3509
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1722
3510
  },
1723
3511
  })
@@ -1750,7 +3538,9 @@ export function useDrainNode() {
1750
3538
  },
1751
3539
  onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
1752
3540
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
1753
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3541
+ queryClient.invalidateQueries({
3542
+ queryKey: ['resource', 'nodes', '', variables.name],
3543
+ })
1754
3544
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1755
3545
 
1756
3546
  const evicted = data?.evictedPods?.length ?? 0
@@ -1771,72 +3561,181 @@ export function useDrainNode() {
1771
3561
  // Helm API hooks
1772
3562
  // ============================================================================
1773
3563
 
3564
+ function helmNamespaceParams(namespaces: string[] = []) {
3565
+ return namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
3566
+ }
3567
+
1774
3568
  // List all Helm releases
1775
- export function useHelmReleases(namespace?: string) {
1776
- const params = namespace ? `?namespace=${namespace}` : ''
3569
+ export function useHelmReleases(namespaces: string[] = []) {
3570
+ const params = helmNamespaceParams(namespaces)
1777
3571
  return useQuery<HelmRelease[]>({
1778
- queryKey: ['helm-releases', namespace],
3572
+ queryKey: ['helm-releases', namespaces],
1779
3573
  queryFn: () => fetchJSON(`/helm/releases${params}`),
1780
3574
  staleTime: 30000, // 30 seconds
1781
3575
  })
1782
3576
  }
1783
3577
 
1784
3578
  // Get details for a specific Helm release
1785
- export function useHelmRelease(namespace: string, name: string) {
3579
+ export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
1786
3580
  return useQuery<HelmReleaseDetail>({
1787
3581
  queryKey: ['helm-release', namespace, name],
1788
3582
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
1789
- enabled: Boolean(namespace && name),
3583
+ enabled: Boolean(namespace && name) && (options?.enabled ?? true),
1790
3584
  staleTime: 5000,
1791
3585
  refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
1792
3586
  })
1793
3587
  }
1794
3588
 
1795
- // Get manifest for a Helm release (optionally at a specific revision).
1796
- // `enabled` lets callers skip the query when the user's Cloud role
1797
- // would 403 the read — saves a round-trip and avoids a transient
1798
- // "error" state that the role-gated empty panel doesn't need.
1799
- export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
1800
- const params = revision ? `?revision=${revision}` : ''
1801
- return useQuery<string>({
1802
- queryKey: ['helm-manifest', namespace, name, revision],
1803
- queryFn: async () => {
1804
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`)
1805
- if (!response.ok) {
1806
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1807
- throw new Error(error.error || `HTTP ${response.status}`)
1808
- }
1809
- return response.text()
1810
- },
1811
- enabled: Boolean(namespace && name && enabled),
1812
- staleTime: 60000, // 1 minute
3589
+ // Get manifest for a Helm release (optionally at a specific revision).
3590
+ // `enabled` lets callers skip the query when the user's Cloud role
3591
+ // would 403 the read — saves a round-trip and avoids a transient
3592
+ // "error" state that the role-gated empty panel doesn't need.
3593
+ export function useHelmManifest(
3594
+ namespace: string,
3595
+ name: string,
3596
+ revision?: number,
3597
+ enabled = true,
3598
+ ) {
3599
+ const params = revision ? `?revision=${revision}` : ''
3600
+ return useQuery<string>({
3601
+ queryKey: ['helm-manifest', namespace, name, revision],
3602
+ queryFn: async () => {
3603
+ const response = await apiFetch(
3604
+ `${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
3605
+ )
3606
+ if (!response.ok) {
3607
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
3608
+ throw new Error(error.error || `HTTP ${response.status}`)
3609
+ }
3610
+ return response.text()
3611
+ },
3612
+ enabled: Boolean(namespace && name && enabled),
3613
+ staleTime: 60000, // 1 minute
3614
+ })
3615
+ }
3616
+
3617
+ // Get values for a Helm release. `enabled` see useHelmManifest.
3618
+ export function useHelmValues(
3619
+ namespace: string,
3620
+ name: string,
3621
+ allValues?: boolean,
3622
+ enabled = true,
3623
+ revision?: number,
3624
+ ) {
3625
+ const params = new URLSearchParams()
3626
+ if (allValues) params.set('all', 'true')
3627
+ if (revision && revision > 0) params.set('revision', String(revision))
3628
+ const query = params.toString() ? `?${params.toString()}` : ''
3629
+ return useQuery<HelmValues>({
3630
+ queryKey: ['helm-values', namespace, name, allValues, revision],
3631
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
3632
+ enabled: Boolean(namespace && name && enabled),
3633
+ staleTime: 60000,
3634
+ })
3635
+ }
3636
+
3637
+ // Get diff between two revisions. `enabled` see useHelmManifest.
3638
+ export function useHelmManifestDiff(
3639
+ namespace: string,
3640
+ name: string,
3641
+ revision1: number,
3642
+ revision2: number,
3643
+ enabled = true,
3644
+ ) {
3645
+ return useQuery<ManifestDiff>({
3646
+ queryKey: ['helm-diff', namespace, name, revision1, revision2],
3647
+ queryFn: () =>
3648
+ fetchJSON(
3649
+ `/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
3650
+ ),
3651
+ enabled: Boolean(
3652
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3653
+ ),
3654
+ staleTime: 60000,
3655
+ })
3656
+ }
3657
+
3658
+ export function useHelmValuesDiff(
3659
+ namespace: string,
3660
+ name: string,
3661
+ revision1: number,
3662
+ revision2: number,
3663
+ allValues = false,
3664
+ enabled = true,
3665
+ ) {
3666
+ return useQuery<ValuesDiff>({
3667
+ queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
3668
+ queryFn: () => {
3669
+ const params = new URLSearchParams({
3670
+ revision1: String(revision1),
3671
+ revision2: String(revision2),
3672
+ })
3673
+ if (allValues) params.set('all', 'true')
3674
+ return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
3675
+ },
3676
+ enabled: Boolean(
3677
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3678
+ ),
3679
+ staleTime: 60000,
3680
+ })
3681
+ }
3682
+
3683
+ export function useHelmNotesDiff(
3684
+ namespace: string,
3685
+ name: string,
3686
+ revision1: number,
3687
+ revision2: number,
3688
+ enabled = true,
3689
+ ) {
3690
+ return useQuery<NotesDiff>({
3691
+ queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
3692
+ queryFn: () =>
3693
+ fetchJSON(
3694
+ `/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
3695
+ ),
3696
+ enabled: Boolean(
3697
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3698
+ ),
3699
+ staleTime: 60000,
1813
3700
  })
1814
3701
  }
1815
3702
 
1816
- // Get values for a Helm release. `enabled` see useHelmManifest.
1817
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
1818
- const params = allValues ? '?all=true' : ''
1819
- return useQuery<HelmValues>({
1820
- queryKey: ['helm-values', namespace, name, allValues],
1821
- queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
1822
- enabled: Boolean(namespace && name && enabled),
3703
+ export function useHelmHooksDiff(
3704
+ namespace: string,
3705
+ name: string,
3706
+ revision1: number,
3707
+ revision2: number,
3708
+ enabled = true,
3709
+ ) {
3710
+ return useQuery<HooksDiff>({
3711
+ queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
3712
+ queryFn: () =>
3713
+ fetchJSON(
3714
+ `/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
3715
+ ),
3716
+ enabled: Boolean(
3717
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3718
+ ),
1823
3719
  staleTime: 60000,
1824
3720
  })
1825
3721
  }
1826
3722
 
1827
- // Get diff between two revisions. `enabled` see useHelmManifest.
1828
- export function useHelmManifestDiff(
3723
+ export function useHelmResourceDiff(
1829
3724
  namespace: string,
1830
3725
  name: string,
1831
3726
  revision1: number,
1832
3727
  revision2: number,
1833
3728
  enabled = true,
1834
3729
  ) {
1835
- return useQuery<ManifestDiff>({
1836
- queryKey: ['helm-diff', namespace, name, revision1, revision2],
3730
+ return useQuery<ResourceDiff>({
3731
+ queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
1837
3732
  queryFn: () =>
1838
- fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
1839
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3733
+ fetchJSON(
3734
+ `/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
3735
+ ),
3736
+ enabled: Boolean(
3737
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3738
+ ),
1840
3739
  staleTime: 60000,
1841
3740
  })
1842
3741
  }
@@ -1852,11 +3751,24 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
1852
3751
  })
1853
3752
  }
1854
3753
 
3754
+ // Available chart versions for a release (newest-first), for the upgrade dialog's
3755
+ // version picker. Empty when the source can't be resolved — the dialog then falls
3756
+ // back to the latest version from upgrade-info.
3757
+ export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
3758
+ return useQuery<string[]>({
3759
+ queryKey: ['helm-release-versions', namespace, name],
3760
+ queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
3761
+ enabled: Boolean(namespace && name && enabled),
3762
+ staleTime: 30000,
3763
+ retry: false,
3764
+ })
3765
+ }
3766
+
1855
3767
  // Batch check for upgrade availability (for list view)
1856
- export function useHelmBatchUpgradeInfo(namespace?: string, enabled = true) {
1857
- const params = namespace ? `?namespace=${namespace}` : ''
3768
+ export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
3769
+ const params = helmNamespaceParams(namespaces)
1858
3770
  return useQuery<BatchUpgradeInfo>({
1859
- queryKey: ['helm-batch-upgrade-info', namespace],
3771
+ queryKey: ['helm-batch-upgrade-info', namespaces],
1860
3772
  queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
1861
3773
  enabled,
1862
3774
  staleTime: 30000, // 30 seconds - keep in sync with release list
@@ -1873,10 +3785,21 @@ export function useHelmRollback() {
1873
3785
  const queryClient = useQueryClient()
1874
3786
 
1875
3787
  return useMutation({
1876
- mutationFn: async ({ namespace, name, revision }: { namespace: string; name: string; revision: number }) => {
1877
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`, {
1878
- method: 'POST',
1879
- })
3788
+ mutationFn: async ({
3789
+ namespace,
3790
+ name,
3791
+ revision,
3792
+ }: {
3793
+ namespace: string
3794
+ name: string
3795
+ revision: number
3796
+ }) => {
3797
+ const response = await apiFetch(
3798
+ `${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
3799
+ {
3800
+ method: 'POST',
3801
+ },
3802
+ )
1880
3803
  if (!response.ok) {
1881
3804
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
1882
3805
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -1889,7 +3812,9 @@ export function useHelmRollback() {
1889
3812
  },
1890
3813
  onSuccess: (_, variables) => {
1891
3814
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
1892
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
3815
+ queryClient.invalidateQueries({
3816
+ queryKey: ['helm-release', variables.namespace, variables.name],
3817
+ })
1893
3818
  },
1894
3819
  })
1895
3820
  }
@@ -1967,30 +3892,49 @@ function streamHelmProgress(
1967
3892
 
1968
3893
  if (data.type === 'complete') {
1969
3894
  resolve(data)
3895
+ return
1970
3896
  } else if (data.type === 'error') {
1971
3897
  reject(new Error(data.message || failureLabel))
3898
+ return
1972
3899
  }
1973
- } catch {
1974
- // Ignore parse errors
3900
+ } catch (err) {
3901
+ reject(
3902
+ err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
3903
+ )
3904
+ return
1975
3905
  }
1976
3906
  }
1977
3907
  }
1978
3908
  }
3909
+
3910
+ reject(new Error(`${failureLabel}: stream ended before completion`))
1979
3911
  })
1980
3912
  .catch(reject)
1981
3913
  })
1982
3914
  }
1983
3915
 
1984
- // Upgrade a release with progress streaming via SSE
3916
+ // When `values` is provided, the upgrade applies exactly those edited values
3917
+ // instead of carrying the release's prior values over blindly.
1985
3918
  export function upgradeWithProgress(
1986
3919
  namespace: string,
1987
3920
  name: string,
1988
3921
  version: string,
1989
- onProgress: (event: InstallProgressEvent) => void
3922
+ repositoryName: string | undefined,
3923
+ onProgress: (event: InstallProgressEvent) => void,
3924
+ values?: Record<string, unknown>,
1990
3925
  ): Promise<void> {
3926
+ const params = new URLSearchParams({ version })
3927
+ if (repositoryName) params.set('repository', repositoryName)
3928
+ const options: RequestInit = values
3929
+ ? {
3930
+ method: 'POST',
3931
+ headers: { 'Content-Type': 'application/json' },
3932
+ body: JSON.stringify({ values }),
3933
+ }
3934
+ : { method: 'POST' }
1991
3935
  return streamHelmProgress(
1992
- `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?version=${encodeURIComponent(version)}`,
1993
- { method: 'POST' },
3936
+ `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
3937
+ options,
1994
3938
  onProgress,
1995
3939
  'Upgrade failed',
1996
3940
  ).then(() => {})
@@ -2001,7 +3945,7 @@ export function rollbackWithProgress(
2001
3945
  namespace: string,
2002
3946
  name: string,
2003
3947
  revision: number,
2004
- onProgress: (event: InstallProgressEvent) => void
3948
+ onProgress: (event: InstallProgressEvent) => void,
2005
3949
  ): Promise<void> {
2006
3950
  return streamHelmProgress(
2007
3951
  `${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
@@ -2011,15 +3955,29 @@ export function rollbackWithProgress(
2011
3955
  ).then(() => {})
2012
3956
  }
2013
3957
 
2014
- // Preview values change (dry-run upgrade)
3958
+ // When `version` is supplied, preview renders against that target chart version
3959
+ // instead of the release's current chart.
2015
3960
  export function useHelmPreviewValues() {
2016
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown> }>({
2017
- mutationFn: async ({ namespace, name, values }) => {
2018
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
2019
- method: 'POST',
2020
- headers: { 'Content-Type': 'application/json' },
2021
- body: JSON.stringify({ values }),
2022
- })
3961
+ return useMutation<
3962
+ ValuesPreviewResponse,
3963
+ Error,
3964
+ {
3965
+ namespace: string
3966
+ name: string
3967
+ values: Record<string, unknown>
3968
+ version?: string
3969
+ repository?: string
3970
+ }
3971
+ >({
3972
+ mutationFn: async ({ namespace, name, values, version, repository }) => {
3973
+ const response = await apiFetch(
3974
+ `${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
3975
+ {
3976
+ method: 'POST',
3977
+ headers: { 'Content-Type': 'application/json' },
3978
+ body: JSON.stringify({ values, version, repository }),
3979
+ },
3980
+ )
2023
3981
  if (!response.ok) {
2024
3982
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2025
3983
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2034,7 +3992,15 @@ export function useHelmApplyValues() {
2034
3992
  const queryClient = useQueryClient()
2035
3993
 
2036
3994
  return useMutation({
2037
- mutationFn: async ({ namespace, name, values }: { namespace: string; name: string; values: Record<string, unknown> }) => {
3995
+ mutationFn: async ({
3996
+ namespace,
3997
+ name,
3998
+ values,
3999
+ }: {
4000
+ namespace: string
4001
+ name: string
4002
+ values: Record<string, unknown>
4003
+ }) => {
2038
4004
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
2039
4005
  method: 'PUT',
2040
4006
  headers: { 'Content-Type': 'application/json' },
@@ -2052,8 +4018,12 @@ export function useHelmApplyValues() {
2052
4018
  },
2053
4019
  onSuccess: (_, variables) => {
2054
4020
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
2055
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
2056
- queryClient.invalidateQueries({ queryKey: ['helm-values', variables.namespace, variables.name] })
4021
+ queryClient.invalidateQueries({
4022
+ queryKey: ['helm-release', variables.namespace, variables.name],
4023
+ })
4024
+ queryClient.invalidateQueries({
4025
+ queryKey: ['helm-values', variables.namespace, variables.name],
4026
+ })
2057
4027
  },
2058
4028
  })
2059
4029
  }
@@ -2070,29 +4040,103 @@ export function useHelmRepositories() {
2070
4040
  })
2071
4041
  }
2072
4042
 
4043
+ // Shared mutation fn + cache invalidation for the helm-repo
4044
+ // update endpoint. Hoisted so useUpdateRepository and
4045
+ // useUpdateRepositorySilent can't drift on URL, error parsing, or
4046
+ // invalidation keys — only the toast meta differs.
4047
+ async function updateRepositoryFn(repoName: string): Promise<unknown> {
4048
+ const response = await apiFetch(`${getApiBase()}/helm/repositories/${repoName}/update`, {
4049
+ method: 'POST',
4050
+ })
4051
+ if (!response.ok) {
4052
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4053
+ throw new Error(error.error || `HTTP ${response.status}`)
4054
+ }
4055
+ return response.json()
4056
+ }
4057
+
4058
+ function invalidateHelmAfterRepoUpdate(queryClient: ReturnType<typeof useQueryClient>) {
4059
+ queryClient.invalidateQueries({ queryKey: ['helm-repositories'] })
4060
+ queryClient.invalidateQueries({ queryKey: ['helm-charts'] })
4061
+ }
4062
+
2073
4063
  // Update a repository index
2074
4064
  export function useUpdateRepository() {
2075
4065
  const queryClient = useQueryClient()
2076
-
2077
4066
  return useMutation({
2078
- mutationFn: async (repoName: string) => {
2079
- const response = await apiFetch(`${getApiBase()}/helm/repositories/${repoName}/update`, {
2080
- method: 'POST',
2081
- })
2082
- if (!response.ok) {
2083
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2084
- throw new Error(error.error || `HTTP ${response.status}`)
2085
- }
2086
- return response.json()
2087
- },
4067
+ mutationFn: updateRepositoryFn,
2088
4068
  meta: {
2089
4069
  errorMessage: 'Failed to update repository',
2090
4070
  successMessage: 'Repository updated',
2091
4071
  },
2092
- onSuccess: () => {
2093
- queryClient.invalidateQueries({ queryKey: ['helm-repositories'] })
2094
- queryClient.invalidateQueries({ queryKey: ['helm-charts'] })
4072
+ onSuccess: () => invalidateHelmAfterRepoUpdate(queryClient),
4073
+ })
4074
+ }
4075
+
4076
+ // Same endpoint as useUpdateRepository but without meta — the
4077
+ // caller surfaces ONE aggregate toast for the whole batch, so the
4078
+ // global MutationCache must NOT fire a per-call toast (otherwise
4079
+ // N failures = N identical "Failed to update repository" toasts
4080
+ // with no repo name).
4081
+ export function useUpdateRepositorySilent() {
4082
+ const queryClient = useQueryClient()
4083
+ return useMutation({
4084
+ mutationFn: updateRepositoryFn,
4085
+ onSuccess: () => invalidateHelmAfterRepoUpdate(queryClient),
4086
+ })
4087
+ }
4088
+
4089
+ // Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
4090
+ // track upgrades for the user's own OCI-published charts.
4091
+ export function useHelmOCISources() {
4092
+ return useQuery<string[]>({
4093
+ queryKey: ['helm-oci-sources'],
4094
+ queryFn: () => fetchJSON('/helm/oci-sources'),
4095
+ })
4096
+ }
4097
+
4098
+ async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
4099
+ const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
4100
+ method,
4101
+ headers: { 'Content-Type': 'application/json' },
4102
+ body: JSON.stringify({ source }),
4103
+ })
4104
+ if (!response.ok) {
4105
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4106
+ throw new Error(error.error || `HTTP ${response.status}`)
4107
+ }
4108
+ return response.json()
4109
+ }
4110
+
4111
+ // Invalidate the upgrade-info queries so a newly-registered source is probed
4112
+ // immediately and "source not tracked" re-resolves.
4113
+ function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
4114
+ queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
4115
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
4116
+ queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
4117
+ }
4118
+
4119
+ export function useAddOCISource() {
4120
+ const queryClient = useQueryClient()
4121
+ return useMutation({
4122
+ mutationFn: (source: string) => mutateOCISource('POST', source),
4123
+ meta: {
4124
+ errorMessage: 'Failed to add chart source',
4125
+ successMessage: 'Chart source added',
4126
+ },
4127
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
4128
+ })
4129
+ }
4130
+
4131
+ export function useRemoveOCISource() {
4132
+ const queryClient = useQueryClient()
4133
+ return useMutation({
4134
+ mutationFn: (source: string) => mutateOCISource('DELETE', source),
4135
+ meta: {
4136
+ errorMessage: 'Failed to remove chart source',
4137
+ successMessage: 'Chart source removed',
2095
4138
  },
4139
+ onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2096
4140
  })
2097
4141
  }
2098
4142
 
@@ -2163,11 +4207,15 @@ export interface InstallProgressEvent {
2163
4207
  // Install a chart with progress streaming via SSE
2164
4208
  export function installChartWithProgress(
2165
4209
  req: InstallChartRequest,
2166
- onProgress: (event: InstallProgressEvent) => void
4210
+ onProgress: (event: InstallProgressEvent) => void,
2167
4211
  ): Promise<HelmRelease> {
2168
4212
  return streamHelmProgress(
2169
4213
  `${getApiBase()}/helm/releases/install-stream`,
2170
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) },
4214
+ {
4215
+ method: 'POST',
4216
+ headers: { 'Content-Type': 'application/json' },
4217
+ body: JSON.stringify(req),
4218
+ },
2171
4219
  onProgress,
2172
4220
  'Install failed',
2173
4221
  ).then((event) => event.release as HelmRelease)
@@ -2183,8 +4231,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
2183
4231
  // Search charts on ArtifactHub
2184
4232
  export function useArtifactHubSearch(
2185
4233
  query: string,
2186
- options?: { offset?: number; limit?: number; official?: boolean; verified?: boolean; sort?: ArtifactHubSortOption },
2187
- enabled = true
4234
+ options?: {
4235
+ offset?: number
4236
+ limit?: number
4237
+ official?: boolean
4238
+ verified?: boolean
4239
+ sort?: ArtifactHubSortOption
4240
+ },
4241
+ enabled = true,
2188
4242
  ) {
2189
4243
  const params = new URLSearchParams()
2190
4244
  if (query) params.set('query', query)
@@ -2195,7 +4249,15 @@ export function useArtifactHubSearch(
2195
4249
  if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
2196
4250
 
2197
4251
  return useQuery<ArtifactHubSearchResult>({
2198
- queryKey: ['artifacthub-search', query, options?.offset, options?.limit, options?.official, options?.verified, options?.sort],
4252
+ queryKey: [
4253
+ 'artifacthub-search',
4254
+ query,
4255
+ options?.offset,
4256
+ options?.limit,
4257
+ options?.official,
4258
+ options?.verified,
4259
+ options?.sort,
4260
+ ],
2199
4261
  queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
2200
4262
  enabled: enabled && query.length > 0,
2201
4263
  staleTime: 60000, // 1 minute
@@ -2203,7 +4265,12 @@ export function useArtifactHubSearch(
2203
4265
  }
2204
4266
 
2205
4267
  // Get chart detail from ArtifactHub
2206
- export function useArtifactHubChart(repoName: string, chartName: string, version?: string, enabled = true) {
4268
+ export function useArtifactHubChart(
4269
+ repoName: string,
4270
+ chartName: string,
4271
+ version?: string,
4272
+ enabled = true,
4273
+ ) {
2207
4274
  const path = version
2208
4275
  ? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
2209
4276
  : `/helm/artifacthub/charts/${repoName}/${chartName}`
@@ -2222,8 +4289,10 @@ export function useArtifactHubChart(repoName: string, chartName: string, version
2222
4289
 
2223
4290
  interface GitOpsMutationConfig<TVariables> {
2224
4291
  getPath: (variables: TVariables) => string
4292
+ getBody?: (variables: TVariables) => unknown
2225
4293
  errorMessage: string
2226
- successMessage: string
4294
+ successMessage?: string
4295
+ getSuccessMessage?: (data: GitOpsOperationResponse) => string
2227
4296
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
2228
4297
  }
2229
4298
 
@@ -2238,6 +4307,8 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2238
4307
  mutationFn: async (variables: TVariables): Promise<GitOpsOperationResponse> => {
2239
4308
  const response = await apiFetch(`${getApiBase()}${config.getPath(variables)}`, {
2240
4309
  method: 'POST',
4310
+ headers: config.getBody ? { 'Content-Type': 'application/json' } : undefined,
4311
+ body: config.getBody ? JSON.stringify(config.getBody(variables)) : undefined,
2241
4312
  })
2242
4313
  if (!response.ok) {
2243
4314
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
@@ -2249,9 +4320,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2249
4320
  errorMessage: config.errorMessage,
2250
4321
  successMessage: config.successMessage,
2251
4322
  },
2252
- onSuccess: (_, variables) => {
2253
- config.getInvalidateKeys(variables).forEach(key =>
2254
- queryClient.invalidateQueries({ queryKey: key })
4323
+ onSuccess: (data, variables) => {
4324
+ if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
4325
+ config.getInvalidateKeys(variables).forEach((key) =>
4326
+ queryClient.invalidateQueries({ queryKey: key }),
2255
4327
  )
2256
4328
  },
2257
4329
  })
@@ -2260,16 +4332,76 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
2260
4332
 
2261
4333
  // Common variable types
2262
4334
  type FluxResourceVars = { kind: string; namespace: string; name: string }
4335
+ // ArgoAppVars identifies the target Application. Used by mutations that don't
4336
+ // take a body (terminate, suspend, resume, refresh).
2263
4337
  type ArgoAppVars = { namespace: string; name: string }
4338
+ // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
4339
+ // useArgoSync sends these — splitting the type prevents callers from passing
4340
+ // resources/revision/prune to mutations that would silently drop them.
4341
+ export type ArgoSyncVars = ArgoAppVars & {
4342
+ resources?: Array<{
4343
+ group?: string
4344
+ kind: string
4345
+ namespace?: string
4346
+ name: string
4347
+ }>
4348
+ revision?: string
4349
+ prune?: boolean
4350
+ dryRun?: boolean
4351
+ force?: boolean
4352
+ applyOnly?: boolean
4353
+ // Free-form Argo SyncOption strings, e.g. "Replace=true",
4354
+ // "ServerSideApply=true", "PruneLast=true". Caller is responsible for
4355
+ // spelling.
4356
+ syncOptions?: string[]
4357
+ }
4358
+
4359
+ export interface ArgoResourceValidationResult {
4360
+ outcome: 'succeeded' | 'failed' | 'inconclusive'
4361
+ message: string
4362
+ resource?: {
4363
+ group?: string
4364
+ kind: string
4365
+ namespace?: string
4366
+ name: string
4367
+ status?: string
4368
+ message?: string
4369
+ }
4370
+ }
4371
+
4372
+ export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
4373
+ return {
4374
+ namespace,
4375
+ name,
4376
+ ...opts,
4377
+ resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
4378
+ revision: undefined,
4379
+ prune: false,
4380
+ applyOnly: false,
4381
+ }
4382
+ }
4383
+
4384
+ // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
4385
+ // DryRun mirror the sync flags so the rollback dialog can offer the same
4386
+ // safety net.
4387
+ type ArgoRollbackVars = ArgoAppVars & {
4388
+ id: number
4389
+ prune?: boolean
4390
+ dryRun?: boolean
4391
+ }
2264
4392
 
2265
4393
  // Standard invalidation patterns
2266
4394
  const fluxInvalidateKeys = (v: FluxResourceVars) => [
2267
4395
  ['resources', v.kind],
2268
4396
  ['resource', v.kind, v.namespace, v.name],
4397
+ ['gitops-tree', v.kind, v.namespace, v.name],
4398
+ ['gitops-insights', v.kind, v.namespace, v.name],
2269
4399
  ]
2270
4400
  const argoInvalidateKeys = (v: ArgoAppVars) => [
2271
4401
  ['resources', 'applications'],
2272
4402
  ['resource', 'applications', v.namespace, v.name],
4403
+ ['gitops-tree', 'applications', v.namespace, v.name],
4404
+ ['gitops-insights', 'applications', v.namespace, v.name],
2273
4405
  ]
2274
4406
 
2275
4407
  // ============================================================================
@@ -2314,17 +4446,59 @@ export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
2314
4446
  // ArgoCD API hooks
2315
4447
  // ============================================================================
2316
4448
 
2317
- export const useArgoSync = createGitOpsMutation<ArgoAppVars>({
4449
+ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
2318
4450
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/sync`,
4451
+ getBody: (v) => ({
4452
+ resources: v.resources,
4453
+ revision: v.revision,
4454
+ prune: v.prune,
4455
+ dryRun: v.dryRun,
4456
+ force: v.force,
4457
+ applyOnly: v.applyOnly,
4458
+ syncOptions: v.syncOptions,
4459
+ }),
2319
4460
  errorMessage: 'Failed to trigger sync',
2320
4461
  successMessage: 'Sync initiated',
2321
4462
  getInvalidateKeys: argoInvalidateKeys,
2322
4463
  })
2323
4464
 
4465
+ export function useArgoResourceValidation() {
4466
+ const queryClient = useQueryClient()
4467
+ return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
4468
+ mutationFn: async (variables) => {
4469
+ const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
4470
+ method: 'POST',
4471
+ headers: { 'Content-Type': 'application/json' },
4472
+ body: JSON.stringify({
4473
+ resources: variables.resources,
4474
+ force: variables.force,
4475
+ syncOptions: variables.syncOptions,
4476
+ }),
4477
+ })
4478
+ if (!response.ok) {
4479
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4480
+ throw new Error(error.error || `HTTP ${response.status}`)
4481
+ }
4482
+ return response.json() as Promise<ArgoResourceValidationResult>
4483
+ },
4484
+ onSettled: (_, __, variables) => {
4485
+ argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
4486
+ },
4487
+ })
4488
+ }
4489
+
4490
+ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
4491
+ getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
4492
+ getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
4493
+ errorMessage: 'Failed to roll back application',
4494
+ successMessage: 'Rollback initiated',
4495
+ getInvalidateKeys: argoInvalidateKeys,
4496
+ })
4497
+
2324
4498
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
2325
4499
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
2326
4500
  errorMessage: 'Failed to terminate sync',
2327
- successMessage: 'Sync terminated',
4501
+ getSuccessMessage: (data) => data.message,
2328
4502
  getInvalidateKeys: argoInvalidateKeys,
2329
4503
  })
2330
4504
 
@@ -2347,11 +4521,22 @@ export function useArgoRefresh() {
2347
4521
  const queryClient = useQueryClient()
2348
4522
 
2349
4523
  return useMutation({
2350
- mutationFn: async ({ namespace, name, hard = false }: { namespace: string; name: string; hard?: boolean }) => {
4524
+ mutationFn: async ({
4525
+ namespace,
4526
+ name,
4527
+ hard = false,
4528
+ }: {
4529
+ namespace: string
4530
+ name: string
4531
+ hard?: boolean
4532
+ }) => {
2351
4533
  const params = hard ? '?type=hard' : ''
2352
- const response = await apiFetch(`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`, {
2353
- method: 'POST',
2354
- })
4534
+ const response = await apiFetch(
4535
+ `${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
4536
+ {
4537
+ method: 'POST',
4538
+ },
4539
+ )
2355
4540
  if (!response.ok) {
2356
4541
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2357
4542
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2363,8 +4548,13 @@ export function useArgoRefresh() {
2363
4548
  successMessage: 'Application refreshed',
2364
4549
  },
2365
4550
  onSuccess: (_, variables) => {
2366
- queryClient.invalidateQueries({ queryKey: ['resources', 'applications'] })
2367
- queryClient.invalidateQueries({ queryKey: ['resource', 'applications', variables.namespace, variables.name] })
4551
+ // Match the standard Argo invalidation set so the GitOps detail page
4552
+ // (insights strip, resource tree) refetches after Refresh / Hard
4553
+ // Refresh — without these two extra keys the user clicks Refresh and
4554
+ // sees stale insight/tree data until the next staleTime tick.
4555
+ argoInvalidateKeys(variables).forEach((key) =>
4556
+ queryClient.invalidateQueries({ queryKey: key }),
4557
+ )
2368
4558
  },
2369
4559
  })
2370
4560
  }
@@ -2421,7 +4611,9 @@ export function useSwitchContext() {
2421
4611
  } catch (error) {
2422
4612
  clearTimeout(timeoutId)
2423
4613
  if (error instanceof Error && error.name === 'AbortError') {
2424
- throw new Error('Context switch timed out. The cluster may be unreachable.')
4614
+ throw new Error('Context switch timed out. The cluster may be unreachable.', {
4615
+ cause: error,
4616
+ })
2425
4617
  }
2426
4618
  throw error
2427
4619
  }
@@ -2441,6 +4633,142 @@ export function useSwitchContext() {
2441
4633
  })
2442
4634
  }
2443
4635
 
4636
+ // ============================================================================
4637
+ // Active namespace switcher
4638
+ // ============================================================================
4639
+
4640
+ export interface NamespaceScope {
4641
+ actives: string[]
4642
+ kubeconfigNamespace: string
4643
+ /**
4644
+ * 'cluster-wide' — no per-user pick; user can list across namespaces.
4645
+ * 'namespace' — per-user view filter pinned to one or more namespaces.
4646
+ * 'restricted' — user can't list namespaces and isn't pinned to any.
4647
+ */
4648
+ mode: 'cluster-wide' | 'namespace' | 'restricted'
4649
+ accessibleNamespaces: string[]
4650
+ /** false when accessibleNamespaces is a best-effort short list (no list perm). */
4651
+ authoritative: boolean
4652
+ /** false when clearing would leave no usable namespace fallback. */
4653
+ canClearNamespace: boolean
4654
+ /** true when the backend informer cache is pinned to a namespace. */
4655
+ cacheScoped: boolean
4656
+ cacheScopeNamespace?: string
4657
+ /** true when this client may rebuild the local cache for another namespace. */
4658
+ namespaceRescope: boolean
4659
+ }
4660
+
4661
+ export function useNamespaceScope() {
4662
+ return useQuery<NamespaceScope>({
4663
+ queryKey: ['namespace-scope'],
4664
+ queryFn: () => fetchJSON('/cluster/namespace-scope'),
4665
+ staleTime: 30000,
4666
+ })
4667
+ }
4668
+
4669
+ const NAMESPACE_SWITCH_TIMEOUT = 5000
4670
+ const NAMESPACE_RESCOPE_TIMEOUT = 120000
4671
+
4672
+ export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
4673
+ if (typeof window === 'undefined') return
4674
+ const enabled = window.localStorage.getItem('radar:debug:namespaces')
4675
+ if (enabled !== '1' && enabled !== 'true') return
4676
+ console.log(`[namespace-debug] ${label}`, {
4677
+ t: Math.round(performance.now()),
4678
+ href: window.location.href,
4679
+ ...payload,
4680
+ })
4681
+ }
4682
+
4683
+ export function useSetActiveNamespace() {
4684
+ const queryClient = useQueryClient()
4685
+ return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
4686
+ meta: {
4687
+ // Surface 403s (RBAC drift, denied bookmark) and network errors via the
4688
+ // global toast. Without this, App.tsx call sites that mutate without
4689
+ // their own onError (bookmark reconciliation, back-nav, topology
4690
+ // maximize/clear, command palette) silently revert when the scope
4691
+ // refetches and the mirror effect overwrites local state.
4692
+ errorMessage: 'Failed to update namespace selection',
4693
+ },
4694
+ mutationFn: async ({ namespaces }) => {
4695
+ debugNamespaceLog('mutation:start', { namespaces })
4696
+ const controller = new AbortController()
4697
+ const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
4698
+ // cacheScoped is a stable per-process property (the server's --namespace-scope
4699
+ // flag). If the scope query is missing/stale we can't yet tell a cheap
4700
+ // view-filter change from a cache-rebuilding rescope, so bias to the long
4701
+ // timeout — only a confirmed non-scoped session gets the fast switch timeout.
4702
+ // Aborting a real rebuild at 5s surfaces a spurious failure while the server
4703
+ // keeps going.
4704
+ const isRescope = currentScope?.cacheScoped !== false
4705
+ const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
4706
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
4707
+ const startedAt = performance.now()
4708
+ try {
4709
+ const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
4710
+ method: 'POST',
4711
+ headers: { 'Content-Type': 'application/json' },
4712
+ body: JSON.stringify({ namespaces }),
4713
+ signal: controller.signal,
4714
+ })
4715
+ clearTimeout(timeoutId)
4716
+ debugNamespaceLog('mutation:response', {
4717
+ namespaces,
4718
+ status: response.status,
4719
+ durationMs: Math.round(performance.now() - startedAt),
4720
+ })
4721
+ if (!response.ok) {
4722
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4723
+ throw new Error(error.error || `HTTP ${response.status}`)
4724
+ }
4725
+ return response.json()
4726
+ } catch (error) {
4727
+ clearTimeout(timeoutId)
4728
+ debugNamespaceLog('mutation:error', {
4729
+ namespaces,
4730
+ durationMs: Math.round(performance.now() - startedAt),
4731
+ error: error instanceof Error ? error.message : String(error),
4732
+ })
4733
+ if (error instanceof Error && error.name === 'AbortError') {
4734
+ throw new Error(
4735
+ isRescope
4736
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
4737
+ : 'Namespace switch timed out. The cluster may be unreachable.',
4738
+ { cause: error },
4739
+ )
4740
+ }
4741
+ throw error
4742
+ }
4743
+ },
4744
+ onSuccess: (scope) => {
4745
+ debugNamespaceLog('mutation:success-before-scope-cache-write', {
4746
+ actives: scope.actives,
4747
+ mode: scope.mode,
4748
+ accessibleCount: scope.accessibleNamespaces.length,
4749
+ })
4750
+ if (scope.cacheScoped) {
4751
+ queryClient.removeQueries({
4752
+ predicate: (query) => query.queryKey[0] !== 'namespace-scope',
4753
+ })
4754
+ }
4755
+ queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
4756
+ if (scope.cacheScoped) {
4757
+ queryClient.invalidateQueries()
4758
+ }
4759
+ debugNamespaceLog('mutation:success-after-scope-cache-write')
4760
+ },
4761
+ onError: () => {
4762
+ // A failed switch can leave the server's stored pick out of sync
4763
+ // with the cached scope (network timeout after the server wrote;
4764
+ // partial mutation). Refetch so the displayed picker matches what
4765
+ // the server actually persisted instead of what we assumed.
4766
+ debugNamespaceLog('mutation:on-error-invalidate-scope')
4767
+ queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
4768
+ },
4769
+ })
4770
+ }
4771
+
2444
4772
  // ============================================================================
2445
4773
  // Image Filesystem Inspection
2446
4774
  // ============================================================================
@@ -2453,7 +4781,7 @@ export function useImageMetadata(
2453
4781
  namespace: string,
2454
4782
  podName: string,
2455
4783
  pullSecrets: string[],
2456
- enabled = true
4784
+ enabled = true,
2457
4785
  ) {
2458
4786
  const params = new URLSearchParams()
2459
4787
  params.set('image', image)
@@ -2476,7 +4804,7 @@ export function useImageFilesystem(
2476
4804
  namespace: string,
2477
4805
  podName: string,
2478
4806
  pullSecrets: string[],
2479
- enabled = true
4807
+ enabled = true,
2480
4808
  ) {
2481
4809
  const params = new URLSearchParams()
2482
4810
  params.set('image', image)
@@ -2489,9 +4817,7 @@ export function useImageFilesystem(
2489
4817
  return useQuery<ImageFilesystem>({
2490
4818
  queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
2491
4819
  // Use skipToken to completely prevent the query from running when disabled
2492
- queryFn: shouldFetch
2493
- ? () => fetchJSON(`/images/inspect?${params.toString()}`)
2494
- : skipToken,
4820
+ queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
2495
4821
  staleTime: 300000, // 5 minutes - image content doesn't change
2496
4822
  retry: false, // Don't retry on auth errors
2497
4823
  })
@@ -2515,6 +4841,44 @@ export interface WorkloadLogsResponse {
2515
4841
  timestamp: string
2516
4842
  content: string
2517
4843
  }[]
4844
+ emptyReason?: string
4845
+ emptyMessage?: string
4846
+ command?: string
4847
+ }
4848
+
4849
+ export interface WorkloadRun {
4850
+ kind: string
4851
+ namespace: string
4852
+ name: string
4853
+ phase: string
4854
+ active: boolean
4855
+ startedAt?: string
4856
+ finishedAt?: string
4857
+ scheduledAt?: string
4858
+ trigger?: 'manual' | 'schedule' | string
4859
+ message?: string
4860
+ succeeded?: number
4861
+ failed?: number
4862
+ running?: number
4863
+ desired?: number
4864
+ parallelism?: number
4865
+ progress?: string
4866
+ template?: string
4867
+ launcher?: {
4868
+ kind: string
4869
+ namespace?: string
4870
+ name: string
4871
+ group?: string
4872
+ }
4873
+ podTotal?: number
4874
+ podSucceeded?: number
4875
+ podFailed?: number
4876
+ podRunning?: number
4877
+ podPending?: number
4878
+ }
4879
+
4880
+ export interface WorkloadRunsResponse {
4881
+ runs: WorkloadRun[]
2518
4882
  }
2519
4883
 
2520
4884
  // Fetch pods for a workload
@@ -2527,6 +4891,31 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
2527
4891
  })
2528
4892
  }
2529
4893
 
4894
+ export function useWorkloadRuns(
4895
+ kind: string,
4896
+ namespace: string,
4897
+ name: string,
4898
+ enabled = true,
4899
+ options?: { refetchActive?: boolean; clusterScoped?: boolean },
4900
+ ) {
4901
+ const clusterScoped = options?.clusterScoped ?? false
4902
+ const ns = clusterScoped ? '_' : namespace
4903
+ const params = new URLSearchParams()
4904
+ if (clusterScoped) params.set('clusterScoped', 'true')
4905
+ const queryString = params.toString()
4906
+
4907
+ return useQuery<WorkloadRunsResponse>({
4908
+ queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
4909
+ queryFn: () =>
4910
+ fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
4911
+ enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
4912
+ staleTime: 10000,
4913
+ refetchInterval: options?.refetchActive
4914
+ ? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
4915
+ : false,
4916
+ })
4917
+ }
4918
+
2530
4919
  // Fetch logs for a workload (non-streaming)
2531
4920
  export function useWorkloadLogs(
2532
4921
  kind: string,
@@ -2536,7 +4925,7 @@ export function useWorkloadLogs(
2536
4925
  container?: string
2537
4926
  tailLines?: number
2538
4927
  sinceSeconds?: number
2539
- }
4928
+ },
2540
4929
  ) {
2541
4930
  const params = new URLSearchParams()
2542
4931
  if (options?.container) params.set('container', options.container)
@@ -2545,8 +4934,19 @@ export function useWorkloadLogs(
2545
4934
  const queryString = params.toString()
2546
4935
 
2547
4936
  return useQuery<WorkloadLogsResponse>({
2548
- queryKey: ['workload-logs', kind, namespace, name, options?.container, options?.tailLines, options?.sinceSeconds],
2549
- queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`),
4937
+ queryKey: [
4938
+ 'workload-logs',
4939
+ kind,
4940
+ namespace,
4941
+ name,
4942
+ options?.container,
4943
+ options?.tailLines,
4944
+ options?.sinceSeconds,
4945
+ ],
4946
+ queryFn: () =>
4947
+ fetchJSON(
4948
+ `/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
4949
+ ),
2550
4950
  enabled: Boolean(kind && namespace && name),
2551
4951
  staleTime: 5000,
2552
4952
  })
@@ -2561,7 +4961,7 @@ export function createWorkloadLogStream(
2561
4961
  container?: string
2562
4962
  tailLines?: number
2563
4963
  sinceSeconds?: number
2564
- }
4964
+ },
2565
4965
  ): EventSource {
2566
4966
  const params = new URLSearchParams()
2567
4967
  if (options?.container) params.set('container', options.container)
@@ -2569,9 +4969,12 @@ export function createWorkloadLogStream(
2569
4969
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
2570
4970
  const queryString = params.toString()
2571
4971
 
2572
- return new EventSource(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`, {
2573
- withCredentials: getCredentialsMode() === 'include',
2574
- })
4972
+ return new EventSource(
4973
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
4974
+ {
4975
+ withCredentials: getCredentialsMode() === 'include',
4976
+ },
4977
+ )
2575
4978
  }
2576
4979
 
2577
4980
  // ============================================================================
@@ -2603,6 +5006,59 @@ export interface DiagErrorEntry {
2603
5006
  level: string
2604
5007
  }
2605
5008
 
5009
+ export type DiagSyncPhase = 'not_started' | 'syncing_critical' | 'syncing_deferred' | 'complete'
5010
+
5011
+ export interface DiagInformerSyncStatus {
5012
+ kind: string
5013
+ key: string
5014
+ deferred: boolean
5015
+ synced: boolean
5016
+ syncedAt?: string
5017
+ items: number
5018
+ lastError?: string
5019
+ lastErrorAt?: string
5020
+ forbiddenSeen?: boolean
5021
+ }
5022
+
5023
+ export interface DiagCacheSyncStatus {
5024
+ phase: DiagSyncPhase
5025
+ syncStarted?: string
5026
+ elapsedSec: number
5027
+ criticalTotal: number
5028
+ criticalSynced: number
5029
+ deferredTotal: number
5030
+ deferredSynced: number
5031
+ informers: DiagInformerSyncStatus[]
5032
+ pendingCritical?: string[]
5033
+ pendingDeferred?: string[]
5034
+ promotedKinds?: string[]
5035
+ }
5036
+
5037
+ export interface DiagSampleWindow {
5038
+ count: number
5039
+ last: number
5040
+ min: number
5041
+ p50: number
5042
+ p95: number
5043
+ p99: number
5044
+ max: number
5045
+ }
5046
+
5047
+ export interface DiagPerfSnapshot {
5048
+ topology: {
5049
+ totalBuilds: number
5050
+ durationUs: DiagSampleWindow
5051
+ nodeCount: DiagSampleWindow
5052
+ edgeCount: DiagSampleWindow
5053
+ payloadBytes: DiagSampleWindow
5054
+ estimatedNodes: DiagSampleWindow
5055
+ }
5056
+ sse: {
5057
+ totalBroadcasts: number
5058
+ totalDrops: number
5059
+ }
5060
+ }
5061
+
2606
5062
  export interface DiagnosticsSnapshot {
2607
5063
  timestamp: string
2608
5064
  radarVersion: string
@@ -2666,6 +5122,7 @@ export interface DiagnosticsSnapshot {
2666
5122
  typedCount: number
2667
5123
  dynamicCount: number
2668
5124
  watchedCRDs: string[]
5125
+ syncStatus?: DiagCacheSyncStatus
2669
5126
  }
2670
5127
  prometheus?: {
2671
5128
  connected: boolean
@@ -2696,6 +5153,7 @@ export interface DiagnosticsSnapshot {
2696
5153
  sse?: {
2697
5154
  connectedClients: number
2698
5155
  }
5156
+ perf?: DiagPerfSnapshot
2699
5157
  runtime?: {
2700
5158
  heapMB: number
2701
5159
  heapObjectsK: number
@@ -2711,6 +5169,7 @@ export interface DiagnosticsSnapshot {
2711
5169
  debugEvents: boolean
2712
5170
  mcpEnabled: boolean
2713
5171
  hasPrometheusURL: boolean
5172
+ hasPrometheusHeaders: boolean
2714
5173
  }
2715
5174
  recentErrors?: DiagErrorEntry[]
2716
5175
  totalErrorsRecorded?: number