@skyhook-io/k8s-ui 1.8.8 → 1.8.10

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +73 -38
  4. package/src/components/checks/ChecksView.tsx +105 -63
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +72 -43
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/SummaryTile.tsx +9 -1
  26. package/src/components/ui/Toast.tsx +1 -1
  27. package/src/components/ui/index.ts +2 -0
  28. package/src/components/ui/severity-tone.ts +56 -0
  29. package/src/filter-state/filter-state-core.test.ts +98 -0
  30. package/src/filter-state/filter-state-core.ts +138 -0
  31. package/src/filter-state/filter-state.tsx +127 -0
  32. package/src/filter-state/index.ts +16 -0
  33. package/src/index.ts +4 -0
  34. package/src/theme/variables.css +14 -0
  35. package/src/utils/api-resources.test.ts +54 -0
  36. package/src/utils/api-resources.ts +68 -1
  37. package/tsconfig.json +2 -2
@@ -1,8 +1,8 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
2
  import { createElement } from 'react'
3
3
  import { renderToString } from 'react-dom/server'
4
4
  import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
5
- import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
5
+ import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
6
6
  import { IssueRow } from './IssuesView'
7
7
 
8
8
  const base: Issue = {
@@ -18,6 +18,10 @@ const base: Issue = {
18
18
  }
19
19
  const mk = (o: Partial<Issue>): Issue => ({ ...base, ...o })
20
20
 
21
+ afterEach(() => {
22
+ vi.useRealTimers()
23
+ })
24
+
21
25
  describe('compareIssues', () => {
22
26
  it('orders critical before warning regardless of observed age', () => {
23
27
  const warn = mk({ id: 'w', severity: 'warning', first_seen: '2026-05-01T00:00:00Z' }) // newer
@@ -62,8 +66,45 @@ describe('category/group label fallbacks', () => {
62
66
  expect(groupLabel('runtime')).toBe('Runtime')
63
67
  expect(groupLabel('some_future_group')).toBe('Some future group')
64
68
  })
65
- it('groupBadgeClass falls back to a non-empty neutral class for an unknown group', () => {
66
- expect(groupBadgeClass('totally_unknown_group')).toBeTruthy()
69
+ it('keeps ordinary group chips neutral and emphasizes control-plane/unknown groups', () => {
70
+ expect(groupBadgeClass('runtime')).toContain('text-theme-text-secondary')
71
+ expect(groupBadgeClass('runtime')).not.toContain('ring-theme-border-light')
72
+ expect(groupBadgeClass('control_plane')).toContain('text-theme-text-primary')
73
+ expect(groupBadgeClass('control_plane')).toContain('ring-theme-border-light')
74
+ expect(groupBadgeClass('unknown')).toContain('ring-theme-border-light')
75
+ expect(groupBadgeClass('some_future_group')).toContain('text-theme-text-secondary')
76
+ expect(groupBadgeClass('some_future_group')).not.toContain('ring-theme-border-light')
77
+ })
78
+ })
79
+
80
+ describe('IssueRow', () => {
81
+ it('keeps relative age visible and adds since-deploy as a secondary tag', () => {
82
+ vi.useFakeTimers()
83
+ vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
84
+
85
+ const html = renderToString(createElement(IssueRow, {
86
+ issue: mk({
87
+ id: 'baseline',
88
+ first_seen: '2026-06-28T12:00:00Z',
89
+ issue_timing: 'started_at_resource_creation',
90
+ }),
91
+ open: false,
92
+ onToggle: () => undefined,
93
+ }))
94
+
95
+ expect(html).toContain('2d')
96
+ expect(html).toContain('since deploy')
97
+ })
98
+
99
+ it('uses the category-group chip class in the queue row', () => {
100
+ const html = renderToString(createElement(IssueRow, {
101
+ issue: mk({ category_group: 'control_plane' }),
102
+ open: false,
103
+ onToggle: () => undefined,
104
+ }))
105
+
106
+ expect(html).toContain('Control plane')
107
+ expect(html).toContain(groupBadgeClass('control_plane'))
67
108
  })
68
109
  })
69
110
 
@@ -1,11 +1,28 @@
1
1
  import type { IssueSeverity } from './types';
2
2
  import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge';
3
+ import { NEUTRAL_CHIP_CLASS } from '../ui/CardSection';
4
+ import {
5
+ TONE_HEADER_BAND_CLASS,
6
+ TONE_RAIL_CLASS,
7
+ TONE_SOLID_CLASS,
8
+ TONE_TEXT_CLASS,
9
+ type SeverityTone,
10
+ } from '../ui/severity-tone';
3
11
 
4
12
  // Visual language for the 2-tier Issues severity. critical = red, warning =
5
13
  // amber. Issues and Checks are different severity axes but must read as one
6
- // product — both pull from the canonical Badge severity tones
7
- // (BADGE_SEVERITY_COLORS), so their pills match each other and every status
8
- // badge elsewhere.
14
+ // product, so the color strings are shared via the tone module; here we only map
15
+ // each tier onto its tone. The pill still pulls from the canonical Badge tones
16
+ // so it matches every status badge elsewhere.
17
+ const ISSUE_SEVERITY_TONE: Record<IssueSeverity, SeverityTone> = {
18
+ critical: 'red',
19
+ warning: 'amber',
20
+ };
21
+
22
+ const byTone = <T,>(toneMap: Record<SeverityTone, T>): Record<IssueSeverity, T> => ({
23
+ critical: toneMap[ISSUE_SEVERITY_TONE.critical],
24
+ warning: toneMap[ISSUE_SEVERITY_TONE.warning],
25
+ });
9
26
 
10
27
  export const ISSUE_SEVERITY_LABEL: Record<IssueSeverity, string> = {
11
28
  critical: 'Critical',
@@ -18,39 +35,17 @@ export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
18
35
  warning: sev.warning,
19
36
  };
20
37
 
21
- // Solid fill — dots + the proportional distribution bar segments.
22
- export const ISSUE_SEVERITY_FILL_CLASS: Record<IssueSeverity, string> = {
23
- critical: 'bg-red-500',
24
- warning: 'bg-amber-500',
25
- };
38
+ export const ISSUE_SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS);
39
+ export const ISSUE_SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS);
40
+ export const ISSUE_SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS);
41
+ export const ISSUE_SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS);
26
42
 
27
- export const ISSUE_SEVERITY_TEXT_CLASS: Record<IssueSeverity, string> = {
28
- critical: 'text-red-600 dark:text-red-400',
29
- warning: 'text-amber-600 dark:text-amber-400',
30
- };
43
+ const GROUP_CHIP_EMPHASIS_CLASS = 'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-primary ring-1 ring-theme-border-light';
31
44
 
32
- // Left accent rail on a queue row — the scan-down severity cue.
33
- export const ISSUE_SEVERITY_RAIL_CLASS: Record<IssueSeverity, string> = {
34
- critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
35
- warning: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
36
- };
37
-
38
- // Category-group accent — the quiet classification tag (severity is the loud
39
- // one). One hue per group; unknown/unmapped falls back to a neutral theme tag.
40
- const GROUP_BADGE_CLASS: Record<string, string> = {
41
- scheduling: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
42
- startup: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
43
- runtime: 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900',
44
- configuration: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
45
- networking: 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:ring-indigo-900',
46
- storage: 'bg-cyan-50 text-cyan-700 ring-1 ring-cyan-200 dark:bg-cyan-950/40 dark:text-cyan-300 dark:ring-cyan-900',
47
- scaling: 'bg-fuchsia-50 text-fuchsia-700 ring-1 ring-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-300 dark:ring-fuchsia-900',
48
- security: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
49
- control_plane: 'bg-slate-100 text-slate-600 ring-1 ring-slate-200 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-700',
50
- };
45
+ const EMPHASIZED_GROUPS = new Set(['control_plane', 'unknown']);
51
46
 
52
47
  export function groupBadgeClass(group: string): string {
53
- return GROUP_BADGE_CLASS[group] ?? 'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border';
48
+ return EMPHASIZED_GROUPS.has(group) ? GROUP_CHIP_EMPHASIS_CLASS : NEUTRAL_CHIP_CLASS;
54
49
  }
55
50
 
56
51
  // Display labels. The server emits raw snake_case category/group enums (so a
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import type { APIResource } from '../../types'
4
+ import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar } from './ResourcesSidebar'
5
+
6
+ const sqlInstance: APIResource = {
7
+ group: 'sql.cnrm.cloud.google.com',
8
+ version: 'v1beta1',
9
+ kind: 'SQLInstance',
10
+ name: 'sqlinstances',
11
+ namespaced: true,
12
+ isCrd: true,
13
+ verbs: ['list'],
14
+ }
15
+
16
+ describe('ResourcesSidebar CRD group labels', () => {
17
+ it('keeps raw API groups available for filtering and hover recovery', () => {
18
+ expect(resourceMatchesSidebarFilter(sqlInstance, 'cnrm')).toBe(true)
19
+ expect(resourceMatchesSidebarFilter(sqlInstance, 'google')).toBe(true)
20
+ expect(rawCRDGroupTitle([sqlInstance])).toBe('API group: sql.cnrm.cloud.google.com')
21
+ })
22
+
23
+ it('renders the raw API group in the category title while keeping the friendly label visible', () => {
24
+ const html = renderToString(
25
+ <ResourcesSidebar
26
+ selectedKind={null}
27
+ onSelectedKindChange={() => {}}
28
+ apiResources={[sqlInstance]}
29
+ resourceCounts={{ 'sql.cnrm.cloud.google.com/SQLInstance': 1 }}
30
+ />
31
+ )
32
+
33
+ expect(html).toContain('Config Connector')
34
+ expect(html).toContain('title="API group: sql.cnrm.cloud.google.com"')
35
+ })
36
+ })
@@ -92,6 +92,25 @@ const CORE_RESOURCE_TYPES = [
92
92
  { kind: 'hpas', label: 'HPAs' },
93
93
  ] as const
