@skyhook-io/radar-app 1.8.7 → 1.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +58 -50
  3. package/src/api/client.argoResourceSync.test.ts +69 -0
  4. package/src/api/client.rightsizing.test.ts +32 -0
  5. package/src/api/client.ts +1222 -234
  6. package/src/api/timelineSource.ts +4 -2
  7. package/src/components/applications/ApplicationsView.tsx +613 -219
  8. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  9. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  10. package/src/components/cost/CostTrendChart.tsx +103 -72
  11. package/src/components/cost/CostView.test.ts +12 -0
  12. package/src/components/cost/CostView.tsx +494 -229
  13. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  14. package/src/components/cost/CostViewTabs.tsx +40 -0
  15. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  16. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  17. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  19. package/src/components/cost/cloud-console.test.ts +39 -0
  20. package/src/components/cost/cloud-console.ts +81 -0
  21. package/src/components/cost/errors.ts +8 -0
  22. package/src/components/cost/format.test.ts +27 -0
  23. package/src/components/cost/format.ts +46 -0
  24. package/src/components/cost/kinds.ts +5 -0
  25. package/src/components/diagnose/AISettings.tsx +7 -12
  26. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  27. package/src/components/gitops/GitOpsView.tsx +81 -14
  28. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  29. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  30. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  31. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  32. package/src/components/home/CostCard.tsx +21 -36
  33. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  34. package/src/components/resource/RightsizingStrip.tsx +319 -123
  35. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  36. package/src/components/rightsizing/copy.test.ts +56 -0
  37. package/src/components/rightsizing/model.test.ts +227 -0
  38. package/src/components/rightsizing/model.ts +158 -0
  39. package/src/components/rightsizing/presentation.test.ts +104 -0
  40. package/src/components/rightsizing/presentation.ts +94 -0
  41. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  42. package/src/components/settings/SettingsDialog.tsx +1268 -318
  43. package/src/components/timeline/TimelineList.tsx +35 -8
  44. package/src/components/timeline/TimelineView.tsx +156 -26
  45. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  46. package/src/components/workload/WorkloadView.tsx +711 -328
  47. package/src/index.css +5 -1
  48. 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
@@ -292,7 +320,15 @@ export interface DashboardCRDCount {
292
320
  }
293
321
 
294
322
  // Re-export shared types from k8s-ui — single source of truth
295
- import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue, IssueRecentChange } from '@skyhook-io/k8s-ui'
323
+ import type {
324
+ AuditCardData,
325
+ AuditFinding,
326
+ ResourceGroup,
327
+ CheckMeta,
328
+ Check,
329
+ Issue,
330
+ IssueRecentChange,
331
+ } from '@skyhook-io/k8s-ui'
296
332
  export type DashboardAudit = AuditCardData
297
333
  export type { AuditFinding, ResourceGroup, CheckMeta, Check }
298
334
 
