@skyhook-io/radar-app 1.8.3 → 1.8.6

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 (32) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. package/src/index.ts +15 -0
package/src/RadarApp.tsx CHANGED
@@ -25,6 +25,7 @@ import { ThemeProvider } from './context/ThemeContext';
25
25
  import { ToastProvider, showApiError, showApiSuccess } from './components/ui/Toast';
26
26
  import { setApiBase, setBasename } from './api/config';
27
27
  import { NavCustomizationProvider } from './context/NavCustomization';
28
+ import { FilterLocationBridge } from './filter/FilterLocationBridge';
28
29
  import type { NavCustomization } from './context/NavCustomization';
29
30
 
30
31
  // Declare the shape of mutation meta here — inlined rather than in a
@@ -153,7 +154,9 @@ export function RadarApp({
153
154
  <QueryClientProvider client={client}>
154
155
  <ToastProvider>
155
156
  <NavCustomizationProvider value={navSlots}>
156
- <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
157
+ <FilterLocationBridge>
158
+ <App manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
159
+ </FilterLocationBridge>
157
160
  </NavCustomizationProvider>
158
161
  </ToastProvider>
159
162
  </QueryClientProvider>
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ ApiError,
4
+ getVisibleLiveMetrics,
5
+ isMetricsUnavailableError,
6
+ isLiveMetricsUnavailable,
7
+ normalizeNodeMetricsHistory,
8
+ normalizePodMetricsHistory,
9
+ shouldFetchLiveMetrics,
10
+ } from './client'
11
+
12
+ describe('metrics unavailable classification', () => {
13
+ it('only treats metrics-shaped API failures as metrics unavailable', () => {
14
+ expect(isMetricsUnavailableError(new ApiError('Node metrics not found (metrics-server may not be installed)', 404))).toBe(true)
15
+ expect(isMetricsUnavailableError(new ApiError('the server could not find the requested resource (get nodes.metrics.k8s.io)', 500))).toBe(true)
16
+ expect(isMetricsUnavailableError(new ApiError('failed to get node metrics: the server could not find the requested resource', 500))).toBe(true)
17
+ expect(isMetricsUnavailableError(new ApiError('the server is currently unable to handle the request (get nodes.metrics.k8s.io)', 500))).toBe(true)
18
+ expect(isMetricsUnavailableError(new ApiError('the server could not find the requested resource', 500))).toBe(false)
19
+ expect(isMetricsUnavailableError(new ApiError('no access to nodes', 403))).toBe(false)
20
+ expect(isMetricsUnavailableError(new ApiError('metrics-server forbidden', 500))).toBe(false)
21
+ expect(isMetricsUnavailableError(new ApiError('database unavailable', 500))).toBe(false)
22
+ })
23
+
24
+ it('uses the server-owned unavailable flag for history responses', () => {
25
+ const podHistory = normalizePodMetricsHistory({
26
+ namespace: 'default',
27
+ name: 'api',
28
+ containers: [],
29
+ metricsUnavailable: true,
30
+ collectionError: 'the server could not find the requested resource (get pods.metrics.k8s.io)',
31
+ rawCollectionError: 'the server could not find the requested resource',
32
+ metricsUnavailableDiagnosis: 'The v1beta1.metrics.k8s.io APIService is not registered. Install metrics-server or restore that APIService.',
33
+ })
34
+ expect(podHistory.collectionError).toBeUndefined()
35
+ expect(podHistory.rawCollectionError).toBeUndefined()
36
+ expect(podHistory.metricsUnavailable).toBe(true)
37
+ expect(podHistory.metricsUnavailableReason).toBe('the server could not find the requested resource')
38
+ expect(podHistory.metricsUnavailableDiagnosis).toBe('The v1beta1.metrics.k8s.io APIService is not registered. Install metrics-server or restore that APIService.')
39
+
40
+ const nodeHistory = normalizeNodeMetricsHistory({
41
+ name: 'kind-worker',
42
+ dataPoints: [],
43
+ metricsUnavailable: true,
44
+ collectionError: 'Node metrics not found (metrics-server may not be installed)',
45
+ rawCollectionError: 'the server could not find the requested resource',
46
+ metricsUnavailableDiagnosis: 'The v1beta1.metrics.k8s.io APIService is not Available (FailedDiscoveryCheck). Check the metrics-server Service, endpoints, and API aggregation/TLS configuration.',
47
+ })
48
+ expect(nodeHistory.collectionError).toBeUndefined()
49
+ expect(nodeHistory.rawCollectionError).toBeUndefined()
50
+ expect(nodeHistory.metricsUnavailable).toBe(true)
51
+ expect(nodeHistory.metricsUnavailableReason).toBe('the server could not find the requested resource')
52
+ expect(nodeHistory.metricsUnavailableDiagnosis).toBe('The v1beta1.metrics.k8s.io APIService is not Available (FailedDiscoveryCheck). Check the metrics-server Service, endpoints, and API aggregation/TLS configuration.')
53
+ })
54
+
55
+ it('does not infer history unavailability from collection-error copy', () => {
56
+ const history = normalizeNodeMetricsHistory({
57
+ name: 'kind-worker',
58
+ dataPoints: [],
59
+ collectionError: 'Node metrics not found (metrics-server may not be installed)',
60
+ rawCollectionError: 'the server could not find the requested resource',
61
+ })
62
+ expect(history.collectionError).toBe('Node metrics not found (metrics-server may not be installed)')
63
+ expect(history.rawCollectionError).toBe('the server could not find the requested resource')
64
+ expect(history.metricsUnavailable).toBeUndefined()
65
+ expect(history.metricsUnavailableReason).toBeUndefined()
66
+ })
67
+
68
+ it('keeps non-metrics collection errors visible', () => {
69
+ const history = normalizeNodeMetricsHistory({
70
+ name: 'kind-worker',
71
+ dataPoints: [],
72
+ collectionError: 'forbidden: no access to nodes',
73
+ })
74
+ expect(history.collectionError).toBe('forbidden: no access to nodes')
75
+ expect(history.metricsUnavailable).toBeUndefined()
76
+ })
77
+
78
+ it('keeps generic not-found collection errors visible without a metrics API signal', () => {
79
+ const history = normalizeNodeMetricsHistory({
80
+ name: 'kind-worker',
81
+ dataPoints: [],
82
+ collectionError: 'the server could not find the requested resource',
83
+ })
84
+ expect(history.collectionError).toBe('the server could not find the requested resource')
85
+ expect(history.metricsUnavailable).toBeUndefined()
86
+ })
87
+
88
+ it('waits for history classification before live metrics fetches', () => {
89
+ expect(shouldFetchLiveMetrics(false, false)).toBe(false)
90
+ expect(shouldFetchLiveMetrics(true, false)).toBe(true)
91
+ expect(shouldFetchLiveMetrics(true, true)).toBe(false)
92
+ })
93
+
94
+ it('does not expose cached live metrics while the live query is disabled', () => {
95
+ const cachedMetrics = { usage: { cpu: '10m', memory: '20Mi' } }
96
+ expect(getVisibleLiveMetrics(false, false, cachedMetrics)).toBeUndefined()
97
+ expect(getVisibleLiveMetrics(true, true, cachedMetrics)).toBeUndefined()
98
+ expect(getVisibleLiveMetrics(true, false, cachedMetrics)).toBe(cachedMetrics)
99
+ })
100
+
101
+ it('treats null live metrics as unavailable only after live fetch is enabled', () => {
102
+ expect(isLiveMetricsUnavailable(false, null)).toBe(false)
103
+ expect(isLiveMetricsUnavailable(true, null)).toBe(true)
104
+ expect(isLiveMetricsUnavailable(true, undefined)).toBe(false)
105
+ })
106
+ })
package/src/api/client.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  HelmValues,
18
18
  ManifestDiff,
19
19
  NotesDiff,
20
+ HooksDiff,
20
21
  ResourceDiff,
21
22
  UpgradeInfo,
22
23
  BatchUpgradeInfo,
@@ -35,6 +36,15 @@ import type { GitOpsOperationResponse } from '../types/gitops'
35
36
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
36
37
  import { pluralToKind } from '../utils/navigation'
37
38
 
39
+ // Auto-refresh cadences (ms) — named constants for each polled hook's
40
+ // refetchInterval below, so the poll rate reads clearly at each call site.
41
+ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
42
+ const AUDIT_REFRESH_INTERVAL_MS = 60_000
43
+ const ISSUES_REFRESH_INTERVAL_MS = 30_000
44
+ const COST_REFRESH_INTERVAL_MS = 60_000
45
+ const CHANGES_REFRESH_INTERVAL_MS = 60_000
46
+ const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
47
+
38
48
  // Wrapper around fetch that always includes credentials (for session cookies)
39
49
  // and handles 401 responses globally. Merges caller-provided headers with
40
50
  // auth headers from the config module so library consumers (Radar Hub) can
@@ -93,6 +103,47 @@ export function isForbiddenError(error: unknown): boolean {
93
103
  return error instanceof ApiError && error.status === 403
94
104
  }
95
105
 
106
+ const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
107
+
108
+ function mentionsMetricsAPIGroup(message: string): boolean {
109
+ 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
+ ))
115
+ }
116
+
117
+ function hasMetricsUnavailablePhrase(message: string): boolean {
118
+ return (
119
+ message.includes('may not be installed') ||
120
+ message.includes('not found') ||
121
+ message.includes('could not find the requested resource') ||
122
+ message.includes('no matches for kind') ||
123
+ message.includes('no resource matches') ||
124
+ message.includes('no metrics known') ||
125
+ message.includes('not available') ||
126
+ message.includes('unable to fetch metrics') ||
127
+ message.includes('currently unable to handle the request')
128
+ )
129
+ }
130
+
131
+ export function isMetricsUnavailableError(error: unknown): boolean {
132
+ if (!(error instanceof ApiError)) return false
133
+ if (error.status !== 404 && error.status !== 500) return false
134
+ return [error.message, error.data?.error].some((message) => {
135
+ if (typeof message !== 'string') return false
136
+ const normalized = message.toLowerCase()
137
+ const hasMetricsSignal = (
138
+ normalized.includes('metrics-server') ||
139
+ mentionsMetricsAPIGroup(normalized) ||
140
+ normalized.includes('pod metrics') ||
141
+ normalized.includes('node metrics')
142
+ )
143
+ return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
144
+ })
145
+ }
146
+
96
147
  export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
