@skyhook-io/radar-app 1.8.5 → 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 +1 -1
- package/src/App.tsx +8 -2
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.ts +115 -14
- package/src/components/applications/ApplicationsView.tsx +9 -6
- package/src/components/home/HomeView.tsx +8 -6
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PodRenderer.tsx +10 -4
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -1721,6 +1721,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1721
1721
|
{!showNavRail && (
|
|
1722
1722
|
<button
|
|
1723
1723
|
onClick={() => setShowCommandPalette(true)}
|
|
1724
|
+
aria-label="Open command palette"
|
|
1724
1725
|
className="hidden lg:flex items-center gap-2 h-7 px-2.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1725
1726
|
>
|
|
1726
1727
|
<Search className="w-3.5 h-3.5" />
|
|
@@ -1742,6 +1743,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1742
1743
|
<Tooltip content="Open local terminal">
|
|
1743
1744
|
<button
|
|
1744
1745
|
onClick={() => openLocalTerminal()}
|
|
1746
|
+
aria-label="Open local terminal"
|
|
1745
1747
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1746
1748
|
>
|
|
1747
1749
|
<SquareTerminal className="w-4 h-4" />
|
|
@@ -1769,6 +1771,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1769
1771
|
<Tooltip content="Keyboard shortcuts (?)">
|
|
1770
1772
|
<button
|
|
1771
1773
|
onClick={() => setShowHelp(true)}
|
|
1774
|
+
aria-label="Show keyboard shortcuts"
|
|
1772
1775
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1773
1776
|
>
|
|
1774
1777
|
<HelpCircle className="w-4 h-4" />
|
|
@@ -1777,6 +1780,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1777
1780
|
<Tooltip content="Report a bug / Diagnostics">
|
|
1778
1781
|
<button
|
|
1779
1782
|
onClick={() => setShowDiagnostics(true)}
|
|
1783
|
+
aria-label="Open diagnostics"
|
|
1780
1784
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1781
1785
|
>
|
|
1782
1786
|
<Bug className="w-4 h-4" />
|
|
@@ -2406,12 +2410,12 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
|
|
|
2406
2410
|
return (
|
|
2407
2411
|
<div className={`fixed ${bottom} right-4 z-40 flex items-center gap-1.5`}>
|
|
2408
2412
|
<Tooltip content="Report bug / Diagnostics" position="top">
|
|
2409
|
-
<button onClick={onBugReport} className={btnClass}>
|
|
2413
|
+
<button onClick={onBugReport} aria-label="Open diagnostics" className={btnClass}>
|
|
2410
2414
|
<Bug className="w-3.5 h-3.5" />
|
|
2411
2415
|
</button>
|
|
2412
2416
|
</Tooltip>
|
|
2413
2417
|
<Tooltip content="Keyboard shortcuts (?)" position="top">
|
|
2414
|
-
<button onClick={onHelp} className={btnClass}>
|
|
2418
|
+
<button onClick={onHelp} aria-label="Show keyboard shortcuts" className={btnClass}>
|
|
2415
2419
|
?
|
|
2416
2420
|
</button>
|
|
2417
2421
|
</Tooltip>
|
|
@@ -2563,6 +2567,7 @@ function GitHubStarButton() {
|
|
|
2563
2567
|
target="_blank"
|
|
2564
2568
|
rel="noopener noreferrer"
|
|
2565
2569
|
onClick={handleClick}
|
|
2570
|
+
aria-label={starred ? 'Open Radar on GitHub' : 'Star Radar on GitHub'}
|
|
2566
2571
|
className="flex items-center gap-1.5 h-7 px-2 rounded-md transition-colors bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary"
|
|
2567
2572
|
>
|
|
2568
2573
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
@@ -2618,6 +2623,7 @@ function ThemeToggle() {
|
|
|
2618
2623
|
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
|
2619
2624
|
<button
|
|
2620
2625
|
onClick={toggleTheme}
|
|
2626
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
2621
2627
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
2622
2628
|
>
|
|
2623
2629
|
{theme === 'dark' ? (
|
|
@@ -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
|
@@ -103,6 +103,47 @@ export function isForbiddenError(error: unknown): boolean {
|
|
|
103
103
|
return error instanceof ApiError && error.status === 403
|
|
104
104
|
}
|
|
105
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
|
+
|
|
106
147
|
export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
107
148
|
const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
|
|
108
149
|
if (!response.ok) {
|
|
@@ -1253,25 +1294,44 @@ export interface NodeMetrics {
|
|
|
1253
1294
|
}
|
|
1254
1295
|
}
|
|
1255
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
|
+
|
|
1256
1310
|
// Fetch metrics for a specific pod
|
|
1257
|
-
export function usePodMetrics(namespace: string, podName: string) {
|
|
1258
|
-
return useQuery<PodMetrics>({
|
|
1311
|
+
export function usePodMetrics(namespace: string, podName: string, options?: { enabled?: boolean }) {
|
|
1312
|
+
return useQuery<PodMetrics | null>({
|
|
1259
1313
|
queryKey: ['pod-metrics', namespace, podName],
|
|
1260
|
-
queryFn: () =>
|
|
1261
|
-
enabled: Boolean(namespace && podName),
|
|
1262
|
-
staleTime: 15000,
|
|
1263
|
-
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,
|
|
1264
1321
|
})
|
|
1265
1322
|
}
|
|
1266
1323
|
|
|
1267
1324
|
// Fetch metrics for a specific node
|
|
1268
|
-
export function useNodeMetrics(nodeName: string) {
|
|
1269
|
-
return useQuery<NodeMetrics>({
|
|
1325
|
+
export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }) {
|
|
1326
|
+
return useQuery<NodeMetrics | null>({
|
|
1270
1327
|
queryKey: ['node-metrics', nodeName],
|
|
1271
|
-
queryFn: () =>
|
|
1272
|
-
enabled: Boolean(nodeName),
|
|
1328
|
+
queryFn: () => fetchMetricsOrNull<NodeMetrics>(`/metrics/nodes/${nodeName}`),
|
|
1329
|
+
enabled: Boolean(nodeName) && (options?.enabled ?? true),
|
|
1273
1330
|
staleTime: 15000,
|
|
1274
1331
|
refetchInterval: 30000,
|
|
1332
|
+
refetchOnMount: 'always',
|
|
1333
|
+
refetchOnReconnect: 'always',
|
|
1334
|
+
retry: retryMetricsQuery,
|
|
1275
1335
|
})
|
|
1276
1336
|
}
|
|
1277
1337
|
|
|
@@ -1295,19 +1355,57 @@ export interface PodMetricsHistory {
|
|
|
1295
1355
|
name: string
|
|
1296
1356
|
containers: ContainerMetricsHistory[]
|
|
1297
1357
|
collectionError?: string
|
|
1358
|
+
rawCollectionError?: string
|
|
1359
|
+
metricsUnavailableDiagnosis?: string
|
|
1360
|
+
metricsUnavailable?: boolean
|
|
1361
|
+
metricsUnavailableReason?: string
|
|
1298
1362
|
}
|
|
1299
1363
|
|
|
1300
1364
|
export interface NodeMetricsHistory {
|
|
1301
1365
|
name: string
|
|
1302
1366
|
dataPoints: MetricsDataPoint[]
|
|
1303
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
|
|
1304
1402
|
}
|
|
1305
1403
|
|
|
1306
1404
|
// Fetch historical metrics for a pod (last ~1 hour)
|
|
1307
1405
|
export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
1308
1406
|
return useQuery<PodMetricsHistory>({
|
|
1309
1407
|
queryKey: ['pod-metrics-history', namespace, podName],
|
|
1310
|
-
queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}/history`),
|
|
1408
|
+
queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
|
|
1311
1409
|
enabled: Boolean(namespace && podName),
|
|
1312
1410
|
staleTime: 25000, // Slightly less than poll interval
|
|
1313
1411
|
refetchInterval: 30000, // Match the backend poll interval
|
|
@@ -1318,7 +1416,7 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
|
1318
1416
|
export function useNodeMetricsHistory(nodeName: string) {
|
|
1319
1417
|
return useQuery<NodeMetricsHistory>({
|
|
1320
1418
|
queryKey: ['node-metrics-history', nodeName],
|
|
1321
|
-
queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}/history`),
|
|
1419
|
+
queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
|
|
1322
1420
|
enabled: Boolean(nodeName),
|
|
1323
1421
|
staleTime: 25000,
|
|
1324
1422
|
refetchInterval: 30000,
|
|
@@ -1555,9 +1653,12 @@ export function useAutoPromConnect(): void {
|
|
|
1555
1653
|
const timeout = window.setTimeout(() => {
|
|
1556
1654
|
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
1557
1655
|
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
1558
|
-
apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
|
|
1559
|
-
.then(resp => {
|
|
1656
|
+
apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, { method: 'POST' })
|
|
1657
|
+
.then(async resp => {
|
|
1560
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')
|
|
1561
1662
|
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
1562
1663
|
})
|
|
1563
1664
|
.catch(() => {
|
|
@@ -83,7 +83,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
83
83
|
// the page header from vanishing while loading / on error, the wrapper shows
|
|
84
84
|
// the same header bar above those states. (Keep title + description in sync
|
|
85
85
|
// with ApplicationsList's PageHeader.)
|
|
86
|
-
if (query.isLoading
|
|
86
|
+
if (query.isLoading) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
89
|
+
<ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (query.error) {
|
|
87
94
|
return (
|
|
88
95
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
89
96
|
<div className="shrink-0 border-b border-theme-border px-4 py-4">
|
|
@@ -93,11 +100,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
93
100
|
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
94
101
|
/>
|
|
95
102
|
</div>
|
|
96
|
-
{query.
|
|
97
|
-
<CenteredEmpty icon={Boxes} headline="Loading applications…" />
|
|
98
|
-
) : (
|
|
99
|
-
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
100
|
-
)}
|
|
103
|
+
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
101
104
|
</div>
|
|
102
105
|
)
|
|
103
106
|
}
|
|
@@ -359,11 +359,8 @@ function ProblemsPanel({
|
|
|
359
359
|
<div className="divide-y divide-theme-border">
|
|
360
360
|
{issues.map((issue) => {
|
|
361
361
|
const ref = subjectRef(issue)
|
|
362
|
-
const age = issue.first_seen
|
|
363
|
-
|
|
364
|
-
? 'since deploy'
|
|
365
|
-
: formatCompactAge(issue.first_seen)
|
|
366
|
-
: ''
|
|
362
|
+
const age = issue.first_seen ? formatCompactAge(issue.first_seen) : ''
|
|
363
|
+
const startedAtDeploy = issue.issue_timing === 'started_at_resource_creation'
|
|
367
364
|
|
|
368
365
|
return (
|
|
369
366
|
<button
|
|
@@ -381,7 +378,12 @@ function ProblemsPanel({
|
|
|
381
378
|
<div className="flex items-center gap-1.5">
|
|
382
379
|
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
383
380
|
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
384
|
-
{age
|
|
381
|
+
{(age || startedAtDeploy) && (
|
|
382
|
+
<span className="ml-auto flex shrink-0 items-center gap-1">
|
|
383
|
+
{age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
|
|
384
|
+
{startedAtDeploy && <span className="badge-sm text-[10px] text-theme-text-secondary">since deploy</span>}
|
|
385
|
+
</span>
|
|
386
|
+
)}
|
|
385
387
|
</div>
|
|
386
388
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
387
389
|
<span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NodeRenderer as BaseNodeRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NodeRenderer'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
-
import { useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
3
|
+
import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
4
4
|
import { serializeColumnFilters } from '../resource-utils'
|
|
5
5
|
|
|
6
6
|
interface NodeRendererProps {
|
|
@@ -13,8 +13,13 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
|
|
|
13
13
|
const nodeName = data.metadata?.name
|
|
14
14
|
|
|
15
15
|
// Fetch node metrics
|
|
16
|
-
const
|
|
17
|
-
const { data: metricsHistory } =
|
|
16
|
+
const metricsHistoryQuery = useNodeMetricsHistory(nodeName)
|
|
17
|
+
const { data: metricsHistory } = metricsHistoryQuery
|
|
18
|
+
const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
|
|
19
|
+
const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
|
|
20
|
+
const { data: metrics } = useNodeMetrics(nodeName, { enabled: liveMetricsEnabled })
|
|
21
|
+
const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
|
|
22
|
+
const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
|
|
18
23
|
|
|
19
24
|
// Determine whether to hide metrics-server section (Prometheus has data)
|
|
20
25
|
const { data: prometheusStatus } = usePrometheusStatus()
|
|
@@ -36,8 +41,9 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
|
|
|
36
41
|
params.set('filters', serializeColumnFilters({ node: [nodeName] }))
|
|
37
42
|
navigate(`/resources/pods?${params.toString()}`)
|
|
38
43
|
} : undefined}
|
|
39
|
-
metrics={
|
|
44
|
+
metrics={visibleMetrics}
|
|
40
45
|
metricsHistory={metricsHistory}
|
|
46
|
+
metricsUnavailable={metricsUnavailable}
|
|
41
47
|
hideMetricsServer={hideMetricsServer}
|
|
42
48
|
/>
|
|
43
49
|
)
|
|
@@ -3,7 +3,7 @@ import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-compon
|
|
|
3
3
|
import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { useOpenTerminal, useOpenLogs } from '../../dock'
|
|
5
5
|
import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
|
|
6
|
-
import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
6
|
+
import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
7
7
|
import { useRBACSubject } from '../../../api/rbac'
|
|
8
8
|
import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
|
|
9
9
|
import { ImageFilesystemModal } from '../ImageFilesystemModal'
|
|
@@ -34,8 +34,13 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
34
34
|
const showPortForward = canPortForward || !isLocal
|
|
35
35
|
|
|
36
36
|
// Metrics
|
|
37
|
-
const
|
|
38
|
-
const { data: metricsHistory } =
|
|
37
|
+
const metricsHistoryQuery = usePodMetricsHistory(namespace, podName)
|
|
38
|
+
const { data: metricsHistory } = metricsHistoryQuery
|
|
39
|
+
const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
|
|
40
|
+
const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
|
|
41
|
+
const { data: metrics } = usePodMetrics(namespace, podName, { enabled: liveMetricsEnabled })
|
|
42
|
+
const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
|
|
43
|
+
const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
|
|
39
44
|
|
|
40
45
|
// Hide metrics-server section when Prometheus has CPU data
|
|
41
46
|
const { data: prometheusStatus } = usePrometheusStatus()
|
|
@@ -82,8 +87,9 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
82
87
|
disabled={disabled}
|
|
83
88
|
/>
|
|
84
89
|
)}
|
|
85
|
-
metrics={
|
|
90
|
+
metrics={visibleMetrics}
|
|
86
91
|
metricsHistory={metricsHistory}
|
|
92
|
+
metricsUnavailable={metricsUnavailable}
|
|
87
93
|
hideMetricsServer={hideMetricsServer}
|
|
88
94
|
renderImageBrowser={({ image, namespace: ns, podName: pod, pullSecrets, onClose, onSwitchToPodFiles }) => (
|
|
89
95
|
<ImageFilesystemModal
|