@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.
- package/package.json +1 -1
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +73 -38
- package/src/components/checks/ChecksView.tsx +105 -63
- package/src/components/checks/severity.ts +29 -39
- package/src/components/gitops/GitOpsTableView.tsx +72 -43
- package/src/components/issues/IssuesView.tsx +186 -123
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
- package/src/components/issues/ResourceIssuesSection.tsx +21 -3
- package/src/components/issues/index.ts +0 -2
- package/src/components/issues/issues.test.ts +45 -4
- package/src/components/issues/severity.ts +27 -32
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
- package/src/components/resources/ResourcesSidebar.tsx +24 -6
- package/src/components/resources/ResourcesView.tsx +10 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/severity-tone.ts +56 -0
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/index.ts +4 -0
- package/src/theme/variables.css +14 -0
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- 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,
|
|
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('
|
|
66
|
-
expect(groupBadgeClass('
|
|
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
|
|
7
|
-
//
|
|
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
|
-
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 && (
|
|
190
|
+
{!hideMetricsServer && (currentMetrics?.usage || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
|
|
186
191
|
<Section title="Resource Usage" icon={Activity} defaultExpanded>
|
|
187
|
-
{
|
|
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
|
-
{
|
|
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
|
-
) :
|
|
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">{
|
|
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(
|
|
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
|
-
{
|
|
264
|
+
{!showMetricsUnavailable && currentMetrics?.timestamp && (
|
|
257
265
|
<div className="mt-2 text-xs text-theme-text-tertiary">
|
|
258
|
-
Last updated: {new Date(
|
|
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 —
|
|
57
|
-
message="
|
|
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
|
|