94
94
 
95
+ export function resourceMatchesSidebarFilter(resource: Pick<APIResource, 'kind' | 'name' | 'group'>, term: string): boolean {
96
+ const normalized = term.trim().toLowerCase()
97
+ if (!normalized) return true
98
+ return resource.kind.toLowerCase().includes(normalized) ||
99
+ resource.name.toLowerCase().includes(normalized) ||
100
+ resource.group.toLowerCase().includes(normalized)
101
+ }
102
+
103
+ export function rawCRDGroupTitle(resources: Pick<APIResource, 'group' | 'isCrd'>[]): string | undefined {
104
+ const groups = Array.from(new Set(
105
+ resources
106
+ .filter(resource => resource.isCrd && resource.group)
107
+ .map(resource => resource.group)
108
+ )).sort()
109
+
110
+ if (groups.length === 0) return undefined
111
+ return groups.length === 1 ? `API group: ${groups[0]}` : `API groups: ${groups.join(', ')}`
112
+ }
113
+
95
114
  // Resource type button in sidebar
96
115
  interface ResourceTypeButtonProps {
97
116
  resource: APIResource
@@ -347,12 +366,10 @@ export function ResourcesSidebar({
347
366
  return sortedCategories
348
367
  .map(category => {
349
368
  const categoryMatches = category.name.toLowerCase().includes(term)
369
+ const rawGroupMatches = category.resources.some((resource: APIResource) => resource.group.toLowerCase().includes(term))
350
370
  // If the group name matches, show all its resources
351
- if (categoryMatches) return category
352
- const matchingResources = category.visibleResources.filter((resource: any) =>
353
- resource.kind.toLowerCase().includes(term) ||
354
- resource.name.toLowerCase().includes(term)
355
- )
371
+ if (categoryMatches || rawGroupMatches) return category
372
+ const matchingResources = category.visibleResources.filter((resource: APIResource) => resourceMatchesSidebarFilter(resource, term))
356
373
  if (matchingResources.length === 0) return null
357
374
  return {
358
375
  ...category,
@@ -550,6 +567,7 @@ export function ResourcesSidebar({
550
567
  // Dynamic categories from API
551
568
  filteredCategories.map((category) => {
552
569
  const isExpanded = effectiveExpandedCategories.has(category.name)
570
+ const rawGroupTitle = rawCRDGroupTitle(category.resources)
553
571
  return (
554
572
  <div key={category.name} className="mb-2">
555
573
  <button
@@ -561,7 +579,7 @@ export function ResourcesSidebar({
561
579
  ) : (
562
580
  <ChevronRight className="w-3 h-3" />
563
581
  )}
564
- <span className="flex-1 text-left">{category.name}</span>
582
+ <span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
565
583
  {!isExpanded && (
566
584
  <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', category.total < 1000 ? 'w-8' : 'w-9')}>
567
585
  {category.total}
@@ -1709,8 +1709,16 @@ function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
1709
1709
  if (k === 'providerconfig' || k === 'providerconfigs') return false
1710
1710
  if (group.endsWith('.upbound.io')) return true
1711
1711
  if (group === 'kubernetes.crossplane.io' || group === 'helm.crossplane.io') return true
1712
- // Reserved Crossplane core groups — never MRs.
1713
- if (group === 'crossplane.io' || group === 'pkg.crossplane.io' || group === 'apiextensions.crossplane.io') {
1712
+ // Reserved Crossplane core groups — never MRs. protection.crossplane.io
1713
+ // (Usage/ClusterUsage) and ops.crossplane.io (Operations) are Crossplane v2's
1714
+ // new core groups: lifecycle/orchestration objects, not provider resources.
1715
+ if (
1716
+ group === 'crossplane.io' ||
1717
+ group === 'pkg.crossplane.io' ||
1718
+ group === 'apiextensions.crossplane.io' ||
1719
+ group === 'protection.crossplane.io' ||
1720
+ group === 'ops.crossplane.io'
1721
+ ) {
1714
1722
  return false
1715
1723
  }
1716
1724
  // Any other *.crossplane.io subgroup is presumed to be a provider's MR group.
@@ -0,0 +1,47 @@
1
+ import { Info } from 'lucide-react'
2
+ import { Tooltip } from '../../ui/Tooltip'
3
+
4
+ interface MetricsUnavailableNoticeProps {
5
+ rawError?: string
6
+ diagnosis?: string
7
+ }
8
+
9
+ export function MetricsUnavailableNotice({ rawError, diagnosis }: MetricsUnavailableNoticeProps) {
10
+ return (
11
+ <div className="card-inner-lg text-xs text-theme-text-tertiary">
12
+ <div className="flex items-center gap-1.5">
13
+ <span className="min-w-0 leading-5">
14
+ Metrics unavailable. Radar cannot read metrics.k8s.io.
15
+ </span>
16
+ {rawError && (
17
+ <Tooltip
18
+ content={(
19
+ <span className="space-y-1">
20
+ <span className="block">
21
+ This panel uses Kubernetes metrics.k8s.io for live CPU and memory; Prometheus data does not fill it.
22
+ </span>
23
+ <span className="block">
24
+ {diagnosis || 'Check that metrics-server is installed and healthy, and that the v1beta1.metrics.k8s.io APIService is Available.'}
25
+ </span>
26
+ <span className="block">
27
+ Raw error: <span className="font-mono break-words">{rawError}</span>
28
+ </span>
29
+ </span>
30
+ )}
31
+ delay={150}
32
+ position="left"
33
+ >
34
+ <button
35
+ type="button"
36
+ className="inline-flex shrink-0 cursor-help items-center gap-1 text-xs font-medium leading-5 text-theme-text-tertiary hover:text-theme-text-secondary"
37
+ aria-label="Metrics error details"
38
+ >
39
+ <Info className="h-3.5 w-3.5" />
40
+ <span>Details</span>
41
+ </button>
42
+ </Tooltip>
43
+ )}
44
+ </div>
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { NodeRenderer } from './NodeRenderer'
4
+
5
+ const node = {
6
+ metadata: { name: 'kind-worker', labels: {} },
7
+ spec: {},
8
+ status: {
9
+ nodeInfo: {
10
+ osImage: 'Container-Optimized OS',
11
+ architecture: 'amd64',
12
+ kernelVersion: '6.1.0',
13
+ containerRuntimeVersion: 'containerd://1.7',
14
+ kubeletVersion: 'v1.33.0',
15
+ kubeProxyVersion: 'v1.33.0',
16
+ },
17
+ capacity: { cpu: '4', memory: '8Gi', pods: '110' },
18
+ allocatable: { cpu: '4', memory: '7Gi', pods: '110' },
19
+ conditions: [],
20
+ },
21
+ }
22
+
23
+ describe('NodeRenderer metrics', () => {
24
+ it('renders a calm unavailable state when metrics-server is absent', () => {
25
+ const html = renderToString(
26
+ <NodeRenderer
27
+ data={node}
28
+ metricsUnavailable
29
+ metricsHistory={{
30
+ dataPoints: [],
31
+ metricsUnavailableReason: 'the server could not find the requested resource',
32
+ }}
33
+ />,
34
+ )
35
+
36
+ expect(html).toContain('Resource Usage')
37
+ expect(html).toContain('Metrics unavailable')
38
+ expect(html).toContain('Radar cannot read metrics.k8s.io')
39
+ expect(html).toContain('Metrics error details')
40
+ expect(html).not.toContain('Install or repair metrics-server and its APIService')
41
+ expect(html).not.toContain('Metrics collection error')
42
+ expect(html).not.toContain('Collecting metrics data')
43
+ })
44
+
45
+ it('does not let stale live metrics hide an unavailable state', () => {
46
+ const html = renderToString(
47
+ <NodeRenderer
48
+ data={node}
49
+ metricsUnavailable
50
+ metrics={{ usage: { cpu: '100m', memory: '256Mi' }, timestamp: '2026-06-30T00:00:00Z' }}
51
+ />,
52
+ )
53
+
54
+ expect(html).toContain('Metrics unavailable')
55
+ expect(html).not.toContain('100m')
56
+ expect(html).not.toContain('Last updated')
57
+ })
58
+
59
+ it('keeps buffered historical charts visible under an unavailable notice', () => {
60
+ const html = renderToString(
61
+ <NodeRenderer
62
+ data={node}
63
+ metricsUnavailable
64
+ metricsHistory={{
65
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
66
+ }}
67
+ />,
68
+ )
69
+
70
+ expect(html).toContain('Metrics unavailable')
71
+ expect(html).toContain('CPU')
72
+ expect(html).toContain('Memory')
73
+ expect(html).not.toContain('Last updated')
74
+ })
75
+
76
+ it('keeps non-absence collection errors visible', () => {
77
+ const html = renderToString(
78
+ <NodeRenderer
79
+ data={node}
80
+ metricsHistory={{ dataPoints: [], collectionError: 'forbidden: no access to nodes' }}
81
+ />,
82
+ )
83
+
84
+ expect(html).toContain('Metrics collection error')
85
+ expect(html).toContain('forbidden: no access to nodes')
86
+ })
87
+
88
+ it('warns about non-absence collection errors even when historical samples exist', () => {
89
+ const html = renderToString(
90
+ <NodeRenderer
91
+ data={node}
92
+ metricsHistory={{
93
+ collectionError: 'forbidden: no access to nodes',
94
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
95
+ }}
96
+ />,
97
+ )
98
+
99
+ expect(html).toContain('Metrics collection error')
100
+ expect(html).toContain('forbidden: no access to nodes')
101
+ expect(html).toContain('CPU')
102
+ })
103
+ })
@@ -5,13 +5,15 @@ import { MetricsChart } from '../../ui/MetricsChart'
5
5
  import { formatMemoryString } from '../../../utils/format'
6
6
  import { getExtendedCapacityRows } from '../../../utils/extended-resources'
7
7
  import type { MetricsDataPoint } from '../../../types/core'
8
+ import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
8
9
 
9
10
  interface NodeRendererProps {
10
11
  data: any
11
12
  relationships?: { pods?: any[] }
12
13
  onViewPods?: () => void
13
14
  metrics?: { usage?: { cpu: string; memory: string }; timestamp?: string }
14
- metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string }
15
+ metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
16
+ metricsUnavailable?: boolean
15
17
  hideMetricsServer?: boolean
16
18
  }
17
19
 
@@ -59,7 +61,7 @@ function getNodeProblems(data: any): string[] {
59
61
  return problems
60
62
  }
61
63
 
62
- export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, hideMetricsServer }: NodeRendererProps) {
64
+ export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer }: NodeRendererProps) {
63
65
  const status = data.status || {}
64
66
  const spec = data.spec || {}
65
67
  const metadata = data.metadata || {}
@@ -74,6 +76,9 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
74
76
  const problems = getNodeProblems(data)
75
77
  const hasProblems = problems.length > 0
76
78
  const isCordoned = !!spec.unschedulable
79
+ const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
80
+ const hasMetricsHistory = !!metricsHistory?.dataPoints?.length
81
+ const currentMetrics = metricsUnavailable ? undefined : metrics
77
82
 
78
83
  // Extract platform info from labels
79
84
  const instanceType = labels['node.kubernetes.io/instance-type']
@@ -122,13 +127,13 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
122
127
  label: 'CPU',
123
128
  capacity: capacity.cpu,
124
129
  allocatable: allocatable.cpu,
125
- inUse: metrics?.usage?.cpu,
130
+ inUse: currentMetrics?.usage?.cpu,
126
131
  },
127
132
  {
128
133
  label: 'Memory',
129
134
  capacity: formatMemory(capacity.memory),
130
135
  allocatable: formatMemory(allocatable.memory),
131
- inUse: metrics?.usage?.memory ? formatMemory(metrics.usage.memory) : undefined,
136
+ inUse: currentMetrics?.usage?.memory ? formatMemory(currentMetrics.usage.memory) : undefined,
132
137
  },
133
138
  {
134
139
  label: 'Pods',
@@ -182,15 +187,18 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
182
187
  </Section>
183
188
 
184
189
  {/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
185
- {!hideMetricsServer && (metrics?.usage || metricsHistory?.dataPoints?.length || metricsHistory?.collectionError) && (
190
+ {!hideMetricsServer && (currentMetrics?.usage || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
186
191
  <Section title="Resource Usage" icon={Activity} defaultExpanded>
187
- {metricsHistory?.collectionError && !metricsHistory?.dataPoints?.length && (
192
+ {showMetricsUnavailable && (
193
+ <MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
194
+ )}
195
+ {metricsHistory?.collectionError && (
188
196
  <div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
189
197
  <span className="font-medium">Metrics collection error:</span>{' '}
190
198
  <span className="break-all">{metricsHistory.collectionError}</span>
191
199
  </div>
192
200
  )}
193
- {metricsHistory?.dataPoints && metricsHistory.dataPoints.length > 0 ? (
201
+ {hasMetricsHistory && metricsHistory?.dataPoints ? (
194
202
  <div className="space-y-4">
195
203
  {/* CPU Usage with Chart */}
196
204
  <div className="card-inner-lg">
@@ -224,7 +232,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
224
232
  />
225
233
  </div>
226
234
  </div>
227
- ) : metrics?.usage ? (
235
+ ) : showMetricsUnavailable ? null : currentMetrics?.usage ? (
228
236
  /* Fallback to simple display if no history yet */
229
237
  <div className="space-y-3">
230
238
  <div className="card-inner-lg">
@@ -232,7 +240,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
232
240
  <span className="text-sm font-medium text-theme-text-primary">CPU</span>
233
241
  </div>
234
242
  <div className="flex items-baseline gap-2">
235
- <span className="text-lg font-medium text-blue-400">{metrics.usage.cpu}</span>
243
+ <span className="text-lg font-medium text-blue-400">{currentMetrics.usage.cpu}</span>
236
244
  <span className="text-sm text-theme-text-tertiary">
237
245
  / {allocatable.cpu || capacity.cpu || '?'} allocatable
238
246
  </span>
@@ -243,7 +251,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
243
251
  <span className="text-sm font-medium text-theme-text-primary">Memory</span>
244
252
  </div>
245
253
  <div className="flex items-baseline gap-2">
246
- <span className="text-lg font-medium text-purple-400">{formatMemory(metrics.usage.memory)}</span>
254
+ <span className="text-lg font-medium text-purple-400">{formatMemory(currentMetrics.usage.memory)}</span>
247
255
  <span className="text-sm text-theme-text-tertiary">
248
256
  / {formatMemory(allocatable.memory) || formatMemory(capacity.memory) || '?'} allocatable
249
257
  </span>
@@ -253,9 +261,9 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
253
261
  ) : (
254
262
  <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
255
263
  )}
256
- {metrics?.timestamp && (
264
+ {!showMetricsUnavailable && currentMetrics?.timestamp && (
257
265
  <div className="mt-2 text-xs text-theme-text-tertiary">
258
- Last updated: {new Date(metrics.timestamp).toLocaleTimeString()}
266
+ Last updated: {new Date(currentMetrics.timestamp).toLocaleTimeString()}
259
267
  </div>
260
268
  )}
261
269
  </Section>
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { PVCRenderer } from './PVCRenderer'
4
+
5
+ describe('PVCRenderer', () => {
6
+ it('does not reassure indefinitely pending PVCs as normal', () => {
7
+ const html = renderToString(
8
+ <PVCRenderer
9
+ data={{
10
+ metadata: { name: 'data', namespace: 'demo' },
11
+ spec: { storageClassName: 'standard' },
12
+ status: { phase: 'Pending' },
13
+ }}
14
+ />,
15
+ )
16
+
17
+ expect(html).toContain('Pending')
18
+ expect(html).toContain('not yet bound')
19
+ expect(html).toContain('check the StorageClass')
20
+ expect(html).not.toContain('This is normal')
21
+ expect(html).not.toContain('expected indefinitely')
22
+ })
23
+ })
@@ -53,8 +53,8 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
53
53
  {isPending && (
54
54
  <AlertBanner
55
55
  variant="info"
56
- title="Pending — awaiting binding"
57
- message="Not yet bound to a volume. This is normal while provisioning, and expected indefinitely for a WaitForFirstConsumer StorageClass until a Pod that mounts this claim is scheduled."
56
+ title="Pending — not yet bound"
57
+ message="A PVC stays Pending while its volume is provisioned, or for a WaitForFirstConsumer StorageClass until a Pod that mounts it is scheduled. If it stays Pending, check the StorageClass, its provisioner, and storage quota."
58
58
  />
59
59
  )}
60
60