@skyhook-io/radar-app 1.8.7 → 1.8.9

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +69 -61
  3. package/src/RadarApp.tsx +15 -1
  4. package/src/api/apiResources.ts +1 -1
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.rightsizing.test.ts +32 -0
  7. package/src/api/client.ts +1222 -244
  8. package/src/api/timelineSource.ts +4 -2
  9. package/src/components/applications/ApplicationsView.tsx +613 -219
  10. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  12. package/src/components/cost/CostTrendChart.tsx +103 -72
  13. package/src/components/cost/CostView.test.ts +12 -0
  14. package/src/components/cost/CostView.tsx +494 -229
  15. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  16. package/src/components/cost/CostViewTabs.tsx +40 -0
  17. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  18. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  19. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  20. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  21. package/src/components/cost/cloud-console.test.ts +39 -0
  22. package/src/components/cost/cloud-console.ts +81 -0
  23. package/src/components/cost/errors.ts +8 -0
  24. package/src/components/cost/format.test.ts +27 -0
  25. package/src/components/cost/format.ts +46 -0
  26. package/src/components/cost/kinds.ts +5 -0
  27. package/src/components/diagnose/AISettings.tsx +7 -12
  28. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  29. package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
  30. package/src/components/diagnose/InvestigationView.tsx +16 -3
  31. package/src/components/diagnose/parts.tsx +140 -73
  32. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  33. package/src/components/gitops/GitOpsView.tsx +81 -14
  34. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  35. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  36. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  37. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  38. package/src/components/home/CostCard.tsx +21 -36
  39. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  40. package/src/components/resource/RightsizingStrip.tsx +319 -123
  41. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  42. package/src/components/rightsizing/copy.test.ts +56 -0
  43. package/src/components/rightsizing/model.test.ts +227 -0
  44. package/src/components/rightsizing/model.ts +158 -0
  45. package/src/components/rightsizing/presentation.test.ts +104 -0
  46. package/src/components/rightsizing/presentation.ts +94 -0
  47. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  48. package/src/components/settings/SettingsDialog.tsx +1268 -318
  49. package/src/components/timeline/TimelineList.tsx +35 -8
  50. package/src/components/timeline/TimelineView.tsx +156 -26
  51. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  52. package/src/components/workload/WorkloadView.tsx +711 -328
  53. package/src/context/DiagnoseCustomization.tsx +36 -2
  54. package/src/index.css +5 -1
  55. package/src/index.ts +4 -1
  56. package/src/main.tsx +1 -1