97
148
  const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
98
149
  if (!response.ok) {
@@ -326,7 +377,7 @@ export function useDashboard(namespaces: string[] = []) {
326
377
  queryKey: ['dashboard', namespaces],
327
378
  queryFn: () => fetchJSON(`/dashboard${params}`),
328
379
  staleTime: 15000, // 15 seconds
329
- refetchInterval: 30000, // Refresh every 30 seconds
380
+ refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
330
381
  })
331
382
  }
332
383
 
@@ -337,7 +388,7 @@ export function useAudit(namespaces: string[] = []) {
337
388
  queryKey: ['audit', namespaces],
338
389
  queryFn: () => fetchJSON(`/audit${params}`),
339
390
  staleTime: 30000,
340
- refetchInterval: 60000,
391
+ refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
341
392
  placeholderData: (prev) => prev,
342
393
  })
343
394
  }
@@ -368,7 +419,7 @@ export function useIssues(namespaces: string[] = []) {
368
419
  queryKey: ['issues', namespaces],
369
420
  queryFn: () => fetchJSON(`/issues${params}`),
370
421
  staleTime: 30000,
371
- refetchInterval: 30000,
422
+ refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
372
423
  })
373
424
  }
374
425
 
@@ -516,7 +567,7 @@ export function useOpenCostSummary() {
516
567
  return useQuery<OpenCostSummary>({
517
568
  queryKey: ['opencost-summary'],
518
569
  queryFn: () => fetchJSON('/opencost/summary'),
519
- refetchInterval: 60000, // Refresh every minute
570
+ refetchInterval: COST_REFRESH_INTERVAL_MS,
520
571
  staleTime: 30000,
521
572
  placeholderData: (prev) => prev, // Keep previous data visible during refetch
522
573
  })
@@ -954,7 +1005,7 @@ export function useApplications(namespaces: string[]) {
954
1005
  queryKey: ['applications', namespaces],
955
1006
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
956
1007
  staleTime: 30_000,
957
- refetchInterval: 60_000,
1008
+ refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
958
1009
  })
