@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.
- package/package.json +5 -5
- package/src/App.tsx +256 -94
- package/src/RadarApp.tsx +4 -1
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.ts +147 -20
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -300
- package/src/components/applications/ApplicationsView.tsx +22 -7
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/gitops/GitOpsView.tsx +22 -7
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +4 -4
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +20 -7
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +3 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PodRenderer.tsx +10 -4
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +5 -1
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- 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
|
-
<
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: () =>
|
|
1251
|
-
enabled: Boolean(namespace && podName),
|
|
1252
|
-
staleTime: 15000,
|
|
1253
|
-
refetchInterval: 30000,
|
|
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: () =>
|
|
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-
|
|
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}
|