@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.5",
3
+ "version": "1.8.6",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
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: () => fetchJSON(`/metrics/pods/${namespace}/${podName}`),
1261
- enabled: Boolean(namespace && podName),
1262
- staleTime: 15000, // Metrics are fresh for 15 seconds
1263
- 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,
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: () => fetchJSON(`/metrics/nodes/${nodeName}`),
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 || query.error) {
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.isLoading ? (
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
- ? issue.issue_timing === 'started_at_resource_creation'
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 && <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{age}</span>}
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 { data: metrics } = useNodeMetrics(nodeName)
17
- const { data: metricsHistory } = useNodeMetricsHistory(nodeName)
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={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 { data: metrics } = usePodMetrics(namespace, podName)
38
- const { data: metricsHistory } = usePodMetricsHistory(namespace, podName)
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={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