@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
package/src/api/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
- import type { AppRow } from '@skyhook-io/k8s-ui'
2
+ import type { AppHistory, AppRow, ArgoSyncOpts } from '@skyhook-io/k8s-ui'
3
3
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
4
4
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
5
5
  import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
@@ -31,6 +31,9 @@ import type {
31
31
  ArtifactHubChartDetail,
32
32
  GitOpsResourceTree,
33
33
  GitOpsInsight,
34
+ GitOpsInsightRef,
35
+ GitOpsResourceDiff,
36
+ ArgoRevisionMetadata,
34
37
  } from '../types'
35
38
  import type { GitOpsOperationResponse } from '../types/gitops'
36
39
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
@@ -42,6 +45,9 @@ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
42
45
  const AUDIT_REFRESH_INTERVAL_MS = 60_000
43
46
  const ISSUES_REFRESH_INTERVAL_MS = 30_000
44
47
  const COST_REFRESH_INTERVAL_MS = 60_000
48
+ const COST_DISCOVERY_RETRY_INTERVAL_MS = 5_000
49
+ export const COST_DISCOVERY_GRACE_MS = 30_000
50
+ const COST_TREND_REFRESH_INTERVAL_MS = 120_000
45
51
  const CHANGES_REFRESH_INTERVAL_MS = 60_000
46
52
  const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
47
53
 
@@ -54,12 +60,23 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
54
60
  for (const [k, v] of Object.entries(getAuthHeaders())) {
55
61
  if (!headers.has(k)) headers.set(k, v)
56
62
  }