959
1010
  }
960
1011
 
@@ -1113,7 +1164,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1113
1164
  queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1114
1165
  queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1115
1166
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1116
- refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
1167
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1117
1168
  enabled,
1118
1169
  })
1119
1170
  }
@@ -1243,25 +1294,44 @@ export interface NodeMetrics {
1243
1294
  }
1244
1295
  }
1245
1296
 
1297
+ async function fetchMetricsOrNull<T>(path: string): Promise<T | null> {
1298
+ try {
1299
+ return await fetchJSON<T>(path)
1300
+ } catch (error) {
1301
+ if (isMetricsUnavailableError(error)) return null
1302
+ throw error
1303
+ }
1304
+ }
1305
+
1306
+ function retryMetricsQuery(failureCount: number, error: unknown): boolean {
1307
+ return !isMetricsUnavailableError(error) && failureCount < 1
1308
+ }
1309
+
1246
1310
  // Fetch metrics for a specific pod
1247
- export function usePodMetrics(namespace: string, podName: string) {
1248
- return useQuery<PodMetrics>({
1311
+ export function usePodMetrics(namespace: string, podName: string, options?: { enabled?: boolean }) {
1312
+ return useQuery<PodMetrics | null>({
1249
1313
  queryKey: ['pod-metrics', namespace, podName],
1250
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}`),
1251
- enabled: Boolean(namespace && podName),
1252
- staleTime: 15000, // Metrics are fresh for 15 seconds
1253
- refetchInterval: 30000, // Refresh every 30 seconds
1314
+ queryFn: () => fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
1315
+ enabled: Boolean(namespace && podName) && (options?.enabled ?? true),
1316
+ staleTime: 15000,
1317
+ refetchInterval: 30000,
1318
+ refetchOnMount: 'always',
1319
+ refetchOnReconnect: 'always',
1320
+ retry: retryMetricsQuery,
1254
1321
  })
1255
1322
  }
1256
1323
 
1257
1324
  // Fetch metrics for a specific node
1258
- export function useNodeMetrics(nodeName: string) {
1259
- return useQuery<NodeMetrics>({
1325
+ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }) {
1326
+ return useQuery<NodeMetrics | null>({
1260
1327
  queryKey: ['node-metrics', nodeName],
1261
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}`),
1262
- enabled: Boolean(nodeName),
1328
+ queryFn: () => fetchMetricsOrNull<NodeMetrics>(`/metrics/nodes/${nodeName}`),
1329
+ enabled: Boolean(nodeName) && (options?.enabled ?? true),
1263
1330
  staleTime: 15000,
1264
1331
  refetchInterval: 30000,
1332
+ refetchOnMount: 'always',
1333
+ refetchOnReconnect: 'always',
1334
+ retry: retryMetricsQuery,
1265
1335
  })
1266
1336
  }
1267
1337
 
@@ -1285,19 +1355,57 @@ export interface PodMetricsHistory {
1285
1355
  name: string
1286
1356
  containers: ContainerMetricsHistory[]
1287
1357
  collectionError?: string
1358
+ rawCollectionError?: string
1359
+ metricsUnavailableDiagnosis?: string
1360
+ metricsUnavailable?: boolean
1361
+ metricsUnavailableReason?: string
1288
1362
  }
1289
1363
 
1290
1364
  export interface NodeMetricsHistory {
1291
1365
  name: string
1292
1366
  dataPoints: MetricsDataPoint[]
1293
1367
  collectionError?: string
1368
+ rawCollectionError?: string
1369
+ metricsUnavailableDiagnosis?: string
1370
+ metricsUnavailable?: boolean
1371
+ metricsUnavailableReason?: string
1372
+ }
1373
+
1374
+ function withoutCollectionError<T extends { collectionError?: string; rawCollectionError?: string }>(history: T): T {
1375
+ const next = { ...history }
1376
+ delete next.collectionError
1377
+ delete next.rawCollectionError
1378
+ return next
1379
+ }
1380
+
1381
+ export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1382
+ if (history.metricsUnavailable !== true) return history
1383
+ return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1384
+ }
1385
+
1386
+ export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1387
+ if (history.metricsUnavailable !== true) return history
1388
+ return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1389
+ }
1390
+
1391
+ export function shouldFetchLiveMetrics(historySettled: boolean, metricsUnavailable: boolean): boolean {
1392
+ return historySettled && !metricsUnavailable
1393
+ }
1394
+
1395
+ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: unknown): boolean {
1396
+ return liveMetricsEnabled && metrics === null
1397
+ }
1398
+
1399
+ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUnavailable: boolean, metrics: T | null | undefined): T | undefined {
1400
+ if (!liveMetricsEnabled || metricsUnavailable) return undefined
1401
+ return metrics ?? undefined
1294
1402
  }