package/src/api/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
- import type { AppHistory, 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
@@ -227,15 +255,6 @@ export interface DashboardResourceCounts {
227
255
  restricted?: string[] // Resource kinds the user cannot list due to RBAC
228
256
  }
229
257
 
230
- export interface DashboardEvent {
231
- type: string
232
- reason: string
233
- message: string
234
- involvedObject: string
235
- namespace: string
236
- timestamp: string
237
- }
238
-
239
258
  export interface DashboardChange {
240
259
  kind: string
241
260
  namespace: string
@@ -292,7 +311,15 @@ export interface DashboardCRDCount {
292
311
  }
293
312
 
294
313
  // 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'
314
+ import type {
315
+ AuditCardData,
316
+ AuditFinding,
317
+ ResourceGroup,
318
+ CheckMeta,
319
+ Check,
320
+ Issue,
321
+ IssueRecentChange,
322
+ } from '@skyhook-io/k8s-ui'
296
323
  export type DashboardAudit = AuditCardData
297
324
  export type { AuditFinding, ResourceGroup, CheckMeta, Check }
298
325
 
@@ -351,7 +378,6 @@ export interface DashboardResponse {
351
378
  health: DashboardHealth
352
379
  problems: DashboardProblem[]
353
380
  resourceCounts: DashboardResourceCounts
354
- recentEvents: DashboardEvent[]
355
381
  recentChanges: DashboardChange[]
356
382
  topologySummary: DashboardTopologySummary
357
383
  trafficSummary: DashboardTrafficSummary | null
@@ -361,7 +387,11 @@ export interface DashboardResponse {
361
387
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
362
388
  audit: DashboardAudit | null
363
389
  gitopsControllers: DashboardGitOpsControllers | null
364
- nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
390
+ nodeVersionSkew: {
391
+ versions: Record<string, string[]>
392
+ minVersion: string
393
+ maxVersion: string
394
+ } | null
365
395
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
366
396
  partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
367
397
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
@@ -437,7 +467,13 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
437
467
  // the "Operational Issues" section in the resource detail. Cluster-scoped
438
468
  // resources pass "_" for namespace; namespaced ones also scope the scan via
439
469
  // ?namespaces= for a cheap, bounded Compose.
440
- export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
470
+ export function useResourceIssues(
471
+ kind: string,
472
+ group: string | undefined,
473
+ namespace: string,
474
+ name: string,
475
+ enabled = true,
476
+ ) {
441
477
  const clusterScoped = !namespace
442
478
  const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
443
479
  const params = new URLSearchParams()
@@ -550,7 +586,8 @@ export interface OpenCostNamespaceCost {
550
586
  idleCost?: number
551
587
  }
552
588
 
553
- export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error'
589
+ export type CostUnavailableReason =
590
+ 'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
554
591
 
555
592
  export interface OpenCostSummary {
556
593
  available: boolean
@@ -564,11 +601,41 @@ export interface OpenCostSummary {
564
601
  namespaces?: OpenCostNamespaceCost[]
565
602
  }
566
603
 
604
+ const noPrometheusFirstSeenAt = new Map<string, number>()
605
+
606
+ function costRefetchInterval(
607
+ defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
608
+ contextName?: string,
609
+ ) {
610
+ return (query: {
611
+ queryHash?: string
612
+ queryKey?: unknown
613
+ state: {
614
+ data?: { available?: boolean; reason?: CostUnavailableReason }
615
+ dataUpdatedAt?: number
616
+ }
617
+ }) => {
618
+ const data = query.state.data
619
+ const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
620
+ if (data?.available === false && data.reason === 'no_prometheus') {
621
+ const now = Date.now()
622
+ const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
623
+ noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
624
+ return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
625
+ ? COST_DISCOVERY_RETRY_INTERVAL_MS
626
+ : defaultInterval
627
+ }
628
+ noPrometheusFirstSeenAt.delete(queryID)
629
+ return defaultInterval
630
+ }
631
+ }
632
+
567
633
  export function useOpenCostSummary() {
634
+ const clusterInfo = useClusterInfo()
568
635
  return useQuery<OpenCostSummary>({
569
636
  queryKey: ['opencost-summary'],
570
637
  queryFn: () => fetchJSON('/opencost/summary'),
571
- refetchInterval: COST_REFRESH_INTERVAL_MS,
638
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
572
639
  staleTime: 30000,
573
640
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
574
641
  })
@@ -584,6 +651,10 @@ export interface OpenCostWorkloadCost {
584
651
  replicas: number
585
652
  cpuUsageCost?: number
586
653
  memoryUsageCost?: number
654
+ cpuUsageAvailable: boolean
655
+ memoryUsageAvailable: boolean
656
+ cpuAllocationUse: number
657
+ memoryAllocationUse: number
587
658
  efficiency?: number
588
659
  idleCost?: number
589
660
  }
@@ -596,14 +667,44 @@ export interface OpenCostWorkloadResponse {
596
667
  }
597
668
 
598
669
  export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
670
+ const clusterInfo = useClusterInfo()
599
671
  return useQuery<OpenCostWorkloadResponse>({
600
672
  queryKey: ['opencost-workloads', namespace],
601
673
  queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
602
674
  enabled: (options?.enabled ?? true) && Boolean(namespace),
675
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
603
676
  staleTime: 30000,
604
677
  })
605
678
  }
606
679
 
680
+ export interface OpenCostWorkloadDetailResponse {
681
+ available: boolean
682
+ reason?: CostUnavailableReason
683
+ namespace: string
684
+ kind: string
685
+ name: string
686
+ current?: OpenCostWorkloadCost
687
+ }
688
+
689
+ export function useOpenCostWorkload(
690
+ kind: string,
691
+ namespace: string,
692
+ name: string,
693
+ options?: { enabled?: boolean },
694
+ ) {
695
+ const clusterInfo = useClusterInfo()
696
+ return useQuery<OpenCostWorkloadDetailResponse>({
697
+ queryKey: ['opencost-workload', kind, namespace, name],
698
+ queryFn: () =>
699
+ fetchJSON(
700
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
701
+ ),
702
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
703
+ staleTime: 30000,
704
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
705
+ })
706
+ }
707
+
607
708
  // Cost trend over time
608
709
  export type CostTimeRange = '6h' | '24h' | '7d'
609
710
 
@@ -625,18 +726,175 @@ export interface OpenCostTrendResponse {
625
726
  }
626
727
 
627
728
  export function useOpenCostTrend(range_: CostTimeRange = '24h') {
729
+ const clusterInfo = useClusterInfo()
628
730
  return useQuery<OpenCostTrendResponse>({
629
731
  queryKey: ['opencost-trend', range_],
630
732
  queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
631
733
  staleTime: 60000,
632
- refetchInterval: 120000, // Refresh every 2 minutes
734
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
633
735
  placeholderData: (prev) => prev,
634
736
  })
635
737
  }
636
738
 
739
+ export interface OpenCostWorkloadTrendResponse {
740
+ available: boolean
741
+ reason?: CostUnavailableReason
742
+ namespace: string
743
+ kind: string
744
+ name: string
745
+ range: string
746
+ windowTotalCost?: number
747
+ dataPoints?: OpenCostTrendDataPoint[]
748
+ }
749
+
750
+ export function useOpenCostWorkloadTrend(
751
+ kind: string,
752
+ namespace: string,
753
+ name: string,
754
+ range_: CostTimeRange = '24h',
755
+ options?: { enabled?: boolean },
756
+ ) {
757
+ const clusterInfo = useClusterInfo()
758
+ return useQuery<OpenCostWorkloadTrendResponse>({
759
+ queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
760
+ queryFn: () =>
761
+ fetchJSON(
762
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
763
+ ),
764
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
765
+ staleTime: 60000,
766
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
767
+ })
768
+ }
769
+
770
+ export interface OpenCostApplicationWorkloadRef {
771
+ kind: string
772
+ namespace: string
773
+ name: string
774
+ }
775
+
776
+ export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
777
+ reason: CostUnavailableReason
778
+ scaledToZero?: boolean
779
+ }
780
+
781
+ export interface OpenCostApplicationCostCoverage {
782
+ total: number
783
+ included: number
784
+ unavailable?: OpenCostApplicationWorkloadStatus[]
785
+ unsupported?: OpenCostApplicationWorkloadRef[]
786
+ }
787
+
788
+ export interface OpenCostApplicationCostTotals {
789
+ hourlyCost: number
790
+ cpuCost: number
791
+ memoryCost: number
792
+ replicas: number
793
+ cpuUsageCost?: number
794
+ memoryUsageCost?: number
795
+ cpuUsageAvailable: boolean
796
+ memoryUsageAvailable: boolean
797
+ cpuAllocationUse: number
798
+ memoryAllocationUse: number
799
+ }
800
+
801
+ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
802
+ available: boolean
803
+ reason?: CostUnavailableReason
804
+ scaledToZero?: boolean
805
+ current?: OpenCostWorkloadCost
806
+ }
807
+
808
+ export interface OpenCostApplicationCostResponse {
809
+ available: boolean
810
+ reason?: CostUnavailableReason
811
+ partial?: boolean
812
+ totals: OpenCostApplicationCostTotals
813
+ coverage: OpenCostApplicationCostCoverage
814
+ workloads?: OpenCostApplicationWorkloadCost[]
815
+ }
816
+
817
+ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
818
+ windowTotalCost?: number
819
+ dataPoints?: OpenCostTrendDataPoint[]
820
+ }
821
+
822
+ export interface OpenCostApplicationCostTrendResponse {
823
+ available: boolean
824
+ reason?: CostUnavailableReason
825
+ range: string
826
+ partial?: boolean
827
+ windowTotalCost?: number
828
+ dataPoints?: OpenCostTrendDataPoint[]
829
+ series?: OpenCostApplicationCostTrendSeries[]
830
+ coverage: OpenCostApplicationCostCoverage
831
+ }
832
+
833
+ function stableOpenCostWorkloadRefs(
834
+ workloads: OpenCostApplicationWorkloadRef[],
835
+ ): OpenCostApplicationWorkloadRef[] {
836
+ const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
837
+ for (const workload of workloads) {
838
+ if (!workload.kind || !workload.namespace || !workload.name) continue
839
+ const ref = {
840
+ kind: workload.kind,
841
+ namespace: workload.namespace,
842
+ name: workload.name,
843
+ }
844
+ byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
845
+ }
846
+ return [...byKey.values()].sort((a, b) =>
847
+ `${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
848
+ )
849
+ }
850
+
851
+ export function useOpenCostApplicationCost(
852
+ workloads: OpenCostApplicationWorkloadRef[],
853
+ options?: { enabled?: boolean },
854
+ ) {
855
+ const clusterInfo = useClusterInfo()
856
+ const refs = stableOpenCostWorkloadRefs(workloads)
857
+ return useQuery<OpenCostApplicationCostResponse>({
858
+ queryKey: ['opencost-application', refs],
859
+ queryFn: ({ signal }) =>
860
+ fetchJSON('/opencost/application', {
861
+ method: 'POST',
862
+ headers: { 'Content-Type': 'application/json' },
863
+ body: JSON.stringify({ workloads: refs }),
864
+ signal,
865
+ }),
866
+ enabled: (options?.enabled ?? true) && refs.length > 0,
867
+ staleTime: 30000,
868
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
869
+ })
870
+ }
871
+
872
+ export function useOpenCostApplicationCostTrend(
873
+ workloads: OpenCostApplicationWorkloadRef[],
874
+ range_: CostTimeRange = '24h',
875
+ options?: { enabled?: boolean },
876
+ ) {
877
+ const clusterInfo = useClusterInfo()
878
+ const refs = stableOpenCostWorkloadRefs(workloads)
879
+ return useQuery<OpenCostApplicationCostTrendResponse>({
880
+ queryKey: ['opencost-application-trend', refs, range_],
881
+ queryFn: ({ signal }) =>
882
+ fetchJSON('/opencost/application/trend', {
883
+ method: 'POST',
884
+ headers: { 'Content-Type': 'application/json' },
885
+ body: JSON.stringify({ workloads: refs, range: range_ }),
886
+ signal,
887
+ }),
888
+ enabled: (options?.enabled ?? true) && refs.length > 0,
889
+ staleTime: 60000,
890
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
891
+ })
892
+ }
893
+
637
894
  // Node cost breakdown
638
895
  export interface OpenCostNodeCost {
639
896
  name: string
897
+ providerID?: string
640
898
  instanceType?: string
641
899
  region?: string
642
900
  hourlyCost: number
@@ -651,11 +909,12 @@ export interface OpenCostNodeResponse {
651
909
  }
652
910
 
653
911
  export function useOpenCostNodes() {
912
+ const clusterInfo = useClusterInfo()
654
913
  return useQuery<OpenCostNodeResponse>({
655
914
  queryKey: ['opencost-nodes'],
656
915
  queryFn: () => fetchJSON('/opencost/nodes'),
657
916
  staleTime: 60000,
658
- refetchInterval: 120000,
917
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
659
918
  placeholderData: (prev) => prev,
660
919
  })
661
920
  }
@@ -816,7 +1075,15 @@ const SEARCH_MIN_QUERY = 2
816
1075
  // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
817
1076
  // overlapping scans on a new query. keepPreviousData avoids flicker while the
818
1077
  // next query resolves.
819
- export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
1078
+ export function useSearch(
1079
+ query: string,
1080
+ opts?: {
1081
+ limit?: number
1082
+ context?: 'summary' | 'none'
1083
+ enabled?: boolean
1084
+ globalNs?: boolean
1085
+ },
1086
+ ) {
820
1087
  const trimmed = query.trim()
821
1088
  const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
822
1089
  const limit = opts?.limit ?? 20
@@ -828,7 +1095,10 @@ export function useSearch(query: string, opts?: { limit?: number; context?: 'sum
828
1095
  return useQuery<SearchResult>({
829
1096
  queryKey: ['search', trimmed, limit, context, globalNs],
830
1097
  queryFn: ({ signal }) =>
831
- fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
1098
+ fetchJSON<SearchResult>(
1099
+ `/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
1100
+ signal,
1101
+ ),
832
1102
  enabled,
833
1103
  staleTime: 2000,
834
1104
  placeholderData: (prev) => prev, // keepPreviousData
@@ -863,7 +1133,10 @@ export function useCapabilities() {
863
1133
 
864
1134
  // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
865
1135
  // have these permissions in specific namespaces.
866
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) {
1136
+ export function useNamespaceCapabilities(
1137
+ namespace: string | undefined,
1138
+ globalCaps: Capabilities | undefined,
1139
+ ) {
867
1140
  const needsCheck = namespace && globalCaps
868
1141
  return useQuery<Capabilities>({
869
1142
  queryKey: ['capabilities', namespace],
@@ -902,7 +1175,11 @@ export function useAuthMe() {
902
1175
  // CloudRole.AtLeast — the frontend must agree with the backend on what
903
1176
  // "member-or-higher" means; otherwise we'd hide a button the
904
1177
  // backend would happily honor (or vice versa).
905
- const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
1178
+ const CLOUD_ROLE_RANK: Record<string, number> = {
1179
+ viewer: 1,
1180
+ member: 2,
1181
+ owner: 3,
1182
+ }
906
1183
 
907
1184
  /**
908
1185
  * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
@@ -983,7 +1260,15 @@ export function useNamespaces() {
983
1260
  }
984
1261
 
985
1262
  // Topology (for manual refresh)
986
- export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean; includeReplicaSets?: boolean; refetchInterval?: number | false }) {
1263
+ export function useTopology(
1264
+ namespaces: string[],
1265
+ viewMode: string = 'resources',
1266
+ options?: {
1267
+ enabled?: boolean
1268
+ includeReplicaSets?: boolean
1269
+ refetchInterval?: number | false
1270
+ },
1271
+ ) {
987
1272
  const params = new URLSearchParams()
988
1273
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
989
1274
  if (viewMode) params.set('view', viewMode)
@@ -1016,7 +1301,11 @@ export function useApplications(namespaces: string[], options?: { enabled?: bool
1016
1301
  })
1017
1302
  }
1018
1303
 
1019
- export function useApplicationHistory(appKey: string | undefined, namespaces: string[], options?: { enabled?: boolean }) {
1304
+ export function useApplicationHistory(
1305
+ appKey: string | undefined,
1306
+ namespaces: string[],
1307
+ options?: { enabled?: boolean },
1308
+ ) {
1020
1309
  const params = new URLSearchParams()
1021
1310
  if (appKey) params.set('app', appKey)
1022
1311
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
@@ -1031,7 +1320,14 @@ export function useApplicationHistory(appKey: string | undefined, namespaces: st
1031
1320
  })
1032
1321
  }
1033
1322
 
1034
- export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = [], options?: { enabled?: boolean }) {
1323
+ export function useGitOpsTree(
1324
+ kind: string,
1325
+ namespace: string,
1326
+ name: string,
1327
+ group?: string,
1328
+ namespaces: string[] = [],
1329
+ options?: { enabled?: boolean },
1330
+ ) {
1035
1331
  const ns = namespace || '_'
1036
1332
  const params = new URLSearchParams()
1037
1333
  if (group) params.set('group', group)
@@ -1040,7 +1336,8 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1040
1336
 
1041
1337
  return useQuery<GitOpsResourceTree>({
1042
1338
  queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
1043
- queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1339
+ queryFn: () =>
1340
+ fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1044
1341
  enabled: Boolean(kind && name) && (options?.enabled ?? true),
1045
1342
  staleTime: 5000,
1046
1343
  })
@@ -1048,11 +1345,17 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1048
1345
 
1049
1346
  // Poll fast (2s) while a sync/rollback is in flight so the user sees the
1050
1347
  // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
1051
- // operationState.phase from "Running" -> Succeeded/Failed when done, so this
1348
+ // operationState.phase from Running/Terminating to a terminal phase, so this
1052
1349
  // auto-quiesces on completion.
1053
1350
  const INSIGHTS_RUNNING_POLL_MS = 2000
1054
1351
 
1055
- export function useGitOpsInsights(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
1352
+ export function useGitOpsInsights(
1353
+ kind: string,
1354
+ namespace: string,
1355
+ name: string,
1356
+ group?: string,
1357
+ namespaces: string[] = [],
1358
+ ) {
1056
1359
  const ns = namespace || '_'
1057
1360
  const params = new URLSearchParams()
1058
1361
  if (group) params.set('group', group)
@@ -1061,19 +1364,71 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
1061
1364
 
1062
1365
  return useQuery<GitOpsInsight>({
1063
1366
  queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
1064
- queryFn: () => fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1367
+ queryFn: () =>
1368
+ fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1065
1369
  enabled: Boolean(kind && name),
1066
1370
  staleTime: 5000,
1067
1371
  refetchInterval: (query) => {
1068
1372
  const phase = query.state.data?.summary?.operationPhase
1069
- return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
1373
+ return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
1070
1374
  },
1071
1375
  })
1072
1376
  }
1073
1377
 
1378
+ // Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
1379
+ // ns/name identify the Application; the ref identifies the managed resource.
1380
+ // Fetched on demand — the caller mounts this only when the user opens "Full
1381
+ // diff", so it's enabled whenever the ref is resolvable. Errors surface via
1382
+ // fetchJSON's ApiError (server {"error"} string as .message).
1383
+ export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
1384
+ const ns = appNamespace || '_'
1385
+ const params = new URLSearchParams()
1386
+ if (ref.group) params.set('group', ref.group)
1387
+ params.set('kind', ref.kind)
1388
+ if (ref.namespace) params.set('resourceNamespace', ref.namespace)
1389
+ params.set('resourceName', ref.name)
1390
+
1391
+ return useQuery<GitOpsResourceDiff>({
1392
+ queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
1393
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
1394
+ enabled: Boolean(appName && ref.kind && ref.name),
1395
+ staleTime: 15_000,
1396
+ })
1397
+ }
1398
+
1399
+ // Git commit metadata for one deployed revision of an Argo CD Application.
1400
+ // Enabled only when a revision is known and the caller passes `enabled` (gated
1401
+ // on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
1402
+ // metadata is effectively immutable.
1403
+ export function useArgoRevisionMetadata(
1404
+ appNamespace: string,
1405
+ appName: string,
1406
+ revision: string | undefined,
1407
+ opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
1408
+ ) {
1409
+ const ns = appNamespace || '_'
1410
+ const params = new URLSearchParams()
1411
+ if (revision) params.set('revision', revision)
1412
+ if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
1413
+ if (opts?.project) params.set('project', opts.project)
1414
+
1415
+ return useQuery<ArgoRevisionMetadata>({
1416
+ queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
1417
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
1418
+ enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
1419
+ staleTime: 5 * 60_000,
1420
+ })
1421
+ }
1422
+
1074
1423
  // Generic resource fetching - returns resource with relationships
1075
1424
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
1076
- export function useResource<T>(kind: string, namespace: string, name: string, group?: string, options?: { enabled?: boolean; refetchInterval?: number | false }) {
1425
+ export function useResource<T>(
1426
+ kind: string,
1427
+ namespace: string,
1428
+ name: string,
1429
+ group?: string,
1430
+ options?: { enabled?: boolean; refetchInterval?: number | false },
1431
+ ) {
1077
1432
  // For cluster-scoped resources, use '_' as namespace placeholder
1078
1433
  const ns = namespace || '_'
1079
1434
  const params = new URLSearchParams()
@@ -1082,8 +1437,9 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1082
1437
 
1083
1438
  const query = useQuery<ResourceWithRelationships<T>>({
1084
1439
  queryKey: ['resource', kind, namespace, name, group],
1085
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1086
- enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1440
+ queryFn: () =>
1441
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1442
+ enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1087
1443
  refetchInterval: options?.refetchInterval,
1088
1444
  })
1089
1445
 
@@ -1098,7 +1454,12 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1098
1454
  }
1099
1455
 
1100
1456
  // Hook that returns full response with relationships explicitly
1101
- export function useResourceWithRelationships<T>(kind: string, namespace: string, name: string, group?: string) {
1457
+ export function useResourceWithRelationships<T>(
1458
+ kind: string,
1459
+ namespace: string,
1460
+ name: string,
1461
+ group?: string,
1462
+ ) {
1102
1463
  const ns = namespace || '_'
1103
1464
  const params = new URLSearchParams()
1104
1465
  if (group) params.set('group', group)
@@ -1106,7 +1467,8 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
1106
1467
 
1107
1468
  return useQuery<ResourceWithRelationships<T>>({
1108
1469
  queryKey: ['resource', kind, namespace, name, group],
1109
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1470
+ queryFn: () =>
1471
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1110
1472
  enabled: Boolean(kind && name),
1111
1473
  })
1112
1474
  }
@@ -1199,7 +1561,11 @@ async function fetchChangesPage(
1199
1561
  // (Rows dropped by content filters inside the store query do not; see the
1200
1562
  // known limitation on the server's handleChanges.)
1201
1563
  const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
1202
- return { events, epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '', maxSeq }
1564
+ return {
1565
+ events,
1566
+ epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
1567
+ maxSeq,
1568
+ }
1203
1569
  }
1204
1570
 
1205
1571
  // Highest store-assigned arrival number in the cached page — the delta cursor.
@@ -1250,7 +1616,10 @@ export async function runDeltaSyncFetch(deps: {
1250
1616
  const meta = metaStore.get(metaKey)
1251
1617
  const cursor = deltaFetchCursor(meta, cached, now)
1252
1618
  if (cursor > 0) {
1253
- const delta = await fetchChangesPage(`${path}${queryString ? '&' : '?'}since_seq=${cursor}`, signal)
1619
+ const delta = await fetchChangesPage(
1620
+ `${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
1621
+ signal,
1622
+ )
1254
1623
  if (delta.epoch && delta.epoch === meta!.epoch) {
1255
1624
  meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
1256
1625
  // Returning the cached reference on an empty delta skips re-renders.
@@ -1260,7 +1629,11 @@ export async function runDeltaSyncFetch(deps: {
1260
1629
  // cursor is meaningless. Fall through to a full resync.
1261
1630
  }
1262
1631
  const full = await fetchChangesPage(path, signal)
1263
- metaStore.set(metaKey, { epoch: full.epoch, lastFullMs: now, highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)) })
1632
+ metaStore.set(metaKey, {
1633
+ epoch: full.epoch,
1634
+ lastFullMs: now,
1635
+ highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
1636
+ })
1264
1637
  return full.events
1265
1638
  }
1266
1639
 
@@ -1288,7 +1661,18 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1288
1661
  }
1289
1662
 
1290
1663
  export function useChanges(options: UseChangesOptions = {}) {
1291
- const { namespaces = [], kinds, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true, deltaSync = false } = options
1664
+ const {
1665
+ namespaces = [],
1666
+ kinds,
1667
+ timeRange = '1h',
1668
+ filter = 'all',
1669
+ includeK8sEvents = true,
1670
+ includeManaged = false,
1671
+ includeDeleted = true,
1672
+ limit = 200,
1673
+ enabled = true,
1674
+ deltaSync = false,
1675
+ } = options
1292
1676
  const queryClient = useQueryClient()
1293
1677
 
1294
1678
  // Only a single-kind selection narrows the server query; a multi-kind
@@ -1311,7 +1695,17 @@ export function useChanges(options: UseChangesOptions = {}) {
1311
1695
 
1312
1696
  const queryString = params.toString()
1313
1697
  const path = `/changes${queryString ? `?${queryString}` : ''}`
1314
- const queryKey = ['changes', namespaces, serverKind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit]
1698
+ const queryKey = [
1699
+ 'changes',
1700
+ namespaces,
1701
+ serverKind,
1702
+ timeRange,
1703
+ filter,
1704
+ includeK8sEvents,
1705
+ includeManaged,
1706
+ includeDeleted,
1707
+ limit,
1708
+ ]
1315
1709
 
1316
1710
  return useQuery<TimelineEvent[]>({
1317
1711
  queryKey,
@@ -1320,7 +1714,16 @@ export function useChanges(options: UseChangesOptions = {}) {
1320
1714
 
1321
1715
  const metaKey = JSON.stringify(queryKey)
1322
1716
  const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
1323
- return runDeltaSyncFetch({ path, queryString, limit, metaKey, cached, metaStore: changesDeltaMeta, now: Date.now(), signal })
1717
+ return runDeltaSyncFetch({
1718
+ path,
1719
+ queryString,
1720
+ limit,
1721
+ metaKey,
1722
+ cached,
1723
+ metaStore: changesDeltaMeta,
1724
+ now: Date.now(),
1725
+ signal,
1726
+ })
1324
1727
  },
1325
1728
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1326
1729
  refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
@@ -1329,7 +1732,12 @@ export function useChanges(options: UseChangesOptions = {}) {
1329
1732
  }
1330
1733
 
1331
1734
  // Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
1332
- export function useResourceChildren(kind: string, namespace: string, name: string, timeRange: TimeRange = '1h') {
1735
+ export function useResourceChildren(
1736
+ kind: string,
1737
+ namespace: string,
1738
+ name: string,
1739
+ timeRange: TimeRange = '1h',
1740
+ ) {
1333
1741
  const sinceDate = getTimeRangeDate(timeRange)
1334
1742
  const params = new URLSearchParams()
1335
1743
  if (sinceDate) {
@@ -1358,7 +1766,11 @@ export interface ResourceEventsResult {
1358
1766
  // K8s events and resource updates are fetched separately so a high-frequency
1359
1767
  // informer update stream (e.g. a CrashLoop status field flapping every few
1360
1768
  // seconds) can never starve out user-meaningful K8s events under a shared limit.
1361
- export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
1769
+ export function useResourceEvents(
1770
+ kind: string,
1771
+ namespace: string,
1772
+ name: string,
1773
+ ): ResourceEventsResult {
1362
1774
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
1363
1775
  // but callers pass the URL-form kind ("pods").
1364
1776
  const singularKind = pluralToKind(kind)
@@ -1424,8 +1836,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1424
1836
  export interface ContainerMetrics {
1425
1837
  name: string
1426
1838
  usage: {
1427
- cpu: string // e.g., "10m" (millicores)
1428
- memory: string // e.g., "128Mi"
1839
+ cpu: string // e.g., "10m" (millicores)
1840
+ memory: string // e.g., "128Mi"
1429
1841
  }
1430
1842
  }
1431
1843
 
@@ -1500,8 +1912,8 @@ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }
1500
1912
 
1501
1913
  export interface MetricsDataPoint {
1502
1914
  timestamp: string
1503
- cpu: number // CPU in nanocores
1504
- memory: number // Memory in bytes
1915
+ cpu: number // CPU in nanocores
1916
+ memory: number // Memory in bytes
1505
1917
  }
1506
1918
 
1507
1919
  export interface ContainerMetricsHistory {
@@ -1530,7 +1942,9 @@ export interface NodeMetricsHistory {
1530
1942
  metricsUnavailableReason?: string
1531
1943
  }
1532
1944
 
1533
- function withoutCollectionError<T extends { collectionError?: string; rawCollectionError?: string }>(history: T): T {
1945
+ function withoutCollectionError<
1946
+ T extends { collectionError?: string; rawCollectionError?: string },
1947
+ >(history: T): T {
1534
1948
  const next = { ...history }
1535
1949
  delete next.collectionError
1536
1950
  delete next.rawCollectionError
@@ -1539,15 +1953,26 @@ function withoutCollectionError<T extends { collectionError?: string; rawCollect
1539
1953
 
1540
1954
  export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1541
1955
  if (history.metricsUnavailable !== true) return history
1542
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1956
+ return {
1957
+ ...withoutCollectionError(history),
1958
+ metricsUnavailable: true,
1959
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1960
+ }
1543
1961
  }
1544
1962
 
1545
1963
  export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1546
1964
  if (history.metricsUnavailable !== true) return history
1547
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1965
+ return {
1966
+ ...withoutCollectionError(history),
1967
+ metricsUnavailable: true,
1968
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1969
+ }
1548
1970
  }
1549
1971
 
1550
- export function shouldFetchLiveMetrics(historySettled: boolean, metricsUnavailable: boolean): boolean {
1972
+ export function shouldFetchLiveMetrics(
1973
+ historySettled: boolean,
1974
+ metricsUnavailable: boolean,
1975
+ ): boolean {
1551
1976
  return historySettled && !metricsUnavailable
1552
1977
  }
1553
1978
 
@@ -1555,7 +1980,11 @@ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: u
1555
1980
  return liveMetricsEnabled && metrics === null
1556
1981
  }
1557
1982
 
1558
- export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUnavailable: boolean, metrics: T | null | undefined): T | undefined {
1983
+ export function getVisibleLiveMetrics<T>(
1984
+ liveMetricsEnabled: boolean,
1985
+ metricsUnavailable: boolean,
1986
+ metrics: T | null | undefined,
1987
+ ): T | undefined {
1559
1988
  if (!liveMetricsEnabled || metricsUnavailable) return undefined
1560
1989
  return metrics ?? undefined
1561
1990
  }
@@ -1564,7 +1993,10 @@ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUna
1564
1993
  export function usePodMetricsHistory(namespace: string, podName: string) {
1565
1994
  return useQuery<PodMetricsHistory>({
1566
1995
  queryKey: ['pod-metrics-history', namespace, podName],
1567
- queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
1996
+ queryFn: async () =>
1997
+ normalizePodMetricsHistory(
1998
+ await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
1999
+ ),
1568
2000
  enabled: Boolean(namespace && podName),
1569
2001
  staleTime: 25000, // Slightly less than poll interval
1570
2002
  refetchInterval: 30000, // Match the backend poll interval
@@ -1575,7 +2007,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1575
2007
  export function useNodeMetricsHistory(nodeName: string) {
1576
2008
  return useQuery<NodeMetricsHistory>({
1577
2009
  queryKey: ['node-metrics-history', nodeName],
1578
- queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
2010
+ queryFn: async () =>
2011
+ normalizeNodeMetricsHistory(
2012
+ await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
2013
+ ),
1579
2014
  enabled: Boolean(nodeName),
1580
2015
  staleTime: 25000,
1581
2016
  refetchInterval: 30000,
@@ -1586,20 +2021,20 @@ export function useNodeMetricsHistory(nodeName: string) {
1586
2021
  export interface TopPodMetrics {
1587
2022
  namespace: string
1588
2023
  name: string
1589
- cpu: number // nanocores (usage)
1590
- memory: number // bytes (usage)
1591
- cpuRequest: number // nanocores (sum across containers)
1592
- cpuLimit: number // nanocores (sum across containers)
2024
+ cpu: number // nanocores (usage)
2025
+ memory: number // bytes (usage)
2026
+ cpuRequest: number // nanocores (sum across containers)
2027
+ cpuLimit: number // nanocores (sum across containers)
1593
2028
  memoryRequest: number // bytes (sum across containers)
1594
- memoryLimit: number // bytes (sum across containers)
2029
+ memoryLimit: number // bytes (sum across containers)
1595
2030
  }
1596
2031
 
1597
2032
  export interface TopNodeMetrics {
1598
2033
  name: string
1599
- cpu: number // nanocores (usage)
1600
- memory: number // bytes (usage)
1601
- podCount: number // pods scheduled on this node
1602
- cpuAllocatable: number // nanocores
2034
+ cpu: number // nanocores (usage)
2035
+ memory: number // bytes (usage)
2036
+ podCount: number // pods scheduled on this node
2037
+ cpuAllocatable: number // nanocores
1603
2038
  memoryAllocatable: number // bytes
1604
2039
  }
1605
2040
 
@@ -1675,11 +2110,13 @@ export interface PrometheusResourceMetrics {
1675
2110
  range: string
1676
2111
  result: PrometheusQueryResult
1677
2112
  query?: string // PromQL query (included when result is empty, for diagnostics)
1678
- hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
2113
+ hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
1679
2114
  }
1680
2115
 
1681
- export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
1682
- export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
2116
+ export type PrometheusMetricCategory =
2117
+ 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
2118
+ export type PrometheusTimeRange =
2119
+ '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
1683
2120
 
1684
2121
  // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
1685
2122
  // HasData=false silently indicates the CSI driver doesn't report or Prom
@@ -1693,17 +2130,49 @@ export interface PrometheusPVCUsage {
1693
2130
  hasData: boolean
1694
2131
  }
1695
2132
 
1696
- export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
2133
+ export type RightsizingFit =
2134
+ 'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
2135
+ export type RightsizingConfidence = 'low' | 'medium' | 'high'
2136
+ export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
1697
2137
 
1698
2138
  export interface RightsizingRow {
1699
2139
  container: string
1700
2140
  resource: 'cpu' | 'memory'
2141
+ fit: RightsizingFit
2142
+ confidence: RightsizingConfidence
1701
2143
  currentRequest?: string
2144
+ currentRequestValue?: number
1702
2145
  currentLimit?: string
1703
- p95?: string
2146
+ currentLimitValue?: number
2147
+ observed?: {
2148
+ name: 'P95' | 'P99' | 'Max'
2149
+ value: number
2150
+ formatted: string
2151
+ }
2152
+ peak?: {
2153
+ name: 'P99'
2154
+ value: number
2155
+ formatted: string
2156
+ }
2157
+ calculatedRequest?: string
2158
+ calculatedRequestValue?: number
1704
2159
  recommendedRequest?: string
1705
- tone: RightsizingTone
1706
- message: string
2160
+ recommendedRequestValue?: number
2161
+ reductionLimited?: boolean
2162
+ bursty?: boolean
2163
+ recommendationReason?: string
2164
+ sampleCount: number
2165
+ expectedSamples: number
2166
+ coverage: number
2167
+ hpaManaged: boolean
2168
+ hpaEvidenceAvailable: boolean
2169
+ throttleAvailable?: boolean
2170
+ throttleRatio?: number
2171
+ currentPodOOM?: boolean
2172
+ windowOomEvidence?: boolean
2173
+ oomEvidenceAvailable: boolean
2174
+ limitConflict?: boolean
2175
+ queryError?: string
1707
2176
  }
1708
2177
 
1709
2178
  export interface PrometheusRightsizing {
@@ -1711,11 +2180,46 @@ export interface PrometheusRightsizing {
1711
2180
  namespace: string
1712
2181
  name: string
1713
2182
  window: string
2183
+ source: 'radar'
2184
+ ownerCoverage: RightsizingOwnerCoverage
2185
+ scaledToZero: boolean
1714
2186
  sampleAvailable: boolean
1715
2187
  rows: RightsizingRow[]
1716
2188
  reason?: string
1717
2189
  }
1718
2190
 
2191
+ export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
2192
+
2193
+ export interface RightsizingScanWorkload {
2194
+ kind: string
2195
+ namespace: string
2196
+ name: string
2197
+ replicas: number
2198
+ scaledToZero: boolean
2199
+ rows: RightsizingRow[]
2200
+ }
2201
+
2202
+ export interface RightsizingScanCoverage {
2203
+ workloadsDiscovered: number
2204
+ workloadsEvaluated: number
2205
+ workloadsWithData: number
2206
+ batches: number
2207
+ completedBatches: number
2208
+ restrictedKinds?: string[]
2209
+ unavailableKinds?: string[]
2210
+ }
2211
+
2212
+ export interface RightsizingScanResponse {
2213
+ state: RightsizingScanState
2214
+ scannedAt: string
2215
+ window: string
2216
+ source: 'radar'
2217
+ coverage: RightsizingScanCoverage
2218
+ workloads: RightsizingScanWorkload[]
2219
+ warnings?: { code: string; message: string }[]
2220
+ reason?: string
2221
+ }
2222
+
1719
2223
  // Check Prometheus availability
1720
2224
  export function usePrometheusStatus() {
1721
2225
  return useQuery<PrometheusStatus>({
@@ -1726,12 +2230,32 @@ export function usePrometheusStatus() {
1726
2230
  })
1727
2231
  }
1728
2232
 
2233
+ export interface ArgoStatus {
2234
+ // configured = a URL or token is set; connected = a probe has landed and the
2235
+ // client is live. The two differ right after a restart (configured, reconnecting).
2236
+ configured: boolean
2237
+ connected: boolean
2238
+ address?: string
2239
+ }
2240
+
2241
+ export function useArgoStatus(enabled = true) {
2242
+ return useQuery<ArgoStatus>({
2243
+ queryKey: ['argocd-status'],
2244
+ queryFn: () => fetchJSON('/integrations/argocd/status'),
2245
+ enabled,
2246
+ staleTime: 30000,
2247
+ refetchInterval: 60000,
2248
+ })
2249
+ }
2250
+
1729
2251
  // Connect to Prometheus (trigger discovery)
1730
2252
  export function usePrometheusConnect() {
1731
2253
  const queryClient = useQueryClient()
1732
2254
  return useMutation({
1733
2255
  mutationFn: async () => {
1734
- const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
2256
+ const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
2257
+ method: 'POST',
2258
+ })
1735
2259
  if (!resp.ok) {
1736
2260
  const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
1737
2261
  throw new Error(body.error || `HTTP ${resp.status}`)
@@ -1792,7 +2316,9 @@ export function useAutoPromConnect(): void {
1792
2316
 
1793
2317
  // Persist the "we've connected here before" signal once a connection lands.
1794
2318
  if (status?.connected) {
1795
- try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
2319
+ try {
2320
+ window.localStorage.setItem(promAutoConnectKey(context), '1')
2321
+ } catch {
1796
2322
  // localStorage can throw in some restricted browser modes — fail open.
1797
2323
  }
1798
2324
  return
@@ -1800,7 +2326,9 @@ export function useAutoPromConnect(): void {
1800
2326
 
1801
2327
  if (attemptedRef.current === context) return
1802
2328
  let cached: string | null = null
1803
- try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
2329
+ try {
2330
+ cached = window.localStorage.getItem(promAutoConnectKey(context))
2331
+ } catch {
1804
2332
  // keep the null fallback
1805
2333
  }
1806
2334
 
@@ -1812,16 +2340,20 @@ export function useAutoPromConnect(): void {
1812
2340
  const timeout = window.setTimeout(() => {
1813
2341
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1814
2342
  // meta-driven toast handler stays silent — the user didn't click anything.
1815
- apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, { method: 'POST' })
1816
- .then(async resp => {
2343
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
2344
+ method: 'POST',
2345
+ })
2346
+ .then(async (resp) => {
1817
2347
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1818
- const nextStatus = await resp.json() as PrometheusStatus
2348
+ const nextStatus = (await resp.json()) as PrometheusStatus
1819
2349
  queryClient.setQueryData(['prometheus-status'], nextStatus)
1820
2350
  if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
1821
2351
  queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1822
2352
  })
1823
2353
  .catch(() => {
1824
- try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
2354
+ try {
2355
+ window.localStorage.removeItem(promAutoConnectKey(context))
2356
+ } catch {
1825
2357
  // ignore — manual CTA will render once status refreshes
1826
2358
  }
1827
2359
  attemptedRef.current = null
@@ -1879,8 +2411,7 @@ export function usePrometheusClusterMetrics(
1879
2411
  ) {
1880
2412
  return useQuery<PrometheusResourceMetrics>({
1881
2413
  queryKey: ['prometheus-cluster-metrics', category, range],
1882
- queryFn: () =>
1883
- fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
2414
+ queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
1884
2415
  enabled,
1885
2416
  staleTime: 30000,
1886
2417
  refetchInterval: 60000,
@@ -1899,22 +2430,75 @@ export function usePrometheusPVCUsage(namespace: string, name: string, enabled =
1899
2430
  }
1900
2431
 
1901
2432
  // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
1902
- export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
2433
+ export function usePrometheusRightsizing(
2434
+ kind: string,
2435
+ namespace: string,
2436
+ name: string,
2437
+ enabled = true,
2438
+ ) {
1903
2439
  return useQuery<PrometheusRightsizing>({
1904
2440
  queryKey: ['prometheus-rightsizing', kind, namespace, name],
1905
2441
  queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
1906
2442
  enabled: enabled && Boolean(kind && namespace && name),
1907
- staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
2443
+ staleTime: 5 * 60 * 1000,
1908
2444
  refetchInterval: 10 * 60 * 1000,
1909
2445
  })
1910
2446
  }
1911
2447
 
2448
+ const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
2449
+
2450
+ export function getRightsizingScanCacheConfig(
2451
+ namespaces: string[],
2452
+ context = '',
2453
+ ): {
2454
+ namespaceKey: string
2455
+ queryKey: readonly ['prometheus-rightsizing-scan', string, string]
2456
+ queryFn: typeof skipToken
2457
+ gcTime: number
2458
+ } {
2459
+ const namespaceKey = [...namespaces].sort().join(',')
2460
+ return {
2461
+ namespaceKey,
2462
+ queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
2463
+ queryFn: skipToken,
2464
+ gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
2465
+ }
2466
+ }
2467
+
2468
+ // A fleet rightsizing scan is intentionally manual. It can query seven days of
2469
+ // Prometheus history for many containers, so navigation alone must never run it.
2470
+ export function useRightsizingScan(namespaces: string[], context = '') {
2471
+ const queryClient = useQueryClient()
2472
+ const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
2473
+ const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
2474
+ const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
2475
+ const mutation = useMutation({
2476
+ mutationFn: async (startedScope: typeof scanScope) => {
2477
+ const params = new URLSearchParams()
2478
+ if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
2479
+ const query = params.toString()
2480
+ return fetchJSON<RightsizingScanResponse>(
2481
+ `/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
2482
+ {
2483
+ method: 'POST',
2484
+ },
2485
+ )
2486
+ },
2487
+ onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
2488
+ })
2489
+ return {
2490
+ ...mutation,
2491
+ data: snapshot.data,
2492
+ mutate: () => mutation.mutate(scanScope),
2493
+ mutateAsync: () => mutation.mutateAsync(scanScope),
2494
+ }
2495
+ }
2496
+
1912
2497
  // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
1913
2498
  export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
1914
2499
  return useQuery<PrometheusQueryResult>({
1915
2500
  queryKey: ['promql-range', query, range],
1916
- queryFn: () =>
1917
- fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
2501
+ queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
1918
2502
  enabled: enabled && Boolean(query),
1919
2503
  staleTime: 30000,
1920
2504
  refetchInterval: 60000,
@@ -1947,12 +2531,16 @@ export interface LogStreamEvent {
1947
2531
  }
1948
2532
 
1949
2533
  // Fetch pod logs (non-streaming)
1950
- export function usePodLogs(namespace: string, podName: string, options?: {
1951
- container?: string
1952
- tailLines?: number
1953
- previous?: boolean
1954
- sinceSeconds?: number
1955
- }) {
2534
+ export function usePodLogs(
2535
+ namespace: string,
2536
+ podName: string,
2537
+ options?: {
2538
+ container?: string
2539
+ tailLines?: number
2540
+ previous?: boolean
2541
+ sinceSeconds?: number
2542
+ },
2543
+ ) {
1956
2544
  const params = new URLSearchParams()
1957
2545
  if (options?.container) params.set('container', options.container)
1958
2546
  if (options?.tailLines) params.set('tailLines', String(options.tailLines))
@@ -1961,8 +2549,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
1961
2549
  const queryString = params.toString()
1962
2550
 
1963
2551
  return useQuery<LogsResponse>({
1964
- queryKey: ['pod-logs', namespace, podName, options?.container, options?.tailLines, options?.previous, options?.sinceSeconds],
1965
- queryFn: () => fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
2552
+ queryKey: [
2553
+ 'pod-logs',
2554
+ namespace,
2555
+ podName,
2556
+ options?.container,
2557
+ options?.tailLines,
2558
+ options?.previous,
2559
+ options?.sinceSeconds,
2560
+ ],
2561
+ queryFn: () =>
2562
+ fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
1966
2563
  enabled: Boolean(namespace && podName),
1967
2564
  staleTime: 5000, // Allow refetch after 5 seconds
1968
2565
  })
@@ -1977,7 +2574,7 @@ export function createLogStream(
1977
2574
  tailLines?: number
1978
2575
  previous?: boolean
1979
2576
  sinceSeconds?: number
1980
- }
2577
+ },
1981
2578
  ): EventSource {
1982
2579
  const params = new URLSearchParams()
1983
2580
  if (options?.container) params.set('container', options.container)
@@ -1986,9 +2583,12 @@ export function createLogStream(
1986
2583
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
1987
2584
  const queryString = params.toString()
1988
2585
 
1989
- return new EventSource(`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`, {
1990
- withCredentials: getCredentialsMode() === 'include',
1991
- })
2586
+ return new EventSource(
2587
+ `${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
2588
+ {
2589
+ withCredentials: getCredentialsMode() === 'include',
2590
+ },
2591
+ )
1992
2592
  }
1993
2593
 
1994
2594
  // ============================================================================
@@ -2021,8 +2621,23 @@ export function useUpdateResource() {
2021
2621
  const queryClient = useQueryClient()
2022
2622
 
2023
2623
  return useMutation({
2024
- mutationFn: async ({ kind, namespace, name, yaml, force = true }: { kind: string; namespace: string; name: string; yaml: string; force?: boolean }) => {
2025
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2624
+ mutationFn: async ({
2625
+ kind,
2626
+ namespace,
2627
+ name,
2628
+ yaml,
2629
+ force = true,
2630
+ }: {
2631
+ kind: string
2632
+ namespace: string
2633
+ name: string
2634
+ yaml: string
2635
+ force?: boolean
2636
+ }) => {
2637
+ const url = new URL(
2638
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2639
+ window.location.origin,
2640
+ )
2026
2641
  if (!force) {
2027
2642
  url.searchParams.set('force', 'false')
2028
2643
  }
@@ -2050,16 +2665,22 @@ export function useUpdateResource() {
2050
2665
  // lagging cache — the change appears not to have taken effect.
2051
2666
  if (updated && typeof updated === 'object' && updated.metadata) {
2052
2667
  queryClient.setQueriesData(
2053
- { queryKey: ['resource', variables.kind, variables.namespace, variables.name] },
2668
+ {
2669
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2670
+ },
2054
2671
  (old: any) =>
2055
2672
  old && typeof old === 'object' && 'resource' in old
2056
2673
  ? { ...old, resource: updated }
2057
- : { resource: updated }
2674
+ : { resource: updated },
2058
2675
  )
2059
2676
  } else {
2060
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
2677
+ queryClient.invalidateQueries({
2678
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2679
+ })
2061
2680
  }
2062
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2681
+ queryClient.invalidateQueries({
2682
+ queryKey: ['resources', variables.kind],
2683
+ })
2063
2684
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2064
2685
  },
2065
2686
  })
@@ -2068,13 +2689,24 @@ export function useUpdateResource() {
2068
2689
  // Cascade delete preview — shows resources that will be garbage-collected
2069
2690
  export interface CascadeDeletePreview {
2070
2691
  root: { kind: string; namespace: string; name: string; group?: string }
2071
- dependents: { kind: string; namespace: string; name: string; group?: string }[]
2692
+ dependents: {
2693
+ kind: string
2694
+ namespace: string
2695
+ name: string
2696
+ group?: string
2697
+ }[]
2072
2698
  }
2073
2699
 
2074
- export function useCascadeDeletePreview(kind: string, namespace: string, name: string, enabled: boolean) {
2700
+ export function useCascadeDeletePreview(
2701
+ kind: string,
2702
+ namespace: string,
2703
+ name: string,
2704
+ enabled: boolean,
2705
+ ) {
2075
2706
  return useQuery<CascadeDeletePreview>({
2076
2707
  queryKey: ['cascade-preview', kind, namespace, name],
2077
- queryFn: () => fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2708
+ queryFn: () =>
2709
+ fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2078
2710
  enabled,
2079
2711
  staleTime: 30_000,
2080
2712
  })
@@ -2085,8 +2717,23 @@ export function useDeleteResource() {
2085
2717
  const queryClient = useQueryClient()
2086
2718
 
2087
2719
  return useMutation({
2088
- mutationFn: async ({ kind, group, namespace, name, force }: { kind: string; group?: string; namespace: string; name: string; force?: boolean }) => {
2089
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2720
+ mutationFn: async ({
2721
+ kind,
2722
+ group,
2723
+ namespace,
2724
+ name,
2725
+ force,
2726
+ }: {
2727
+ kind: string
2728
+ group?: string
2729
+ namespace: string
2730
+ name: string
2731
+ force?: boolean
2732
+ }) => {
2733
+ const url = new URL(
2734
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2735
+ window.location.origin,
2736
+ )
2090
2737
  if (group) {
2091
2738
  url.searchParams.set('group', group)
2092
2739
  }
@@ -2108,7 +2755,9 @@ export function useDeleteResource() {
2108
2755
  successMessage: 'Resource deleted',
2109
2756
  },
2110
2757
  onSuccess: (_, variables) => {
2111
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2758
+ queryClient.invalidateQueries({
2759
+ queryKey: ['resources', variables.kind],
2760
+ })
2112
2761
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2113
2762
  },
2114
2763
  })
@@ -2118,10 +2767,24 @@ export function useBulkDeleteResources() {
2118
2767
  const queryClient = useQueryClient()
2119
2768
 
2120
2769
  return useMutation({
2121
- mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
2770
+ mutationFn: async ({
2771
+ items,
2772
+ force,
2773
+ }: {
2774
+ items: Array<{
2775
+ kind: string
2776
+ group?: string
2777
+ namespace: string
2778
+ name: string
2779
+ }>
2780
+ force?: boolean
2781
+ }) => {
2122
2782
  const results = await Promise.allSettled(
2123
2783
  items.map(async ({ kind, group, namespace, name }) => {
2124
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2784
+ const url = new URL(
2785
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2786
+ window.location.origin,
2787
+ )
2125
2788
  if (group) url.searchParams.set('group', group)
2126
2789
  if (force) url.searchParams.set('force', 'true')
2127
2790
  const response = await apiFetch(url.toString(), { method: 'DELETE' })
@@ -2130,9 +2793,9 @@ export function useBulkDeleteResources() {
2130
2793
  throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
2131
2794
  }
2132
2795
  return { kind, namespace, name }
2133
- })
2796
+ }),
2134
2797
  )
2135
- const failed = results.filter(r => r.status === 'rejected')
2798
+ const failed = results.filter((r) => r.status === 'rejected')
2136
2799
  if (failed.length > 0) {
2137
2800
  throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
2138
2801
  }
@@ -2165,13 +2828,19 @@ interface BulkWorkloadMutationResult {
2165
2828
  }
2166
2829
 
2167
2830
  function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
2168
- return results.flatMap(r => r.status === 'rejected'
2169
- ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2170
- : []
2831
+ return results.flatMap((r) =>
2832
+ r.status === 'rejected'
2833
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2834
+ : [],
2171
2835
  )
2172
2836
  }
2173
2837
 
2174
- function bulkWorkloadFailureMessage(action: string, failed: number, total: number, messages: string[]): string {
2838
+ function bulkWorkloadFailureMessage(
2839
+ action: string,
2840
+ failed: number,
2841
+ total: number,
2842
+ messages: string[],
2843
+ ): string {
2175
2844
  return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
2176
2845
  }
2177
2846
 
@@ -2179,27 +2848,45 @@ export function useBulkRestartWorkloads() {
2179
2848
  const queryClient = useQueryClient()
2180
2849
 
2181
2850
  return useMutation({
2182
- mutationFn: async ({ items }: { items: BulkWorkloadItem[] }): Promise<BulkWorkloadMutationResult> => {
2851
+ mutationFn: async ({
2852
+ items,
2853
+ }: {
2854
+ items: BulkWorkloadItem[]
2855
+ }): Promise<BulkWorkloadMutationResult> => {
2183
2856
  if (items.length === 0) {
2184
2857
  return { requested: 0, succeeded: 0, failedMessages: [] }
2185
2858
  }
2186
2859
  const results = await Promise.allSettled(
2187
2860
  items.map(async ({ kind, namespace, name }) => {
2188
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
2189
- method: 'POST',
2190
- })
2861
+ const response = await apiFetch(
2862
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
2863
+ {
2864
+ method: 'POST',
2865
+ },
2866
+ )
2191
2867
  if (!response.ok) {
2192
2868
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2193
2869
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2194
2870
  }
2195
2871
  return { kind, namespace, name }
2196
- })
2872
+ }),
2197
2873
  )
2198
2874
  const failedMessages = failedBulkWorkloadMessages(results)
2199
2875
  if (failedMessages.length === items.length) {
2200
- throw new Error(bulkWorkloadFailureMessage('restart', failedMessages.length, items.length, failedMessages))
2876
+ throw new Error(
2877
+ bulkWorkloadFailureMessage(
2878
+ 'restart',
2879
+ failedMessages.length,
2880
+ items.length,
2881
+ failedMessages,
2882
+ ),
2883
+ )
2884
+ }
2885
+ return {
2886
+ requested: items.length,
2887
+ succeeded: items.length - failedMessages.length,
2888
+ failedMessages,
2201
2889
  }
2202
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2203
2890
  },
2204
2891
  meta: {
2205
2892
  errorMessage: 'Failed to restart some workloads',
@@ -2225,29 +2912,44 @@ export function useBulkScaleWorkloads() {
2225
2912
  const queryClient = useQueryClient()
2226
2913
 
2227
2914
  return useMutation({
2228
- mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }): Promise<BulkWorkloadMutationResult> => {
2915
+ mutationFn: async ({
2916
+ items,
2917
+ replicas,
2918
+ }: {
2919
+ items: BulkWorkloadItem[]
2920
+ replicas: number
2921
+ }): Promise<BulkWorkloadMutationResult> => {
2229
2922
  if (items.length === 0) {
2230
2923
  return { requested: 0, succeeded: 0, failedMessages: [] }
2231
2924
  }
2232
2925
  const results = await Promise.allSettled(
2233
2926
  items.map(async ({ kind, namespace, name }) => {
2234
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
2235
- method: 'POST',
2236
- headers: { 'Content-Type': 'application/json' },
2237
- body: JSON.stringify({ replicas }),
2238
- })
2927
+ const response = await apiFetch(
2928
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
2929
+ {
2930
+ method: 'POST',
2931
+ headers: { 'Content-Type': 'application/json' },
2932
+ body: JSON.stringify({ replicas }),
2933
+ },
2934
+ )
2239
2935
  if (!response.ok) {
2240
2936
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2241
2937
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2242
2938
  }
2243
2939
  return { kind, namespace, name }
2244
- })
2940
+ }),
2245
2941
  )
2246
2942
  const failedMessages = failedBulkWorkloadMessages(results)
2247
2943
  if (failedMessages.length === items.length) {
2248
- throw new Error(bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages))
2944
+ throw new Error(
2945
+ bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
2946
+ )
2947
+ }
2948
+ return {
2949
+ requested: items.length,
2950
+ succeeded: items.length - failedMessages.length,
2951
+ failedMessages,
2249
2952
  }
2250
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2251
2953
  },
2252
2954
  meta: {
2253
2955
  errorMessage: 'Failed to scale some workloads',
@@ -2281,7 +2983,17 @@ export function useApplyResource() {
2281
2983
  const queryClient = useQueryClient()
2282
2984
 
2283
2985
  return useMutation({
2284
- mutationFn: async ({ yaml, mode = 'apply', dryRun = false, force = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean; force?: boolean }) => {
2986
+ mutationFn: async ({
2987
+ yaml,
2988
+ mode = 'apply',
2989
+ dryRun = false,
2990
+ force = false,
2991
+ }: {
2992
+ yaml: string
2993
+ mode?: 'apply' | 'create'
2994
+ dryRun?: boolean
2995
+ force?: boolean
2996
+ }) => {
2285
2997
  const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
2286
2998
  url.searchParams.set('mode', mode)
2287
2999
  if (dryRun) {
@@ -2314,11 +3026,19 @@ export function useApplyResource() {
2314
3026
  // CronJob operations
2315
3027
  // ============================================================================
2316
3028
 
2317
- function invalidateCronJobOperationQueries(queryClient: ReturnType<typeof useQueryClient>, namespace: string, name: string) {
3029
+ function invalidateCronJobOperationQueries(
3030
+ queryClient: ReturnType<typeof useQueryClient>,
3031
+ namespace: string,
3032
+ name: string,
3033
+ ) {
2318
3034
  queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2319
3035
  queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2320
- queryClient.invalidateQueries({ queryKey: ['resource', 'cronjobs', namespace, name] })
2321
- queryClient.invalidateQueries({ queryKey: ['workload-runs', 'cronjobs', namespace, name] })
3036
+ queryClient.invalidateQueries({
3037
+ queryKey: ['resource', 'cronjobs', namespace, name],
3038
+ })
3039
+ queryClient.invalidateQueries({
3040
+ queryKey: ['workload-runs', 'cronjobs', namespace, name],
3041
+ })
2322
3042
  queryClient.invalidateQueries({ queryKey: ['applications'] })
2323
3043
  queryClient.invalidateQueries({ queryKey: ['dashboard'] })
2324
3044
  queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
@@ -2409,10 +3129,21 @@ export function useRestartWorkload() {
2409
3129
  const queryClient = useQueryClient()
2410
3130
 
2411
3131
  return useMutation({
2412
- mutationFn: async ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => {
2413
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
2414
- method: 'POST',
2415
- })
3132
+ mutationFn: async ({
3133
+ kind,
3134
+ namespace,
3135
+ name,
3136
+ }: {
3137
+ kind: string
3138
+ namespace: string
3139
+ name: string
3140
+ }) => {
3141
+ const response = await apiFetch(
3142
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
3143
+ {
3144
+ method: 'POST',
3145
+ },
3146
+ )
2416
3147
  if (!response.ok) {
2417
3148
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2418
3149
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2424,7 +3155,9 @@ export function useRestartWorkload() {
2424
3155
  successMessage: 'Workload restarting',
2425
3156
  },
2426
3157
  onSuccess: (_, variables) => {
2427
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
3158
+ queryClient.invalidateQueries({
3159
+ queryKey: ['resources', variables.kind],
3160
+ })
2428
3161
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2429
3162
  },
2430
3163
  })
@@ -2435,12 +3168,25 @@ export function useScaleWorkload() {
2435
3168
  const queryClient = useQueryClient()
2436
3169
 
2437
3170
  return useMutation({
2438
- mutationFn: async ({ kind, namespace, name, replicas }: { kind: string; namespace: string; name: string; replicas: number }) => {
2439
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
2440
- method: 'POST',
2441
- headers: { 'Content-Type': 'application/json' },
2442
- body: JSON.stringify({ replicas }),
2443
- })
3171
+ mutationFn: async ({
3172
+ kind,
3173
+ namespace,
3174
+ name,
3175
+ replicas,
3176
+ }: {
3177
+ kind: string
3178
+ namespace: string
3179
+ name: string
3180
+ replicas: number
3181
+ }) => {
3182
+ const response = await apiFetch(
3183
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
3184
+ {
3185
+ method: 'POST',
3186
+ headers: { 'Content-Type': 'application/json' },
3187
+ body: JSON.stringify({ replicas }),
3188
+ },
3189
+ )
2444
3190
  if (!response.ok) {
2445
3191
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2446
3192
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2452,8 +3198,12 @@ export function useScaleWorkload() {
2452
3198
  successMessage: 'Workload scaled',
2453
3199
  },
2454
3200
  onSuccess: (_, variables) => {
2455
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2456
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
3201
+ queryClient.invalidateQueries({
3202
+ queryKey: ['resources', variables.kind],
3203
+ })
3204
+ queryClient.invalidateQueries({
3205
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3206
+ })
2457
3207
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2458
3208
  },
2459
3209
  })
@@ -2473,7 +3223,12 @@ export interface WorkloadRevision {
2473
3223
  template?: string // Pod template spec as YAML (for revision diff)
2474
3224
  }
2475
3225
 
2476
- export function useWorkloadRevisions(kind: string, namespace: string, name: string, enabled = true) {
3226
+ export function useWorkloadRevisions(
3227
+ kind: string,
3228
+ namespace: string,
3229
+ name: string,
3230
+ enabled = true,
3231
+ ) {
2477
3232
  return useQuery<WorkloadRevision[]>({
2478
3233
  queryKey: ['workload-revisions', kind, namespace, name],
2479
3234
  queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
@@ -2484,12 +3239,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
2484
3239
  export function useRollbackWorkload() {
2485
3240
  const queryClient = useQueryClient()
2486
3241
  return useMutation({
2487
- mutationFn: async ({ kind, namespace, name, revision }: { kind: string; namespace: string; name: string; revision: number }) => {
2488
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`, {
2489
- method: 'POST',
2490
- headers: { 'Content-Type': 'application/json' },
2491
- body: JSON.stringify({ revision }),
2492
- })
3242
+ mutationFn: async ({
3243
+ kind,
3244
+ namespace,
3245
+ name,
3246
+ revision,
3247
+ }: {
3248
+ kind: string
3249
+ namespace: string
3250
+ name: string
3251
+ revision: number
3252
+ }) => {
3253
+ const response = await apiFetch(
3254
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
3255
+ {
3256
+ method: 'POST',
3257
+ headers: { 'Content-Type': 'application/json' },
3258
+ body: JSON.stringify({ revision }),
3259
+ },
3260
+ )
2493
3261
  if (!response.ok) {
2494
3262
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2495
3263
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2501,9 +3269,15 @@ export function useRollbackWorkload() {
2501
3269
  successMessage: 'Rollback initiated',
2502
3270
  },
2503
3271
  onSuccess: (_, variables) => {
2504
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2505
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
2506
- queryClient.invalidateQueries({ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name] })
3272
+ queryClient.invalidateQueries({
3273
+ queryKey: ['resources', variables.kind],
3274
+ })
3275
+ queryClient.invalidateQueries({
3276
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3277
+ })
3278
+ queryClient.invalidateQueries({
3279
+ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
3280
+ })
2507
3281
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2508
3282
  },
2509
3283
  })
@@ -2533,7 +3307,9 @@ export function useCordonNode() {
2533
3307
  },
2534
3308
  onSuccess: (_, variables) => {
2535
3309
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2536
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3310
+ queryClient.invalidateQueries({
3311
+ queryKey: ['resource', 'nodes', '', variables.name],
3312
+ })
2537
3313
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2538
3314
  },
2539
3315
  })
@@ -2559,7 +3335,9 @@ export function useUncordonNode() {
2559
3335
  },
2560
3336
  onSuccess: (_, variables) => {
2561
3337
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2562
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3338
+ queryClient.invalidateQueries({
3339
+ queryKey: ['resource', 'nodes', '', variables.name],
3340
+ })
2563
3341
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2564
3342
  },
2565
3343
  })
@@ -2592,7 +3370,9 @@ export function useDrainNode() {
2592
3370
  },
2593
3371
  onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
2594
3372
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2595
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3373
+ queryClient.invalidateQueries({
3374
+ queryKey: ['resource', 'nodes', '', variables.name],
3375
+ })
2596
3376
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2597
3377
 
2598
3378
  const evicted = data?.evictedPods?.length ?? 0
@@ -2642,12 +3422,19 @@ export function useHelmRelease(namespace: string, name: string, options?: { enab
2642
3422
  // `enabled` lets callers skip the query when the user's Cloud role
2643
3423
  // would 403 the read — saves a round-trip and avoids a transient
2644
3424
  // "error" state that the role-gated empty panel doesn't need.
2645
- export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
3425
+ export function useHelmManifest(
3426
+ namespace: string,
3427
+ name: string,
3428
+ revision?: number,
3429
+ enabled = true,
3430
+ ) {
2646
3431
  const params = revision ? `?revision=${revision}` : ''
2647
3432
  return useQuery<string>({
2648
3433
  queryKey: ['helm-manifest', namespace, name, revision],
2649
3434
  queryFn: async () => {
2650
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`)
3435
+ const response = await apiFetch(
3436
+ `${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
3437
+ )
2651
3438
  if (!response.ok) {
2652
3439
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2653
3440
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2660,7 +3447,13 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
2660
3447
  }
2661
3448
 
2662
3449
  // Get values for a Helm release. `enabled` see useHelmManifest.
2663
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
3450
+ export function useHelmValues(
3451
+ namespace: string,
3452
+ name: string,
3453
+ allValues?: boolean,
3454
+ enabled = true,
3455
+ revision?: number,
3456
+ ) {
2664
3457
  const params = new URLSearchParams()
2665
3458
  if (allValues) params.set('all', 'true')
2666
3459
  if (revision && revision > 0) params.set('revision', String(revision))
@@ -2684,8 +3477,12 @@ export function useHelmManifestDiff(
2684
3477
  return useQuery<ManifestDiff>({
2685
3478
  queryKey: ['helm-diff', namespace, name, revision1, revision2],
2686
3479
  queryFn: () =>
2687
- fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
2688
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3480
+ fetchJSON(
3481
+ `/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
3482
+ ),
3483
+ enabled: Boolean(
3484
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3485
+ ),
2689
3486
  staleTime: 60000,
2690
3487
  })
2691
3488
  }
@@ -2708,7 +3505,9 @@ export function useHelmValuesDiff(
2708
3505
  if (allValues) params.set('all', 'true')
2709
3506
  return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
2710
3507
  },
2711
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3508
+ enabled: Boolean(
3509
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3510
+ ),
2712
3511
  staleTime: 60000,
2713
3512
  })
2714
3513
  }
@@ -2723,8 +3522,12 @@ export function useHelmNotesDiff(
2723
3522
  return useQuery<NotesDiff>({
2724
3523
  queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
2725
3524
  queryFn: () =>
2726
- fetchJSON(`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`),
2727
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3525
+ fetchJSON(
3526
+ `/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
3527
+ ),
3528
+ enabled: Boolean(
3529
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3530
+ ),
2728
3531
  staleTime: 60000,
2729
3532
  })
2730
3533
  }
@@ -2739,8 +3542,12 @@ export function useHelmHooksDiff(
2739
3542
  return useQuery<HooksDiff>({
2740
3543
  queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2741
3544
  queryFn: () =>
2742
- fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
2743
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3545
+ fetchJSON(
3546
+ `/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
3547
+ ),
3548
+ enabled: Boolean(
3549
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3550
+ ),
2744
3551
  staleTime: 60000,
2745
3552
  })
2746
3553
  }
@@ -2755,8 +3562,12 @@ export function useHelmResourceDiff(
2755
3562
  return useQuery<ResourceDiff>({
2756
3563
  queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
2757
3564
  queryFn: () =>
2758
- fetchJSON(`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`),
2759
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3565
+ fetchJSON(
3566
+ `/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
3567
+ ),
3568
+ enabled: Boolean(
3569
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3570
+ ),
2760
3571
  staleTime: 60000,
2761
3572
  })
2762
3573
  }
@@ -2806,10 +3617,21 @@ export function useHelmRollback() {
2806
3617
  const queryClient = useQueryClient()
2807
3618
 
2808
3619
  return useMutation({
2809
- mutationFn: async ({ namespace, name, revision }: { namespace: string; name: string; revision: number }) => {
2810
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`, {
2811
- method: 'POST',
2812
- })
3620
+ mutationFn: async ({
3621
+ namespace,
3622
+ name,
3623
+ revision,
3624
+ }: {
3625
+ namespace: string
3626
+ name: string
3627
+ revision: number
3628
+ }) => {
3629
+ const response = await apiFetch(
3630
+ `${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
3631
+ {
3632
+ method: 'POST',
3633
+ },
3634
+ )
2813
3635
  if (!response.ok) {
2814
3636
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2815
3637
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2822,7 +3644,9 @@ export function useHelmRollback() {
2822
3644
  },
2823
3645
  onSuccess: (_, variables) => {
2824
3646
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
2825
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
3647
+ queryClient.invalidateQueries({
3648
+ queryKey: ['helm-release', variables.namespace, variables.name],
3649
+ })
2826
3650
  },
2827
3651
  })
2828
3652
  }
@@ -2906,7 +3730,9 @@ function streamHelmProgress(
2906
3730
  return
2907
3731
  }
2908
3732
  } catch (err) {
2909
- reject(err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`))
3733
+ reject(
3734
+ err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
3735
+ )
2910
3736
  return
2911
3737
  }
2912
3738
  }
@@ -2927,12 +3753,16 @@ export function upgradeWithProgress(
2927
3753
  version: string,
2928
3754
  repositoryName: string | undefined,
2929
3755
  onProgress: (event: InstallProgressEvent) => void,
2930
- values?: Record<string, unknown>
3756
+ values?: Record<string, unknown>,
2931
3757
  ): Promise<void> {
2932
3758
  const params = new URLSearchParams({ version })
2933
3759
  if (repositoryName) params.set('repository', repositoryName)
2934
3760
  const options: RequestInit = values
2935
- ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values }) }
3761
+ ? {
3762
+ method: 'POST',
3763
+ headers: { 'Content-Type': 'application/json' },
3764
+ body: JSON.stringify({ values }),
3765
+ }
2936
3766
  : { method: 'POST' }
2937
3767
  return streamHelmProgress(
2938
3768
  `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
@@ -2947,7 +3777,7 @@ export function rollbackWithProgress(
2947
3777
  namespace: string,
2948
3778
  name: string,
2949
3779
  revision: number,
2950
- onProgress: (event: InstallProgressEvent) => void
3780
+ onProgress: (event: InstallProgressEvent) => void,
2951
3781
  ): Promise<void> {
2952
3782
  return streamHelmProgress(
2953
3783
  `${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
@@ -2960,13 +3790,26 @@ export function rollbackWithProgress(
2960
3790
  // When `version` is supplied, preview renders against that target chart version
2961
3791
  // instead of the release's current chart.
2962
3792
  export function useHelmPreviewValues() {
2963
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown>; version?: string; repository?: string }>({
3793
+ return useMutation<
3794
+ ValuesPreviewResponse,
3795
+ Error,
3796
+ {
3797
+ namespace: string
3798
+ name: string
3799
+ values: Record<string, unknown>
3800
+ version?: string
3801
+ repository?: string
3802
+ }
3803
+ >({
2964
3804
  mutationFn: async ({ namespace, name, values, version, repository }) => {
2965
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
2966
- method: 'POST',
2967
- headers: { 'Content-Type': 'application/json' },
2968
- body: JSON.stringify({ values, version, repository }),
2969
- })
3805
+ const response = await apiFetch(
3806
+ `${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
3807
+ {
3808
+ method: 'POST',
3809
+ headers: { 'Content-Type': 'application/json' },
3810
+ body: JSON.stringify({ values, version, repository }),
3811
+ },
3812
+ )
2970
3813
  if (!response.ok) {
2971
3814
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2972
3815
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2981,7 +3824,15 @@ export function useHelmApplyValues() {
2981
3824
  const queryClient = useQueryClient()
2982
3825
 
2983
3826
  return useMutation({
2984
- mutationFn: async ({ namespace, name, values }: { namespace: string; name: string; values: Record<string, unknown> }) => {
3827
+ mutationFn: async ({
3828
+ namespace,
3829
+ name,
3830
+ values,
3831
+ }: {
3832
+ namespace: string
3833
+ name: string
3834
+ values: Record<string, unknown>
3835
+ }) => {
2985
3836
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
2986
3837
  method: 'PUT',
2987
3838
  headers: { 'Content-Type': 'application/json' },
@@ -2999,8 +3850,12 @@ export function useHelmApplyValues() {
2999
3850
  },
3000
3851
  onSuccess: (_, variables) => {
3001
3852
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
3002
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
3003
- queryClient.invalidateQueries({ queryKey: ['helm-values', variables.namespace, variables.name] })
3853
+ queryClient.invalidateQueries({
3854
+ queryKey: ['helm-release', variables.namespace, variables.name],
3855
+ })
3856
+ queryClient.invalidateQueries({
3857
+ queryKey: ['helm-values', variables.namespace, variables.name],
3858
+ })
3004
3859
  },
3005
3860
  })
3006
3861
  }
@@ -3097,7 +3952,10 @@ export function useAddOCISource() {
3097
3952
  const queryClient = useQueryClient()
3098
3953
  return useMutation({
3099
3954
  mutationFn: (source: string) => mutateOCISource('POST', source),
3100
- meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
3955
+ meta: {
3956
+ errorMessage: 'Failed to add chart source',
3957
+ successMessage: 'Chart source added',
3958
+ },
3101
3959
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
3102
3960
  })
3103
3961
  }
@@ -3106,7 +3964,10 @@ export function useRemoveOCISource() {
3106
3964
  const queryClient = useQueryClient()
3107
3965
  return useMutation({
3108
3966
  mutationFn: (source: string) => mutateOCISource('DELETE', source),
3109
- meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
3967
+ meta: {
3968
+ errorMessage: 'Failed to remove chart source',
3969
+ successMessage: 'Chart source removed',
3970
+ },
3110
3971
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
3111
3972
  })
3112
3973
  }
@@ -3178,11 +4039,15 @@ export interface InstallProgressEvent {
3178
4039
  // Install a chart with progress streaming via SSE
3179
4040
  export function installChartWithProgress(
3180
4041
  req: InstallChartRequest,
3181
- onProgress: (event: InstallProgressEvent) => void
4042
+ onProgress: (event: InstallProgressEvent) => void,
3182
4043
  ): Promise<HelmRelease> {
3183
4044
  return streamHelmProgress(
3184
4045
  `${getApiBase()}/helm/releases/install-stream`,
3185
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) },
4046
+ {
4047
+ method: 'POST',
4048
+ headers: { 'Content-Type': 'application/json' },
4049
+ body: JSON.stringify(req),
4050
+ },
3186
4051
  onProgress,
3187
4052
  'Install failed',
3188
4053
  ).then((event) => event.release as HelmRelease)
@@ -3198,8 +4063,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
3198
4063
  // Search charts on ArtifactHub
3199
4064
  export function useArtifactHubSearch(
3200
4065
  query: string,
3201
- options?: { offset?: number; limit?: number; official?: boolean; verified?: boolean; sort?: ArtifactHubSortOption },
3202
- enabled = true
4066
+ options?: {
4067
+ offset?: number
4068
+ limit?: number
4069
+ official?: boolean
4070
+ verified?: boolean
4071
+ sort?: ArtifactHubSortOption
4072
+ },
4073
+ enabled = true,
3203
4074
  ) {
3204
4075
  const params = new URLSearchParams()
3205
4076
  if (query) params.set('query', query)
@@ -3210,7 +4081,15 @@ export function useArtifactHubSearch(
3210
4081
  if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
3211
4082
 
3212
4083
  return useQuery<ArtifactHubSearchResult>({
3213
- queryKey: ['artifacthub-search', query, options?.offset, options?.limit, options?.official, options?.verified, options?.sort],
4084
+ queryKey: [
4085
+ 'artifacthub-search',
4086
+ query,
4087
+ options?.offset,
4088
+ options?.limit,
4089
+ options?.official,
4090
+ options?.verified,
4091
+ options?.sort,
4092
+ ],
3214
4093
  queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
3215
4094
  enabled: enabled && query.length > 0,
3216
4095
  staleTime: 60000, // 1 minute
@@ -3218,7 +4097,12 @@ export function useArtifactHubSearch(
3218
4097
  }
3219
4098
 
3220
4099
  // Get chart detail from ArtifactHub
3221
- export function useArtifactHubChart(repoName: string, chartName: string, version?: string, enabled = true) {
4100
+ export function useArtifactHubChart(
4101
+ repoName: string,
4102
+ chartName: string,
4103
+ version?: string,
4104
+ enabled = true,
4105
+ ) {
3222
4106
  const path = version
3223
4107
  ? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
3224
4108
  : `/helm/artifacthub/charts/${repoName}/${chartName}`
@@ -3239,7 +4123,8 @@ interface GitOpsMutationConfig<TVariables> {
3239
4123
  getPath: (variables: TVariables) => string
3240
4124
  getBody?: (variables: TVariables) => unknown
3241
4125
  errorMessage: string
3242
- successMessage: string
4126
+ successMessage?: string
4127
+ getSuccessMessage?: (data: GitOpsOperationResponse) => string
3243
4128
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
3244
4129
  }
3245
4130
 
@@ -3267,9 +4152,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
3267
4152
  errorMessage: config.errorMessage,
3268
4153
  successMessage: config.successMessage,
3269
4154
  },
3270
- onSuccess: (_, variables) => {
3271
- config.getInvalidateKeys(variables).forEach(key =>
3272
- queryClient.invalidateQueries({ queryKey: key })
4155
+ onSuccess: (data, variables) => {
4156
+ if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
4157
+ config.getInvalidateKeys(variables).forEach((key) =>
4158
+ queryClient.invalidateQueries({ queryKey: key }),
3273
4159
  )
3274
4160
  },
3275
4161
  })
@@ -3284,8 +4170,13 @@ type ArgoAppVars = { namespace: string; name: string }
3284
4170
  // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
3285
4171
  // useArgoSync sends these — splitting the type prevents callers from passing
3286
4172
  // resources/revision/prune to mutations that would silently drop them.
3287
- type ArgoSyncVars = ArgoAppVars & {
3288
- resources?: Array<{ group?: string; kind: string; namespace?: string; name: string }>
4173
+ export type ArgoSyncVars = ArgoAppVars & {
4174
+ resources?: Array<{
4175
+ group?: string
4176
+ kind: string
4177
+ namespace?: string
4178
+ name: string
4179
+ }>
3289
4180
  revision?: string
3290
4181
  prune?: boolean
3291
4182
  dryRun?: boolean
@@ -3297,6 +4188,31 @@ type ArgoSyncVars = ArgoAppVars & {
3297
4188
  syncOptions?: string[]
3298
4189
  }
3299
4190
 
4191
+ export interface ArgoResourceValidationResult {
4192
+ outcome: 'succeeded' | 'failed' | 'inconclusive'
4193
+ message: string
4194
+ resource?: {
4195
+ group?: string
4196
+ kind: string
4197
+ namespace?: string
4198
+ name: string
4199
+ status?: string
4200
+ message?: string
4201
+ }
4202
+ }
4203
+
4204
+ export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
4205
+ return {
4206
+ namespace,
4207
+ name,
4208
+ ...opts,
4209
+ resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
4210
+ revision: undefined,
4211
+ prune: false,
4212
+ applyOnly: false,
4213
+ }
4214
+ }
4215
+
3300
4216
  // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
3301
4217
  // DryRun mirror the sync flags so the rollback dialog can offer the same
3302
4218
  // safety net.
@@ -3378,6 +4294,31 @@ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
3378
4294
  getInvalidateKeys: argoInvalidateKeys,
3379
4295
  })
3380
4296
 
4297
+ export function useArgoResourceValidation() {
4298
+ const queryClient = useQueryClient()
4299
+ return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
4300
+ mutationFn: async (variables) => {
4301
+ const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
4302
+ method: 'POST',
4303
+ headers: { 'Content-Type': 'application/json' },
4304
+ body: JSON.stringify({
4305
+ resources: variables.resources,
4306
+ force: variables.force,
4307
+ syncOptions: variables.syncOptions,
4308
+ }),
4309
+ })
4310
+ if (!response.ok) {
4311
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4312
+ throw new Error(error.error || `HTTP ${response.status}`)
4313
+ }
4314
+ return response.json() as Promise<ArgoResourceValidationResult>
4315
+ },
4316
+ onSettled: (_, __, variables) => {
4317
+ argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
4318
+ },
4319
+ })
4320
+ }
4321
+
3381
4322
  export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3382
4323
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
3383
4324
  getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
@@ -3389,7 +4330,7 @@ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3389
4330
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
3390
4331
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
3391
4332
  errorMessage: 'Failed to terminate sync',
3392
- successMessage: 'Sync terminated',
4333
+ getSuccessMessage: (data) => data.message,
3393
4334
  getInvalidateKeys: argoInvalidateKeys,
3394
4335
  })
3395
4336
 
@@ -3412,11 +4353,22 @@ export function useArgoRefresh() {
3412
4353
  const queryClient = useQueryClient()
3413
4354
 
3414
4355
  return useMutation({
3415
- mutationFn: async ({ namespace, name, hard = false }: { namespace: string; name: string; hard?: boolean }) => {
4356
+ mutationFn: async ({
4357
+ namespace,
4358
+ name,
4359
+ hard = false,
4360
+ }: {
4361
+ namespace: string
4362
+ name: string
4363
+ hard?: boolean
4364
+ }) => {
3416
4365
  const params = hard ? '?type=hard' : ''
3417
- const response = await apiFetch(`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`, {
3418
- method: 'POST',
3419
- })
4366
+ const response = await apiFetch(
4367
+ `${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
4368
+ {
4369
+ method: 'POST',
4370
+ },
4371
+ )
3420
4372
  if (!response.ok) {
3421
4373
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
3422
4374
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -3433,7 +4385,7 @@ export function useArgoRefresh() {
3433
4385
  // Refresh — without these two extra keys the user clicks Refresh and
3434
4386
  // sees stale insight/tree data until the next staleTime tick.
3435
4387
  argoInvalidateKeys(variables).forEach((key) =>
3436
- queryClient.invalidateQueries({ queryKey: key })
4388
+ queryClient.invalidateQueries({ queryKey: key }),
3437
4389
  )
3438
4390
  },
3439
4391
  })
@@ -3491,7 +4443,9 @@ export function useSwitchContext() {
3491
4443
  } catch (error) {
3492
4444
  clearTimeout(timeoutId)
3493
4445
  if (error instanceof Error && error.name === 'AbortError') {
3494
- throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
4446
+ throw new Error('Context switch timed out. The cluster may be unreachable.', {
4447
+ cause: error,
4448
+ })
3495
4449
  }
3496
4450
  throw error
3497
4451
  }
@@ -3609,9 +4563,12 @@ export function useSetActiveNamespace() {
3609
4563
  error: error instanceof Error ? error.message : String(error),
3610
4564
  })
3611
4565
  if (error instanceof Error && error.name === 'AbortError') {
3612
- throw new Error(isRescope
3613
- ? 'Namespace rescope timed out. The cluster may still be loading.'
3614
- : 'Namespace switch timed out. The cluster may be unreachable.', { cause: error })
4566
+ throw new Error(
4567
+ isRescope
4568
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
4569
+ : 'Namespace switch timed out. The cluster may be unreachable.',
4570
+ { cause: error },
4571
+ )
3615
4572
  }
3616
4573
  throw error
3617
4574
  }
@@ -3623,7 +4580,9 @@ export function useSetActiveNamespace() {
3623
4580
  accessibleCount: scope.accessibleNamespaces.length,
3624
4581
  })
3625
4582
  if (scope.cacheScoped) {
3626
- queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
4583
+ queryClient.removeQueries({
4584
+ predicate: (query) => query.queryKey[0] !== 'namespace-scope',
4585
+ })
3627
4586
  }
3628
4587
  queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
3629
4588
  if (scope.cacheScoped) {
@@ -3654,7 +4613,7 @@ export function useImageMetadata(
3654
4613
  namespace: string,
3655
4614
  podName: string,
3656
4615
  pullSecrets: string[],
3657
- enabled = true
4616
+ enabled = true,
3658
4617
  ) {
3659
4618
  const params = new URLSearchParams()
3660
4619
  params.set('image', image)
@@ -3677,7 +4636,7 @@ export function useImageFilesystem(
3677
4636
  namespace: string,
3678
4637
  podName: string,
3679
4638
  pullSecrets: string[],
3680
- enabled = true
4639
+ enabled = true,
3681
4640
  ) {
3682
4641
  const params = new URLSearchParams()
3683
4642
  params.set('image', image)
@@ -3690,9 +4649,7 @@ export function useImageFilesystem(
3690
4649
  return useQuery<ImageFilesystem>({
3691
4650
  queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
3692
4651
  // Use skipToken to completely prevent the query from running when disabled
3693
- queryFn: shouldFetch
3694
- ? () => fetchJSON(`/images/inspect?${params.toString()}`)
3695
- : skipToken,
4652
+ queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
3696
4653
  staleTime: 300000, // 5 minutes - image content doesn't change
3697
4654
  retry: false, // Don't retry on auth errors
3698
4655
  })
@@ -3766,7 +4723,13 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
3766
4723
  })
3767
4724
  }
3768
4725
 
3769
- export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true, options?: { refetchActive?: boolean; clusterScoped?: boolean }) {
4726
+ export function useWorkloadRuns(
4727
+ kind: string,
4728
+ namespace: string,
4729
+ name: string,
4730
+ enabled = true,
4731
+ options?: { refetchActive?: boolean; clusterScoped?: boolean },
4732
+ ) {
3770
4733
  const clusterScoped = options?.clusterScoped ?? false
3771
4734
  const ns = clusterScoped ? '_' : namespace
3772
4735
  const params = new URLSearchParams()
@@ -3775,11 +4738,12 @@ export function useWorkloadRuns(kind: string, namespace: string, name: string, e
3775
4738
 
3776
4739
  return useQuery<WorkloadRunsResponse>({
3777
4740
  queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
3778
- queryFn: () => fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
4741
+ queryFn: () =>
4742
+ fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
3779
4743
  enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
3780
4744
  staleTime: 10000,
3781
4745
  refetchInterval: options?.refetchActive
3782
- ? (query) => query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
4746
+ ? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
3783
4747
  : false,
3784
4748
  })
3785
4749
  }
@@ -3793,7 +4757,7 @@ export function useWorkloadLogs(
3793
4757
  container?: string
3794
4758
  tailLines?: number
3795
4759
  sinceSeconds?: number
3796
- }
4760
+ },
3797
4761
  ) {
3798
4762
  const params = new URLSearchParams()
3799
4763
  if (options?.container) params.set('container', options.container)
@@ -3802,8 +4766,19 @@ export function useWorkloadLogs(
3802
4766
  const queryString = params.toString()
3803
4767
 
3804
4768
  return useQuery<WorkloadLogsResponse>({
3805
- queryKey: ['workload-logs', kind, namespace, name, options?.container, options?.tailLines, options?.sinceSeconds],
3806
- queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`),
4769
+ queryKey: [
4770
+ 'workload-logs',
4771
+ kind,
4772
+ namespace,
4773
+ name,
4774
+ options?.container,
4775
+ options?.tailLines,
4776
+ options?.sinceSeconds,
4777
+ ],
4778
+ queryFn: () =>
4779
+ fetchJSON(
4780
+ `/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
4781
+ ),
3807
4782
  enabled: Boolean(kind && namespace && name),
3808
4783
  staleTime: 5000,
3809
4784
  })
@@ -3818,7 +4793,7 @@ export function createWorkloadLogStream(
3818
4793
  container?: string
3819
4794
  tailLines?: number
3820
4795
  sinceSeconds?: number
3821
- }
4796
+ },
3822
4797
  ): EventSource {
3823
4798
  const params = new URLSearchParams()
3824
4799
  if (options?.container) params.set('container', options.container)
@@ -3826,9 +4801,12 @@ export function createWorkloadLogStream(
3826
4801
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
3827
4802
  const queryString = params.toString()
3828
4803
 
3829
- return new EventSource(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`, {
3830
- withCredentials: getCredentialsMode() === 'include',
3831
- })
4804
+ return new EventSource(
4805
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
4806
+ {
4807
+ withCredentials: getCredentialsMode() === 'include',
4808
+ },
4809
+ )
3832
4810
  }
3833
4811
 
3834
4812
  // ============================================================================