57
- return fetch(input, { credentials: getCredentialsMode(), ...init, headers }).then(async response => {
63
+ return fetch(input, {
64
+ credentials: getCredentialsMode(),
65
+ ...init,
66
+ headers,
67
+ }).then(async (response) => {
58
68
  const authPrefix = `${getBasename()}/auth`
59
69
  if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
60
70
  // Save current location so user returns to where they were after re-auth.
61
71
  // Editor draft is auto-saved by EditableYamlView via sessionStorage.
62
- try { sessionStorage.setItem('radar_return_path', window.location.pathname + window.location.search) } catch { /* best-effort */ }
72
+ try {
73
+ sessionStorage.setItem(
74
+ 'radar_return_path',
75
+ window.location.pathname + window.location.search,
76
+ )
77
+ } catch {
78
+ /* best-effort */
79
+ }
63
80
 
64
81
  let authMode: string | undefined
65
82
  try {
@@ -78,7 +95,11 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
78
95
  const lastReload = sessionStorage.getItem('radar_proxy_reload')
79
96
  const now = Date.now()
80
97
  if (!lastReload || now - parseInt(lastReload) > 5000) {
81
- try { sessionStorage.setItem('radar_proxy_reload', String(now)) } catch { /* best-effort */ }
98
+ try {
99
+ sessionStorage.setItem('radar_proxy_reload', String(now))
100
+ } catch {
101
+ /* best-effort */
102
+ }
82
103
  window.location.reload()
83
104
  }
84
105
  }
@@ -107,11 +128,12 @@ const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
107
128
 
108
129
  function mentionsMetricsAPIGroup(message: string): boolean {
109
130
  const tokens = message.split(/[^a-z0-9]+/).filter(Boolean)
110
- return tokens.some((token, index) => (
111
- token === METRICS_API_GROUP_TOKENS[0] &&
112
- tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
113
- tokens[index + 2] === METRICS_API_GROUP_TOKENS[2]
114
- ))
131
+ return tokens.some(
132
+ (token, index) =>
133
+ token === METRICS_API_GROUP_TOKENS[0] &&
134
+ tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
135
+ tokens[index + 2] === METRICS_API_GROUP_TOKENS[2],
136
+ )
115
137
  }
116
138
 
117
139
  function hasMetricsUnavailablePhrase(message: string): boolean {
@@ -134,18 +156,18 @@ export function isMetricsUnavailableError(error: unknown): boolean {
134
156
  return [error.message, error.data?.error].some((message) => {
135
157
  if (typeof message !== 'string') return false
136
158
  const normalized = message.toLowerCase()
137
- const hasMetricsSignal = (
159
+ const hasMetricsSignal =
138
160
  normalized.includes('metrics-server') ||
139
161
  mentionsMetricsAPIGroup(normalized) ||
140
162
  normalized.includes('pod metrics') ||
141
163
  normalized.includes('node metrics')
142
- )
143
164
  return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
144
165
  })
145
166
  }
146
167
 
147
- export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
148
- const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
168
+ export async function fetchJSON<T>(path: string, init?: RequestInit | AbortSignal): Promise<T> {
169
+ const requestInit = init instanceof AbortSignal ? { signal: init } : init
170
+ const response = await apiFetch(`${getApiBase()}${path}`, requestInit)
149
171
  if (!response.ok) {
150
172
  const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
151
173
  throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
@@ -209,7 +231,13 @@ export interface MetricSummary {
209
231
  }
210
232
 
211
233
  export interface DashboardResourceCounts {
212
- pods: { total: number; running: number; pending: number; failed: number; succeeded: number }
234
+ pods: {
235
+ total: number
236
+ running: number
237
+ pending: number
238
+ failed: number
239
+ succeeded: number
240
+ }
213
241
  deployments: { total: number; available: number; unavailable: number }
214
242
  statefulSets: WorkloadCount
215
243
  daemonSets: WorkloadCount
@@ -292,7 +320,15 @@ export interface DashboardCRDCount {
292
320
  }
293
321
 
294
322
  // Re-export shared types from k8s-ui — single source of truth
295
- import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue, IssueRecentChange } from '@skyhook-io/k8s-ui'
323
+ import type {
324
+ AuditCardData,
325
+ AuditFinding,
326
+ ResourceGroup,
327
+ CheckMeta,
328
+ Check,
329
+ Issue,
330
+ IssueRecentChange,
331
+ } from '@skyhook-io/k8s-ui'
296
332
  export type DashboardAudit = AuditCardData
297
333
  export type { AuditFinding, ResourceGroup, CheckMeta, Check }
298
334
 
@@ -361,7 +397,11 @@ export interface DashboardResponse {
361
397
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
362
398
  audit: DashboardAudit | null
363
399
  gitopsControllers: DashboardGitOpsControllers | null
364
- nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
400
+ nodeVersionSkew: {
401
+ versions: Record<string, string[]>
402
+ minVersion: string
403
+ maxVersion: string
404
+ } | null
365
405
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
366
406
  partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
367
407
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
@@ -371,11 +411,12 @@ export interface DashboardCRDsResponse {
371
411
  topCRDs: DashboardCRDCount[]
372
412
  }
373
413
 
374
- export function useDashboard(namespaces: string[] = []) {
414
+ export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
375
415
  const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
376
416
  return useQuery<DashboardResponse>({
377
417
  queryKey: ['dashboard', namespaces],
378
418
  queryFn: () => fetchJSON(`/dashboard${params}`),
419
+ enabled: options?.enabled ?? true,
379
420
  staleTime: 15000, // 15 seconds
380
421
  refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
381
422
  })
@@ -436,7 +477,13 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
436
477
  // the "Operational Issues" section in the resource detail. Cluster-scoped
437
478
  // resources pass "_" for namespace; namespaced ones also scope the scan via
438
479
  // ?namespaces= for a cheap, bounded Compose.
439
- export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
480
+ export function useResourceIssues(
481
+ kind: string,
482
+ group: string | undefined,
483
+ namespace: string,
484
+ name: string,
485
+ enabled = true,
486
+ ) {
440
487
  const clusterScoped = !namespace
441
488
  const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
442
489
  const params = new URLSearchParams()
@@ -549,7 +596,8 @@ export interface OpenCostNamespaceCost {
549
596
  idleCost?: number
550
597
  }
551
598
 
552
- export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error'
599
+ export type CostUnavailableReason =
600
+ 'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
553
601
 
554
602
  export interface OpenCostSummary {
555
603
  available: boolean
@@ -563,11 +611,41 @@ export interface OpenCostSummary {
563
611
  namespaces?: OpenCostNamespaceCost[]
564
612
  }
565
613
 
614
+ const noPrometheusFirstSeenAt = new Map<string, number>()
615
+
616
+ function costRefetchInterval(
617
+ defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
618
+ contextName?: string,
619
+ ) {
620
+ return (query: {
621
+ queryHash?: string
622
+ queryKey?: unknown
623
+ state: {
624
+ data?: { available?: boolean; reason?: CostUnavailableReason }
625
+ dataUpdatedAt?: number
626
+ }
627
+ }) => {
628
+ const data = query.state.data
629
+ const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
630
+ if (data?.available === false && data.reason === 'no_prometheus') {
631
+ const now = Date.now()
632
+ const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
633
+ noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
634
+ return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
635
+ ? COST_DISCOVERY_RETRY_INTERVAL_MS
636
+ : defaultInterval
637
+ }
638
+ noPrometheusFirstSeenAt.delete(queryID)
639
+ return defaultInterval
640
+ }
641
+ }
642
+
566
643
  export function useOpenCostSummary() {
644
+ const clusterInfo = useClusterInfo()
567
645
  return useQuery<OpenCostSummary>({
568
646
  queryKey: ['opencost-summary'],
569
647
  queryFn: () => fetchJSON('/opencost/summary'),
570
- refetchInterval: COST_REFRESH_INTERVAL_MS,
648
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
571
649
  staleTime: 30000,
572
650
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
573
651
  })
@@ -583,6 +661,10 @@ export interface OpenCostWorkloadCost {
583
661
  replicas: number
584
662
  cpuUsageCost?: number
585
663
  memoryUsageCost?: number
664
+ cpuUsageAvailable: boolean
665
+ memoryUsageAvailable: boolean
666
+ cpuAllocationUse: number
667
+ memoryAllocationUse: number
586
668
  efficiency?: number
587
669
  idleCost?: number
588
670
  }
@@ -595,11 +677,41 @@ export interface OpenCostWorkloadResponse {
595
677
  }
596
678
 
597
679
  export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
680
+ const clusterInfo = useClusterInfo()
598
681
  return useQuery<OpenCostWorkloadResponse>({
599
682
  queryKey: ['opencost-workloads', namespace],
600
683
  queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
601
684
  enabled: (options?.enabled ?? true) && Boolean(namespace),
685
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
686
+ staleTime: 30000,
687
+ })
688
+ }
689
+
690
+ export interface OpenCostWorkloadDetailResponse {
691
+ available: boolean
692
+ reason?: CostUnavailableReason
693
+ namespace: string
694
+ kind: string
695
+ name: string
696
+ current?: OpenCostWorkloadCost
697
+ }
698
+
699
+ export function useOpenCostWorkload(
700
+ kind: string,
701
+ namespace: string,
702
+ name: string,
703
+ options?: { enabled?: boolean },
704
+ ) {
705
+ const clusterInfo = useClusterInfo()
706
+ return useQuery<OpenCostWorkloadDetailResponse>({
707
+ queryKey: ['opencost-workload', kind, namespace, name],
708
+ queryFn: () =>
709
+ fetchJSON(
710
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
711
+ ),
712
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
602
713
  staleTime: 30000,
714
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
603
715
  })
604
716
  }
605
717
 
@@ -624,18 +736,175 @@ export interface OpenCostTrendResponse {
624
736
  }
625
737
 
626
738
  export function useOpenCostTrend(range_: CostTimeRange = '24h') {
739
+ const clusterInfo = useClusterInfo()
627
740
  return useQuery<OpenCostTrendResponse>({
628
741
  queryKey: ['opencost-trend', range_],
629
742
  queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
630
743
  staleTime: 60000,
631
- refetchInterval: 120000, // Refresh every 2 minutes
744
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
632
745
  placeholderData: (prev) => prev,
633
746
  })
634
747
  }
635
748
 
749
+ export interface OpenCostWorkloadTrendResponse {
750
+ available: boolean
751
+ reason?: CostUnavailableReason
752
+ namespace: string
753
+ kind: string
754
+ name: string
755
+ range: string
756
+ windowTotalCost?: number
757
+ dataPoints?: OpenCostTrendDataPoint[]
758
+ }
759
+
760
+ export function useOpenCostWorkloadTrend(
761
+ kind: string,
762
+ namespace: string,
763
+ name: string,
764
+ range_: CostTimeRange = '24h',
765
+ options?: { enabled?: boolean },
766
+ ) {
767
+ const clusterInfo = useClusterInfo()
768
+ return useQuery<OpenCostWorkloadTrendResponse>({
769
+ queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
770
+ queryFn: () =>
771
+ fetchJSON(
772
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
773
+ ),
774
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
775
+ staleTime: 60000,
776
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
777
+ })
778
+ }
779
+
780
+ export interface OpenCostApplicationWorkloadRef {
781
+ kind: string
782
+ namespace: string
783
+ name: string
784
+ }
785
+
786
+ export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
787
+ reason: CostUnavailableReason
788
+ scaledToZero?: boolean
789
+ }
790
+
791
+ export interface OpenCostApplicationCostCoverage {
792
+ total: number
793
+ included: number
794
+ unavailable?: OpenCostApplicationWorkloadStatus[]
795
+ unsupported?: OpenCostApplicationWorkloadRef[]
796
+ }
797
+
798
+ export interface OpenCostApplicationCostTotals {
799
+ hourlyCost: number
800
+ cpuCost: number
801
+ memoryCost: number
802
+ replicas: number
803
+ cpuUsageCost?: number
804
+ memoryUsageCost?: number
805
+ cpuUsageAvailable: boolean
806
+ memoryUsageAvailable: boolean
807
+ cpuAllocationUse: number
808
+ memoryAllocationUse: number
809
+ }
810
+
811
+ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
812
+ available: boolean
813
+ reason?: CostUnavailableReason
814
+ scaledToZero?: boolean
815
+ current?: OpenCostWorkloadCost
816
+ }
817
+
818
+ export interface OpenCostApplicationCostResponse {
819
+ available: boolean
820
+ reason?: CostUnavailableReason
821
+ partial?: boolean
822
+ totals: OpenCostApplicationCostTotals
823
+ coverage: OpenCostApplicationCostCoverage
824
+ workloads?: OpenCostApplicationWorkloadCost[]
825
+ }
826
+
827
+ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
828
+ windowTotalCost?: number
829
+ dataPoints?: OpenCostTrendDataPoint[]
830
+ }
831
+
832
+ export interface OpenCostApplicationCostTrendResponse {
833
+ available: boolean
834
+ reason?: CostUnavailableReason
835
+ range: string
836
+ partial?: boolean
837
+ windowTotalCost?: number
838
+ dataPoints?: OpenCostTrendDataPoint[]
839
+ series?: OpenCostApplicationCostTrendSeries[]
840
+ coverage: OpenCostApplicationCostCoverage
841
+ }
842
+
843
+ function stableOpenCostWorkloadRefs(
844
+ workloads: OpenCostApplicationWorkloadRef[],
845
+ ): OpenCostApplicationWorkloadRef[] {
846
+ const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
847
+ for (const workload of workloads) {
848
+ if (!workload.kind || !workload.namespace || !workload.name) continue
849
+ const ref = {
850
+ kind: workload.kind,
851
+ namespace: workload.namespace,
852
+ name: workload.name,
853
+ }
854
+ byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
855
+ }
856
+ return [...byKey.values()].sort((a, b) =>
857
+ `${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
858
+ )
859
+ }
860
+
861
+ export function useOpenCostApplicationCost(
862
+ workloads: OpenCostApplicationWorkloadRef[],
863
+ options?: { enabled?: boolean },
864
+ ) {
865
+ const clusterInfo = useClusterInfo()
866
+ const refs = stableOpenCostWorkloadRefs(workloads)
867
+ return useQuery<OpenCostApplicationCostResponse>({
868
+ queryKey: ['opencost-application', refs],
869
+ queryFn: ({ signal }) =>
870
+ fetchJSON('/opencost/application', {
871
+ method: 'POST',
872
+ headers: { 'Content-Type': 'application/json' },
873
+ body: JSON.stringify({ workloads: refs }),
874
+ signal,
875
+ }),
876
+ enabled: (options?.enabled ?? true) && refs.length > 0,
877
+ staleTime: 30000,
878
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
879
+ })
880
+ }
881
+
882
+ export function useOpenCostApplicationCostTrend(
883
+ workloads: OpenCostApplicationWorkloadRef[],
884
+ range_: CostTimeRange = '24h',
885
+ options?: { enabled?: boolean },
886
+ ) {
887
+ const clusterInfo = useClusterInfo()
888
+ const refs = stableOpenCostWorkloadRefs(workloads)
889
+ return useQuery<OpenCostApplicationCostTrendResponse>({
890
+ queryKey: ['opencost-application-trend', refs, range_],
891
+ queryFn: ({ signal }) =>
892
+ fetchJSON('/opencost/application/trend', {
893
+ method: 'POST',
894
+ headers: { 'Content-Type': 'application/json' },
895
+ body: JSON.stringify({ workloads: refs, range: range_ }),
896
+ signal,
897
+ }),
898
+ enabled: (options?.enabled ?? true) && refs.length > 0,
899
+ staleTime: 60000,
900
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
901
+ })
902
+ }
903
+
636
904
  // Node cost breakdown
637
905
  export interface OpenCostNodeCost {
638
906
  name: string
907
+ providerID?: string
639
908
  instanceType?: string
640
909
  region?: string
641
910
  hourlyCost: number
@@ -650,11 +919,12 @@ export interface OpenCostNodeResponse {
650
919
  }
651
920
 
652
921
  export function useOpenCostNodes() {
922
+ const clusterInfo = useClusterInfo()
653
923
  return useQuery<OpenCostNodeResponse>({
654
924
  queryKey: ['opencost-nodes'],
655
925
  queryFn: () => fetchJSON('/opencost/nodes'),
656
926
  staleTime: 60000,
657
- refetchInterval: 120000,
927
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
658
928
  placeholderData: (prev) => prev,
659
929
  })
660
930
  }
@@ -815,7 +1085,15 @@ const SEARCH_MIN_QUERY = 2
815
1085
  // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
816
1086
  // overlapping scans on a new query. keepPreviousData avoids flicker while the
817
1087
  // next query resolves.
818
- export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
1088
+ export function useSearch(
1089
+ query: string,
1090
+ opts?: {
1091
+ limit?: number
1092
+ context?: 'summary' | 'none'
1093
+ enabled?: boolean
1094
+ globalNs?: boolean
1095
+ },
1096
+ ) {
819
1097
  const trimmed = query.trim()
820
1098
  const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
821
1099
  const limit = opts?.limit ?? 20
@@ -827,7 +1105,10 @@ export function useSearch(query: string, opts?: { limit?: number; context?: 'sum
827
1105
  return useQuery<SearchResult>({
828
1106
  queryKey: ['search', trimmed, limit, context, globalNs],
829
1107
  queryFn: ({ signal }) =>
830
- fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
1108
+ fetchJSON<SearchResult>(
1109
+ `/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
1110
+ signal,
1111
+ ),
831
1112
  enabled,
832
1113
  staleTime: 2000,
833
1114
  placeholderData: (prev) => prev, // keepPreviousData
@@ -862,7 +1143,10 @@ export function useCapabilities() {
862
1143
 
863
1144
  // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
864
1145
  // have these permissions in specific namespaces.
865
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) {
1146
+ export function useNamespaceCapabilities(
1147
+ namespace: string | undefined,
1148
+ globalCaps: Capabilities | undefined,
1149
+ ) {
866
1150
  const needsCheck = namespace && globalCaps
867
1151
  return useQuery<Capabilities>({
868
1152
  queryKey: ['capabilities', namespace],
@@ -901,7 +1185,11 @@ export function useAuthMe() {
901
1185
  // CloudRole.AtLeast — the frontend must agree with the backend on what
902
1186
  // "member-or-higher" means; otherwise we'd hide a button the
903
1187
  // backend would happily honor (or vice versa).
904
- const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
1188
+ const CLOUD_ROLE_RANK: Record<string, number> = {
1189
+ viewer: 1,
1190
+ member: 2,
1191
+ owner: 3,
1192
+ }
905
1193
 
906
1194
  /**
907
1195
  * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
@@ -982,34 +1270,74 @@ export function useNamespaces() {
982
1270
  }
983
1271
 
984
1272
  // Topology (for manual refresh)
985
- export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean }) {
1273
+ export function useTopology(
1274
+ namespaces: string[],
1275
+ viewMode: string = 'resources',
1276
+ options?: {
1277
+ enabled?: boolean
1278
+ includeReplicaSets?: boolean
1279
+ refetchInterval?: number | false
1280
+ },
1281
+ ) {
986
1282
  const params = new URLSearchParams()
987
1283
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
988
1284
  if (viewMode) params.set('view', viewMode)
1285
+ if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
989
1286
  const queryString = params.toString()
990
1287
 
991
1288
  return useQuery<Topology>({
992
- queryKey: ['topology', namespaces, viewMode],
1289
+ queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
993
1290
  queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
994
1291
  staleTime: 5000, // 5 seconds
995
1292
  enabled: options?.enabled !== false,
1293
+ refetchInterval: options?.refetchInterval,
996
1294
  })
997
1295
  }
998
1296
 
999
- export function useApplications(namespaces: string[]) {
1297
+ export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
1000
1298
  const params = new URLSearchParams()
1001
1299
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1002
1300
  const queryString = params.toString()
1003
1301
 
1302
+ const enabled = options?.enabled !== false
1004
1303
  return useQuery<{ applications: AppRow[] }>({
1005
1304
  queryKey: ['applications', namespaces],
1006
1305
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
1007
1306
  staleTime: 30_000,
1307
+ // Only poll while a consumer needs the index; gated off it must not keep the
1308
+ // background refetch alive.
1309
+ enabled,
1310
+ refetchInterval: enabled ? APPLICATIONS_REFRESH_INTERVAL_MS : false,
1311
+ })
1312
+ }
1313
+
1314
+ export function useApplicationHistory(
1315
+ appKey: string | undefined,
1316
+ namespaces: string[],
1317
+ options?: { enabled?: boolean },
1318
+ ) {
1319
+ const params = new URLSearchParams()
1320
+ if (appKey) params.set('app', appKey)
1321
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1322
+ const queryString = params.toString()
1323
+
1324
+ return useQuery<AppHistory>({
1325
+ queryKey: ['application-history', appKey, namespaces],
1326
+ queryFn: appKey ? () => fetchJSON(`/applications/history?${queryString}`) : skipToken,
1327
+ enabled: Boolean(appKey) && (options?.enabled ?? true),
1328
+ staleTime: 15_000,
1008
1329
  refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
1009
1330
  })
1010
1331
  }
1011
1332
 
1012
- export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
1333
+ export function useGitOpsTree(
1334
+ kind: string,
1335
+ namespace: string,
1336
+ name: string,
1337
+ group?: string,
1338
+ namespaces: string[] = [],
1339
+ options?: { enabled?: boolean },
1340
+ ) {
1013
1341
  const ns = namespace || '_'
1014
1342
  const params = new URLSearchParams()
1015
1343
  if (group) params.set('group', group)
@@ -1018,19 +1346,26 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1018
1346
 
1019
1347
  return useQuery<GitOpsResourceTree>({
1020
1348
  queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
1021
- queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1022
- enabled: Boolean(kind && name),
1349
+ queryFn: () =>
1350
+ fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1351
+ enabled: Boolean(kind && name) && (options?.enabled ?? true),
1023
1352
  staleTime: 5000,
1024
1353
  })
1025
1354
  }
1026
1355
 
1027
1356
  // Poll fast (2s) while a sync/rollback is in flight so the user sees the
1028
1357
  // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
1029
- // operationState.phase from "Running" -> Succeeded/Failed when done, so this
1358
+ // operationState.phase from Running/Terminating to a terminal phase, so this
1030
1359
  // auto-quiesces on completion.
1031
1360
  const INSIGHTS_RUNNING_POLL_MS = 2000
1032
1361
 
1033
- export function useGitOpsInsights(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
1362
+ export function useGitOpsInsights(
1363
+ kind: string,
1364
+ namespace: string,
1365
+ name: string,
1366
+ group?: string,
1367
+ namespaces: string[] = [],
1368
+ ) {
1034
1369
  const ns = namespace || '_'
1035
1370
  const params = new URLSearchParams()
1036
1371
  if (group) params.set('group', group)
@@ -1039,19 +1374,71 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
1039
1374
 
1040
1375
  return useQuery<GitOpsInsight>({
1041
1376
  queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
1042
- queryFn: () => fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1377
+ queryFn: () =>
1378
+ fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1043
1379
  enabled: Boolean(kind && name),
1044
1380
  staleTime: 5000,
1045
1381
  refetchInterval: (query) => {
1046
1382
  const phase = query.state.data?.summary?.operationPhase
1047
- return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
1383
+ return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
1048
1384
  },
1049
1385
  })
1050
1386
  }
1051
1387
 
1388
+ // Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
1389
+ // ns/name identify the Application; the ref identifies the managed resource.
1390
+ // Fetched on demand — the caller mounts this only when the user opens "Full
1391
+ // diff", so it's enabled whenever the ref is resolvable. Errors surface via
1392
+ // fetchJSON's ApiError (server {"error"} string as .message).
1393
+ export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
1394
+ const ns = appNamespace || '_'
1395
+ const params = new URLSearchParams()
1396
+ if (ref.group) params.set('group', ref.group)
1397
+ params.set('kind', ref.kind)
1398
+ if (ref.namespace) params.set('resourceNamespace', ref.namespace)
1399
+ params.set('resourceName', ref.name)
1400
+
1401
+ return useQuery<GitOpsResourceDiff>({
1402
+ queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
1403
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
1404
+ enabled: Boolean(appName && ref.kind && ref.name),
1405
+ staleTime: 15_000,
1406
+ })
1407
+ }
1408
+
1409
+ // Git commit metadata for one deployed revision of an Argo CD Application.
1410
+ // Enabled only when a revision is known and the caller passes `enabled` (gated
1411
+ // on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
1412
+ // metadata is effectively immutable.
1413
+ export function useArgoRevisionMetadata(
1414
+ appNamespace: string,
1415
+ appName: string,
1416
+ revision: string | undefined,
1417
+ opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
1418
+ ) {
1419
+ const ns = appNamespace || '_'
1420
+ const params = new URLSearchParams()
1421
+ if (revision) params.set('revision', revision)
1422
+ if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
1423
+ if (opts?.project) params.set('project', opts.project)
1424
+
1425
+ return useQuery<ArgoRevisionMetadata>({
1426
+ queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
1427
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
1428
+ enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
1429
+ staleTime: 5 * 60_000,
1430
+ })
1431
+ }
1432
+
1052
1433
  // Generic resource fetching - returns resource with relationships
1053
1434
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
1054
- export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
1435
+ export function useResource<T>(
1436
+ kind: string,
1437
+ namespace: string,
1438
+ name: string,
1439
+ group?: string,
1440
+ options?: { enabled?: boolean; refetchInterval?: number | false },
1441
+ ) {
1055
1442
  // For cluster-scoped resources, use '_' as namespace placeholder
1056
1443
  const ns = namespace || '_'
1057
1444
  const params = new URLSearchParams()
@@ -1060,8 +1447,10 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1060
1447
 
1061
1448
  const query = useQuery<ResourceWithRelationships<T>>({
1062
1449
  queryKey: ['resource', kind, namespace, name, group],
1063
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1064
- enabled: Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1450
+ queryFn: () =>
1451
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1452
+ enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1453
+ refetchInterval: options?.refetchInterval,
1065
1454
  })
1066
1455
 
1067
1456
  // Extract resource and relationships from the response
@@ -1075,7 +1464,12 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1075
1464
  }
1076
1465
 
1077
1466
  // Hook that returns full response with relationships explicitly
1078
- export function useResourceWithRelationships<T>(kind: string, namespace: string, name: string, group?: string) {
1467
+ export function useResourceWithRelationships<T>(
1468
+ kind: string,
1469
+ namespace: string,
1470
+ name: string,
1471
+ group?: string,
1472
+ ) {
1079
1473
  const ns = namespace || '_'
1080
1474
  const params = new URLSearchParams()
1081
1475
  if (group) params.set('group', group)
@@ -1083,7 +1477,8 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
1083
1477
 
1084
1478
  return useQuery<ResourceWithRelationships<T>>({
1085
1479
  queryKey: ['resource', kind, namespace, name, group],
1086
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1480
+ queryFn: () =>
1481
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1087
1482
  enabled: Boolean(kind && name),
1088
1483
  })
1089
1484
  }
@@ -1112,7 +1507,10 @@ export function useResources<T>(
1112
1507
  // Timeline changes (unified view of changes + K8s events)
1113
1508
  export interface UseChangesOptions {
1114
1509
  namespaces?: string[]
1115
- kind?: string
1510
+ // Kind filter. The server narrows to a single kind (tighter result caps), so
1511
+ // exactly one selected kind is pushed server-side; a multi-kind selection
1512
+ // fetches unfiltered and is narrowed client-side by the caller.
1513
+ kinds?: string[]
1116
1514
  timeRange?: TimeRange
1117
1515
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
1118
1516
  includeK8sEvents?: boolean
@@ -1120,6 +1518,133 @@ export interface UseChangesOptions {
1120
1518
  includeDeleted?: boolean
1121
1519
  limit?: number
1122
1520
  enabled?: boolean
1521
+ // Cursor-aware refetches: after the first full load, refetches ask the
1522
+ // server only for events that arrived after the highest seq already cached
1523
+ // and merge them in, instead of re-pulling the whole ring. Intended for the
1524
+ // timeline's full-ring (10k) query, where every SSE nudge would otherwise
1525
+ // re-transfer megabytes for a handful of new events.
1526
+ deltaSync?: boolean
1527
+ }
1528
+
1529
+ // The store epoch guards delta cursors: a restarted store restarts seq
1530
+ // numbering, so an epoch change forces a full resync. A periodic full resync
1531
+ // also runs as anti-entropy for anything a dropped SSE connection or a
1532
+ // server-side eviction could leave behind in the cached copy.
1533
+ const FULL_RESYNC_MS = 5 * 60_000
1534
+
1535
+ export interface ChangesDeltaMeta {
1536
+ epoch: string
1537
+ lastFullMs: number
1538
+ // Highest seq observed in ANY response for this query — not just what
1539
+ // survived the cap. A delta event older than everything cached gets capped
1540
+ // out of the merge; deriving the cursor from cached rows alone would then
1541
+ // re-request that same event on every refetch until the next full resync.
1542
+ highWaterSeq: number
1543
+ }
1544
+ const changesDeltaMeta = new Map<string, ChangesDeltaMeta>()
1545
+
1546
+ // The since_seq cursor for the next refetch, or 0 for a full fetch. Delta
1547
+ // requires an epoch-stamped prior full load, a cached page to merge into, and
1548
+ // the anti-entropy full resync not being due.
1549
+ export function deltaFetchCursor(
1550
+ meta: ChangesDeltaMeta | undefined,
1551
+ cached: TimelineEvent[] | undefined,
1552
+ nowMs: number,
1553
+ ): number {
1554
+ if (!meta?.epoch || !cached) return 0
1555
+ if (nowMs - meta.lastFullMs > FULL_RESYNC_MS) return 0
1556
+ return Math.max(meta.highWaterSeq, maxEventSeq(cached))
1557
+ }
1558
+
1559
+ async function fetchChangesPage(
1560
+ path: string,
1561
+ signal?: AbortSignal,
1562
+ ): Promise<{ events: TimelineEvent[]; epoch: string; maxSeq: number }> {
1563
+ const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
1564
+ if (!response.ok) {
1565
+ const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
1566
+ throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
1567
+ }
1568
+ const events = (await response.json()) as TimelineEvent[]
1569
+ // maxSeq is the page's frontier computed before the server's
1570
+ // cluster-scoped-RBAC filter — rows dropped THERE still advance the cursor.
1571
+ // (Rows dropped by content filters inside the store query do not; see the
1572
+ // known limitation on the server's handleChanges.)
1573
+ const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
1574
+ return {
1575
+ events,
1576
+ epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
1577
+ maxSeq,
1578
+ }
1579
+ }
1580
+
1581
+ // Highest store-assigned arrival number in the cached page — the delta cursor.
1582
+ export function maxEventSeq(events: TimelineEvent[]): number {
1583
+ let max = 0
1584
+ for (const event of events) {
1585
+ if (event.seq && event.seq > max) max = event.seq
1586
+ }
1587
+ return max
1588
+ }
1589
+
1590
+ // Merge a delta page into the cached page: a delta row replaces its cached id
1591
+ // (a K8s Event count bump re-arrives under the same id), new ids are added,
1592
+ // order stays newest-first (arrival number breaks timestamp ties), and the
1593
+ // result is capped to the query's limit by dropping the oldest.
1594
+ export function mergeDeltaEvents(
1595
+ prev: TimelineEvent[],
1596
+ delta: TimelineEvent[],
1597
+ cap: number,
1598
+ ): TimelineEvent[] {
1599
+ if (delta.length === 0) return prev
1600
+ const replaced = new Set(delta.map((event) => event.id))
1601
+ const merged = [...delta, ...prev.filter((event) => !replaced.has(event.id))]
1602
+ merged.sort((a, b) => {
1603
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1604
+ if (byTime !== 0) return byTime
1605
+ return (b.seq ?? 0) - (a.seq ?? 0)
1606
+ })
1607
+ return merged.length > cap ? merged.slice(0, cap) : merged
1608
+ }
1609
+
1610
+ // Delta-sync orchestration for useChanges, extracted so the
1611
+ // full-fetch → delta-poll → epoch-mismatch-resync contract is exercisable
1612
+ // without a React render. State is passed in explicitly — the cached page and
1613
+ // the shared meta store — rather than closed over from module scope, so a
1614
+ // caller (and a test) drives it with fresh state each invocation.
1615
+ export async function runDeltaSyncFetch(deps: {
1616
+ path: string
1617
+ queryString: string
1618
+ limit: number
1619
+ metaKey: string
1620
+ cached: TimelineEvent[] | undefined
1621
+ metaStore: Map<string, ChangesDeltaMeta>
1622
+ now: number
1623
+ signal?: AbortSignal
1624
+ }): Promise<TimelineEvent[]> {
1625
+ const { path, queryString, limit, metaKey, cached, metaStore, now, signal } = deps
1626
+ const meta = metaStore.get(metaKey)
1627
+ const cursor = deltaFetchCursor(meta, cached, now)
1628
+ if (cursor > 0) {
1629
+ const delta = await fetchChangesPage(
1630
+ `${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
1631
+ signal,
1632
+ )
1633
+ if (delta.epoch && delta.epoch === meta!.epoch) {
1634
+ meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
1635
+ // Returning the cached reference on an empty delta skips re-renders.
1636
+ return delta.events.length ? mergeDeltaEvents(cached!, delta.events, limit) : cached!
1637
+ }
1638
+ // Epoch changed — the store restarted and seq numbering reset, so the
1639
+ // cursor is meaningless. Fall through to a full resync.
1640
+ }
1641
+ const full = await fetchChangesPage(path, signal)
1642
+ metaStore.set(metaKey, {
1643
+ epoch: full.epoch,
1644
+ lastFullMs: now,
1645
+ highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
1646
+ })
1647
+ return full.events
1123
1648
  }
1124
1649
 
1125
1650
  function getTimeRangeDate(range: TimeRange): Date | null {
@@ -1136,17 +1661,37 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1136
1661
  return new Date(now.getTime() - 6 * 60 * 60 * 1000)
1137
1662
  case '24h':
1138
1663
  return new Date(now.getTime() - 24 * 60 * 60 * 1000)
1664
+ case '7d':
1665
+ return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
1666
+ case '30d':
1667
+ return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
1139
1668
  default:
1140
1669
  return null
1141
1670
  }
1142
1671
  }
1143
1672
 
1144
1673
  export function useChanges(options: UseChangesOptions = {}) {
1145
- const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
1674
+ const {
1675
+ namespaces = [],
1676
+ kinds,
1677
+ timeRange = '1h',
1678
+ filter = 'all',
1679
+ includeK8sEvents = true,
1680
+ includeManaged = false,
1681
+ includeDeleted = true,
1682
+ limit = 200,
1683
+ enabled = true,
1684
+ deltaSync = false,
1685
+ } = options
1686
+ const queryClient = useQueryClient()
1687
+
1688
+ // Only a single-kind selection narrows the server query; a multi-kind
1689
+ // selection is filtered client-side so the server cap isn't spent on one kind.
1690
+ const serverKind = kinds && kinds.length === 1 ? kinds[0] : undefined
1146
1691
 
1147
1692
  const params = new URLSearchParams()
1148
1693
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1149
- if (kind) params.set('kind', kind)
1694
+ if (serverKind) params.set('kind', serverKind)
1150
1695
  if (filter) params.set('filter', filter)
1151
1696
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
1152
1697
  if (includeManaged) params.set('include_managed', 'true')
@@ -1159,18 +1704,50 @@ export function useChanges(options: UseChangesOptions = {}) {
1159
1704
  }
1160
1705
 
1161
1706
  const queryString = params.toString()
1707
+ const path = `/changes${queryString ? `?${queryString}` : ''}`
1708
+ const queryKey = [
1709
+ 'changes',
1710
+ namespaces,
1711
+ serverKind,
1712
+ timeRange,
1713
+ filter,
1714
+ includeK8sEvents,
1715
+ includeManaged,
1716
+ includeDeleted,
1717
+ limit,
1718
+ ]
1162
1719
 
1163
1720
  return useQuery<TimelineEvent[]>({
1164
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1165
- queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1721
+ queryKey,
1722
+ queryFn: async ({ signal }) => {
1723
+ if (!deltaSync) return fetchJSON(path, signal)
1724
+
1725
+ const metaKey = JSON.stringify(queryKey)
1726
+ const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
1727
+ return runDeltaSyncFetch({
1728
+ path,
1729
+ queryString,
1730
+ limit,
1731
+ metaKey,
1732
+ cached,
1733
+ metaStore: changesDeltaMeta,
1734
+ now: Date.now(),
1735
+ signal,
1736
+ })
1737
+ },
1166
1738
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1167
- refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1739
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
1168
1740
  enabled,
1169
1741
  })
1170
1742
  }
1171
1743
 
1172
1744
  // Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
1173
- export function useResourceChildren(kind: string, namespace: string, name: string, timeRange: TimeRange = '1h') {
1745
+ export function useResourceChildren(
1746
+ kind: string,
1747
+ namespace: string,
1748
+ name: string,
1749
+ timeRange: TimeRange = '1h',
1750
+ ) {
1174
1751
  const sinceDate = getTimeRangeDate(timeRange)
1175
1752
  const params = new URLSearchParams()
1176
1753
  if (sinceDate) {
@@ -1199,7 +1776,11 @@ export interface ResourceEventsResult {
1199
1776
  // K8s events and resource updates are fetched separately so a high-frequency
1200
1777
  // informer update stream (e.g. a CrashLoop status field flapping every few
1201
1778
  // seconds) can never starve out user-meaningful K8s events under a shared limit.
1202
- export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
1779
+ export function useResourceEvents(
1780
+ kind: string,
1781
+ namespace: string,
1782
+ name: string,
1783
+ ): ResourceEventsResult {
1203
1784
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
1204
1785
  // but callers pass the URL-form kind ("pods").
1205
1786
  const singularKind = pluralToKind(kind)
@@ -1265,8 +1846,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1265
1846
  export interface ContainerMetrics {
1266
1847
  name: string
1267
1848
  usage: {
1268
- cpu: string // e.g., "10m" (millicores)
1269
- memory: string // e.g., "128Mi"
1849
+ cpu: string // e.g., "10m" (millicores)
1850
+ memory: string // e.g., "128Mi"
1270
1851
  }
1271
1852
  }
1272
1853
 
@@ -1341,8 +1922,8 @@ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }
1341
1922
 
1342
1923
  export interface MetricsDataPoint {
1343
1924
  timestamp: string
1344
- cpu: number // CPU in nanocores
1345
- memory: number // Memory in bytes
1925
+ cpu: number // CPU in nanocores
1926
+ memory: number // Memory in bytes
1346
1927
  }
1347
1928
 
1348
1929
  export interface ContainerMetricsHistory {
@@ -1371,7 +1952,9 @@ export interface NodeMetricsHistory {
1371
1952
  metricsUnavailableReason?: string
1372
1953
  }
1373
1954
 
1374
- function withoutCollectionError<T extends { collectionError?: string; rawCollectionError?: string }>(history: T): T {
1955
+ function withoutCollectionError<
1956
+ T extends { collectionError?: string; rawCollectionError?: string },
1957
+ >(history: T): T {
1375
1958
  const next = { ...history }
1376
1959
  delete next.collectionError
1377
1960
  delete next.rawCollectionError
@@ -1380,15 +1963,26 @@ function withoutCollectionError<T extends { collectionError?: string; rawCollect
1380
1963
 
1381
1964
  export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1382
1965
  if (history.metricsUnavailable !== true) return history
1383
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1966
+ return {
1967
+ ...withoutCollectionError(history),
1968
+ metricsUnavailable: true,
1969
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1970
+ }
1384
1971
  }
1385
1972
 
1386
1973
  export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1387
1974
  if (history.metricsUnavailable !== true) return history
1388
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1975
+ return {
1976
+ ...withoutCollectionError(history),
1977
+ metricsUnavailable: true,
1978
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1979
+ }
1389
1980
  }
1390
1981
 
1391
- export function shouldFetchLiveMetrics(historySettled: boolean, metricsUnavailable: boolean): boolean {
1982
+ export function shouldFetchLiveMetrics(
1983
+ historySettled: boolean,
1984
+ metricsUnavailable: boolean,
1985
+ ): boolean {
1392
1986
  return historySettled && !metricsUnavailable
1393
1987
  }
1394
1988
 
@@ -1396,7 +1990,11 @@ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: u
1396
1990
  return liveMetricsEnabled && metrics === null
1397
1991
  }
1398
1992
 
1399
- export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUnavailable: boolean, metrics: T | null | undefined): T | undefined {
1993
+ export function getVisibleLiveMetrics<T>(
1994
+ liveMetricsEnabled: boolean,
1995
+ metricsUnavailable: boolean,
1996
+ metrics: T | null | undefined,
1997
+ ): T | undefined {
1400
1998
  if (!liveMetricsEnabled || metricsUnavailable) return undefined
1401
1999
  return metrics ?? undefined
1402
2000
  }
@@ -1405,7 +2003,10 @@ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUna
1405
2003
  export function usePodMetricsHistory(namespace: string, podName: string) {
1406
2004
  return useQuery<PodMetricsHistory>({
1407
2005
  queryKey: ['pod-metrics-history', namespace, podName],
1408
- queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
2006
+ queryFn: async () =>
2007
+ normalizePodMetricsHistory(
2008
+ await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
2009
+ ),
1409
2010
  enabled: Boolean(namespace && podName),
1410
2011
  staleTime: 25000, // Slightly less than poll interval
1411
2012
  refetchInterval: 30000, // Match the backend poll interval
@@ -1416,7 +2017,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1416
2017
  export function useNodeMetricsHistory(nodeName: string) {
1417
2018
  return useQuery<NodeMetricsHistory>({
1418
2019
  queryKey: ['node-metrics-history', nodeName],
1419
- queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
2020
+ queryFn: async () =>
2021
+ normalizeNodeMetricsHistory(
2022
+ await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
2023
+ ),
1420
2024
  enabled: Boolean(nodeName),
1421
2025
  staleTime: 25000,
1422
2026
  refetchInterval: 30000,
@@ -1427,20 +2031,20 @@ export function useNodeMetricsHistory(nodeName: string) {
1427
2031
  export interface TopPodMetrics {
1428
2032
  namespace: string
1429
2033
  name: string
1430
- cpu: number // nanocores (usage)
1431
- memory: number // bytes (usage)
1432
- cpuRequest: number // nanocores (sum across containers)
1433
- cpuLimit: number // nanocores (sum across containers)
2034
+ cpu: number // nanocores (usage)
2035
+ memory: number // bytes (usage)
2036
+ cpuRequest: number // nanocores (sum across containers)
2037
+ cpuLimit: number // nanocores (sum across containers)
1434
2038
  memoryRequest: number // bytes (sum across containers)
1435
- memoryLimit: number // bytes (sum across containers)
2039
+ memoryLimit: number // bytes (sum across containers)
1436
2040
  }
1437
2041
 
1438
2042
  export interface TopNodeMetrics {
1439
2043
  name: string
1440
- cpu: number // nanocores (usage)
1441
- memory: number // bytes (usage)
1442
- podCount: number // pods scheduled on this node
1443
- cpuAllocatable: number // nanocores
2044
+ cpu: number // nanocores (usage)
2045
+ memory: number // bytes (usage)
2046
+ podCount: number // pods scheduled on this node
2047
+ cpuAllocatable: number // nanocores
1444
2048
  memoryAllocatable: number // bytes
1445
2049
  }
1446
2050
 
@@ -1516,11 +2120,13 @@ export interface PrometheusResourceMetrics {
1516
2120
  range: string
1517
2121
  result: PrometheusQueryResult
1518
2122
  query?: string // PromQL query (included when result is empty, for diagnostics)
1519
- hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
2123
+ hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
1520
2124
  }
1521
2125
 
1522
- export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
1523
- export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
2126
+ export type PrometheusMetricCategory =
2127
+ 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
2128
+ export type PrometheusTimeRange =
2129
+ '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
1524
2130
 
1525
2131
  // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
1526
2132
  // HasData=false silently indicates the CSI driver doesn't report or Prom
@@ -1534,17 +2140,49 @@ export interface PrometheusPVCUsage {
1534
2140
  hasData: boolean
1535
2141
  }
1536
2142
 
1537
- export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
2143
+ export type RightsizingFit =
2144
+ 'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
2145
+ export type RightsizingConfidence = 'low' | 'medium' | 'high'
2146
+ export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
1538
2147
 
1539
2148
  export interface RightsizingRow {
1540
2149
  container: string
1541
2150
  resource: 'cpu' | 'memory'
2151
+ fit: RightsizingFit
2152
+ confidence: RightsizingConfidence
1542
2153
  currentRequest?: string
2154
+ currentRequestValue?: number
1543
2155
  currentLimit?: string
1544
- p95?: string
2156
+ currentLimitValue?: number
2157
+ observed?: {
2158
+ name: 'P95' | 'P99' | 'Max'
2159
+ value: number
2160
+ formatted: string
2161
+ }
2162
+ peak?: {
2163
+ name: 'P99'
2164
+ value: number
2165
+ formatted: string
2166
+ }
2167
+ calculatedRequest?: string
2168
+ calculatedRequestValue?: number
1545
2169
  recommendedRequest?: string
1546
- tone: RightsizingTone
1547
- message: string
2170
+ recommendedRequestValue?: number
2171
+ reductionLimited?: boolean
2172
+ bursty?: boolean
2173
+ recommendationReason?: string
2174
+ sampleCount: number
2175
+ expectedSamples: number
2176
+ coverage: number
2177
+ hpaManaged: boolean
2178
+ hpaEvidenceAvailable: boolean
2179
+ throttleAvailable?: boolean
2180
+ throttleRatio?: number
2181
+ currentPodOOM?: boolean
2182
+ windowOomEvidence?: boolean
2183
+ oomEvidenceAvailable: boolean
2184
+ limitConflict?: boolean
2185
+ queryError?: string
1548
2186
  }
1549
2187
 
1550
2188
  export interface PrometheusRightsizing {
@@ -1552,11 +2190,46 @@ export interface PrometheusRightsizing {
1552
2190
  namespace: string
1553
2191
  name: string
1554
2192
  window: string
2193
+ source: 'radar'
2194
+ ownerCoverage: RightsizingOwnerCoverage
2195
+ scaledToZero: boolean
1555
2196
  sampleAvailable: boolean
1556
2197
  rows: RightsizingRow[]
1557
2198
  reason?: string
1558
2199
  }
1559
2200
 
2201
+ export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
2202
+
2203
+ export interface RightsizingScanWorkload {
2204
+ kind: string
2205
+ namespace: string
2206
+ name: string
2207
+ replicas: number
2208
+ scaledToZero: boolean
2209
+ rows: RightsizingRow[]
2210
+ }
2211
+
2212
+ export interface RightsizingScanCoverage {
2213
+ workloadsDiscovered: number
2214
+ workloadsEvaluated: number
2215
+ workloadsWithData: number
2216
+ batches: number
2217
+ completedBatches: number
2218
+ restrictedKinds?: string[]
2219
+ unavailableKinds?: string[]
2220
+ }
2221
+
2222
+ export interface RightsizingScanResponse {
2223
+ state: RightsizingScanState
2224
+ scannedAt: string
2225
+ window: string
2226
+ source: 'radar'
2227
+ coverage: RightsizingScanCoverage
2228
+ workloads: RightsizingScanWorkload[]
2229
+ warnings?: { code: string; message: string }[]
2230
+ reason?: string
2231
+ }
2232
+
1560
2233
  // Check Prometheus availability
1561
2234
  export function usePrometheusStatus() {
1562
2235
  return useQuery<PrometheusStatus>({
@@ -1567,12 +2240,32 @@ export function usePrometheusStatus() {
1567
2240
  })
1568
2241
  }
1569
2242
 
2243
+ export interface ArgoStatus {
2244
+ // configured = a URL or token is set; connected = a probe has landed and the
2245
+ // client is live. The two differ right after a restart (configured, reconnecting).
2246
+ configured: boolean
2247
+ connected: boolean
2248
+ address?: string
2249
+ }
2250
+
2251
+ export function useArgoStatus(enabled = true) {
2252
+ return useQuery<ArgoStatus>({
2253
+ queryKey: ['argocd-status'],
2254
+ queryFn: () => fetchJSON('/integrations/argocd/status'),
2255
+ enabled,
2256
+ staleTime: 30000,
2257
+ refetchInterval: 60000,
2258
+ })
2259
+ }
2260
+
1570
2261
  // Connect to Prometheus (trigger discovery)
1571
2262
  export function usePrometheusConnect() {
1572
2263
  const queryClient = useQueryClient()
1573
2264
  return useMutation({
1574
2265
  mutationFn: async () => {
1575
- const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
2266
+ const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
2267
+ method: 'POST',
2268
+ })
1576
2269
  if (!resp.ok) {
1577
2270
  const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
1578
2271
  throw new Error(body.error || `HTTP ${resp.status}`)
@@ -1633,7 +2326,9 @@ export function useAutoPromConnect(): void {
1633
2326
 
1634
2327
  // Persist the "we've connected here before" signal once a connection lands.
1635
2328
  if (status?.connected) {
1636
- try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
2329
+ try {
2330
+ window.localStorage.setItem(promAutoConnectKey(context), '1')
2331
+ } catch {
1637
2332
  // localStorage can throw in some restricted browser modes — fail open.
1638
2333
  }
1639
2334
  return
@@ -1641,7 +2336,9 @@ export function useAutoPromConnect(): void {
1641
2336
 
1642
2337
  if (attemptedRef.current === context) return
1643
2338
  let cached: string | null = null
1644
- try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
2339
+ try {
2340
+ cached = window.localStorage.getItem(promAutoConnectKey(context))
2341
+ } catch {
1645
2342
  // keep the null fallback
1646
2343
  }
1647
2344
 
@@ -1653,16 +2350,20 @@ export function useAutoPromConnect(): void {
1653
2350
  const timeout = window.setTimeout(() => {
1654
2351
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1655
2352
  // meta-driven toast handler stays silent — the user didn't click anything.
1656
- apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, { method: 'POST' })
1657
- .then(async resp => {
2353
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
2354
+ method: 'POST',
2355
+ })
2356
+ .then(async (resp) => {
1658
2357
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1659
- const nextStatus = await resp.json() as PrometheusStatus
2358
+ const nextStatus = (await resp.json()) as PrometheusStatus
1660
2359
  queryClient.setQueryData(['prometheus-status'], nextStatus)
1661
2360
  if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
1662
2361
  queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1663
2362
  })
1664
2363
  .catch(() => {
1665
- try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
2364
+ try {
2365
+ window.localStorage.removeItem(promAutoConnectKey(context))
2366
+ } catch {
1666
2367
  // ignore — manual CTA will render once status refreshes
1667
2368
  }
1668
2369
  attemptedRef.current = null
@@ -1720,8 +2421,7 @@ export function usePrometheusClusterMetrics(
1720
2421
  ) {
1721
2422
  return useQuery<PrometheusResourceMetrics>({
1722
2423
  queryKey: ['prometheus-cluster-metrics', category, range],
1723
- queryFn: () =>
1724
- fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
2424
+ queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
1725
2425
  enabled,
1726
2426
  staleTime: 30000,
1727
2427
  refetchInterval: 60000,
@@ -1740,22 +2440,75 @@ export function usePrometheusPVCUsage(namespace: string, name: string, enabled =
1740
2440
  }
1741
2441
 
1742
2442
  // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
1743
- export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
2443
+ export function usePrometheusRightsizing(
2444
+ kind: string,
2445
+ namespace: string,
2446
+ name: string,
2447
+ enabled = true,
2448
+ ) {
1744
2449
  return useQuery<PrometheusRightsizing>({
1745
2450
  queryKey: ['prometheus-rightsizing', kind, namespace, name],
1746
2451
  queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
1747
2452
  enabled: enabled && Boolean(kind && namespace && name),
1748
- staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
2453
+ staleTime: 5 * 60 * 1000,
1749
2454
  refetchInterval: 10 * 60 * 1000,
1750
2455
  })
1751
2456
  }
1752
2457
 
2458
+ const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
2459
+
2460
+ export function getRightsizingScanCacheConfig(
2461
+ namespaces: string[],
2462
+ context = '',
2463
+ ): {
2464
+ namespaceKey: string
2465
+ queryKey: readonly ['prometheus-rightsizing-scan', string, string]
2466
+ queryFn: typeof skipToken
2467
+ gcTime: number
2468
+ } {
2469
+ const namespaceKey = [...namespaces].sort().join(',')
2470
+ return {
2471
+ namespaceKey,
2472
+ queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
2473
+ queryFn: skipToken,
2474
+ gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
2475
+ }
2476
+ }
2477
+
2478
+ // A fleet rightsizing scan is intentionally manual. It can query seven days of
2479
+ // Prometheus history for many containers, so navigation alone must never run it.
2480
+ export function useRightsizingScan(namespaces: string[], context = '') {
2481
+ const queryClient = useQueryClient()
2482
+ const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
2483
+ const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
2484
+ const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
2485
+ const mutation = useMutation({
2486
+ mutationFn: async (startedScope: typeof scanScope) => {
2487
+ const params = new URLSearchParams()
2488
+ if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
2489
+ const query = params.toString()
2490
+ return fetchJSON<RightsizingScanResponse>(
2491
+ `/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
2492
+ {
2493
+ method: 'POST',
2494
+ },
2495
+ )
2496
+ },
2497
+ onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
2498
+ })
2499
+ return {
2500
+ ...mutation,
2501
+ data: snapshot.data,
2502
+ mutate: () => mutation.mutate(scanScope),
2503
+ mutateAsync: () => mutation.mutateAsync(scanScope),
2504
+ }
2505
+ }
2506
+
1753
2507
  // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
1754
2508
  export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
1755
2509
  return useQuery<PrometheusQueryResult>({
1756
2510
  queryKey: ['promql-range', query, range],
1757
- queryFn: () =>
1758
- fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
2511
+ queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
1759
2512
  enabled: enabled && Boolean(query),
1760
2513
  staleTime: 30000,
1761
2514
  refetchInterval: 60000,
@@ -1788,12 +2541,16 @@ export interface LogStreamEvent {
1788
2541
  }
1789
2542
 
1790
2543
  // Fetch pod logs (non-streaming)
1791
- export function usePodLogs(namespace: string, podName: string, options?: {
1792
- container?: string
1793
- tailLines?: number
1794
- previous?: boolean
1795
- sinceSeconds?: number
1796
- }) {
2544
+ export function usePodLogs(
2545
+ namespace: string,
2546
+ podName: string,
2547
+ options?: {
2548
+ container?: string
2549
+ tailLines?: number
2550
+ previous?: boolean
2551
+ sinceSeconds?: number
2552
+ },
2553
+ ) {
1797
2554
  const params = new URLSearchParams()
1798
2555
  if (options?.container) params.set('container', options.container)
1799
2556
  if (options?.tailLines) params.set('tailLines', String(options.tailLines))
@@ -1802,8 +2559,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
1802
2559
  const queryString = params.toString()
1803
2560
 
1804
2561
  return useQuery<LogsResponse>({
1805
- queryKey: ['pod-logs', namespace, podName, options?.container, options?.tailLines, options?.previous, options?.sinceSeconds],
1806
- queryFn: () => fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
2562
+ queryKey: [
2563
+ 'pod-logs',
2564
+ namespace,
2565
+ podName,
2566
+ options?.container,
2567
+ options?.tailLines,
2568
+ options?.previous,
2569
+ options?.sinceSeconds,
2570
+ ],
2571
+ queryFn: () =>
2572
+ fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
1807
2573
  enabled: Boolean(namespace && podName),
1808
2574
  staleTime: 5000, // Allow refetch after 5 seconds
1809
2575
  })
@@ -1818,7 +2584,7 @@ export function createLogStream(
1818
2584
  tailLines?: number
1819
2585
  previous?: boolean
1820
2586
  sinceSeconds?: number
1821
- }
2587
+ },
1822
2588
  ): EventSource {
1823
2589
  const params = new URLSearchParams()
1824
2590
  if (options?.container) params.set('container', options.container)
@@ -1827,9 +2593,12 @@ export function createLogStream(
1827
2593
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
1828
2594
  const queryString = params.toString()
1829
2595
 
1830
- return new EventSource(`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`, {
1831
- withCredentials: getCredentialsMode() === 'include',
1832
- })
2596
+ return new EventSource(
2597
+ `${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
2598
+ {
2599
+ withCredentials: getCredentialsMode() === 'include',
2600
+ },
2601
+ )
1833
2602
  }
1834
2603
 
1835
2604
  // ============================================================================
@@ -1862,8 +2631,23 @@ export function useUpdateResource() {
1862
2631
  const queryClient = useQueryClient()
1863
2632
 
1864
2633
  return useMutation({
1865
- mutationFn: async ({ kind, namespace, name, yaml, force = true }: { kind: string; namespace: string; name: string; yaml: string; force?: boolean }) => {
1866
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2634
+ mutationFn: async ({
2635
+ kind,
2636
+ namespace,
2637
+ name,
2638
+ yaml,
2639
+ force = true,
2640
+ }: {
2641
+ kind: string
2642
+ namespace: string
2643
+ name: string
2644
+ yaml: string
2645
+ force?: boolean
2646
+ }) => {
2647
+ const url = new URL(
2648
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2649
+ window.location.origin,
2650
+ )
1867
2651
  if (!force) {
1868
2652
  url.searchParams.set('force', 'false')
1869
2653
  }
@@ -1891,16 +2675,22 @@ export function useUpdateResource() {
1891
2675
  // lagging cache — the change appears not to have taken effect.
1892
2676
  if (updated && typeof updated === 'object' && updated.metadata) {
1893
2677
  queryClient.setQueriesData(
1894
- { queryKey: ['resource', variables.kind, variables.namespace, variables.name] },
2678
+ {
2679
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2680
+ },
1895
2681
  (old: any) =>
1896
2682
  old && typeof old === 'object' && 'resource' in old
1897
2683
  ? { ...old, resource: updated }
1898
- : { resource: updated }
2684
+ : { resource: updated },
1899
2685
  )
1900
2686
  } else {
1901
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
2687
+ queryClient.invalidateQueries({
2688
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2689
+ })
1902
2690
  }
1903
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2691
+ queryClient.invalidateQueries({
2692
+ queryKey: ['resources', variables.kind],
2693
+ })
1904
2694
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1905
2695
  },
1906
2696
  })
@@ -1909,13 +2699,24 @@ export function useUpdateResource() {
1909
2699
  // Cascade delete preview — shows resources that will be garbage-collected
1910
2700
  export interface CascadeDeletePreview {
1911
2701
  root: { kind: string; namespace: string; name: string; group?: string }
1912
- dependents: { kind: string; namespace: string; name: string; group?: string }[]
2702
+ dependents: {
2703
+ kind: string
2704
+ namespace: string
2705
+ name: string
2706
+ group?: string
2707
+ }[]
1913
2708
  }
1914
2709
 
1915
- export function useCascadeDeletePreview(kind: string, namespace: string, name: string, enabled: boolean) {
2710
+ export function useCascadeDeletePreview(
2711
+ kind: string,
2712
+ namespace: string,
2713
+ name: string,
2714
+ enabled: boolean,
2715
+ ) {
1916
2716
  return useQuery<CascadeDeletePreview>({
1917
2717
  queryKey: ['cascade-preview', kind, namespace, name],
1918
- queryFn: () => fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2718
+ queryFn: () =>
2719
+ fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
1919
2720
  enabled,
1920
2721
  staleTime: 30_000,
1921
2722
  })
@@ -1926,8 +2727,23 @@ export function useDeleteResource() {
1926
2727
  const queryClient = useQueryClient()
1927
2728
 
1928
2729
  return useMutation({
1929
- mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
1930
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2730
+ mutationFn: async ({
2731
+ kind,
2732
+ group,
2733
+ namespace,
2734
+ name,
2735
+ force,
2736
+ }: {
2737
+ kind: string
2738
+ group?: string
2739
+ namespace: string
2740
+ name: string
2741
+ force?: boolean
2742
+ }) => {
2743
+ const url = new URL(
2744
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2745
+ window.location.origin,
2746
+ )
1931
2747
  if (group) {
1932
2748
  url.searchParams.set('group', group)
1933
2749
  }
@@ -1949,7 +2765,9 @@ export function useDeleteResource() {
1949
2765
  successMessage: 'Resource deleted',
1950
2766
  },
1951
2767
  onSuccess: (_, variables) => {
1952
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2768
+ queryClient.invalidateQueries({
2769
+ queryKey: ['resources', variables.kind],
2770
+ })
1953
2771
  queryClient.invalidateQueries({ queryKey: ['topology'] })
1954
2772
  },
1955
2773
  })
@@ -1959,10 +2777,24 @@ export function useBulkDeleteResources() {
1959
2777
  const queryClient = useQueryClient()
1960
2778
 
1961
2779
  return useMutation({
1962
- mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
2780
+ mutationFn: async ({
2781
+ items,
2782
+ force,
2783
+ }: {
2784
+ items: Array<{
2785
+ kind: string
2786
+ group?: string
2787
+ namespace: string
2788
+ name: string
2789
+ }>
2790
+ force?: boolean
2791
+ }) => {
1963
2792
  const results = await Promise.allSettled(
1964
2793
  items.map(async ({ kind, group, namespace, name }) => {
1965
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2794
+ const url = new URL(
2795
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2796
+ window.location.origin,
2797
+ )
1966
2798
  if (group) url.searchParams.set('group', group)
1967
2799
  if (force) url.searchParams.set('force', 'true')
1968
2800
  const response = await apiFetch(url.toString(), { method: 'DELETE' })
@@ -1971,9 +2803,9 @@ export function useBulkDeleteResources() {
1971
2803
  throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
1972
2804
  }
1973
2805
  return { kind, namespace, name }
1974
- })
2806
+ }),
1975
2807
  )
1976
- const failed = results.filter(r => r.status === 'rejected')
2808
+ const failed = results.filter((r) => r.status === 'rejected')
1977
2809
  if (failed.length > 0) {
1978
2810
  throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
1979
2811
  }
@@ -2006,13 +2838,19 @@ interface BulkWorkloadMutationResult {
2006
2838
  }
2007
2839
 
2008
2840
  function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
2009
- return results.flatMap(r => r.status === 'rejected'
2010
- ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2011
- : []
2841
+ return results.flatMap((r) =>
2842
+ r.status === 'rejected'
2843
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2844
+ : [],
2012
2845
  )
2013
2846
  }
2014
2847
 
2015
- function bulkWorkloadFailureMessage(action: string, failed: number, total: number, messages: string[]): string {
2848
+ function bulkWorkloadFailureMessage(
2849
+ action: string,
2850
+ failed: number,
2851
+ total: number,
2852
+ messages: string[],
2853
+ ): string {
2016
2854
  return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
2017
2855
  }
2018
2856
 
@@ -2020,27 +2858,45 @@ export function useBulkRestartWorkloads() {
2020
2858
  const queryClient = useQueryClient()
2021
2859
 
2022
2860
  return useMutation({
2023
- mutationFn: async ({ items }: { items: BulkWorkloadItem[] }): Promise<BulkWorkloadMutationResult> => {
2861
+ mutationFn: async ({
2862
+ items,
2863
+ }: {
2864
+ items: BulkWorkloadItem[]
2865
+ }): Promise<BulkWorkloadMutationResult> => {
2024
2866
  if (items.length === 0) {
2025
2867
  return { requested: 0, succeeded: 0, failedMessages: [] }
2026
2868
  }
2027
2869
  const results = await Promise.allSettled(
2028
2870
  items.map(async ({ kind, namespace, name }) => {
2029
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
2030
- method: 'POST',
2031
- })
2871
+ const response = await apiFetch(
2872
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
2873
+ {
2874
+ method: 'POST',
2875
+ },
2876
+ )
2032
2877
  if (!response.ok) {
2033
2878
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2034
2879
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2035
2880
  }
2036
2881
  return { kind, namespace, name }
2037
- })
2882
+ }),
2038
2883
  )
2039
2884
  const failedMessages = failedBulkWorkloadMessages(results)
2040
2885
  if (failedMessages.length === items.length) {
2041
- throw new Error(bulkWorkloadFailureMessage('restart', failedMessages.length, items.length, failedMessages))
2886
+ throw new Error(
2887
+ bulkWorkloadFailureMessage(
2888
+ 'restart',
2889
+ failedMessages.length,
2890
+ items.length,
2891
+ failedMessages,
2892
+ ),
2893
+ )
2894
+ }
2895
+ return {
2896
+ requested: items.length,
2897
+ succeeded: items.length - failedMessages.length,
2898
+ failedMessages,
2042
2899
  }
2043
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2044
2900
  },
2045
2901
  meta: {
2046
2902
  errorMessage: 'Failed to restart some workloads',
@@ -2066,29 +2922,44 @@ export function useBulkScaleWorkloads() {
2066
2922
  const queryClient = useQueryClient()
2067
2923
 
2068
2924
  return useMutation({
2069
- mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }): Promise<BulkWorkloadMutationResult> => {
2925
+ mutationFn: async ({
2926
+ items,
2927
+ replicas,
2928
+ }: {
2929
+ items: BulkWorkloadItem[]
2930
+ replicas: number
2931
+ }): Promise<BulkWorkloadMutationResult> => {
2070
2932
  if (items.length === 0) {
2071
2933
  return { requested: 0, succeeded: 0, failedMessages: [] }
2072
2934
  }
2073
2935
  const results = await Promise.allSettled(
2074
2936
  items.map(async ({ kind, namespace, name }) => {
2075
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
2076
- method: 'POST',
2077
- headers: { 'Content-Type': 'application/json' },
2078
- body: JSON.stringify({ replicas }),
2079
- })
2937
+ const response = await apiFetch(
2938
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
2939
+ {
2940
+ method: 'POST',
2941
+ headers: { 'Content-Type': 'application/json' },
2942
+ body: JSON.stringify({ replicas }),
2943
+ },
2944
+ )
2080
2945
  if (!response.ok) {
2081
2946
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2082
2947
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2083
2948
  }
2084
2949
  return { kind, namespace, name }
2085
- })
2950
+ }),
2086
2951
  )
2087
2952
  const failedMessages = failedBulkWorkloadMessages(results)
2088
2953
  if (failedMessages.length === items.length) {
2089
- throw new Error(bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages))
2954
+ throw new Error(
2955
+ bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
2956
+ )
2957
+ }
2958
+ return {
2959
+ requested: items.length,
2960
+ succeeded: items.length - failedMessages.length,
2961
+ failedMessages,
2090
2962
  }
2091
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2092
2963
  },
2093
2964
  meta: {
2094
2965
  errorMessage: 'Failed to scale some workloads',
@@ -2122,7 +2993,17 @@ export function useApplyResource() {
2122
2993
  const queryClient = useQueryClient()
2123
2994
 
2124
2995
  return useMutation({
2125
- mutationFn: async ({ yaml, mode = 'apply', dryRun = false, force = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean; force?: boolean }) => {
2996
+ mutationFn: async ({
2997
+ yaml,
2998
+ mode = 'apply',
2999
+ dryRun = false,
3000
+ force = false,
3001
+ }: {
3002
+ yaml: string
3003
+ mode?: 'apply' | 'create'
3004
+ dryRun?: boolean
3005
+ force?: boolean
3006
+ }) => {
2126
3007
  const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
2127
3008
  url.searchParams.set('mode', mode)
2128
3009
  if (dryRun) {
@@ -2155,6 +3036,25 @@ export function useApplyResource() {
2155
3036
  // CronJob operations
2156
3037
  // ============================================================================
2157
3038
 
3039
+ function invalidateCronJobOperationQueries(
3040
+ queryClient: ReturnType<typeof useQueryClient>,
3041
+ namespace: string,
3042
+ name: string,
3043
+ ) {
3044
+ queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
3045
+ queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
3046
+ queryClient.invalidateQueries({
3047
+ queryKey: ['resource', 'cronjobs', namespace, name],
3048
+ })
3049
+ queryClient.invalidateQueries({
3050
+ queryKey: ['workload-runs', 'cronjobs', namespace, name],
3051
+ })
3052
+ queryClient.invalidateQueries({ queryKey: ['applications'] })
3053
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] })
3054
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
3055
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
3056
+ }
3057
+
2158
3058
  // Trigger a CronJob (create a Job from it)
2159
3059
  export function useTriggerCronJob() {
2160
3060
  const queryClient = useQueryClient()
@@ -2174,10 +3074,8 @@ export function useTriggerCronJob() {
2174
3074
  errorMessage: 'Failed to trigger CronJob',
2175
3075
  successMessage: 'CronJob triggered',
2176
3076
  },
2177
- onSuccess: () => {
2178
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2179
- queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2180
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3077
+ onSuccess: (_, variables) => {
3078
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2181
3079
  },
2182
3080
  })
2183
3081
  }
@@ -2201,9 +3099,8 @@ export function useSuspendCronJob() {
2201
3099
  errorMessage: 'Failed to suspend CronJob',
2202
3100
  successMessage: 'CronJob suspended',
2203
3101
  },
2204
- onSuccess: () => {
2205
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2206
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3102
+ onSuccess: (_, variables) => {
3103
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2207
3104
  },
2208
3105
  })
2209
3106
  }
@@ -2227,9 +3124,8 @@ export function useResumeCronJob() {
2227
3124
  errorMessage: 'Failed to resume CronJob',
2228
3125
  successMessage: 'CronJob resumed',
2229
3126
  },
2230
- onSuccess: () => {
2231
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2232
- queryClient.invalidateQueries({ queryKey: ['topology'] })
3127
+ onSuccess: (_, variables) => {
3128
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2233
3129
  },
2234
3130
  })
2235
3131
  }
@@ -2243,10 +3139,21 @@ export function useRestartWorkload() {
2243
3139
  const queryClient = useQueryClient()
2244
3140
 
2245
3141
  return useMutation({
2246
- mutationFn: async ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => {
2247
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
2248
- method: 'POST',
2249
- })
3142
+ mutationFn: async ({
3143
+ kind,
3144
+ namespace,
3145
+ name,
3146
+ }: {
3147
+ kind: string
3148
+ namespace: string
3149
+ name: string
3150
+ }) => {
3151
+ const response = await apiFetch(
3152
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
3153
+ {
3154
+ method: 'POST',
3155
+ },
3156
+ )
2250
3157
  if (!response.ok) {
2251
3158
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2252
3159
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2258,7 +3165,9 @@ export function useRestartWorkload() {
2258
3165
  successMessage: 'Workload restarting',
2259
3166
  },
2260
3167
  onSuccess: (_, variables) => {
2261
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
3168
+ queryClient.invalidateQueries({
3169
+ queryKey: ['resources', variables.kind],
3170
+ })
2262
3171
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2263
3172
  },
2264
3173
  })
@@ -2269,12 +3178,25 @@ export function useScaleWorkload() {
2269
3178
  const queryClient = useQueryClient()
2270
3179
 
2271
3180
  return useMutation({
2272
- mutationFn: async ({ kind, namespace, name, replicas }: { kind: string; namespace: string; name: string; replicas: number }) => {
2273
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
2274
- method: 'POST',
2275
- headers: { 'Content-Type': 'application/json' },
2276
- body: JSON.stringify({ replicas }),
2277
- })
3181
+ mutationFn: async ({
3182
+ kind,
3183
+ namespace,
3184
+ name,
3185
+ replicas,
3186
+ }: {
3187
+ kind: string
3188
+ namespace: string
3189
+ name: string
3190
+ replicas: number
3191
+ }) => {
3192
+ const response = await apiFetch(
3193
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
3194
+ {
3195
+ method: 'POST',
3196
+ headers: { 'Content-Type': 'application/json' },
3197
+ body: JSON.stringify({ replicas }),
3198
+ },
3199
+ )
2278
3200
  if (!response.ok) {
2279
3201
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2280
3202
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2286,8 +3208,12 @@ export function useScaleWorkload() {
2286
3208
  successMessage: 'Workload scaled',
2287
3209
  },
2288
3210
  onSuccess: (_, variables) => {
2289
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2290
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
3211
+ queryClient.invalidateQueries({
3212
+ queryKey: ['resources', variables.kind],
3213
+ })
3214
+ queryClient.invalidateQueries({
3215
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3216
+ })
2291
3217
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2292
3218
  },
2293
3219
  })
@@ -2307,7 +3233,12 @@ export interface WorkloadRevision {
2307
3233
  template?: string // Pod template spec as YAML (for revision diff)
2308
3234
  }
2309
3235
 
2310
- export function useWorkloadRevisions(kind: string, namespace: string, name: string, enabled = true) {
3236
+ export function useWorkloadRevisions(
3237
+ kind: string,
3238
+ namespace: string,
3239
+ name: string,
3240
+ enabled = true,
3241
+ ) {
2311
3242
  return useQuery<WorkloadRevision[]>({
2312
3243
  queryKey: ['workload-revisions', kind, namespace, name],
2313
3244
  queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
@@ -2318,12 +3249,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
2318
3249
  export function useRollbackWorkload() {
2319
3250
  const queryClient = useQueryClient()
2320
3251
  return useMutation({
2321
- mutationFn: async ({ kind, namespace, name, revision }: { kind: string; namespace: string; name: string; revision: number }) => {
2322
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`, {
2323
- method: 'POST',
2324
- headers: { 'Content-Type': 'application/json' },
2325
- body: JSON.stringify({ revision }),
2326
- })
3252
+ mutationFn: async ({
3253
+ kind,
3254
+ namespace,
3255
+ name,
3256
+ revision,
3257
+ }: {
3258
+ kind: string
3259
+ namespace: string
3260
+ name: string
3261
+ revision: number
3262
+ }) => {
3263
+ const response = await apiFetch(
3264
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
3265
+ {
3266
+ method: 'POST',
3267
+ headers: { 'Content-Type': 'application/json' },
3268
+ body: JSON.stringify({ revision }),
3269
+ },
3270
+ )
2327
3271
  if (!response.ok) {
2328
3272
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2329
3273
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2335,9 +3279,15 @@ export function useRollbackWorkload() {
2335
3279
  successMessage: 'Rollback initiated',
2336
3280
  },
2337
3281
  onSuccess: (_, variables) => {
2338
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2339
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
2340
- queryClient.invalidateQueries({ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name] })
3282
+ queryClient.invalidateQueries({
3283
+ queryKey: ['resources', variables.kind],
3284
+ })
3285
+ queryClient.invalidateQueries({
3286
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3287
+ })
3288
+ queryClient.invalidateQueries({
3289
+ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
3290
+ })
2341
3291
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2342
3292
  },
2343
3293
  })
@@ -2367,7 +3317,9 @@ export function useCordonNode() {
2367
3317
  },
2368
3318
  onSuccess: (_, variables) => {
2369
3319
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2370
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3320
+ queryClient.invalidateQueries({
3321
+ queryKey: ['resource', 'nodes', '', variables.name],
3322
+ })
2371
3323
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2372
3324
  },
2373
3325
  })
@@ -2393,7 +3345,9 @@ export function useUncordonNode() {
2393
3345
  },
2394
3346
  onSuccess: (_, variables) => {
2395
3347
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2396
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3348
+ queryClient.invalidateQueries({
3349
+ queryKey: ['resource', 'nodes', '', variables.name],
3350
+ })
2397
3351
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2398
3352
  },
2399
3353
  })
@@ -2426,7 +3380,9 @@ export function useDrainNode() {
2426
3380
  },
2427
3381
  onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
2428
3382
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2429
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3383
+ queryClient.invalidateQueries({
3384
+ queryKey: ['resource', 'nodes', '', variables.name],
3385
+ })
2430
3386
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2431
3387
 
2432
3388
  const evicted = data?.evictedPods?.length ?? 0
@@ -2462,11 +3418,11 @@ export function useHelmReleases(namespaces: string[] = []) {
2462
3418
  }
2463
3419
 
2464
3420
  // Get details for a specific Helm release
2465
- export function useHelmRelease(namespace: string, name: string) {
3421
+ export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
2466
3422
  return useQuery<HelmReleaseDetail>({
2467
3423
  queryKey: ['helm-release', namespace, name],
2468
3424
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
2469
- enabled: Boolean(namespace && name),
3425
+ enabled: Boolean(namespace && name) && (options?.enabled ?? true),
2470
3426
  staleTime: 5000,
2471
3427
  refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
2472
3428
  })
@@ -2476,12 +3432,19 @@ export function useHelmRelease(namespace: string, name: string) {
2476
3432
  // `enabled` lets callers skip the query when the user's Cloud role
2477
3433
  // would 403 the read — saves a round-trip and avoids a transient
2478
3434
  // "error" state that the role-gated empty panel doesn't need.
2479
- export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
3435
+ export function useHelmManifest(
3436
+ namespace: string,
3437
+ name: string,
3438
+ revision?: number,
3439
+ enabled = true,
3440
+ ) {
2480
3441
  const params = revision ? `?revision=${revision}` : ''
2481
3442
  return useQuery<string>({
2482
3443
  queryKey: ['helm-manifest', namespace, name, revision],
2483
3444
  queryFn: async () => {
2484
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`)
3445
+ const response = await apiFetch(
3446
+ `${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
3447
+ )
2485
3448
  if (!response.ok) {
2486
3449
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2487
3450
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2494,7 +3457,13 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
2494
3457
  }
2495
3458
 
2496
3459
  // Get values for a Helm release. `enabled` see useHelmManifest.
2497
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
3460
+ export function useHelmValues(
3461
+ namespace: string,
3462
+ name: string,
3463
+ allValues?: boolean,
3464
+ enabled = true,
3465
+ revision?: number,
3466
+ ) {
2498
3467
  const params = new URLSearchParams()
2499
3468
  if (allValues) params.set('all', 'true')
2500
3469
  if (revision && revision > 0) params.set('revision', String(revision))
@@ -2518,8 +3487,12 @@ export function useHelmManifestDiff(
2518
3487
  return useQuery<ManifestDiff>({
2519
3488
  queryKey: ['helm-diff', namespace, name, revision1, revision2],
2520
3489
  queryFn: () =>
2521
- fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
2522
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3490
+ fetchJSON(
3491
+ `/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
3492
+ ),
3493
+ enabled: Boolean(
3494
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3495
+ ),
2523
3496
  staleTime: 60000,
2524
3497
  })
2525
3498
  }
@@ -2542,7 +3515,9 @@ export function useHelmValuesDiff(
2542
3515
  if (allValues) params.set('all', 'true')
2543
3516
  return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
2544
3517
  },
2545
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3518
+ enabled: Boolean(
3519
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3520
+ ),
2546
3521
  staleTime: 60000,
2547
3522
  })
2548
3523
  }
@@ -2557,8 +3532,12 @@ export function useHelmNotesDiff(
2557
3532
  return useQuery<NotesDiff>({
2558
3533
  queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
2559
3534
  queryFn: () =>
2560
- fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
2561
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3535
+ fetchJSON(
3536
+ `/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
3537
+ ),
3538
+ enabled: Boolean(
3539
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3540
+ ),
2562
3541
  staleTime: 60000,
2563
3542
  })
2564
3543
  }
@@ -2573,8 +3552,12 @@ export function useHelmHooksDiff(
2573
3552
  return useQuery<HooksDiff>({
2574
3553
  queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2575
3554
  queryFn: () =>
2576
- fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
2577
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3555
+ fetchJSON(
3556
+ `/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
3557
+ ),
3558
+ enabled: Boolean(
3559
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3560
+ ),
2578
3561
  staleTime: 60000,
2579
3562
  })
2580
3563
  }
@@ -2589,8 +3572,12 @@ export function useHelmResourceDiff(
2589
3572
  return useQuery<ResourceDiff>({
2590
3573
  queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
2591
3574
  queryFn: () =>
2592
- fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
2593
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3575
+ fetchJSON(
3576
+ `/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
3577
+ ),
3578
+ enabled: Boolean(
3579
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3580
+ ),
2594
3581
  staleTime: 60000,
2595
3582
  })
2596
3583
  }
@@ -2640,10 +3627,21 @@ export function useHelmRollback() {
2640
3627
  const queryClient = useQueryClient()
2641
3628
 
2642
3629
  return useMutation({
2643
- mutationFn: async ({ namespace, name, revision }: { namespace: string; name: string; revision: number }) => {
2644
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`, {
2645
- method: 'POST',
2646
- })
3630
+ mutationFn: async ({
3631
+ namespace,
3632
+ name,
3633
+ revision,
3634
+ }: {
3635
+ namespace: string
3636
+ name: string
3637
+ revision: number
3638
+ }) => {
3639
+ const response = await apiFetch(
3640
+ `${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
3641
+ {
3642
+ method: 'POST',
3643
+ },
3644
+ )
2647
3645
  if (!response.ok) {
2648
3646
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2649
3647
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2656,7 +3654,9 @@ export function useHelmRollback() {
2656
3654
  },
2657
3655
  onSuccess: (_, variables) => {
2658
3656
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
2659
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
3657
+ queryClient.invalidateQueries({
3658
+ queryKey: ['helm-release', variables.namespace, variables.name],
3659
+ })
2660
3660
  },
2661
3661
  })
2662
3662
  }
@@ -2740,7 +3740,9 @@ function streamHelmProgress(
2740
3740
  return
2741
3741
  }
2742
3742
  } catch (err) {
2743
- reject(err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`))
3743
+ reject(
3744
+ err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
3745
+ )
2744
3746
  return
2745
3747
  }
2746
3748
  }
@@ -2753,19 +3755,28 @@ function streamHelmProgress(
2753
3755
  })
2754
3756
  }
2755
3757
 
2756
- // Upgrade a release with progress streaming via SSE
3758
+ // When `values` is provided, the upgrade applies exactly those edited values
3759
+ // instead of carrying the release's prior values over blindly.
2757
3760
  export function upgradeWithProgress(
2758
3761
  namespace: string,
2759
3762
  name: string,
2760
3763
  version: string,
2761
3764
  repositoryName: string | undefined,
2762
- onProgress: (event: InstallProgressEvent) => void
3765
+ onProgress: (event: InstallProgressEvent) => void,
3766
+ values?: Record<string, unknown>,
2763
3767
  ): Promise<void> {
2764
3768
  const params = new URLSearchParams({ version })
2765
3769
  if (repositoryName) params.set('repository', repositoryName)
3770
+ const options: RequestInit = values
3771
+ ? {
3772
+ method: 'POST',
3773
+ headers: { 'Content-Type': 'application/json' },
3774
+ body: JSON.stringify({ values }),
3775
+ }
3776
+ : { method: 'POST' }
2766
3777
  return streamHelmProgress(
2767
3778
  `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
2768
- { method: 'POST' },
3779
+ options,
2769
3780
  onProgress,
2770
3781
  'Upgrade failed',
2771
3782
  ).then(() => {})
@@ -2776,7 +3787,7 @@ export function rollbackWithProgress(
2776
3787
  namespace: string,
2777
3788
  name: string,
2778
3789
  revision: number,
2779
- onProgress: (event: InstallProgressEvent) => void
3790
+ onProgress: (event: InstallProgressEvent) => void,
2780
3791
  ): Promise<void> {
2781
3792
  return streamHelmProgress(
2782
3793
  `${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
@@ -2786,15 +3797,29 @@ export function rollbackWithProgress(
2786
3797
  ).then(() => {})
2787
3798
  }
2788
3799
 
2789
- // Preview values change (dry-run upgrade)
3800
+ // When `version` is supplied, preview renders against that target chart version
3801
+ // instead of the release's current chart.
2790
3802
  export function useHelmPreviewValues() {
2791
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown> }>({
2792
- mutationFn: async ({ namespace, name, values }) => {
2793
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
2794
- method: 'POST',
2795
- headers: { 'Content-Type': 'application/json' },
2796
- body: JSON.stringify({ values }),
2797
- })
3803
+ return useMutation<
3804
+ ValuesPreviewResponse,
3805
+ Error,
3806
+ {
3807
+ namespace: string
3808
+ name: string
3809
+ values: Record<string, unknown>
3810
+ version?: string
3811
+ repository?: string
3812
+ }
3813
+ >({
3814
+ mutationFn: async ({ namespace, name, values, version, repository }) => {
3815
+ const response = await apiFetch(
3816
+ `${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
3817
+ {
3818
+ method: 'POST',
3819
+ headers: { 'Content-Type': 'application/json' },
3820
+ body: JSON.stringify({ values, version, repository }),
3821
+ },
3822
+ )
2798
3823
  if (!response.ok) {
2799
3824
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2800
3825
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2809,7 +3834,15 @@ export function useHelmApplyValues() {
2809
3834
  const queryClient = useQueryClient()
2810
3835
 
2811
3836
  return useMutation({
2812
- mutationFn: async ({ namespace, name, values }: { namespace: string; name: string; values: Record<string, unknown> }) => {
3837
+ mutationFn: async ({
3838
+ namespace,
3839
+ name,
3840
+ values,
3841
+ }: {
3842
+ namespace: string
3843
+ name: string
3844
+ values: Record<string, unknown>
3845
+ }) => {
2813
3846
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
2814
3847
  method: 'PUT',
2815
3848
  headers: { 'Content-Type': 'application/json' },
@@ -2827,8 +3860,12 @@ export function useHelmApplyValues() {
2827
3860
  },
2828
3861
  onSuccess: (_, variables) => {
2829
3862
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
2830
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
2831
- queryClient.invalidateQueries({ queryKey: ['helm-values', variables.namespace, variables.name] })
3863
+ queryClient.invalidateQueries({
3864
+ queryKey: ['helm-release', variables.namespace, variables.name],
3865
+ })
3866
+ queryClient.invalidateQueries({
3867
+ queryKey: ['helm-values', variables.namespace, variables.name],
3868
+ })
2832
3869
  },
2833
3870
  })
2834
3871
  }
@@ -2925,7 +3962,10 @@ export function useAddOCISource() {
2925
3962
  const queryClient = useQueryClient()
2926
3963
  return useMutation({
2927
3964
  mutationFn: (source: string) => mutateOCISource('POST', source),
2928
- meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
3965
+ meta: {
3966
+ errorMessage: 'Failed to add chart source',
3967
+ successMessage: 'Chart source added',
3968
+ },
2929
3969
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2930
3970
  })
2931
3971
  }
@@ -2934,7 +3974,10 @@ export function useRemoveOCISource() {
2934
3974
  const queryClient = useQueryClient()
2935
3975
  return useMutation({
2936
3976
  mutationFn: (source: string) => mutateOCISource('DELETE', source),
2937
- meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
3977
+ meta: {
3978
+ errorMessage: 'Failed to remove chart source',
3979
+ successMessage: 'Chart source removed',
3980
+ },
2938
3981
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
2939
3982
  })
2940
3983
  }
@@ -3006,11 +4049,15 @@ export interface InstallProgressEvent {
3006
4049
  // Install a chart with progress streaming via SSE
3007
4050
  export function installChartWithProgress(
3008
4051
  req: InstallChartRequest,
3009
- onProgress: (event: InstallProgressEvent) => void
4052
+ onProgress: (event: InstallProgressEvent) => void,
3010
4053
  ): Promise<HelmRelease> {
3011
4054
  return streamHelmProgress(
3012
4055
  `${getApiBase()}/helm/releases/install-stream`,
3013
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) },
4056
+ {
4057
+ method: 'POST',
4058
+ headers: { 'Content-Type': 'application/json' },
4059
+ body: JSON.stringify(req),
4060
+ },
3014
4061
  onProgress,
3015
4062
  'Install failed',
3016
4063
  ).then((event) => event.release as HelmRelease)
@@ -3026,8 +4073,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
3026
4073
  // Search charts on ArtifactHub
3027
4074
  export function useArtifactHubSearch(
3028
4075
  query: string,
3029
- options?: { offset?: number; limit?: number; official?: boolean; verified?: boolean; sort?: ArtifactHubSortOption },
3030
- enabled = true
4076
+ options?: {
4077
+ offset?: number
4078
+ limit?: number
4079
+ official?: boolean
4080
+ verified?: boolean
4081
+ sort?: ArtifactHubSortOption
4082
+ },
4083
+ enabled = true,
3031
4084
  ) {
3032
4085
  const params = new URLSearchParams()
3033
4086
  if (query) params.set('query', query)
@@ -3038,7 +4091,15 @@ export function useArtifactHubSearch(
3038
4091
  if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
3039
4092
 
3040
4093
  return useQuery<ArtifactHubSearchResult>({
3041
- queryKey: ['artifacthub-search', query, options?.offset, options?.limit, options?.official, options?.verified, options?.sort],
4094
+ queryKey: [
4095
+ 'artifacthub-search',
4096
+ query,
4097
+ options?.offset,
4098
+ options?.limit,
4099
+ options?.official,
4100
+ options?.verified,
4101
+ options?.sort,
4102
+ ],
3042
4103
  queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
3043
4104
  enabled: enabled && query.length > 0,
3044
4105
  staleTime: 60000, // 1 minute
@@ -3046,7 +4107,12 @@ export function useArtifactHubSearch(
3046
4107
  }
3047
4108
 
3048
4109
  // Get chart detail from ArtifactHub
3049
- export function useArtifactHubChart(repoName: string, chartName: string, version?: string, enabled = true) {
4110
+ export function useArtifactHubChart(
4111
+ repoName: string,
4112
+ chartName: string,
4113
+ version?: string,
4114
+ enabled = true,
4115
+ ) {
3050
4116
  const path = version
3051
4117
  ? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
3052
4118
  : `/helm/artifacthub/charts/${repoName}/${chartName}`
@@ -3067,7 +4133,8 @@ interface GitOpsMutationConfig<TVariables> {
3067
4133
  getPath: (variables: TVariables) => string
3068
4134
  getBody?: (variables: TVariables) => unknown
3069
4135
  errorMessage: string
3070
- successMessage: string
4136
+ successMessage?: string
4137
+ getSuccessMessage?: (data: GitOpsOperationResponse) => string
3071
4138
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
3072
4139
  }
3073
4140
 
@@ -3095,9 +4162,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
3095
4162
  errorMessage: config.errorMessage,
3096
4163
  successMessage: config.successMessage,
3097
4164
  },
3098
- onSuccess: (_, variables) => {
3099
- config.getInvalidateKeys(variables).forEach(key =>
3100
- queryClient.invalidateQueries({ queryKey: key })
4165
+ onSuccess: (data, variables) => {
4166
+ if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
4167
+ config.getInvalidateKeys(variables).forEach((key) =>
4168
+ queryClient.invalidateQueries({ queryKey: key }),
3101
4169
  )
3102
4170
  },
3103
4171
  })
@@ -3112,8 +4180,13 @@ type ArgoAppVars = { namespace: string; name: string }
3112
4180
  // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
3113
4181
  // useArgoSync sends these — splitting the type prevents callers from passing
3114
4182
  // resources/revision/prune to mutations that would silently drop them.
3115
- type ArgoSyncVars = ArgoAppVars & {
3116
- resources?: Array<{ group?: string; kind: string; namespace?: string; name: string }>
4183
+ export type ArgoSyncVars = ArgoAppVars & {
4184
+ resources?: Array<{
4185
+ group?: string
4186
+ kind: string
4187
+ namespace?: string
4188
+ name: string
4189
+ }>
3117
4190
  revision?: string
3118
4191
  prune?: boolean
3119
4192
  dryRun?: boolean
@@ -3125,6 +4198,31 @@ type ArgoSyncVars = ArgoAppVars & {
3125
4198
  syncOptions?: string[]
3126
4199
  }
3127
4200
 
4201
+ export interface ArgoResourceValidationResult {
4202
+ outcome: 'succeeded' | 'failed' | 'inconclusive'
4203
+ message: string
4204
+ resource?: {
4205
+ group?: string
4206
+ kind: string
4207
+ namespace?: string
4208
+ name: string
4209
+ status?: string
4210
+ message?: string
4211
+ }
4212
+ }
4213
+
4214
+ export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
4215
+ return {
4216
+ namespace,
4217
+ name,
4218
+ ...opts,
4219
+ resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
4220
+ revision: undefined,
4221
+ prune: false,
4222
+ applyOnly: false,
4223
+ }
4224
+ }
4225
+
3128
4226
  // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
3129
4227
  // DryRun mirror the sync flags so the rollback dialog can offer the same
3130
4228
  // safety net.
@@ -3206,6 +4304,31 @@ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
3206
4304
  getInvalidateKeys: argoInvalidateKeys,
3207
4305
  })
3208
4306
 
4307
+ export function useArgoResourceValidation() {
4308
+ const queryClient = useQueryClient()
4309
+ return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
4310
+ mutationFn: async (variables) => {
4311
+ const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
4312
+ method: 'POST',
4313
+ headers: { 'Content-Type': 'application/json' },
4314
+ body: JSON.stringify({
4315
+ resources: variables.resources,
4316
+ force: variables.force,
4317
+ syncOptions: variables.syncOptions,
4318
+ }),
4319
+ })
4320
+ if (!response.ok) {
4321
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4322
+ throw new Error(error.error || `HTTP ${response.status}`)
4323
+ }
4324
+ return response.json() as Promise<ArgoResourceValidationResult>
4325
+ },
4326
+ onSettled: (_, __, variables) => {
4327
+ argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
4328
+ },
4329
+ })
4330
+ }
4331
+
3209
4332
  export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3210
4333
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
3211
4334
  getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
@@ -3217,7 +4340,7 @@ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3217
4340
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
3218
4341
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
3219
4342
  errorMessage: 'Failed to terminate sync',
3220
- successMessage: 'Sync terminated',
4343
+ getSuccessMessage: (data) => data.message,
3221
4344
  getInvalidateKeys: argoInvalidateKeys,
3222
4345
  })
3223
4346
 
@@ -3240,11 +4363,22 @@ export function useArgoRefresh() {
3240
4363
  const queryClient = useQueryClient()
3241
4364
 
3242
4365
  return useMutation({
3243
- mutationFn: async ({ namespace, name, hard = false }: { namespace: string; name: string; hard?: boolean }) => {
4366
+ mutationFn: async ({
4367
+ namespace,
4368
+ name,
4369
+ hard = false,
4370
+ }: {
4371
+ namespace: string
4372
+ name: string
4373
+ hard?: boolean
4374
+ }) => {
3244
4375
  const params = hard ? '?type=hard' : ''
3245
- const response = await apiFetch(`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`, {
3246
- method: 'POST',
3247
- })
4376
+ const response = await apiFetch(
4377
+ `${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
4378
+ {
4379
+ method: 'POST',
4380
+ },
4381
+ )
3248
4382
  if (!response.ok) {
3249
4383
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
3250
4384
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -3261,7 +4395,7 @@ export function useArgoRefresh() {
3261
4395
  // Refresh — without these two extra keys the user clicks Refresh and
3262
4396
  // sees stale insight/tree data until the next staleTime tick.
3263
4397
  argoInvalidateKeys(variables).forEach((key) =>
3264
- queryClient.invalidateQueries({ queryKey: key })
4398
+ queryClient.invalidateQueries({ queryKey: key }),
3265
4399
  )
3266
4400
  },
3267
4401
  })
@@ -3319,7 +4453,9 @@ export function useSwitchContext() {
3319
4453
  } catch (error) {
3320
4454
  clearTimeout(timeoutId)
3321
4455
  if (error instanceof Error && error.name === 'AbortError') {
3322
- throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
4456
+ throw new Error('Context switch timed out. The cluster may be unreachable.', {
4457
+ cause: error,
4458
+ })
3323
4459
  }
3324
4460
  throw error
3325
4461
  }
@@ -3437,9 +4573,12 @@ export function useSetActiveNamespace() {
3437
4573
  error: error instanceof Error ? error.message : String(error),
3438
4574
  })
3439
4575
  if (error instanceof Error && error.name === 'AbortError') {
3440
- throw new Error(isRescope
3441
- ? 'Namespace rescope timed out. The cluster may still be loading.'
3442
- : 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
4576
+ throw new Error(
4577
+ isRescope
4578
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
4579
+ : 'Namespace switch timed out. The cluster may be unreachable.',
4580
+ { cause: error },
4581
+ )
3443
4582
  }
3444
4583
  throw error
3445
4584
  }
@@ -3451,7 +4590,9 @@ export function useSetActiveNamespace() {
3451
4590
  accessibleCount: scope.accessibleNamespaces.length,
3452
4591
  })
3453
4592
  if (scope.cacheScoped) {
3454
- queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
4593
+ queryClient.removeQueries({
4594
+ predicate: (query) => query.queryKey[0] !== 'namespace-scope',
4595
+ })
3455
4596
  }
3456
4597
  queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
3457
4598
  if (scope.cacheScoped) {
@@ -3482,7 +4623,7 @@ export function useImageMetadata(
3482
4623
  namespace: string,
3483
4624
  podName: string,
3484
4625
  pullSecrets: string[],
3485
- enabled = true
4626
+ enabled = true,
3486
4627
  ) {
3487
4628
  const params = new URLSearchParams()
3488
4629
  params.set('image', image)
@@ -3505,7 +4646,7 @@ export function useImageFilesystem(
3505
4646
  namespace: string,
3506
4647
  podName: string,
3507
4648
  pullSecrets: string[],
3508
- enabled = true
4649
+ enabled = true,
3509
4650
  ) {
3510
4651
  const params = new URLSearchParams()
3511
4652
  params.set('image', image)
@@ -3518,9 +4659,7 @@ export function useImageFilesystem(
3518
4659
  return useQuery<ImageFilesystem>({
3519
4660
  queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
3520
4661
  // Use skipToken to completely prevent the query from running when disabled
3521
- queryFn: shouldFetch
3522
- ? () => fetchJSON(`/images/inspect?${params.toString()}`)
3523
- : skipToken,
4662
+ queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
3524
4663
  staleTime: 300000, // 5 minutes - image content doesn't change
3525
4664
  retry: false, // Don't retry on auth errors
3526
4665
  })
@@ -3544,6 +4683,44 @@ export interface WorkloadLogsResponse {
3544
4683
  timestamp: string
3545
4684
  content: string
3546
4685
  }[]
4686
+ emptyReason?: string
4687
+ emptyMessage?: string
4688
+ command?: string
4689
+ }
4690
+
4691
+ export interface WorkloadRun {
4692
+ kind: string
4693
+ namespace: string
4694
+ name: string
4695
+ phase: string
4696
+ active: boolean
4697
+ startedAt?: string
4698
+ finishedAt?: string
4699
+ scheduledAt?: string
4700
+ trigger?: 'manual' | 'schedule' | string
4701
+ message?: string
4702
+ succeeded?: number
4703
+ failed?: number
4704
+ running?: number
4705
+ desired?: number
4706
+ parallelism?: number
4707
+ progress?: string
4708
+ template?: string
4709
+ launcher?: {
4710
+ kind: string
4711
+ namespace?: string
4712
+ name: string
4713
+ group?: string
4714
+ }
4715
+ podTotal?: number
4716
+ podSucceeded?: number
4717
+ podFailed?: number
4718
+ podRunning?: number
4719
+ podPending?: number
4720
+ }
4721
+
4722
+ export interface WorkloadRunsResponse {
4723
+ runs: WorkloadRun[]
3547
4724
  }
3548
4725
 
3549
4726
  // Fetch pods for a workload
@@ -3556,6 +4733,31 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
3556
4733
  })
3557
4734
  }
3558
4735
 
4736
+ export function useWorkloadRuns(
4737
+ kind: string,
4738
+ namespace: string,
4739
+ name: string,
4740
+ enabled = true,
4741
+ options?: { refetchActive?: boolean; clusterScoped?: boolean },
4742
+ ) {
4743
+ const clusterScoped = options?.clusterScoped ?? false
4744
+ const ns = clusterScoped ? '_' : namespace
4745
+ const params = new URLSearchParams()
4746
+ if (clusterScoped) params.set('clusterScoped', 'true')
4747
+ const queryString = params.toString()
4748
+
4749
+ return useQuery<WorkloadRunsResponse>({
4750
+ queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
4751
+ queryFn: () =>
4752
+ fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
4753
+ enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
4754
+ staleTime: 10000,
4755
+ refetchInterval: options?.refetchActive
4756
+ ? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
4757
+ : false,
4758
+ })
4759
+ }
4760
+
3559
4761
  // Fetch logs for a workload (non-streaming)
3560
4762
  export function useWorkloadLogs(
3561
4763
  kind: string,
@@ -3565,7 +4767,7 @@ export function useWorkloadLogs(
3565
4767
  container?: string
3566
4768
  tailLines?: number
3567
4769
  sinceSeconds?: number
3568
- }
4770
+ },
3569
4771
  ) {
3570
4772
  const params = new URLSearchParams()
3571
4773
  if (options?.container) params.set('container', options.container)
@@ -3574,8 +4776,19 @@ export function useWorkloadLogs(
3574
4776
  const queryString = params.toString()
3575
4777
 
3576
4778
  return useQuery<WorkloadLogsResponse>({
3577
- queryKey: ['workload-logs', kind, namespace, name, options?.container, options?.tailLines, options?.sinceSeconds],
3578
- queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`),
4779
+ queryKey: [
4780
+ 'workload-logs',
4781
+ kind,
4782
+ namespace,
4783
+ name,
4784
+ options?.container,
4785
+ options?.tailLines,
4786
+ options?.sinceSeconds,
4787
+ ],
4788
+ queryFn: () =>
4789
+ fetchJSON(
4790
+ `/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
4791
+ ),
3579
4792
  enabled: Boolean(kind && namespace && name),
3580
4793
  staleTime: 5000,
3581
4794
  })
@@ -3590,7 +4803,7 @@ export function createWorkloadLogStream(
3590
4803
  container?: string
3591
4804
  tailLines?: number
3592
4805
  sinceSeconds?: number
3593
- }
4806
+ },
3594
4807
  ): EventSource {
3595
4808
  const params = new URLSearchParams()
3596
4809
  if (options?.container) params.set('container', options.container)
@@ -3598,9 +4811,12 @@ export function createWorkloadLogStream(
3598
4811
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
3599
4812
  const queryString = params.toString()
3600
4813
 
3601
- return new EventSource(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`, {
3602
- withCredentials: getCredentialsMode() === 'include',
3603
- })
4814
+ return new EventSource(
4815
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
4816
+ {
4817
+ withCredentials: getCredentialsMode() === 'include',
4818
+ },
4819
+ )
3604
4820
  }
3605
4821
 
3606
4822
  // ============================================================================