@@ -361,7 +397,11 @@ export interface DashboardResponse {
361
397
  networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
362
398
  audit: DashboardAudit | null
363
399
  gitopsControllers: DashboardGitOpsControllers | null
364
- nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
400
+ nodeVersionSkew: {
401
+ versions: Record<string, string[]>
402
+ minVersion: string
403
+ maxVersion: string
404
+ } | null
365
405
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
366
406
  partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
367
407
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
@@ -437,7 +477,13 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
437
477
  // the "Operational Issues" section in the resource detail. Cluster-scoped
438
478
  // resources pass "_" for namespace; namespaced ones also scope the scan via
439
479
  // ?namespaces= for a cheap, bounded Compose.
440
- export function useResourceIssues(kind: string, group: string | undefined, namespace: string, name: string, enabled = true) {
480
+ export function useResourceIssues(
481
+ kind: string,
482
+ group: string | undefined,
483
+ namespace: string,
484
+ name: string,
485
+ enabled = true,
486
+ ) {
441
487
  const clusterScoped = !namespace
442
488
  const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
443
489
  const params = new URLSearchParams()
@@ -550,7 +596,8 @@ export interface OpenCostNamespaceCost {
550
596
  idleCost?: number
551
597
  }
552
598
 
553
- export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error'
599
+ export type CostUnavailableReason =
600
+ 'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
554
601
 
555
602
  export interface OpenCostSummary {
556
603
  available: boolean
@@ -564,11 +611,41 @@ export interface OpenCostSummary {
564
611
  namespaces?: OpenCostNamespaceCost[]
565
612
  }
566
613
 
614
+ const noPrometheusFirstSeenAt = new Map<string, number>()
615
+
616
+ function costRefetchInterval(
617
+ defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
618
+ contextName?: string,
619
+ ) {
620
+ return (query: {
621
+ queryHash?: string
622
+ queryKey?: unknown
623
+ state: {
624
+ data?: { available?: boolean; reason?: CostUnavailableReason }
625
+ dataUpdatedAt?: number
626
+ }
627
+ }) => {
628
+ const data = query.state.data
629
+ const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
630
+ if (data?.available === false && data.reason === 'no_prometheus') {
631
+ const now = Date.now()
632
+ const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
633
+ noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
634
+ return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
635
+ ? COST_DISCOVERY_RETRY_INTERVAL_MS
636
+ : defaultInterval
637
+ }
638
+ noPrometheusFirstSeenAt.delete(queryID)
639
+ return defaultInterval
640
+ }
641
+ }
642
+
567
643
  export function useOpenCostSummary() {
644
+ const clusterInfo = useClusterInfo()
568
645
  return useQuery<OpenCostSummary>({
569
646
  queryKey: ['opencost-summary'],
570
647
  queryFn: () => fetchJSON('/opencost/summary'),
571
- refetchInterval: COST_REFRESH_INTERVAL_MS,
648
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
572
649
  staleTime: 30000,
573
650
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
574
651
  })
@@ -584,6 +661,10 @@ export interface OpenCostWorkloadCost {
584
661
  replicas: number
585
662
  cpuUsageCost?: number
586
663
  memoryUsageCost?: number
664
+ cpuUsageAvailable: boolean
665
+ memoryUsageAvailable: boolean
666
+ cpuAllocationUse: number
667
+ memoryAllocationUse: number
587
668
  efficiency?: number
588
669
  idleCost?: number
589
670
  }
@@ -596,11 +677,41 @@ export interface OpenCostWorkloadResponse {
596
677
  }
597
678
 
598
679
  export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
680
+ const clusterInfo = useClusterInfo()
599
681
  return useQuery<OpenCostWorkloadResponse>({
600
682
  queryKey: ['opencost-workloads', namespace],
601
683
  queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
602
684
  enabled: (options?.enabled ?? true) && Boolean(namespace),
685
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
686
+ staleTime: 30000,
687
+ })
688
+ }
689
+
690
+ export interface OpenCostWorkloadDetailResponse {
691
+ available: boolean
692
+ reason?: CostUnavailableReason
693
+ namespace: string
694
+ kind: string
695
+ name: string
696
+ current?: OpenCostWorkloadCost
697
+ }
698
+
699
+ export function useOpenCostWorkload(
700
+ kind: string,
701
+ namespace: string,
702
+ name: string,
703
+ options?: { enabled?: boolean },
704
+ ) {
705
+ const clusterInfo = useClusterInfo()
706
+ return useQuery<OpenCostWorkloadDetailResponse>({
707
+ queryKey: ['opencost-workload', kind, namespace, name],
708
+ queryFn: () =>
709
+ fetchJSON(
710
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
711
+ ),
712
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
603
713
  staleTime: 30000,
714
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
604
715
  })
605
716
  }
606
717
 
@@ -625,18 +736,175 @@ export interface OpenCostTrendResponse {
625
736
  }
626
737
 
627
738
  export function useOpenCostTrend(range_: CostTimeRange = '24h') {
739
+ const clusterInfo = useClusterInfo()
628
740
  return useQuery<OpenCostTrendResponse>({
629
741
  queryKey: ['opencost-trend', range_],
630
742
  queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
631
743
  staleTime: 60000,
632
- refetchInterval: 120000, // Refresh every 2 minutes
744
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
633
745
  placeholderData: (prev) => prev,
634
746
  })
635
747
  }
636
748
 
749
+ export interface OpenCostWorkloadTrendResponse {
750
+ available: boolean
751
+ reason?: CostUnavailableReason
752
+ namespace: string
753
+ kind: string
754
+ name: string
755
+ range: string
756
+ windowTotalCost?: number
757
+ dataPoints?: OpenCostTrendDataPoint[]
758
+ }
759
+
760
+ export function useOpenCostWorkloadTrend(
761
+ kind: string,
762
+ namespace: string,
763
+ name: string,
764
+ range_: CostTimeRange = '24h',
765
+ options?: { enabled?: boolean },
766
+ ) {
767
+ const clusterInfo = useClusterInfo()
768
+ return useQuery<OpenCostWorkloadTrendResponse>({
769
+ queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
770
+ queryFn: () =>
771
+ fetchJSON(
772
+ `/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
773
+ ),
774
+ enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
775
+ staleTime: 60000,
776
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
777
+ })
778
+ }
779
+
780
+ export interface OpenCostApplicationWorkloadRef {
781
+ kind: string
782
+ namespace: string
783
+ name: string
784
+ }
785
+
786
+ export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
787
+ reason: CostUnavailableReason
788
+ scaledToZero?: boolean
789
+ }
790
+
791
+ export interface OpenCostApplicationCostCoverage {
792
+ total: number
793
+ included: number
794
+ unavailable?: OpenCostApplicationWorkloadStatus[]
795
+ unsupported?: OpenCostApplicationWorkloadRef[]
796
+ }
797
+
798
+ export interface OpenCostApplicationCostTotals {
799
+ hourlyCost: number
800
+ cpuCost: number
801
+ memoryCost: number
802
+ replicas: number
803
+ cpuUsageCost?: number
804
+ memoryUsageCost?: number
805
+ cpuUsageAvailable: boolean
806
+ memoryUsageAvailable: boolean
807
+ cpuAllocationUse: number
808
+ memoryAllocationUse: number
809
+ }
810
+
811
+ export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
812
+ available: boolean
813
+ reason?: CostUnavailableReason
814
+ scaledToZero?: boolean
815
+ current?: OpenCostWorkloadCost
816
+ }
817
+
818
+ export interface OpenCostApplicationCostResponse {
819
+ available: boolean
820
+ reason?: CostUnavailableReason
821
+ partial?: boolean
822
+ totals: OpenCostApplicationCostTotals
823
+ coverage: OpenCostApplicationCostCoverage
824
+ workloads?: OpenCostApplicationWorkloadCost[]
825
+ }
826
+
827
+ export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
828
+ windowTotalCost?: number
829
+ dataPoints?: OpenCostTrendDataPoint[]
830
+ }
831
+
832
+ export interface OpenCostApplicationCostTrendResponse {
833
+ available: boolean
834
+ reason?: CostUnavailableReason
835
+ range: string
836
+ partial?: boolean
837
+ windowTotalCost?: number
838
+ dataPoints?: OpenCostTrendDataPoint[]
839
+ series?: OpenCostApplicationCostTrendSeries[]
840
+ coverage: OpenCostApplicationCostCoverage
841
+ }
842
+
843
+ function stableOpenCostWorkloadRefs(
844
+ workloads: OpenCostApplicationWorkloadRef[],
845
+ ): OpenCostApplicationWorkloadRef[] {
846
+ const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
847
+ for (const workload of workloads) {
848
+ if (!workload.kind || !workload.namespace || !workload.name) continue
849
+ const ref = {
850
+ kind: workload.kind,
851
+ namespace: workload.namespace,
852
+ name: workload.name,
853
+ }
854
+ byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
855
+ }
856
+ return [...byKey.values()].sort((a, b) =>
857
+ `${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
858
+ )
859
+ }
860
+
861
+ export function useOpenCostApplicationCost(
862
+ workloads: OpenCostApplicationWorkloadRef[],
863
+ options?: { enabled?: boolean },
864
+ ) {
865
+ const clusterInfo = useClusterInfo()
866
+ const refs = stableOpenCostWorkloadRefs(workloads)
867
+ return useQuery<OpenCostApplicationCostResponse>({
868
+ queryKey: ['opencost-application', refs],
869
+ queryFn: ({ signal }) =>
870
+ fetchJSON('/opencost/application', {
871
+ method: 'POST',
872
+ headers: { 'Content-Type': 'application/json' },
873
+ body: JSON.stringify({ workloads: refs }),
874
+ signal,
875
+ }),
876
+ enabled: (options?.enabled ?? true) && refs.length > 0,
877
+ staleTime: 30000,
878
+ refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
879
+ })
880
+ }
881
+
882
+ export function useOpenCostApplicationCostTrend(
883
+ workloads: OpenCostApplicationWorkloadRef[],
884
+ range_: CostTimeRange = '24h',
885
+ options?: { enabled?: boolean },
886
+ ) {
887
+ const clusterInfo = useClusterInfo()
888
+ const refs = stableOpenCostWorkloadRefs(workloads)
889
+ return useQuery<OpenCostApplicationCostTrendResponse>({
890
+ queryKey: ['opencost-application-trend', refs, range_],
891
+ queryFn: ({ signal }) =>
892
+ fetchJSON('/opencost/application/trend', {
893
+ method: 'POST',
894
+ headers: { 'Content-Type': 'application/json' },
895
+ body: JSON.stringify({ workloads: refs, range: range_ }),
896
+ signal,
897
+ }),
898
+ enabled: (options?.enabled ?? true) && refs.length > 0,
899
+ staleTime: 60000,
900
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
901
+ })
902
+ }
903
+
637
904
  // Node cost breakdown
638
905
  export interface OpenCostNodeCost {
639
906
  name: string
907
+ providerID?: string
640
908
  instanceType?: string
641
909
  region?: string
642
910
  hourlyCost: number
@@ -651,11 +919,12 @@ export interface OpenCostNodeResponse {
651
919
  }
652
920
 
653
921
  export function useOpenCostNodes() {
922
+ const clusterInfo = useClusterInfo()
654
923
  return useQuery<OpenCostNodeResponse>({
655
924
  queryKey: ['opencost-nodes'],
656
925
  queryFn: () => fetchJSON('/opencost/nodes'),
657
926
  staleTime: 60000,
658
- refetchInterval: 120000,
927
+ refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
659
928
  placeholderData: (prev) => prev,
660
929
  })
661
930
  }
@@ -816,7 +1085,15 @@ const SEARCH_MIN_QUERY = 2
816
1085
  // health/issueCount per hit (rich rows). React Query's AbortSignal cancels
817
1086
  // overlapping scans on a new query. keepPreviousData avoids flicker while the
818
1087
  // next query resolves.
819
- export function useSearch(query: string, opts?: { limit?: number; context?: 'summary' | 'none'; enabled?: boolean; globalNs?: boolean }) {
1088
+ export function useSearch(
1089
+ query: string,
1090
+ opts?: {
1091
+ limit?: number
1092
+ context?: 'summary' | 'none'
1093
+ enabled?: boolean
1094
+ globalNs?: boolean
1095
+ },
1096
+ ) {
820
1097
  const trimmed = query.trim()
821
1098
  const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
822
1099
  const limit = opts?.limit ?? 20
@@ -828,7 +1105,10 @@ export function useSearch(query: string, opts?: { limit?: number; context?: 'sum
828
1105
  return useQuery<SearchResult>({
829
1106
  queryKey: ['search', trimmed, limit, context, globalNs],
830
1107
  queryFn: ({ signal }) =>
831
- fetchJSON<SearchResult>(`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`, signal),
1108
+ fetchJSON<SearchResult>(
1109
+ `/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
1110
+ signal,
1111
+ ),
832
1112
  enabled,
833
1113
  staleTime: 2000,
834
1114
  placeholderData: (prev) => prev, // keepPreviousData
@@ -863,7 +1143,10 @@ export function useCapabilities() {
863
1143
 
864
1144
  // Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
865
1145
  // have these permissions in specific namespaces.
866
- export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities | undefined) {
1146
+ export function useNamespaceCapabilities(
1147
+ namespace: string | undefined,
1148
+ globalCaps: Capabilities | undefined,
1149
+ ) {
867
1150
  const needsCheck = namespace && globalCaps
868
1151
  return useQuery<Capabilities>({
869
1152
  queryKey: ['capabilities', namespace],
@@ -902,7 +1185,11 @@ export function useAuthMe() {
902
1185
  // CloudRole.AtLeast — the frontend must agree with the backend on what
903
1186
  // "member-or-higher" means; otherwise we'd hide a button the
904
1187
  // backend would happily honor (or vice versa).
905
- const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
1188
+ const CLOUD_ROLE_RANK: Record<string, number> = {
1189
+ viewer: 1,
1190
+ member: 2,
1191
+ owner: 3,
1192
+ }
906
1193
 
907
1194
  /**
908
1195
  * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
@@ -983,7 +1270,15 @@ export function useNamespaces() {
983
1270
  }
984
1271
 
985
1272
  // Topology (for manual refresh)
986
- export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean; includeReplicaSets?: boolean; refetchInterval?: number | false }) {
1273
+ export function useTopology(
1274
+ namespaces: string[],
1275
+ viewMode: string = 'resources',
1276
+ options?: {
1277
+ enabled?: boolean
1278
+ includeReplicaSets?: boolean
1279
+ refetchInterval?: number | false
1280
+ },
1281
+ ) {
987
1282
  const params = new URLSearchParams()
988
1283
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
989
1284
  if (viewMode) params.set('view', viewMode)
@@ -1016,7 +1311,11 @@ export function useApplications(namespaces: string[], options?: { enabled?: bool
1016
1311
  })
1017
1312
  }
1018
1313
 
1019
- export function useApplicationHistory(appKey: string | undefined, namespaces: string[], options?: { enabled?: boolean }) {
1314
+ export function useApplicationHistory(
1315
+ appKey: string | undefined,
1316
+ namespaces: string[],
1317
+ options?: { enabled?: boolean },
1318
+ ) {
1020
1319
  const params = new URLSearchParams()
1021
1320
  if (appKey) params.set('app', appKey)
1022
1321
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
@@ -1031,7 +1330,14 @@ export function useApplicationHistory(appKey: string | undefined, namespaces: st
1031
1330
  })
1032
1331
  }
1033
1332
 
1034
- export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = [], options?: { enabled?: boolean }) {
1333
+ export function useGitOpsTree(
1334
+ kind: string,
1335
+ namespace: string,
1336
+ name: string,
1337
+ group?: string,
1338
+ namespaces: string[] = [],
1339
+ options?: { enabled?: boolean },
1340
+ ) {
1035
1341
  const ns = namespace || '_'
1036
1342
  const params = new URLSearchParams()
1037
1343
  if (group) params.set('group', group)
@@ -1040,7 +1346,8 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1040
1346
 
1041
1347
  return useQuery<GitOpsResourceTree>({
1042
1348
  queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
1043
- queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1349
+ queryFn: () =>
1350
+ fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1044
1351
  enabled: Boolean(kind && name) && (options?.enabled ?? true),
1045
1352
  staleTime: 5000,
1046
1353
  })
@@ -1048,11 +1355,17 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1048
1355
 
1049
1356
  // Poll fast (2s) while a sync/rollback is in flight so the user sees the
1050
1357
  // outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
1051
- // operationState.phase from "Running" -> Succeeded/Failed when done, so this
1358
+ // operationState.phase from Running/Terminating to a terminal phase, so this
1052
1359
  // auto-quiesces on completion.
1053
1360
  const INSIGHTS_RUNNING_POLL_MS = 2000
1054
1361
 
1055
- export function useGitOpsInsights(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
1362
+ export function useGitOpsInsights(
1363
+ kind: string,
1364
+ namespace: string,
1365
+ name: string,
1366
+ group?: string,
1367
+ namespaces: string[] = [],
1368
+ ) {
1056
1369
  const ns = namespace || '_'
1057
1370
  const params = new URLSearchParams()
1058
1371
  if (group) params.set('group', group)
@@ -1061,19 +1374,71 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
1061
1374
 
1062
1375
  return useQuery<GitOpsInsight>({
1063
1376
  queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
1064
- queryFn: () => fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1377
+ queryFn: () =>
1378
+ fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1065
1379
  enabled: Boolean(kind && name),
1066
1380
  staleTime: 5000,
1067
1381
  refetchInterval: (query) => {
1068
1382
  const phase = query.state.data?.summary?.operationPhase
1069
- return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
1383
+ return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
1070
1384
  },
1071
1385
  })
1072
1386
  }
1073
1387
 
1388
+ // Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
1389
+ // ns/name identify the Application; the ref identifies the managed resource.
1390
+ // Fetched on demand — the caller mounts this only when the user opens "Full
1391
+ // diff", so it's enabled whenever the ref is resolvable. Errors surface via
1392
+ // fetchJSON's ApiError (server {"error"} string as .message).
1393
+ export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
1394
+ const ns = appNamespace || '_'
1395
+ const params = new URLSearchParams()
1396
+ if (ref.group) params.set('group', ref.group)
1397
+ params.set('kind', ref.kind)
1398
+ if (ref.namespace) params.set('resourceNamespace', ref.namespace)
1399
+ params.set('resourceName', ref.name)
1400
+
1401
+ return useQuery<GitOpsResourceDiff>({
1402
+ queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
1403
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
1404
+ enabled: Boolean(appName && ref.kind && ref.name),
1405
+ staleTime: 15_000,
1406
+ })
1407
+ }
1408
+
1409
+ // Git commit metadata for one deployed revision of an Argo CD Application.
1410
+ // Enabled only when a revision is known and the caller passes `enabled` (gated
1411
+ // on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
1412
+ // metadata is effectively immutable.
1413
+ export function useArgoRevisionMetadata(
1414
+ appNamespace: string,
1415
+ appName: string,
1416
+ revision: string | undefined,
1417
+ opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
1418
+ ) {
1419
+ const ns = appNamespace || '_'
1420
+ const params = new URLSearchParams()
1421
+ if (revision) params.set('revision', revision)
1422
+ if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
1423
+ if (opts?.project) params.set('project', opts.project)
1424
+
1425
+ return useQuery<ArgoRevisionMetadata>({
1426
+ queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
1427
+ queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
1428
+ enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
1429
+ staleTime: 5 * 60_000,
1430
+ })
1431
+ }
1432
+
1074
1433
  // Generic resource fetching - returns resource with relationships
1075
1434
  // 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 }) {
1435
+ export function useResource<T>(
1436
+ kind: string,
1437
+ namespace: string,
1438
+ name: string,
1439
+ group?: string,
1440
+ options?: { enabled?: boolean; refetchInterval?: number | false },
1441
+ ) {
1077
1442
  // For cluster-scoped resources, use '_' as namespace placeholder
1078
1443
  const ns = namespace || '_'
1079
1444
  const params = new URLSearchParams()
@@ -1082,8 +1447,9 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1082
1447
 
1083
1448
  const query = useQuery<ResourceWithRelationships<T>>({
1084
1449
  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
1450
+ queryFn: () =>
1451
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1452
+ enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1087
1453
  refetchInterval: options?.refetchInterval,
1088
1454
  })
1089
1455
 
@@ -1098,7 +1464,12 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1098
1464
  }
1099
1465
 
1100
1466
  // Hook that returns full response with relationships explicitly
1101
- export function useResourceWithRelationships<T>(kind: string, namespace: string, name: string, group?: string) {
1467
+ export function useResourceWithRelationships<T>(
1468
+ kind: string,
1469
+ namespace: string,
1470
+ name: string,
1471
+ group?: string,
1472
+ ) {
1102
1473
  const ns = namespace || '_'
1103
1474
  const params = new URLSearchParams()
1104
1475
  if (group) params.set('group', group)
@@ -1106,7 +1477,8 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
1106
1477
 
1107
1478
  return useQuery<ResourceWithRelationships<T>>({
1108
1479
  queryKey: ['resource', kind, namespace, name, group],
1109
- queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1480
+ queryFn: () =>
1481
+ fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1110
1482
  enabled: Boolean(kind && name),
1111
1483
  })
1112
1484
  }
@@ -1199,7 +1571,11 @@ async function fetchChangesPage(
1199
1571
  // (Rows dropped by content filters inside the store query do not; see the
1200
1572
  // known limitation on the server's handleChanges.)
1201
1573
  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 }
1574
+ return {
1575
+ events,
1576
+ epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
1577
+ maxSeq,
1578
+ }
1203
1579
  }
1204
1580
 
1205
1581
  // Highest store-assigned arrival number in the cached page — the delta cursor.
@@ -1250,7 +1626,10 @@ export async function runDeltaSyncFetch(deps: {
1250
1626
  const meta = metaStore.get(metaKey)
1251
1627
  const cursor = deltaFetchCursor(meta, cached, now)
1252
1628
  if (cursor > 0) {
1253
- const delta = await fetchChangesPage(`${path}${queryString ? '&' : '?'}since_seq=${cursor}`, signal)
1629
+ const delta = await fetchChangesPage(
1630
+ `${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
1631
+ signal,
1632
+ )
1254
1633
  if (delta.epoch && delta.epoch === meta!.epoch) {
1255
1634
  meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
1256
1635
  // Returning the cached reference on an empty delta skips re-renders.
@@ -1260,7 +1639,11 @@ export async function runDeltaSyncFetch(deps: {
1260
1639
  // cursor is meaningless. Fall through to a full resync.
1261
1640
  }
1262
1641
  const full = await fetchChangesPage(path, signal)
1263
- metaStore.set(metaKey, { epoch: full.epoch, lastFullMs: now, highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)) })
1642
+ metaStore.set(metaKey, {
1643
+ epoch: full.epoch,
1644
+ lastFullMs: now,
1645
+ highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
1646
+ })
1264
1647
  return full.events
1265
1648
  }
1266
1649
 
@@ -1288,7 +1671,18 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1288
1671
  }
1289
1672
 
1290
1673
  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
1674
+ const {
1675
+ namespaces = [],
1676
+ kinds,
1677
+ timeRange = '1h',
1678
+ filter = 'all',
1679
+ includeK8sEvents = true,
1680
+ includeManaged = false,
1681
+ includeDeleted = true,
1682
+ limit = 200,
1683
+ enabled = true,
1684
+ deltaSync = false,
1685
+ } = options
1292
1686
  const queryClient = useQueryClient()
1293
1687
 
1294
1688
  // Only a single-kind selection narrows the server query; a multi-kind
@@ -1311,7 +1705,17 @@ export function useChanges(options: UseChangesOptions = {}) {
1311
1705
 
1312
1706
  const queryString = params.toString()
1313
1707
  const path = `/changes${queryString ? `?${queryString}` : ''}`
1314
- const queryKey = ['changes', namespaces, serverKind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit]
1708
+ const queryKey = [
1709
+ 'changes',
1710
+ namespaces,
1711
+ serverKind,
1712
+ timeRange,
1713
+ filter,
1714
+ includeK8sEvents,
1715
+ includeManaged,
1716
+ includeDeleted,
1717
+ limit,
1718
+ ]
1315
1719
 
1316
1720
  return useQuery<TimelineEvent[]>({
1317
1721
  queryKey,
@@ -1320,7 +1724,16 @@ export function useChanges(options: UseChangesOptions = {}) {
1320
1724
 
1321
1725
  const metaKey = JSON.stringify(queryKey)
1322
1726
  const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
1323
- return runDeltaSyncFetch({ path, queryString, limit, metaKey, cached, metaStore: changesDeltaMeta, now: Date.now(), signal })
1727
+ return runDeltaSyncFetch({
1728
+ path,
1729
+ queryString,
1730
+ limit,
1731
+ metaKey,
1732
+ cached,
1733
+ metaStore: changesDeltaMeta,
1734
+ now: Date.now(),
1735
+ signal,
1736
+ })
1324
1737
  },
1325
1738
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1326
1739
  refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
@@ -1329,7 +1742,12 @@ export function useChanges(options: UseChangesOptions = {}) {
1329
1742
  }
1330
1743
 
1331
1744
  // 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') {
1745
+ export function useResourceChildren(
1746
+ kind: string,
1747
+ namespace: string,
1748
+ name: string,
1749
+ timeRange: TimeRange = '1h',
1750
+ ) {
1333
1751
  const sinceDate = getTimeRangeDate(timeRange)
1334
1752
  const params = new URLSearchParams()
1335
1753
  if (sinceDate) {
@@ -1358,7 +1776,11 @@ export interface ResourceEventsResult {
1358
1776
  // K8s events and resource updates are fetched separately so a high-frequency
1359
1777
  // informer update stream (e.g. a CrashLoop status field flapping every few
1360
1778
  // seconds) can never starve out user-meaningful K8s events under a shared limit.
1361
- export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
1779
+ export function useResourceEvents(
1780
+ kind: string,
1781
+ namespace: string,
1782
+ name: string,
1783
+ ): ResourceEventsResult {
1362
1784
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
1363
1785
  // but callers pass the URL-form kind ("pods").
1364
1786
  const singularKind = pluralToKind(kind)
@@ -1424,8 +1846,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
1424
1846
  export interface ContainerMetrics {
1425
1847
  name: string
1426
1848
  usage: {
1427
- cpu: string // e.g., "10m" (millicores)
1428
- memory: string // e.g., "128Mi"
1849
+ cpu: string // e.g., "10m" (millicores)
1850
+ memory: string // e.g., "128Mi"
1429
1851
  }
1430
1852
  }
1431
1853
 
@@ -1500,8 +1922,8 @@ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }
1500
1922
 
1501
1923
  export interface MetricsDataPoint {
1502
1924
  timestamp: string
1503
- cpu: number // CPU in nanocores
1504
- memory: number // Memory in bytes
1925
+ cpu: number // CPU in nanocores
1926
+ memory: number // Memory in bytes
1505
1927
  }
1506
1928
 
1507
1929
  export interface ContainerMetricsHistory {
@@ -1530,7 +1952,9 @@ export interface NodeMetricsHistory {
1530
1952
  metricsUnavailableReason?: string
1531
1953
  }
1532
1954
 
1533
- function withoutCollectionError<T extends { collectionError?: string; rawCollectionError?: string }>(history: T): T {
1955
+ function withoutCollectionError<
1956
+ T extends { collectionError?: string; rawCollectionError?: string },
1957
+ >(history: T): T {
1534
1958
  const next = { ...history }
1535
1959
  delete next.collectionError
1536
1960
  delete next.rawCollectionError
@@ -1539,15 +1963,26 @@ function withoutCollectionError<T extends { collectionError?: string; rawCollect
1539
1963
 
1540
1964
  export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1541
1965
  if (history.metricsUnavailable !== true) return history
1542
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1966
+ return {
1967
+ ...withoutCollectionError(history),
1968
+ metricsUnavailable: true,
1969
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1970
+ }
1543
1971
  }
1544
1972
 
1545
1973
  export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1546
1974
  if (history.metricsUnavailable !== true) return history
1547
- return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1975
+ return {
1976
+ ...withoutCollectionError(history),
1977
+ metricsUnavailable: true,
1978
+ metricsUnavailableReason: history.rawCollectionError || history.collectionError,
1979
+ }
1548
1980
  }
1549
1981
 
1550
- export function shouldFetchLiveMetrics(historySettled: boolean, metricsUnavailable: boolean): boolean {
1982
+ export function shouldFetchLiveMetrics(
1983
+ historySettled: boolean,
1984
+ metricsUnavailable: boolean,
1985
+ ): boolean {
1551
1986
  return historySettled && !metricsUnavailable
1552
1987
  }
1553
1988
 
@@ -1555,7 +1990,11 @@ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: u
1555
1990
  return liveMetricsEnabled && metrics === null
1556
1991
  }
1557
1992
 
1558
- export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUnavailable: boolean, metrics: T | null | undefined): T | undefined {
1993
+ export function getVisibleLiveMetrics<T>(
1994
+ liveMetricsEnabled: boolean,
1995
+ metricsUnavailable: boolean,
1996
+ metrics: T | null | undefined,
1997
+ ): T | undefined {
1559
1998
  if (!liveMetricsEnabled || metricsUnavailable) return undefined
1560
1999
  return metrics ?? undefined
1561
2000
  }
@@ -1564,7 +2003,10 @@ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUna
1564
2003
  export function usePodMetricsHistory(namespace: string, podName: string) {
1565
2004
  return useQuery<PodMetricsHistory>({
1566
2005
  queryKey: ['pod-metrics-history', namespace, podName],
1567
- queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
2006
+ queryFn: async () =>
2007
+ normalizePodMetricsHistory(
2008
+ await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
2009
+ ),
1568
2010
  enabled: Boolean(namespace && podName),
1569
2011
  staleTime: 25000, // Slightly less than poll interval
1570
2012
  refetchInterval: 30000, // Match the backend poll interval
@@ -1575,7 +2017,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1575
2017
  export function useNodeMetricsHistory(nodeName: string) {
1576
2018
  return useQuery<NodeMetricsHistory>({
1577
2019
  queryKey: ['node-metrics-history', nodeName],
1578
- queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
2020
+ queryFn: async () =>
2021
+ normalizeNodeMetricsHistory(
2022
+ await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
2023
+ ),
1579
2024
  enabled: Boolean(nodeName),
1580
2025
  staleTime: 25000,
1581
2026
  refetchInterval: 30000,
@@ -1586,20 +2031,20 @@ export function useNodeMetricsHistory(nodeName: string) {
1586
2031
  export interface TopPodMetrics {
1587
2032
  namespace: string
1588
2033
  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)
2034
+ cpu: number // nanocores (usage)
2035
+ memory: number // bytes (usage)
2036
+ cpuRequest: number // nanocores (sum across containers)
2037
+ cpuLimit: number // nanocores (sum across containers)
1593
2038
  memoryRequest: number // bytes (sum across containers)
1594
- memoryLimit: number // bytes (sum across containers)
2039
+ memoryLimit: number // bytes (sum across containers)
1595
2040
  }
1596
2041
 
1597
2042
  export interface TopNodeMetrics {
1598
2043
  name: string
1599
- cpu: number // nanocores (usage)
1600
- memory: number // bytes (usage)
1601
- podCount: number // pods scheduled on this node
1602
- cpuAllocatable: number // nanocores
2044
+ cpu: number // nanocores (usage)
2045
+ memory: number // bytes (usage)
2046
+ podCount: number // pods scheduled on this node
2047
+ cpuAllocatable: number // nanocores
1603
2048
  memoryAllocatable: number // bytes
1604
2049
  }
1605
2050
 
@@ -1675,11 +2120,13 @@ export interface PrometheusResourceMetrics {
1675
2120
  range: string
1676
2121
  result: PrometheusQueryResult
1677
2122
  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)
2123
+ hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
1679
2124
  }
1680
2125
 
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'
2126
+ export type PrometheusMetricCategory =
2127
+ 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
2128
+ export type PrometheusTimeRange =
2129
+ '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
1683
2130
 
1684
2131
  // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
1685
2132
  // HasData=false silently indicates the CSI driver doesn't report or Prom
@@ -1693,17 +2140,49 @@ export interface PrometheusPVCUsage {
1693
2140
  hasData: boolean
1694
2141
  }
1695
2142
 
1696
- export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
2143
+ export type RightsizingFit =
2144
+ 'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
2145
+ export type RightsizingConfidence = 'low' | 'medium' | 'high'
2146
+ export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
1697
2147
 
1698
2148
  export interface RightsizingRow {
1699
2149
  container: string
1700
2150
  resource: 'cpu' | 'memory'
2151
+ fit: RightsizingFit
2152
+ confidence: RightsizingConfidence
1701
2153
  currentRequest?: string
2154
+ currentRequestValue?: number
1702
2155
  currentLimit?: string
1703
- p95?: string
2156
+ currentLimitValue?: number
2157
+ observed?: {
2158
+ name: 'P95' | 'P99' | 'Max'
2159
+ value: number
2160
+ formatted: string
2161
+ }
2162
+ peak?: {
2163
+ name: 'P99'
2164
+ value: number
2165
+ formatted: string
2166
+ }
2167
+ calculatedRequest?: string
2168
+ calculatedRequestValue?: number
1704
2169
  recommendedRequest?: string
1705
- tone: RightsizingTone
1706
- message: string
2170
+ recommendedRequestValue?: number
2171
+ reductionLimited?: boolean
2172
+ bursty?: boolean
2173
+ recommendationReason?: string
2174
+ sampleCount: number
2175
+ expectedSamples: number
2176
+ coverage: number
2177
+ hpaManaged: boolean
2178
+ hpaEvidenceAvailable: boolean
2179
+ throttleAvailable?: boolean
2180
+ throttleRatio?: number
2181
+ currentPodOOM?: boolean
2182
+ windowOomEvidence?: boolean
2183
+ oomEvidenceAvailable: boolean
2184
+ limitConflict?: boolean
2185
+ queryError?: string
1707
2186
  }
1708
2187
 
1709
2188
  export interface PrometheusRightsizing {
@@ -1711,11 +2190,46 @@ export interface PrometheusRightsizing {
1711
2190
  namespace: string
1712
2191
  name: string
1713
2192
  window: string
2193
+ source: 'radar'
2194
+ ownerCoverage: RightsizingOwnerCoverage
2195
+ scaledToZero: boolean
1714
2196
  sampleAvailable: boolean
1715
2197
  rows: RightsizingRow[]
1716
2198
  reason?: string
1717
2199
  }
1718
2200
 
2201
+ export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
2202
+
2203
+ export interface RightsizingScanWorkload {
2204
+ kind: string
2205
+ namespace: string
2206
+ name: string
2207
+ replicas: number
2208
+ scaledToZero: boolean
2209
+ rows: RightsizingRow[]
2210
+ }
2211
+
2212
+ export interface RightsizingScanCoverage {
2213
+ workloadsDiscovered: number
2214
+ workloadsEvaluated: number
2215
+ workloadsWithData: number
2216
+ batches: number
2217
+ completedBatches: number
2218
+ restrictedKinds?: string[]
2219
+ unavailableKinds?: string[]
2220
+ }
2221
+
2222
+ export interface RightsizingScanResponse {
2223
+ state: RightsizingScanState
2224
+ scannedAt: string
2225
+ window: string
2226
+ source: 'radar'
2227
+ coverage: RightsizingScanCoverage
2228
+ workloads: RightsizingScanWorkload[]
2229
+ warnings?: { code: string; message: string }[]
2230
+ reason?: string
2231
+ }
2232
+
1719
2233
  // Check Prometheus availability
1720
2234
  export function usePrometheusStatus() {
1721
2235
  return useQuery<PrometheusStatus>({
@@ -1726,12 +2240,32 @@ export function usePrometheusStatus() {
1726
2240
  })
1727
2241
  }
1728
2242
 
2243
+ export interface ArgoStatus {
2244
+ // configured = a URL or token is set; connected = a probe has landed and the
2245
+ // client is live. The two differ right after a restart (configured, reconnecting).
2246
+ configured: boolean
2247
+ connected: boolean
2248
+ address?: string
2249
+ }
2250
+
2251
+ export function useArgoStatus(enabled = true) {
2252
+ return useQuery<ArgoStatus>({
2253
+ queryKey: ['argocd-status'],
2254
+ queryFn: () => fetchJSON('/integrations/argocd/status'),
2255
+ enabled,
2256
+ staleTime: 30000,
2257
+ refetchInterval: 60000,
2258
+ })
2259
+ }
2260
+
1729
2261
  // Connect to Prometheus (trigger discovery)
1730
2262
  export function usePrometheusConnect() {
1731
2263
  const queryClient = useQueryClient()
1732
2264
  return useMutation({
1733
2265
  mutationFn: async () => {
1734
- const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
2266
+ const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
2267
+ method: 'POST',
2268
+ })
1735
2269
  if (!resp.ok) {
1736
2270
  const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
1737
2271
  throw new Error(body.error || `HTTP ${resp.status}`)
@@ -1792,7 +2326,9 @@ export function useAutoPromConnect(): void {
1792
2326
 
1793
2327
  // Persist the "we've connected here before" signal once a connection lands.
1794
2328
  if (status?.connected) {
1795
- try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
2329
+ try {
2330
+ window.localStorage.setItem(promAutoConnectKey(context), '1')
2331
+ } catch {
1796
2332
  // localStorage can throw in some restricted browser modes — fail open.
1797
2333
  }
1798
2334
  return
@@ -1800,7 +2336,9 @@ export function useAutoPromConnect(): void {
1800
2336
 
1801
2337
  if (attemptedRef.current === context) return
1802
2338
  let cached: string | null = null
1803
- try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
2339
+ try {
2340
+ cached = window.localStorage.getItem(promAutoConnectKey(context))
2341
+ } catch {
1804
2342
  // keep the null fallback
1805
2343
  }
1806
2344
 
@@ -1812,16 +2350,20 @@ export function useAutoPromConnect(): void {
1812
2350
  const timeout = window.setTimeout(() => {
1813
2351
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1814
2352
  // 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 => {
2353
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
2354
+ method: 'POST',
2355
+ })
2356
+ .then(async (resp) => {
1817
2357
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1818
- const nextStatus = await resp.json() as PrometheusStatus
2358
+ const nextStatus = (await resp.json()) as PrometheusStatus
1819
2359
  queryClient.setQueryData(['prometheus-status'], nextStatus)
1820
2360
  if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
1821
2361
  queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1822
2362
  })
1823
2363
  .catch(() => {
1824
- try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
2364
+ try {
2365
+ window.localStorage.removeItem(promAutoConnectKey(context))
2366
+ } catch {
1825
2367
  // ignore — manual CTA will render once status refreshes
1826
2368
  }
1827
2369
  attemptedRef.current = null
@@ -1879,8 +2421,7 @@ export function usePrometheusClusterMetrics(
1879
2421
  ) {
1880
2422
  return useQuery<PrometheusResourceMetrics>({
1881
2423
  queryKey: ['prometheus-cluster-metrics', category, range],
1882
- queryFn: () =>
1883
- fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
2424
+ queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
1884
2425
  enabled,
1885
2426
  staleTime: 30000,
1886
2427
  refetchInterval: 60000,
@@ -1899,22 +2440,75 @@ export function usePrometheusPVCUsage(namespace: string, name: string, enabled =
1899
2440
  }
1900
2441
 
1901
2442
  // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
1902
- export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
2443
+ export function usePrometheusRightsizing(
2444
+ kind: string,
2445
+ namespace: string,
2446
+ name: string,
2447
+ enabled = true,
2448
+ ) {
1903
2449
  return useQuery<PrometheusRightsizing>({
1904
2450
  queryKey: ['prometheus-rightsizing', kind, namespace, name],
1905
2451
  queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
1906
2452
  enabled: enabled && Boolean(kind && namespace && name),
1907
- staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
2453
+ staleTime: 5 * 60 * 1000,
1908
2454
  refetchInterval: 10 * 60 * 1000,
1909
2455
  })
1910
2456
  }
1911
2457
 
2458
+ const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
2459
+
2460
+ export function getRightsizingScanCacheConfig(
2461
+ namespaces: string[],
2462
+ context = '',
2463
+ ): {
2464
+ namespaceKey: string
2465
+ queryKey: readonly ['prometheus-rightsizing-scan', string, string]
2466
+ queryFn: typeof skipToken
2467
+ gcTime: number
2468
+ } {
2469
+ const namespaceKey = [...namespaces].sort().join(',')
2470
+ return {
2471
+ namespaceKey,
2472
+ queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
2473
+ queryFn: skipToken,
2474
+ gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
2475
+ }
2476
+ }
2477
+
2478
+ // A fleet rightsizing scan is intentionally manual. It can query seven days of
2479
+ // Prometheus history for many containers, so navigation alone must never run it.
2480
+ export function useRightsizingScan(namespaces: string[], context = '') {
2481
+ const queryClient = useQueryClient()
2482
+ const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
2483
+ const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
2484
+ const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
2485
+ const mutation = useMutation({
2486
+ mutationFn: async (startedScope: typeof scanScope) => {
2487
+ const params = new URLSearchParams()
2488
+ if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
2489
+ const query = params.toString()
2490
+ return fetchJSON<RightsizingScanResponse>(
2491
+ `/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
2492
+ {
2493
+ method: 'POST',
2494
+ },
2495
+ )
2496
+ },
2497
+ onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
2498
+ })
2499
+ return {
2500
+ ...mutation,
2501
+ data: snapshot.data,
2502
+ mutate: () => mutation.mutate(scanScope),
2503
+ mutateAsync: () => mutation.mutateAsync(scanScope),
2504
+ }
2505
+ }
2506
+
1912
2507
  // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
1913
2508
  export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
1914
2509
  return useQuery<PrometheusQueryResult>({
1915
2510
  queryKey: ['promql-range', query, range],
1916
- queryFn: () =>
1917
- fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
2511
+ queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
1918
2512
  enabled: enabled && Boolean(query),
1919
2513
  staleTime: 30000,
1920
2514
  refetchInterval: 60000,
@@ -1947,12 +2541,16 @@ export interface LogStreamEvent {
1947
2541
  }
1948
2542
 
1949
2543
  // 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
- }) {
2544
+ export function usePodLogs(
2545
+ namespace: string,
2546
+ podName: string,
2547
+ options?: {
2548
+ container?: string
2549
+ tailLines?: number
2550
+ previous?: boolean
2551
+ sinceSeconds?: number
2552
+ },
2553
+ ) {
1956
2554
  const params = new URLSearchParams()
1957
2555
  if (options?.container) params.set('container', options.container)
1958
2556
  if (options?.tailLines) params.set('tailLines', String(options.tailLines))
@@ -1961,8 +2559,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
1961
2559
  const queryString = params.toString()
1962
2560
 
1963
2561
  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}` : ''}`),
2562
+ queryKey: [
2563
+ 'pod-logs',
2564
+ namespace,
2565
+ podName,
2566
+ options?.container,
2567
+ options?.tailLines,
2568
+ options?.previous,
2569
+ options?.sinceSeconds,
2570
+ ],
2571
+ queryFn: () =>
2572
+ fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
1966
2573
  enabled: Boolean(namespace && podName),
1967
2574
  staleTime: 5000, // Allow refetch after 5 seconds
1968
2575
  })
@@ -1977,7 +2584,7 @@ export function createLogStream(
1977
2584
  tailLines?: number
1978
2585
  previous?: boolean
1979
2586
  sinceSeconds?: number
1980
- }
2587
+ },
1981
2588
  ): EventSource {
1982
2589
  const params = new URLSearchParams()
1983
2590
  if (options?.container) params.set('container', options.container)
@@ -1986,9 +2593,12 @@ export function createLogStream(
1986
2593
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
1987
2594
  const queryString = params.toString()
1988
2595
 
1989
- return new EventSource(`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`, {
1990
- withCredentials: getCredentialsMode() === 'include',
1991
- })
2596
+ return new EventSource(
2597
+ `${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
2598
+ {
2599
+ withCredentials: getCredentialsMode() === 'include',
2600
+ },
2601
+ )
1992
2602
  }
1993
2603
 
1994
2604
  // ============================================================================
@@ -2021,8 +2631,23 @@ export function useUpdateResource() {
2021
2631
  const queryClient = useQueryClient()
2022
2632
 
2023
2633
  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)
2634
+ mutationFn: async ({
2635
+ kind,
2636
+ namespace,
2637
+ name,
2638
+ yaml,
2639
+ force = true,
2640
+ }: {
2641
+ kind: string
2642
+ namespace: string
2643
+ name: string
2644
+ yaml: string
2645
+ force?: boolean
2646
+ }) => {
2647
+ const url = new URL(
2648
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2649
+ window.location.origin,
2650
+ )
2026
2651
  if (!force) {
2027
2652
  url.searchParams.set('force', 'false')
2028
2653
  }
@@ -2050,16 +2675,22 @@ export function useUpdateResource() {
2050
2675
  // lagging cache — the change appears not to have taken effect.
2051
2676
  if (updated && typeof updated === 'object' && updated.metadata) {
2052
2677
  queryClient.setQueriesData(
2053
- { queryKey: ['resource', variables.kind, variables.namespace, variables.name] },
2678
+ {
2679
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2680
+ },
2054
2681
  (old: any) =>
2055
2682
  old && typeof old === 'object' && 'resource' in old
2056
2683
  ? { ...old, resource: updated }
2057
- : { resource: updated }
2684
+ : { resource: updated },
2058
2685
  )
2059
2686
  } else {
2060
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
2687
+ queryClient.invalidateQueries({
2688
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
2689
+ })
2061
2690
  }
2062
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2691
+ queryClient.invalidateQueries({
2692
+ queryKey: ['resources', variables.kind],
2693
+ })
2063
2694
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2064
2695
  },
2065
2696
  })
@@ -2068,13 +2699,24 @@ export function useUpdateResource() {
2068
2699
  // Cascade delete preview — shows resources that will be garbage-collected
2069
2700
  export interface CascadeDeletePreview {
2070
2701
  root: { kind: string; namespace: string; name: string; group?: string }
2071
- dependents: { kind: string; namespace: string; name: string; group?: string }[]
2702
+ dependents: {
2703
+ kind: string
2704
+ namespace: string
2705
+ name: string
2706
+ group?: string
2707
+ }[]
2072
2708
  }
2073
2709
 
2074
- export function useCascadeDeletePreview(kind: string, namespace: string, name: string, enabled: boolean) {
2710
+ export function useCascadeDeletePreview(
2711
+ kind: string,
2712
+ namespace: string,
2713
+ name: string,
2714
+ enabled: boolean,
2715
+ ) {
2075
2716
  return useQuery<CascadeDeletePreview>({
2076
2717
  queryKey: ['cascade-preview', kind, namespace, name],
2077
- queryFn: () => fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2718
+ queryFn: () =>
2719
+ fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
2078
2720
  enabled,
2079
2721
  staleTime: 30_000,
2080
2722
  })
@@ -2085,8 +2727,23 @@ export function useDeleteResource() {
2085
2727
  const queryClient = useQueryClient()
2086
2728
 
2087
2729
  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)
2730
+ mutationFn: async ({
2731
+ kind,
2732
+ group,
2733
+ namespace,
2734
+ name,
2735
+ force,
2736
+ }: {
2737
+ kind: string
2738
+ group?: string
2739
+ namespace: string
2740
+ name: string
2741
+ force?: boolean
2742
+ }) => {
2743
+ const url = new URL(
2744
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2745
+ window.location.origin,
2746
+ )
2090
2747
  if (group) {
2091
2748
  url.searchParams.set('group', group)
2092
2749
  }
@@ -2108,7 +2765,9 @@ export function useDeleteResource() {
2108
2765
  successMessage: 'Resource deleted',
2109
2766
  },
2110
2767
  onSuccess: (_, variables) => {
2111
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2768
+ queryClient.invalidateQueries({
2769
+ queryKey: ['resources', variables.kind],
2770
+ })
2112
2771
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2113
2772
  },
2114
2773
  })
@@ -2118,10 +2777,24 @@ export function useBulkDeleteResources() {
2118
2777
  const queryClient = useQueryClient()
2119
2778
 
2120
2779
  return useMutation({
2121
- mutationFn: async ({ items, force }: { items: Array<{ kind: string; group?: string; namespace: string; name: string }>; force?: boolean }) => {
2780
+ mutationFn: async ({
2781
+ items,
2782
+ force,
2783
+ }: {
2784
+ items: Array<{
2785
+ kind: string
2786
+ group?: string
2787
+ namespace: string
2788
+ name: string
2789
+ }>
2790
+ force?: boolean
2791
+ }) => {
2122
2792
  const results = await Promise.allSettled(
2123
2793
  items.map(async ({ kind, group, namespace, name }) => {
2124
- const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
2794
+ const url = new URL(
2795
+ `${getApiBase()}/resources/${kind}/${namespace}/${name}`,
2796
+ window.location.origin,
2797
+ )
2125
2798
  if (group) url.searchParams.set('group', group)
2126
2799
  if (force) url.searchParams.set('force', 'true')
2127
2800
  const response = await apiFetch(url.toString(), { method: 'DELETE' })
@@ -2130,9 +2803,9 @@ export function useBulkDeleteResources() {
2130
2803
  throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
2131
2804
  }
2132
2805
  return { kind, namespace, name }
2133
- })
2806
+ }),
2134
2807
  )
2135
- const failed = results.filter(r => r.status === 'rejected')
2808
+ const failed = results.filter((r) => r.status === 'rejected')
2136
2809
  if (failed.length > 0) {
2137
2810
  throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
2138
2811
  }
@@ -2165,13 +2838,19 @@ interface BulkWorkloadMutationResult {
2165
2838
  }
2166
2839
 
2167
2840
  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
- : []
2841
+ return results.flatMap((r) =>
2842
+ r.status === 'rejected'
2843
+ ? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
2844
+ : [],
2171
2845
  )
2172
2846
  }
2173
2847
 
2174
- function bulkWorkloadFailureMessage(action: string, failed: number, total: number, messages: string[]): string {
2848
+ function bulkWorkloadFailureMessage(
2849
+ action: string,
2850
+ failed: number,
2851
+ total: number,
2852
+ messages: string[],
2853
+ ): string {
2175
2854
  return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
2176
2855
  }
2177
2856
 
@@ -2179,27 +2858,45 @@ export function useBulkRestartWorkloads() {
2179
2858
  const queryClient = useQueryClient()
2180
2859
 
2181
2860
  return useMutation({
2182
- mutationFn: async ({ items }: { items: BulkWorkloadItem[] }): Promise<BulkWorkloadMutationResult> => {
2861
+ mutationFn: async ({
2862
+ items,
2863
+ }: {
2864
+ items: BulkWorkloadItem[]
2865
+ }): Promise<BulkWorkloadMutationResult> => {
2183
2866
  if (items.length === 0) {
2184
2867
  return { requested: 0, succeeded: 0, failedMessages: [] }
2185
2868
  }
2186
2869
  const results = await Promise.allSettled(
2187
2870
  items.map(async ({ kind, namespace, name }) => {
2188
- const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
2189
- method: 'POST',
2190
- })
2871
+ const response = await apiFetch(
2872
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
2873
+ {
2874
+ method: 'POST',
2875
+ },
2876
+ )
2191
2877
  if (!response.ok) {
2192
2878
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2193
2879
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2194
2880
  }
2195
2881
  return { kind, namespace, name }
2196
- })
2882
+ }),
2197
2883
  )
2198
2884
  const failedMessages = failedBulkWorkloadMessages(results)
2199
2885
  if (failedMessages.length === items.length) {
2200
- throw new Error(bulkWorkloadFailureMessage('restart', failedMessages.length, items.length, failedMessages))
2886
+ throw new Error(
2887
+ bulkWorkloadFailureMessage(
2888
+ 'restart',
2889
+ failedMessages.length,
2890
+ items.length,
2891
+ failedMessages,
2892
+ ),
2893
+ )
2894
+ }
2895
+ return {
2896
+ requested: items.length,
2897
+ succeeded: items.length - failedMessages.length,
2898
+ failedMessages,
2201
2899
  }
2202
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2203
2900
  },
2204
2901
  meta: {
2205
2902
  errorMessage: 'Failed to restart some workloads',
@@ -2225,29 +2922,44 @@ export function useBulkScaleWorkloads() {
2225
2922
  const queryClient = useQueryClient()
2226
2923
 
2227
2924
  return useMutation({
2228
- mutationFn: async ({ items, replicas }: { items: BulkWorkloadItem[]; replicas: number }): Promise<BulkWorkloadMutationResult> => {
2925
+ mutationFn: async ({
2926
+ items,
2927
+ replicas,
2928
+ }: {
2929
+ items: BulkWorkloadItem[]
2930
+ replicas: number
2931
+ }): Promise<BulkWorkloadMutationResult> => {
2229
2932
  if (items.length === 0) {
2230
2933
  return { requested: 0, succeeded: 0, failedMessages: [] }
2231
2934
  }
2232
2935
  const results = await Promise.allSettled(
2233
2936
  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
- })
2937
+ const response = await apiFetch(
2938
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
2939
+ {
2940
+ method: 'POST',
2941
+ headers: { 'Content-Type': 'application/json' },
2942
+ body: JSON.stringify({ replicas }),
2943
+ },
2944
+ )
2239
2945
  if (!response.ok) {
2240
2946
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2241
2947
  throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
2242
2948
  }
2243
2949
  return { kind, namespace, name }
2244
- })
2950
+ }),
2245
2951
  )
2246
2952
  const failedMessages = failedBulkWorkloadMessages(results)
2247
2953
  if (failedMessages.length === items.length) {
2248
- throw new Error(bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages))
2954
+ throw new Error(
2955
+ bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
2956
+ )
2957
+ }
2958
+ return {
2959
+ requested: items.length,
2960
+ succeeded: items.length - failedMessages.length,
2961
+ failedMessages,
2249
2962
  }
2250
- return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
2251
2963
  },
2252
2964
  meta: {
2253
2965
  errorMessage: 'Failed to scale some workloads',
@@ -2281,7 +2993,17 @@ export function useApplyResource() {
2281
2993
  const queryClient = useQueryClient()
2282
2994
 
2283
2995
  return useMutation({
2284
- mutationFn: async ({ yaml, mode = 'apply', dryRun = false, force = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean; force?: boolean }) => {
2996
+ mutationFn: async ({
2997
+ yaml,
2998
+ mode = 'apply',
2999
+ dryRun = false,
3000
+ force = false,
3001
+ }: {
3002
+ yaml: string
3003
+ mode?: 'apply' | 'create'
3004
+ dryRun?: boolean
3005
+ force?: boolean
3006
+ }) => {
2285
3007
  const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
2286
3008
  url.searchParams.set('mode', mode)
2287
3009
  if (dryRun) {
@@ -2314,11 +3036,19 @@ export function useApplyResource() {
2314
3036
  // CronJob operations
2315
3037
  // ============================================================================
2316
3038
 
2317
- function invalidateCronJobOperationQueries(queryClient: ReturnType<typeof useQueryClient>, namespace: string, name: string) {
3039
+ function invalidateCronJobOperationQueries(
3040
+ queryClient: ReturnType<typeof useQueryClient>,
3041
+ namespace: string,
3042
+ name: string,
3043
+ ) {
2318
3044
  queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2319
3045
  queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2320
- queryClient.invalidateQueries({ queryKey: ['resource', 'cronjobs', namespace, name] })
2321
- queryClient.invalidateQueries({ queryKey: ['workload-runs', 'cronjobs', namespace, name] })
3046
+ queryClient.invalidateQueries({
3047
+ queryKey: ['resource', 'cronjobs', namespace, name],
3048
+ })
3049
+ queryClient.invalidateQueries({
3050
+ queryKey: ['workload-runs', 'cronjobs', namespace, name],
3051
+ })
2322
3052
  queryClient.invalidateQueries({ queryKey: ['applications'] })
2323
3053
  queryClient.invalidateQueries({ queryKey: ['dashboard'] })
2324
3054
  queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
@@ -2409,10 +3139,21 @@ export function useRestartWorkload() {
2409
3139
  const queryClient = useQueryClient()
2410
3140
 
2411
3141
  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
- })
3142
+ mutationFn: async ({
3143
+ kind,
3144
+ namespace,
3145
+ name,
3146
+ }: {
3147
+ kind: string
3148
+ namespace: string
3149
+ name: string
3150
+ }) => {
3151
+ const response = await apiFetch(
3152
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
3153
+ {
3154
+ method: 'POST',
3155
+ },
3156
+ )
2416
3157
  if (!response.ok) {
2417
3158
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2418
3159
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2424,7 +3165,9 @@ export function useRestartWorkload() {
2424
3165
  successMessage: 'Workload restarting',
2425
3166
  },
2426
3167
  onSuccess: (_, variables) => {
2427
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
3168
+ queryClient.invalidateQueries({
3169
+ queryKey: ['resources', variables.kind],
3170
+ })
2428
3171
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2429
3172
  },
2430
3173
  })
@@ -2435,12 +3178,25 @@ export function useScaleWorkload() {
2435
3178
  const queryClient = useQueryClient()
2436
3179
 
2437
3180
  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
- })
3181
+ mutationFn: async ({
3182
+ kind,
3183
+ namespace,
3184
+ name,
3185
+ replicas,
3186
+ }: {
3187
+ kind: string
3188
+ namespace: string
3189
+ name: string
3190
+ replicas: number
3191
+ }) => {
3192
+ const response = await apiFetch(
3193
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
3194
+ {
3195
+ method: 'POST',
3196
+ headers: { 'Content-Type': 'application/json' },
3197
+ body: JSON.stringify({ replicas }),
3198
+ },
3199
+ )
2444
3200
  if (!response.ok) {
2445
3201
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2446
3202
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2452,8 +3208,12 @@ export function useScaleWorkload() {
2452
3208
  successMessage: 'Workload scaled',
2453
3209
  },
2454
3210
  onSuccess: (_, variables) => {
2455
- queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
2456
- queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
3211
+ queryClient.invalidateQueries({
3212
+ queryKey: ['resources', variables.kind],
3213
+ })
3214
+ queryClient.invalidateQueries({
3215
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3216
+ })
2457
3217
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2458
3218
  },
2459
3219
  })
@@ -2473,7 +3233,12 @@ export interface WorkloadRevision {
2473
3233
  template?: string // Pod template spec as YAML (for revision diff)
2474
3234
  }
2475
3235
 
2476
- export function useWorkloadRevisions(kind: string, namespace: string, name: string, enabled = true) {
3236
+ export function useWorkloadRevisions(
3237
+ kind: string,
3238
+ namespace: string,
3239
+ name: string,
3240
+ enabled = true,
3241
+ ) {
2477
3242
  return useQuery<WorkloadRevision[]>({
2478
3243
  queryKey: ['workload-revisions', kind, namespace, name],
2479
3244
  queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
@@ -2484,12 +3249,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
2484
3249
  export function useRollbackWorkload() {
2485
3250
  const queryClient = useQueryClient()
2486
3251
  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
- })
3252
+ mutationFn: async ({
3253
+ kind,
3254
+ namespace,
3255
+ name,
3256
+ revision,
3257
+ }: {
3258
+ kind: string
3259
+ namespace: string
3260
+ name: string
3261
+ revision: number
3262
+ }) => {
3263
+ const response = await apiFetch(
3264
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
3265
+ {
3266
+ method: 'POST',
3267
+ headers: { 'Content-Type': 'application/json' },
3268
+ body: JSON.stringify({ revision }),
3269
+ },
3270
+ )
2493
3271
  if (!response.ok) {
2494
3272
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2495
3273
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2501,9 +3279,15 @@ export function useRollbackWorkload() {
2501
3279
  successMessage: 'Rollback initiated',
2502
3280
  },
2503
3281
  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] })
3282
+ queryClient.invalidateQueries({
3283
+ queryKey: ['resources', variables.kind],
3284
+ })
3285
+ queryClient.invalidateQueries({
3286
+ queryKey: ['resource', variables.kind, variables.namespace, variables.name],
3287
+ })
3288
+ queryClient.invalidateQueries({
3289
+ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
3290
+ })
2507
3291
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2508
3292
  },
2509
3293
  })
@@ -2533,7 +3317,9 @@ export function useCordonNode() {
2533
3317
  },
2534
3318
  onSuccess: (_, variables) => {
2535
3319
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2536
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3320
+ queryClient.invalidateQueries({
3321
+ queryKey: ['resource', 'nodes', '', variables.name],
3322
+ })
2537
3323
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2538
3324
  },
2539
3325
  })
@@ -2559,7 +3345,9 @@ export function useUncordonNode() {
2559
3345
  },
2560
3346
  onSuccess: (_, variables) => {
2561
3347
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2562
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3348
+ queryClient.invalidateQueries({
3349
+ queryKey: ['resource', 'nodes', '', variables.name],
3350
+ })
2563
3351
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2564
3352
  },
2565
3353
  })
@@ -2592,7 +3380,9 @@ export function useDrainNode() {
2592
3380
  },
2593
3381
  onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
2594
3382
  queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
2595
- queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
3383
+ queryClient.invalidateQueries({
3384
+ queryKey: ['resource', 'nodes', '', variables.name],
3385
+ })
2596
3386
  queryClient.invalidateQueries({ queryKey: ['topology'] })
2597
3387
 
2598
3388
  const evicted = data?.evictedPods?.length ?? 0
@@ -2642,12 +3432,19 @@ export function useHelmRelease(namespace: string, name: string, options?: { enab
2642
3432
  // `enabled` lets callers skip the query when the user's Cloud role
2643
3433
  // would 403 the read — saves a round-trip and avoids a transient
2644
3434
  // "error" state that the role-gated empty panel doesn't need.
2645
- export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
3435
+ export function useHelmManifest(
3436
+ namespace: string,
3437
+ name: string,
3438
+ revision?: number,
3439
+ enabled = true,
3440
+ ) {
2646
3441
  const params = revision ? `?revision=${revision}` : ''
2647
3442
  return useQuery<string>({
2648
3443
  queryKey: ['helm-manifest', namespace, name, revision],
2649
3444
  queryFn: async () => {
2650
- const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`)
3445
+ const response = await apiFetch(
3446
+ `${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
3447
+ )
2651
3448
  if (!response.ok) {
2652
3449
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2653
3450
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2660,7 +3457,13 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
2660
3457
  }
2661
3458
 
2662
3459
  // Get values for a Helm release. `enabled` see useHelmManifest.
2663
- export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true, revision?: number) {
3460
+ export function useHelmValues(
3461
+ namespace: string,
3462
+ name: string,
3463
+ allValues?: boolean,
3464
+ enabled = true,
3465
+ revision?: number,
3466
+ ) {
2664
3467
  const params = new URLSearchParams()
2665
3468
  if (allValues) params.set('all', 'true')
2666
3469
  if (revision && revision > 0) params.set('revision', String(revision))
@@ -2684,8 +3487,12 @@ export function useHelmManifestDiff(
2684
3487
  return useQuery<ManifestDiff>({
2685
3488
  queryKey: ['helm-diff', namespace, name, revision1, revision2],
2686
3489
  queryFn: () =>
2687
- fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
2688
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3490
+ fetchJSON(
3491
+ `/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
3492
+ ),
3493
+ enabled: Boolean(
3494
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3495
+ ),
2689
3496
  staleTime: 60000,
2690
3497
  })
2691
3498
  }
@@ -2708,7 +3515,9 @@ export function useHelmValuesDiff(
2708
3515
  if (allValues) params.set('all', 'true')
2709
3516
  return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
2710
3517
  },
2711
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
3518
+ enabled: Boolean(
3519
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3520
+ ),
2712
3521
  staleTime: 60000,
2713
3522
  })
2714
3523
  }
@@ -2723,8 +3532,12 @@ export function useHelmNotesDiff(
2723
3532
  return useQuery<NotesDiff>({
2724
3533
  queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
2725
3534
  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),
3535
+ fetchJSON(
3536
+ `/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
3537
+ ),
3538
+ enabled: Boolean(
3539
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3540
+ ),
2728
3541
  staleTime: 60000,
2729
3542
  })
2730
3543
  }
@@ -2739,8 +3552,12 @@ export function useHelmHooksDiff(
2739
3552
  return useQuery<HooksDiff>({
2740
3553
  queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2741
3554
  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),
3555
+ fetchJSON(
3556
+ `/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
3557
+ ),
3558
+ enabled: Boolean(
3559
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3560
+ ),
2744
3561
  staleTime: 60000,
2745
3562
  })
2746
3563
  }
@@ -2755,8 +3572,12 @@ export function useHelmResourceDiff(
2755
3572
  return useQuery<ResourceDiff>({
2756
3573
  queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
2757
3574
  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),
3575
+ fetchJSON(
3576
+ `/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
3577
+ ),
3578
+ enabled: Boolean(
3579
+ namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
3580
+ ),
2760
3581
  staleTime: 60000,
2761
3582
  })
2762
3583
  }
@@ -2806,10 +3627,21 @@ export function useHelmRollback() {
2806
3627
  const queryClient = useQueryClient()
2807
3628
 
2808
3629
  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
- })
3630
+ mutationFn: async ({
3631
+ namespace,
3632
+ name,
3633
+ revision,
3634
+ }: {
3635
+ namespace: string
3636
+ name: string
3637
+ revision: number
3638
+ }) => {
3639
+ const response = await apiFetch(
3640
+ `${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
3641
+ {
3642
+ method: 'POST',
3643
+ },
3644
+ )
2813
3645
  if (!response.ok) {
2814
3646
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2815
3647
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2822,7 +3654,9 @@ export function useHelmRollback() {
2822
3654
  },
2823
3655
  onSuccess: (_, variables) => {
2824
3656
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
2825
- queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
3657
+ queryClient.invalidateQueries({
3658
+ queryKey: ['helm-release', variables.namespace, variables.name],
3659
+ })
2826
3660
  },
2827
3661
  })
2828
3662
  }
@@ -2906,7 +3740,9 @@ function streamHelmProgress(
2906
3740
  return
2907
3741
  }
2908
3742
  } catch (err) {
2909
- reject(err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`))
3743
+ reject(
3744
+ err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
3745
+ )
2910
3746
  return
2911
3747
  }
2912
3748
  }
@@ -2927,12 +3763,16 @@ export function upgradeWithProgress(
2927
3763
  version: string,
2928
3764
  repositoryName: string | undefined,
2929
3765
  onProgress: (event: InstallProgressEvent) => void,
2930
- values?: Record<string, unknown>
3766
+ values?: Record<string, unknown>,
2931
3767
  ): Promise<void> {
2932
3768
  const params = new URLSearchParams({ version })
2933
3769
  if (repositoryName) params.set('repository', repositoryName)
2934
3770
  const options: RequestInit = values
2935
- ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values }) }
3771
+ ? {
3772
+ method: 'POST',
3773
+ headers: { 'Content-Type': 'application/json' },
3774
+ body: JSON.stringify({ values }),
3775
+ }
2936
3776
  : { method: 'POST' }
2937
3777
  return streamHelmProgress(
2938
3778
  `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
@@ -2947,7 +3787,7 @@ export function rollbackWithProgress(
2947
3787
  namespace: string,
2948
3788
  name: string,
2949
3789
  revision: number,
2950
- onProgress: (event: InstallProgressEvent) => void
3790
+ onProgress: (event: InstallProgressEvent) => void,
2951
3791
  ): Promise<void> {
2952
3792
  return streamHelmProgress(
2953
3793
  `${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
@@ -2960,13 +3800,26 @@ export function rollbackWithProgress(
2960
3800
  // When `version` is supplied, preview renders against that target chart version
2961
3801
  // instead of the release's current chart.
2962
3802
  export function useHelmPreviewValues() {
2963
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown>; version?: string; repository?: string }>({
3803
+ return useMutation<
3804
+ ValuesPreviewResponse,
3805
+ Error,
3806
+ {
3807
+ namespace: string
3808
+ name: string
3809
+ values: Record<string, unknown>
3810
+ version?: string
3811
+ repository?: string
3812
+ }
3813
+ >({
2964
3814
  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
- })
3815
+ const response = await apiFetch(
3816
+ `${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
3817
+ {
3818
+ method: 'POST',
3819
+ headers: { 'Content-Type': 'application/json' },
3820
+ body: JSON.stringify({ values, version, repository }),
3821
+ },
3822
+ )
2970
3823
  if (!response.ok) {
2971
3824
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2972
3825
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -2981,7 +3834,15 @@ export function useHelmApplyValues() {
2981
3834
  const queryClient = useQueryClient()
2982
3835
 
2983
3836
  return useMutation({
2984
- mutationFn: async ({ namespace, name, values }: { namespace: string; name: string; values: Record<string, unknown> }) => {
3837
+ mutationFn: async ({
3838
+ namespace,
3839
+ name,
3840
+ values,
3841
+ }: {
3842
+ namespace: string
3843
+ name: string
3844
+ values: Record<string, unknown>
3845
+ }) => {
2985
3846
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
2986
3847
  method: 'PUT',
2987
3848
  headers: { 'Content-Type': 'application/json' },
@@ -2999,8 +3860,12 @@ export function useHelmApplyValues() {
2999
3860
  },
3000
3861
  onSuccess: (_, variables) => {
3001
3862
  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] })
3863
+ queryClient.invalidateQueries({
3864
+ queryKey: ['helm-release', variables.namespace, variables.name],
3865
+ })
3866
+ queryClient.invalidateQueries({
3867
+ queryKey: ['helm-values', variables.namespace, variables.name],
3868
+ })
3004
3869
  },
3005
3870
  })
3006
3871
  }
@@ -3097,7 +3962,10 @@ export function useAddOCISource() {
3097
3962
  const queryClient = useQueryClient()
3098
3963
  return useMutation({
3099
3964
  mutationFn: (source: string) => mutateOCISource('POST', source),
3100
- meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
3965
+ meta: {
3966
+ errorMessage: 'Failed to add chart source',
3967
+ successMessage: 'Chart source added',
3968
+ },
3101
3969
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
3102
3970
  })
3103
3971
  }
@@ -3106,7 +3974,10 @@ export function useRemoveOCISource() {
3106
3974
  const queryClient = useQueryClient()
3107
3975
  return useMutation({
3108
3976
  mutationFn: (source: string) => mutateOCISource('DELETE', source),
3109
- meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
3977
+ meta: {
3978
+ errorMessage: 'Failed to remove chart source',
3979
+ successMessage: 'Chart source removed',
3980
+ },
3110
3981
  onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
3111
3982
  })
3112
3983
  }
@@ -3178,11 +4049,15 @@ export interface InstallProgressEvent {
3178
4049
  // Install a chart with progress streaming via SSE
3179
4050
  export function installChartWithProgress(
3180
4051
  req: InstallChartRequest,
3181
- onProgress: (event: InstallProgressEvent) => void
4052
+ onProgress: (event: InstallProgressEvent) => void,
3182
4053
  ): Promise<HelmRelease> {
3183
4054
  return streamHelmProgress(
3184
4055
  `${getApiBase()}/helm/releases/install-stream`,
3185
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) },
4056
+ {
4057
+ method: 'POST',
4058
+ headers: { 'Content-Type': 'application/json' },
4059
+ body: JSON.stringify(req),
4060
+ },
3186
4061
  onProgress,
3187
4062
  'Install failed',
3188
4063
  ).then((event) => event.release as HelmRelease)
@@ -3198,8 +4073,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
3198
4073
  // Search charts on ArtifactHub
3199
4074
  export function useArtifactHubSearch(
3200
4075
  query: string,
3201
- options?: { offset?: number; limit?: number; official?: boolean; verified?: boolean; sort?: ArtifactHubSortOption },
3202
- enabled = true
4076
+ options?: {
4077
+ offset?: number
4078
+ limit?: number
4079
+ official?: boolean
4080
+ verified?: boolean
4081
+ sort?: ArtifactHubSortOption
4082
+ },
4083
+ enabled = true,
3203
4084
  ) {
3204
4085
  const params = new URLSearchParams()
3205
4086
  if (query) params.set('query', query)
@@ -3210,7 +4091,15 @@ export function useArtifactHubSearch(
3210
4091
  if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
3211
4092
 
3212
4093
  return useQuery<ArtifactHubSearchResult>({
3213
- queryKey: ['artifacthub-search', query, options?.offset, options?.limit, options?.official, options?.verified, options?.sort],
4094
+ queryKey: [
4095
+ 'artifacthub-search',
4096
+ query,
4097
+ options?.offset,
4098
+ options?.limit,
4099
+ options?.official,
4100
+ options?.verified,
4101
+ options?.sort,
4102
+ ],
3214
4103
  queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
3215
4104
  enabled: enabled && query.length > 0,
3216
4105
  staleTime: 60000, // 1 minute
@@ -3218,7 +4107,12 @@ export function useArtifactHubSearch(
3218
4107
  }
3219
4108
 
3220
4109
  // Get chart detail from ArtifactHub
3221
- export function useArtifactHubChart(repoName: string, chartName: string, version?: string, enabled = true) {
4110
+ export function useArtifactHubChart(
4111
+ repoName: string,
4112
+ chartName: string,
4113
+ version?: string,
4114
+ enabled = true,
4115
+ ) {
3222
4116
  const path = version
3223
4117
  ? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
3224
4118
  : `/helm/artifacthub/charts/${repoName}/${chartName}`
@@ -3239,7 +4133,8 @@ interface GitOpsMutationConfig<TVariables> {
3239
4133
  getPath: (variables: TVariables) => string
3240
4134
  getBody?: (variables: TVariables) => unknown
3241
4135
  errorMessage: string
3242
- successMessage: string
4136
+ successMessage?: string
4137
+ getSuccessMessage?: (data: GitOpsOperationResponse) => string
3243
4138
  getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
3244
4139
  }
3245
4140
 
@@ -3267,9 +4162,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
3267
4162
  errorMessage: config.errorMessage,
3268
4163
  successMessage: config.successMessage,
3269
4164
  },
3270
- onSuccess: (_, variables) => {
3271
- config.getInvalidateKeys(variables).forEach(key =>
3272
- queryClient.invalidateQueries({ queryKey: key })
4165
+ onSuccess: (data, variables) => {
4166
+ if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
4167
+ config.getInvalidateKeys(variables).forEach((key) =>
4168
+ queryClient.invalidateQueries({ queryKey: key }),
3273
4169
  )
3274
4170
  },
3275
4171
  })
@@ -3284,8 +4180,13 @@ type ArgoAppVars = { namespace: string; name: string }
3284
4180
  // ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
3285
4181
  // useArgoSync sends these — splitting the type prevents callers from passing
3286
4182
  // 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 }>
4183
+ export type ArgoSyncVars = ArgoAppVars & {
4184
+ resources?: Array<{
4185
+ group?: string
4186
+ kind: string
4187
+ namespace?: string
4188
+ name: string
4189
+ }>
3289
4190
  revision?: string
3290
4191
  prune?: boolean
3291
4192
  dryRun?: boolean
@@ -3297,6 +4198,31 @@ type ArgoSyncVars = ArgoAppVars & {
3297
4198
  syncOptions?: string[]
3298
4199
  }
3299
4200
 
4201
+ export interface ArgoResourceValidationResult {
4202
+ outcome: 'succeeded' | 'failed' | 'inconclusive'
4203
+ message: string
4204
+ resource?: {
4205
+ group?: string
4206
+ kind: string
4207
+ namespace?: string
4208
+ name: string
4209
+ status?: string
4210
+ message?: string
4211
+ }
4212
+ }
4213
+
4214
+ export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
4215
+ return {
4216
+ namespace,
4217
+ name,
4218
+ ...opts,
4219
+ resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
4220
+ revision: undefined,
4221
+ prune: false,
4222
+ applyOnly: false,
4223
+ }
4224
+ }
4225
+
3300
4226
  // ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
3301
4227
  // DryRun mirror the sync flags so the rollback dialog can offer the same
3302
4228
  // safety net.
@@ -3378,6 +4304,31 @@ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
3378
4304
  getInvalidateKeys: argoInvalidateKeys,
3379
4305
  })
3380
4306
 
4307
+ export function useArgoResourceValidation() {
4308
+ const queryClient = useQueryClient()
4309
+ return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
4310
+ mutationFn: async (variables) => {
4311
+ const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
4312
+ method: 'POST',
4313
+ headers: { 'Content-Type': 'application/json' },
4314
+ body: JSON.stringify({
4315
+ resources: variables.resources,
4316
+ force: variables.force,
4317
+ syncOptions: variables.syncOptions,
4318
+ }),
4319
+ })
4320
+ if (!response.ok) {
4321
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
4322
+ throw new Error(error.error || `HTTP ${response.status}`)
4323
+ }
4324
+ return response.json() as Promise<ArgoResourceValidationResult>
4325
+ },
4326
+ onSettled: (_, __, variables) => {
4327
+ argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
4328
+ },
4329
+ })
4330
+ }
4331
+
3381
4332
  export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3382
4333
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
3383
4334
  getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
@@ -3389,7 +4340,7 @@ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
3389
4340
  export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
3390
4341
  getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
3391
4342
  errorMessage: 'Failed to terminate sync',
3392
- successMessage: 'Sync terminated',
4343
+ getSuccessMessage: (data) => data.message,
3393
4344
  getInvalidateKeys: argoInvalidateKeys,
3394
4345
  })
3395
4346
 
@@ -3412,11 +4363,22 @@ export function useArgoRefresh() {
3412
4363
  const queryClient = useQueryClient()
3413
4364
 
3414
4365
  return useMutation({
3415
- mutationFn: async ({ namespace, name, hard = false }: { namespace: string; name: string; hard?: boolean }) => {
4366
+ mutationFn: async ({
4367
+ namespace,
4368
+ name,
4369
+ hard = false,
4370
+ }: {
4371
+ namespace: string
4372
+ name: string
4373
+ hard?: boolean
4374
+ }) => {
3416
4375
  const params = hard ? '?type=hard' : ''
3417
- const response = await apiFetch(`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`, {
3418
- method: 'POST',
3419
- })
4376
+ const response = await apiFetch(
4377
+ `${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
4378
+ {
4379
+ method: 'POST',
4380
+ },
4381
+ )
3420
4382
  if (!response.ok) {
3421
4383
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
3422
4384
  throw new Error(error.error || `HTTP ${response.status}`)
@@ -3433,7 +4395,7 @@ export function useArgoRefresh() {
3433
4395
  // Refresh — without these two extra keys the user clicks Refresh and
3434
4396
  // sees stale insight/tree data until the next staleTime tick.
3435
4397
  argoInvalidateKeys(variables).forEach((key) =>
3436
- queryClient.invalidateQueries({ queryKey: key })
4398
+ queryClient.invalidateQueries({ queryKey: key }),
3437
4399
  )
3438
4400
  },
3439
4401
  })
@@ -3491,7 +4453,9 @@ export function useSwitchContext() {
3491
4453
  } catch (error) {
3492
4454
  clearTimeout(timeoutId)
3493
4455
  if (error instanceof Error && error.name === 'AbortError') {
3494
- throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
4456
+ throw new Error('Context switch timed out. The cluster may be unreachable.', {
4457
+ cause: error,
4458
+ })
3495
4459
  }
3496
4460
  throw error
3497
4461
  }
@@ -3609,9 +4573,12 @@ export function useSetActiveNamespace() {
3609
4573
  error: error instanceof Error ? error.message : String(error),
3610
4574
  })
3611
4575
  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 })
4576
+ throw new Error(
4577
+ isRescope
4578
+ ? 'Namespace rescope timed out. The cluster may still be loading.'
4579
+ : 'Namespace switch timed out. The cluster may be unreachable.',
4580
+ { cause: error },
4581
+ )
3615
4582
  }
3616
4583
  throw error
3617
4584
  }
@@ -3623,7 +4590,9 @@ export function useSetActiveNamespace() {
3623
4590
  accessibleCount: scope.accessibleNamespaces.length,
3624
4591
  })
3625
4592
  if (scope.cacheScoped) {
3626
- queryClient.removeQueries({ predicate: query => query.queryKey[0] !== 'namespace-scope' })
4593
+ queryClient.removeQueries({
4594
+ predicate: (query) => query.queryKey[0] !== 'namespace-scope',
4595
+ })
3627
4596
  }
3628
4597
  queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
3629
4598
  if (scope.cacheScoped) {
@@ -3654,7 +4623,7 @@ export function useImageMetadata(
3654
4623
  namespace: string,
3655
4624
  podName: string,
3656
4625
  pullSecrets: string[],
3657
- enabled = true
4626
+ enabled = true,
3658
4627
  ) {
3659
4628
  const params = new URLSearchParams()
3660
4629
  params.set('image', image)
@@ -3677,7 +4646,7 @@ export function useImageFilesystem(
3677
4646
  namespace: string,
3678
4647
  podName: string,
3679
4648
  pullSecrets: string[],
3680
- enabled = true
4649
+ enabled = true,
3681
4650
  ) {
3682
4651
  const params = new URLSearchParams()
3683
4652
  params.set('image', image)
@@ -3690,9 +4659,7 @@ export function useImageFilesystem(
3690
4659
  return useQuery<ImageFilesystem>({
3691
4660
  queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
3692
4661
  // Use skipToken to completely prevent the query from running when disabled
3693
- queryFn: shouldFetch
3694
- ? () => fetchJSON(`/images/inspect?${params.toString()}`)
3695
- : skipToken,
4662
+ queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
3696
4663
  staleTime: 300000, // 5 minutes - image content doesn't change
3697
4664
  retry: false, // Don't retry on auth errors
3698
4665
  })
@@ -3766,7 +4733,13 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
3766
4733
  })
3767
4734
  }
3768
4735
 
3769
- export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true, options?: { refetchActive?: boolean; clusterScoped?: boolean }) {
4736
+ export function useWorkloadRuns(
4737
+ kind: string,
4738
+ namespace: string,
4739
+ name: string,
4740
+ enabled = true,
4741
+ options?: { refetchActive?: boolean; clusterScoped?: boolean },
4742
+ ) {
3770
4743
  const clusterScoped = options?.clusterScoped ?? false
3771
4744
  const ns = clusterScoped ? '_' : namespace
3772
4745
  const params = new URLSearchParams()
@@ -3775,11 +4748,12 @@ export function useWorkloadRuns(kind: string, namespace: string, name: string, e
3775
4748
 
3776
4749
  return useQuery<WorkloadRunsResponse>({
3777
4750
  queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
3778
- queryFn: () => fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
4751
+ queryFn: () =>
4752
+ fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
3779
4753
  enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
3780
4754
  staleTime: 10000,
3781
4755
  refetchInterval: options?.refetchActive
3782
- ? (query) => query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
4756
+ ? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
3783
4757
  : false,
3784
4758
  })
3785
4759
  }
@@ -3793,7 +4767,7 @@ export function useWorkloadLogs(
3793
4767
  container?: string
3794
4768
  tailLines?: number
3795
4769
  sinceSeconds?: number
3796
- }
4770
+ },
3797
4771
  ) {
3798
4772
  const params = new URLSearchParams()
3799
4773
  if (options?.container) params.set('container', options.container)
@@ -3802,8 +4776,19 @@ export function useWorkloadLogs(
3802
4776
  const queryString = params.toString()
3803
4777
 
3804
4778
  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}` : ''}`),
4779
+ queryKey: [
4780
+ 'workload-logs',
4781
+ kind,
4782
+ namespace,
4783
+ name,
4784
+ options?.container,
4785
+ options?.tailLines,
4786
+ options?.sinceSeconds,
4787
+ ],
4788
+ queryFn: () =>
4789
+ fetchJSON(
4790
+ `/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
4791
+ ),
3807
4792
  enabled: Boolean(kind && namespace && name),
3808
4793
  staleTime: 5000,
3809
4794
  })
@@ -3818,7 +4803,7 @@ export function createWorkloadLogStream(
3818
4803
  container?: string
3819
4804
  tailLines?: number
3820
4805
  sinceSeconds?: number
3821
- }
4806
+ },
3822
4807
  ): EventSource {
3823
4808
  const params = new URLSearchParams()
3824
4809
  if (options?.container) params.set('container', options.container)
@@ -3826,9 +4811,12 @@ export function createWorkloadLogStream(
3826
4811
  if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
3827
4812
  const queryString = params.toString()
3828
4813
 
3829
- return new EventSource(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`, {
3830
- withCredentials: getCredentialsMode() === 'include',
3831
- })
4814
+ return new EventSource(
4815
+ `${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
4816
+ {
4817
+ withCredentials: getCredentialsMode() === 'include',
4818
+ },
4819
+ )
3832
4820
  }
3833
4821
 
3834
4822
  // ============================================================================