1295
1403
 
1296
1404
  // Fetch historical metrics for a pod (last ~1 hour)
1297
1405
  export function usePodMetricsHistory(namespace: string, podName: string) {
1298
1406
  return useQuery<PodMetricsHistory>({
1299
1407
  queryKey: ['pod-metrics-history', namespace, podName],
1300
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}/history`),
1408
+ queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
1301
1409
  enabled: Boolean(namespace && podName),
1302
1410
  staleTime: 25000, // Slightly less than poll interval
1303
1411
  refetchInterval: 30000, // Match the backend poll interval
@@ -1308,7 +1416,7 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1308
1416
  export function useNodeMetricsHistory(nodeName: string) {
1309
1417
  return useQuery<NodeMetricsHistory>({
1310
1418
  queryKey: ['node-metrics-history', nodeName],
1311
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}/history`),
1419
+ queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
1312
1420
  enabled: Boolean(nodeName),
1313
1421
  staleTime: 25000,
1314
1422
  refetchInterval: 30000,
@@ -1545,9 +1653,12 @@ export function useAutoPromConnect(): void {
1545
1653
  const timeout = window.setTimeout(() => {
1546
1654
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1547
1655
  // meta-driven toast handler stays silent — the user didn't click anything.
1548
- apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
1549
- .then(resp => {
1656
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, { method: 'POST' })
1657
+ .then(async resp => {
1550
1658
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1659
+ const nextStatus = await resp.json() as PrometheusStatus
1660
+ queryClient.setQueryData(['prometheus-status'], nextStatus)
1661
+ if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
1551
1662
  queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1552
1663
  })
1553
1664
  .catch(() => {
@@ -2452,6 +2563,22 @@ export function useHelmNotesDiff(
2452
2563
  })
2453
2564
  }
2454
2565
 
2566
+ export function useHelmHooksDiff(
2567
+ namespace: string,
2568
+ name: string,
2569
+ revision1: number,
2570
+ revision2: number,
2571
+ enabled = true,
2572
+ ) {
2573
+ return useQuery<HooksDiff>({
2574
+ queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
2575
+ queryFn: () =>
2576
+ fetchJSON(`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`),
2577
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
2578
+ staleTime: 60000,
2579
+ })
2580
+ }
2581
+
2455
2582
  export function useHelmResourceDiff(
2456
2583
  namespace: string,
2457
2584
  name: string,
@@ -254,7 +254,7 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
254
254
 
255
255
  {connection.error && (
256
256
  <div className="w-full bg-theme-elevated border border-theme-border rounded-lg p-3 mb-6 overflow-auto max-h-32">
257
- <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-all">
257
+ <code className="text-xs text-red-400 font-mono whitespace-pre-wrap break-words">
258
258
  {connection.error}
259
259
  </code>
260
260
  </div>
@@ -14,6 +14,8 @@ import { parseContextName, type ParsedContextName } from '../utils/context-name'
14
14
 
15
15
  interface ContextSwitcherProps {
16
16
  className?: string
17
+ variant?: 'chip' | 'segment'
18
+ label?: string
17
19
  }
18
20
 
19
21
  export interface ContextSwitcherHandle {
@@ -24,7 +26,7 @@ interface ParsedContext extends ParsedContextName {
24
26
  context: ContextInfo
25
27
  }
26
28
 
27
- export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
29
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label }, ref) => {
28
30
  const [showConfirm, setShowConfirm] = useState(false)
29
31
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
30
32
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -191,6 +193,8 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
191
193
  <ClusterSwitcher
192
194
  ref={ref}
193
195
  className={className}
196
+ variant={variant}
197
+ label={label}
194
198
  currentId={currentId}
195
199
  currentName={currentRaw}
196
200
  currentSourceLabel={currentSourceLabel}