@skyhook-io/k8s-ui 1.14.10 → 1.14.12
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 +4 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- package/src/utils/topology-neighborhood.ts +40 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.12",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./src/components/resources/*.ts",
|
|
20
20
|
"./src/components/resources/*.tsx"
|
|
21
21
|
],
|
|
22
|
+
"./components/resources/KueueAdmissionSection": "./src/components/resources/KueueAdmissionSection.tsx",
|
|
22
23
|
"./components/resources/renderers/*": [
|
|
23
24
|
"./src/components/resources/renderers/*.tsx",
|
|
24
25
|
"./src/components/resources/renderers/*.ts"
|
|
@@ -88,7 +89,7 @@
|
|
|
88
89
|
"yaml": ">=2.0.0"
|
|
89
90
|
},
|
|
90
91
|
"devDependencies": {
|
|
91
|
-
"@types/node": "^26.2
|
|
92
|
+
"@types/node": "^26.6.2",
|
|
92
93
|
"@types/react": "^19.3.0",
|
|
93
94
|
"@types/react-dom": "^19.3.0",
|
|
94
95
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
"clsx": "^2.1.1",
|
|
99
100
|
"diff": "^9.0.0",
|
|
100
101
|
"elkjs": "^0.11.1",
|
|
102
|
+
"jsdom": "^29.1.1",
|
|
101
103
|
"lucide-react": "^1.37.0",
|
|
102
104
|
"monaco-editor": "0.55.1",
|
|
103
105
|
"react": "^19.3.0",
|
|
@@ -37,12 +37,14 @@ import { EmptyState } from "../ui/EmptyState";
|
|
|
37
37
|
import { ResourceRefBadge } from "../ui/drawer-components";
|
|
38
38
|
import { TopologyGraph } from "../topology/TopologyGraph";
|
|
39
39
|
import { pluralize } from "../../utils/pluralize";
|
|
40
|
+
import { canonicalResourceGroup } from "../../utils/api-resources";
|
|
40
41
|
import { kindToPluralWithGroup, refToSelectedResource } from "../../utils/navigation";
|
|
41
42
|
import {
|
|
42
43
|
batchRunParentNodes,
|
|
43
44
|
tagWorkloadOwnership,
|
|
44
45
|
seedNodeIds,
|
|
45
46
|
ownershipOf,
|
|
47
|
+
topologyNodeResourceKind,
|
|
46
48
|
workloadKey,
|
|
47
49
|
type NeighborhoodSeed,
|
|
48
50
|
} from "../../utils/topology-neighborhood";
|
|
@@ -534,9 +536,14 @@ export function ApplicationDetail({
|
|
|
534
536
|
return;
|
|
535
537
|
}
|
|
536
538
|
const ns = (node.data?.namespace as string) || "";
|
|
539
|
+
const resourceKind = topologyNodeResourceKind(node);
|
|
540
|
+
const group = topologyGroup(node);
|
|
537
541
|
const match = workloads.find(
|
|
538
542
|
(w) =>
|
|
539
|
-
w.kind ===
|
|
543
|
+
w.kind === resourceKind &&
|
|
544
|
+
canonicalResourceGroup(w.kind, w.group) === canonicalResourceGroup(resourceKind, group) &&
|
|
545
|
+
w.name === node.name &&
|
|
546
|
+
w.namespace === ns,
|
|
540
547
|
);
|
|
541
548
|
if (match) {
|
|
542
549
|
setSelected(workloadKey(match));
|
|
@@ -545,9 +552,12 @@ export function ApplicationDetail({
|
|
|
545
552
|
const parents = appGraph ? batchRunParentNodes(appGraph, node) : [];
|
|
546
553
|
const parentWorkload = parents.flatMap((parent) => {
|
|
547
554
|
const parentNamespace = (parent.data?.namespace as string) || "";
|
|
555
|
+
const parentKind = topologyNodeResourceKind(parent);
|
|
556
|
+
const parentGroup = topologyGroup(parent);
|
|
548
557
|
const workload = workloads.find(
|
|
549
558
|
(candidate) =>
|
|
550
|
-
candidate.kind ===
|
|
559
|
+
candidate.kind === parentKind &&
|
|
560
|
+
canonicalResourceGroup(candidate.kind, candidate.group) === canonicalResourceGroup(parentKind, parentGroup) &&
|
|
551
561
|
candidate.name === parent.name &&
|
|
552
562
|
candidate.namespace === parentNamespace,
|
|
553
563
|
);
|
|
@@ -557,9 +567,8 @@ export function ApplicationDetail({
|
|
|
557
567
|
onSelectWorkloadRun(parentWorkload, node);
|
|
558
568
|
return;
|
|
559
569
|
}
|
|
560
|
-
const group = topologyGroup(node);
|
|
561
570
|
onNavigateToResource?.({
|
|
562
|
-
kind: kindToPluralWithGroup(
|
|
571
|
+
kind: kindToPluralWithGroup(resourceKind, group ?? ""),
|
|
563
572
|
namespace: ns,
|
|
564
573
|
name: node.name,
|
|
565
574
|
group,
|
|
@@ -2446,14 +2455,18 @@ function ApplicationHistoryLine({
|
|
|
2446
2455
|
onNavigateToResource?: (resource: ResourceRef) => void;
|
|
2447
2456
|
onOpenSource?: (source: AppSourceRef) => void;
|
|
2448
2457
|
}) {
|
|
2449
|
-
const
|
|
2450
|
-
? workloads.
|
|
2451
|
-
(
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2458
|
+
const candidates = item.resource
|
|
2459
|
+
? workloads.filter(candidate =>
|
|
2460
|
+
candidate.kind.toLowerCase() === item.resource!.kind.toLowerCase() &&
|
|
2461
|
+
candidate.namespace === item.resource!.namespace &&
|
|
2462
|
+
candidate.name === item.resource!.name)
|
|
2463
|
+
: [];
|
|
2464
|
+
const resourceGroup = item.resource
|
|
2465
|
+
? canonicalResourceGroup(item.resource.kind, item.resource.group)
|
|
2456
2466
|
: undefined;
|
|
2467
|
+
const workload = resourceGroup !== undefined
|
|
2468
|
+
? candidates.find(candidate => canonicalResourceGroup(candidate.kind, candidate.group) === resourceGroup)
|
|
2469
|
+
: candidates.length === 1 ? candidates[0] : undefined;
|
|
2457
2470
|
const Icon =
|
|
2458
2471
|
item.category === "deployment"
|
|
2459
2472
|
? GitCommit
|
|
@@ -7,9 +7,7 @@ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
|
7
7
|
|
|
8
8
|
export interface AuditFinding {
|
|
9
9
|
kind: string
|
|
10
|
-
/** API group
|
|
11
|
-
* (built-ins → e.g. "apps"/"batch"; CRDs → ""). Part of the resource key
|
|
12
|
-
* used to join findings onto topology nodes / list rows. */
|
|
10
|
+
/** Actual API group; part of the identity joining findings to topology nodes and list rows. */
|
|
13
11
|
group?: string
|
|
14
12
|
namespace: string
|
|
15
13
|
name: string
|
|
@@ -36,10 +36,56 @@ describe('audit coverage presentation', () => {
|
|
|
36
36
|
)
|
|
37
37
|
expect(html).toContain(expected)
|
|
38
38
|
if (missingInputs.length) {
|
|
39
|
-
expect(html).toContain('Unavailable inputs
|
|
40
|
-
expect(html).toContain('
|
|
39
|
+
expect(html).toContain('Unavailable inputs (')
|
|
40
|
+
expect(html).toContain('Secret')
|
|
41
41
|
expect(html).not.toContain('Every audited resource passed')
|
|
42
42
|
}
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
describe('replica placement coverage explanation', () => {
|
|
48
|
+
for (const inputs of [['replicasets'], ['replicaset-ownership'], ['replicasets', 'replicaset-ownership', 'secrets']]) {
|
|
49
|
+
it(`names the unevaluated check for ${inputs.join(', ')}`, () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
<FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
|
|
52
|
+
<ChecksView checks={[]} catalog={{}} anyData evaluated={0} missingInputs={inputs} />
|
|
53
|
+
</FilterLocationProvider>,
|
|
54
|
+
)
|
|
55
|
+
expect(html).toContain('Running replicas on same node')
|
|
56
|
+
expect(html).toContain('could not be fully evaluated')
|
|
57
|
+
expect(html).toContain('affected Deployments were skipped, not passed')
|
|
58
|
+
expect(html).toContain('No findings in the available data')
|
|
59
|
+
expect(html).not.toContain('replicaset-ownership')
|
|
60
|
+
if (inputs.includes('replicasets')) expect(html).toContain('ReplicaSet')
|
|
61
|
+
if (inputs.includes('replicaset-ownership')) expect(html).toContain('ReplicaSet ownership')
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
it('does not imply missing replica evidence for unrelated unavailable inputs', () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
|
|
67
|
+
<ChecksView checks={[]} catalog={{}} anyData evaluated={4} missingInputs={['secrets']} />
|
|
68
|
+
</FilterLocationProvider>,
|
|
69
|
+
)
|
|
70
|
+
expect(html).toContain('Unavailable inputs (')
|
|
71
|
+
expect(html).toContain('Secret')
|
|
72
|
+
expect(html).not.toContain('Running replicas on same node')
|
|
73
|
+
expect(html).not.toContain('skipped, not passed')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
describe('unavailable input details', () => {
|
|
79
|
+
it('keeps a long input list collapsed with readable kinds and preserves unknown reasons', () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
<FilterLocationProvider value={{ searchParams: new URLSearchParams(), update: () => {} }}>
|
|
82
|
+
<ChecksView checks={[]} catalog={{}} anyData evaluated={0}
|
|
83
|
+
missingInputs={['serviceaccounts', 'horizontalpodautoscalers', 'limitranges', 'poddisruptionbudgets', 'configmap-references', 'secret-references', 'unrecognized-input']} />
|
|
84
|
+
</FilterLocationProvider>,
|
|
85
|
+
)
|
|
86
|
+
expect(html).toContain('Unavailable inputs (7)')
|
|
87
|
+
expect(html).toContain('aria-expanded="false"')
|
|
88
|
+
expect(html).toContain('ServiceAccount, HorizontalPodAutoscaler, LimitRange, PodDisruptionBudget, ConfigMap references, Secret references, unrecognized-input')
|
|
89
|
+
expect(html).not.toContain('Every audited resource passed')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ChecksView } from './ChecksView'
|
|
4
|
+
import type { Check, EffectiveCheckFinding } from './types'
|
|
5
|
+
|
|
6
|
+
function check(messages: string[], cluster = 'one'): Check {
|
|
7
|
+
const findings: EffectiveCheckFinding[] = messages.map((message, i) => ({
|
|
8
|
+
source: 'radar_builtin',
|
|
9
|
+
resource: { cluster_id: cluster, group: '', kind: 'Secret', namespace: 'test', name: `cert-${i}` },
|
|
10
|
+
checkID: 'tlsCertificateExpiry', category: 'Reliability', originalSeverity: 'danger',
|
|
11
|
+
effectiveSeverity: 'high', message,
|
|
12
|
+
state: { visibility: 'visible', source: 'detector_default', scoreImpact: 'counts', alertImpact: 'alerts', complianceImpact: 'counts' },
|
|
13
|
+
}))
|
|
14
|
+
return {
|
|
15
|
+
id: cluster, source: 'radar_builtin', subject: findings[0].resource,
|
|
16
|
+
checkID: 'tlsCertificateExpiry', category: 'Reliability', effectiveSeverity: 'high',
|
|
17
|
+
title: 'TLS certificate expiring', message: messages[0], affectedFindings: findings.length,
|
|
18
|
+
affectedResources: findings.length, representativeFinding: findings[0], findings,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function render(checks: Check[]) {
|
|
23
|
+
return renderToStaticMarkup(<ChecksView checks={checks} catalog={{}} anyData clusterLabel={(c) => c.subject.cluster_id} />)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deadline = 'TLS certificate expires in 2d (2026-09-22T12:00:00Z)'
|
|
27
|
+
|
|
28
|
+
describe('finding evidence', () => {
|
|
29
|
+
it.each([1, 2])('preserves common evidence once for %i findings', (count) => {
|
|
30
|
+
expect(render([check(Array(count).fill(deadline))]).split(deadline)).toHaveLength(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('preserves distinct evidence, including messages that differ only by resource name', () => {
|
|
34
|
+
const messages = ['cert-0 expires tomorrow', 'cert-1 expires tomorrow']
|
|
35
|
+
const html = render([check(messages)])
|
|
36
|
+
for (const message of messages) expect(html).toContain(message)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('preserves the nonempty evidence in a mixed empty list', () => {
|
|
40
|
+
expect(render([check(['', deadline])])).toContain(deadline)
|
|
41
|
+
expect(render([check(['', ''])])).not.toContain('whitespace-pre-wrap')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('compares hidden findings too instead of applying the first deadline to the whole list', () => {
|
|
45
|
+
const html = render([check([...Array(8).fill(deadline), 'A different deadline'])])
|
|
46
|
+
expect(html.split(deadline)).toHaveLength(9)
|
|
47
|
+
expect(html).toContain('View all 9')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps common evidence within each cluster group', () => {
|
|
51
|
+
const html = render([check([deadline, deadline], 'one'), check(['Different cluster deadline'], 'two')])
|
|
52
|
+
expect(html.split(deadline)).toHaveLength(2)
|
|
53
|
+
expect(html).toContain('Different cluster deadline')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
|
-
import { AlertBanner, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
4
|
+
import { AlertBanner, Disclosure, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
5
5
|
import { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from '../ui/Collapse'
|
|
6
6
|
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
7
7
|
import type { CheckMeta, CheckReference } from '../audit'
|
|
@@ -17,9 +17,19 @@ import {
|
|
|
17
17
|
} from './severity'
|
|
18
18
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
19
19
|
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
20
|
+
import { CORE_RESOURCES } from '../../utils/api-resources'
|
|
20
21
|
|
|
21
22
|
const CATEGORIES: readonly string[] = ['Security', 'Reliability', 'Efficiency']
|
|
22
23
|
|
|
24
|
+
const AUDIT_INPUT_LABELS = new Map<string, string>([
|
|
25
|
+
...CORE_RESOURCES.map(({ name, kind }) => [name, kind] as const),
|
|
26
|
+
['limitranges', 'LimitRange'],
|
|
27
|
+
['poddisruptionbudgets', 'PodDisruptionBudget'],
|
|
28
|
+
['replicaset-ownership', 'ReplicaSet ownership'],
|
|
29
|
+
['configmap-references', 'ConfigMap references'],
|
|
30
|
+
['secret-references', 'Secret references'],
|
|
31
|
+
])
|
|
32
|
+
|
|
23
33
|
// Leading severity glyph, one per tier of the 4-tier ladder: critical = octagon,
|
|
24
34
|
// high = triangle, medium = circle, low = info.
|
|
25
35
|
const CHECK_SEVERITY_ICON: Record<CheckSeverity, ComponentType<{ className?: string }>> = {
|
|
@@ -355,7 +365,19 @@ export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs
|
|
|
355
365
|
<AlertBanner
|
|
356
366
|
variant="warning"
|
|
357
367
|
title="Some checks could not run"
|
|
358
|
-
message={
|
|
368
|
+
message={
|
|
369
|
+
<>
|
|
370
|
+
Findings cover only available inputs.
|
|
371
|
+
<Disclosure summary={`Unavailable inputs (${missingInputs.length})`} className="mt-2">
|
|
372
|
+
{missingInputs.map(input => AUDIT_INPUT_LABELS.get(input) ?? input).join(', ')}.
|
|
373
|
+
</Disclosure>
|
|
374
|
+
{(missingInputs.includes('replicasets') || missingInputs.includes('replicaset-ownership')) && (
|
|
375
|
+
<p className="mt-2">
|
|
376
|
+
<em>Running replicas on same node</em> could not be fully evaluated: ReplicaSet inventory or ownership evidence was unavailable, so affected Deployments were skipped, not passed.
|
|
377
|
+
</p>
|
|
378
|
+
)}
|
|
379
|
+
</>
|
|
380
|
+
}
|
|
359
381
|
/>
|
|
360
382
|
)}
|
|
361
383
|
|
|
@@ -803,32 +825,21 @@ function ResourceList({
|
|
|
803
825
|
onResourceClick?: (ref: CheckResourceRef) => void
|
|
804
826
|
}) {
|
|
805
827
|
const [showAll, setShowAll] = useState(false)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
// name, then compare: all-same → it repeats the check or varies only by the
|
|
809
|
-
// object name (already on the line) → drop it; still-different → real new info
|
|
810
|
-
// (e.g. a container name) → keep it.
|
|
811
|
-
const showMessage = useMemo(() => {
|
|
812
|
-
if (check.findings.length === 0) return false
|
|
813
|
-
const norm = (f: EffectiveCheckFinding) => {
|
|
814
|
-
const n = f.resource.name
|
|
815
|
-
return n ? (f.message ?? '').split(n).join('') : f.message ?? ''
|
|
816
|
-
}
|
|
817
|
-
const first = norm(check.findings[0])
|
|
818
|
-
return check.findings.some((f) => norm(f) !== first)
|
|
819
|
-
}, [check.findings])
|
|
828
|
+
const commonMessage = check.findings[0]?.message || ''
|
|
829
|
+
const shareMessage = !!commonMessage && check.findings.every((f) => f.message === commonMessage)
|
|
820
830
|
const list = showAll ? check.findings : check.findings.slice(0, RESOURCE_CAP)
|
|
821
831
|
const hidden = check.findings.length - list.length
|
|
822
832
|
|
|
823
833
|
return (
|
|
824
834
|
<section className="flex flex-col gap-1.5">
|
|
825
835
|
{label && <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</h4>}
|
|
836
|
+
{shareMessage && <p className="px-2 text-xs whitespace-pre-wrap [overflow-wrap:anywhere] text-theme-text-secondary">{commonMessage}</p>}
|
|
826
837
|
<ul className="flex flex-col gap-px">
|
|
827
838
|
{list.map((f, i) => (
|
|
828
839
|
<FindingLine
|
|
829
840
|
key={`${f.resource.group}/${f.resource.kind}/${f.resource.namespace}/${f.resource.name}#${i}`}
|
|
830
841
|
finding={f}
|
|
831
|
-
showMessage={
|
|
842
|
+
showMessage={!shareMessage}
|
|
832
843
|
resourceHref={resourceHref}
|
|
833
844
|
onResourceClick={onResourceClick}
|
|
834
845
|
/>
|
|
@@ -862,18 +873,18 @@ function FindingLine({
|
|
|
862
873
|
const linkable = !!(onResourceClick || resourceHref)
|
|
863
874
|
const body = (
|
|
864
875
|
<>
|
|
865
|
-
<span className="
|
|
866
|
-
|
|
867
|
-
{
|
|
868
|
-
|
|
876
|
+
<span className="flex min-w-0 items-baseline gap-2">
|
|
877
|
+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
|
|
878
|
+
<span className={`min-w-0 break-all font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
|
|
879
|
+
{r.namespace ? `${r.namespace} / ` : ''}
|
|
880
|
+
{r.name}
|
|
881
|
+
</span>
|
|
882
|
+
{linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/f:opacity-100" />}
|
|
869
883
|
</span>
|
|
870
|
-
{
|
|
871
|
-
{showMessage && <span className="ml-1 truncate text-xs text-theme-text-tertiary">{finding.message}</span>}
|
|
884
|
+
{showMessage && finding.message && <span className="text-xs whitespace-pre-wrap [overflow-wrap:anywhere] text-theme-text-tertiary">{finding.message}</span>}
|
|
872
885
|
</>
|
|
873
886
|
)
|
|
874
|
-
|
|
875
|
-
// larger resource name (their line-heights differ).
|
|
876
|
-
const cls = 'group/f flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
|
|
887
|
+
const cls = 'group/f flex w-full min-w-0 flex-col gap-0.5 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
|
|
877
888
|
return (
|
|
878
889
|
<li>
|
|
879
890
|
{onResourceClick ? (
|
|
@@ -889,7 +900,7 @@ function FindingLine({
|
|
|
889
900
|
{body}
|
|
890
901
|
</a>
|
|
891
902
|
) : (
|
|
892
|
-
<span className="flex
|
|
903
|
+
<span className="flex min-w-0 flex-col gap-0.5 rounded-md px-2 py-1 text-sm">{body}</span>
|
|
893
904
|
)}
|
|
894
905
|
</li>
|
|
895
906
|
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, StrictMode } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { NodeTerminalTab, type NodeDebugPod } from './NodeTerminalTab'
|
|
6
|
+
|
|
7
|
+
vi.mock('./TerminalTab', () => ({ TerminalTab: () => <div>terminal</div> }))
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
|
+
|
|
10
|
+
const roots = new Set<Root>()
|
|
11
|
+
const pod = (uid: string): NodeDebugPod => ({ namespace: 'default', podName: `debug-${uid}`, uid, containerName: 'debug' })
|
|
12
|
+
function deferred<T>() {
|
|
13
|
+
let resolve!: (value: T) => void
|
|
14
|
+
const promise = new Promise<T>(done => { resolve = done })
|
|
15
|
+
return { promise, resolve }
|
|
16
|
+
}
|
|
17
|
+
async function mount(create: () => Promise<NodeDebugPod>, cleanup = vi.fn(async (_nodeName: string, _pod: NodeDebugPod) => {}), strict = false) {
|
|
18
|
+
const element = document.createElement('div')
|
|
19
|
+
document.body.appendChild(element)
|
|
20
|
+
const root = createRoot(element)
|
|
21
|
+
roots.add(root)
|
|
22
|
+
const component = <NodeTerminalTab nodeName="same-node" createNodeDebugPod={create} cleanupNodeDebugPod={cleanup} createSession={async () => ({ wsUrl: '' })} />
|
|
23
|
+
await act(async () => { root.render(strict ? <StrictMode>{component}</StrictMode> : component) })
|
|
24
|
+
return { root, element, cleanup }
|
|
25
|
+
}
|
|
26
|
+
async function unmount(root: Root) {
|
|
27
|
+
await act(async () => root.unmount())
|
|
28
|
+
roots.delete(root)
|
|
29
|
+
}
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
for (const root of roots) await unmount(root)
|
|
32
|
+
document.body.replaceChildren()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('node terminal pod ownership', () => {
|
|
36
|
+
it('closing one terminal cleans only its pod on a shared node', async () => {
|
|
37
|
+
const cleanup = vi.fn(async (_nodeName: string, _pod: NodeDebugPod) => {})
|
|
38
|
+
const first = await mount(async () => pod('a'), cleanup)
|
|
39
|
+
const second = await mount(async () => pod('b'), cleanup)
|
|
40
|
+
await unmount(first.root)
|
|
41
|
+
expect(cleanup.mock.calls).toEqual([['same-node', pod('a')]])
|
|
42
|
+
expect(second.element.textContent).toBe('terminal')
|
|
43
|
+
await unmount(second.root)
|
|
44
|
+
expect(cleanup.mock.calls).toEqual([['same-node', pod('a')], ['same-node', pod('b')]])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('cleans a late creation exactly once after unload and unmount', async () => {
|
|
48
|
+
const pending = deferred<NodeDebugPod>()
|
|
49
|
+
const terminal = await mount(() => pending.promise)
|
|
50
|
+
await act(async () => { window.dispatchEvent(new Event('beforeunload')) })
|
|
51
|
+
await unmount(terminal.root)
|
|
52
|
+
expect(terminal.cleanup).not.toHaveBeenCalled()
|
|
53
|
+
await act(async () => { pending.resolve(pod('late')) })
|
|
54
|
+
expect(terminal.cleanup.mock.calls).toEqual([['same-node', pod('late')]])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not duplicate cleanup when unload precedes unmount', async () => {
|
|
58
|
+
const terminal = await mount(async () => pod('ready'))
|
|
59
|
+
await act(async () => { window.dispatchEvent(new Event('beforeunload')) })
|
|
60
|
+
await unmount(terminal.root)
|
|
61
|
+
expect(terminal.cleanup.mock.calls).toEqual([['same-node', pod('ready')]])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('gives a retry its own cleanup even when it resolves after unmount', async () => {
|
|
65
|
+
const pending = deferred<NodeDebugPod>()
|
|
66
|
+
const create = vi.fn<() => Promise<NodeDebugPod>>()
|
|
67
|
+
.mockRejectedValueOnce(new Error('creation failed'))
|
|
68
|
+
.mockReturnValueOnce(pending.promise)
|
|
69
|
+
const terminal = await mount(create)
|
|
70
|
+
await act(async () => { terminal.element.querySelector('button')!.click() })
|
|
71
|
+
expect(create).toHaveBeenCalledTimes(2)
|
|
72
|
+
await unmount(terminal.root)
|
|
73
|
+
await act(async () => { pending.resolve(pod('retry')) })
|
|
74
|
+
expect(terminal.cleanup.mock.calls).toEqual([['same-node', pod('retry')]])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('isolates Strict Mode creation results resolving out of order', async () => {
|
|
78
|
+
const old = deferred<NodeDebugPod>()
|
|
79
|
+
const current = deferred<NodeDebugPod>()
|
|
80
|
+
const create = vi.fn<() => Promise<NodeDebugPod>>()
|
|
81
|
+
.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
|
|
82
|
+
const terminal = await mount(create, undefined, true)
|
|
83
|
+
await act(async () => { current.resolve(pod('current')); old.resolve(pod('old')) })
|
|
84
|
+
expect(terminal.cleanup.mock.calls).toEqual([['same-node', pod('old')]])
|
|
85
|
+
expect(terminal.element.textContent).toBe('terminal')
|
|
86
|
+
await unmount(terminal.root)
|
|
87
|
+
expect(terminal.cleanup.mock.calls).toEqual([['same-node', pod('old')], ['same-node', pod('current')]])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -2,17 +2,20 @@ import { useEffect, useRef, useState, useCallback } from 'react'
|
|
|
2
2
|
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react'
|
|
3
3
|
import { TerminalTab } from './TerminalTab'
|
|
4
4
|
|
|
5
|
+
export interface NodeDebugPod {
|
|
6
|
+
podName: string
|
|
7
|
+
namespace: string
|
|
8
|
+
uid: string
|
|
9
|
+
containerName: string
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export interface NodeTerminalTabProps {
|
|
6
13
|
nodeName: string
|
|
7
14
|
isActive?: boolean
|
|
8
|
-
/** Create a debug pod
|
|
9
|
-
createNodeDebugPod: (nodeName: string) => Promise<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
containerName: string
|
|
13
|
-
}>
|
|
14
|
-
/** Clean up debug pod(s) for this node */
|
|
15
|
-
cleanupNodeDebugPod: (nodeName: string) => Promise<void>
|
|
15
|
+
/** Create a debug pod and return its identity and exec coordinates. */
|
|
16
|
+
createNodeDebugPod: (nodeName: string) => Promise<NodeDebugPod>
|
|
17
|
+
/** Clean up only this creation result, using its UID as a precondition. */
|
|
18
|
+
cleanupNodeDebugPod: (nodeName: string, pod: NodeDebugPod) => Promise<void>
|
|
16
19
|
/** Return WebSocket URL for exec into a pod container */
|
|
17
20
|
createSession: (namespace: string, podName: string, containerName: string) => Promise<{ wsUrl: string }>
|
|
18
21
|
}
|
|
@@ -24,15 +27,11 @@ export function NodeTerminalTab({
|
|
|
24
27
|
cleanupNodeDebugPod,
|
|
25
28
|
createSession,
|
|
26
29
|
}: NodeTerminalTabProps) {
|
|
27
|
-
const [debugPod, setDebugPod] = useState<
|
|
28
|
-
podName: string
|
|
29
|
-
namespace: string
|
|
30
|
-
containerName: string
|
|
31
|
-
} | null>(null)
|
|
30
|
+
const [debugPod, setDebugPod] = useState<NodeDebugPod | null>(null)
|
|
32
31
|
const [error, setError] = useState<string | null>(null)
|
|
33
32
|
const [isCreating, setIsCreating] = useState(true)
|
|
34
|
-
const
|
|
35
|
-
// Stable refs
|
|
33
|
+
const [attempt, setAttempt] = useState(0)
|
|
34
|
+
// Stable refs avoid restarting creation when the host renders new callbacks.
|
|
36
35
|
const createNodeDebugPodRef = useRef(createNodeDebugPod)
|
|
37
36
|
const cleanupNodeDebugPodRef = useRef(cleanupNodeDebugPod)
|
|
38
37
|
const createSessionRef = useRef(createSession)
|
|
@@ -40,44 +39,49 @@ export function NodeTerminalTab({
|
|
|
40
39
|
useEffect(() => { cleanupNodeDebugPodRef.current = cleanupNodeDebugPod }, [cleanupNodeDebugPod])
|
|
41
40
|
useEffect(() => { createSessionRef.current = createSession }, [createSession])
|
|
42
41
|
|
|
43
|
-
const createPod = useCallback(
|
|
44
|
-
cleanupDoneRef.current = false
|
|
45
|
-
setIsCreating(true)
|
|
46
|
-
setError(null)
|
|
47
|
-
try {
|
|
48
|
-
const result = await createNodeDebugPodRef.current(nodeName)
|
|
49
|
-
setDebugPod(result)
|
|
50
|
-
} catch (err) {
|
|
51
|
-
setError(err instanceof Error ? err.message : 'Failed to create debug pod')
|
|
52
|
-
} finally {
|
|
53
|
-
setIsCreating(false)
|
|
54
|
-
}
|
|
55
|
-
}, [nodeName])
|
|
42
|
+
const createPod = useCallback(() => setAttempt(value => value + 1), [])
|
|
56
43
|
|
|
57
44
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
45
|
+
// Each attempt owns its result, including retries and Strict Mode remounts.
|
|
46
|
+
let disposed = false
|
|
47
|
+
let pod: NodeDebugPod | null = null
|
|
48
|
+
let cleanupDone = false
|
|
49
|
+
const cleanup = cleanupNodeDebugPodRef.current
|
|
50
|
+
const dispose = () => {
|
|
51
|
+
disposed = true
|
|
52
|
+
if (!pod || cleanupDone) return
|
|
53
|
+
cleanupDone = true
|
|
54
|
+
cleanup(nodeName, pod).catch((err) => {
|
|
55
|
+
console.warn('[NodeTerminal] Cleanup failed:', err)
|
|
56
|
+
})
|
|
66
57
|
}
|
|
67
|
-
}, [nodeName, createPod])
|
|
68
58
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
setDebugPod(null)
|
|
60
|
+
setIsCreating(true)
|
|
61
|
+
setError(null)
|
|
62
|
+
const create = async () => {
|
|
63
|
+
try {
|
|
64
|
+
pod = await createNodeDebugPodRef.current(nodeName)
|
|
65
|
+
if (disposed) {
|
|
66
|
+
dispose()
|
|
67
|
+
} else {
|
|
68
|
+
setDebugPod(pod)
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (!disposed) setError(err instanceof Error ? err.message : 'Failed to create debug pod')
|
|
72
|
+
} finally {
|
|
73
|
+
if (!disposed) setIsCreating(false)
|
|
76
74
|
}
|
|
77
75
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
void create()
|
|
77
|
+
|
|
78
|
+
// The host uses keepalive for best-effort delivery during page unload.
|
|
79
|
+
window.addEventListener('beforeunload', dispose)
|
|
80
|
+
return () => {
|
|
81
|
+
window.removeEventListener('beforeunload', dispose)
|
|
82
|
+
dispose()
|
|
83
|
+
}
|
|
84
|
+
}, [nodeName, attempt])
|
|
81
85
|
|
|
82
86
|
if (isCreating) {
|
|
83
87
|
return (
|
|
@@ -9,10 +9,11 @@ import type { GitOpsChange, GitOpsInsightSummary } from '../../types'
|
|
|
9
9
|
// 3.0+ default). Radar then reads the live resources itself, and every
|
|
10
10
|
// problem below is Radar's finding — the "Radar" markers say so per row;
|
|
11
11
|
// this line says so once.
|
|
12
|
-
// - The Application
|
|
13
|
-
// resources from here at all, so
|
|
14
|
-
//
|
|
15
|
-
// pass nothing)
|
|
12
|
+
// - The Application (or a Flux object with spec.kubeConfig) deploys to
|
|
13
|
+
// another cluster. Radar can't read its resources from here at all, so
|
|
14
|
+
// nothing is derived. For Argo, a standalone Radar may add a pointer to
|
|
15
|
+
// Radar Cloud (the host passes it; embedded hosts pass nothing); Radar
|
|
16
|
+
// Cloud doesn't resolve Flux targets, so Flux gets no pointer.
|
|
16
17
|
//
|
|
17
18
|
// Copy stays in plain words; the field names live behind the docs link.
|
|
18
19
|
|
|
@@ -35,6 +36,8 @@ export const APP_TREE_API_ERROR_NO_FINDINGS = "Radar didn't find a problem on it
|
|
|
35
36
|
|
|
36
37
|
export const REMOTE_DESTINATION_NOTICE =
|
|
37
38
|
"This application deploys to a different cluster, so its resources aren't visible from here."
|
|
39
|
+
export const REMOTE_FLUX_TARGET_NOTICE =
|
|
40
|
+
"This applies its resources to a different cluster, so they aren't visible from here."
|
|
38
41
|
|
|
39
42
|
export type HealthSourceNoticeSummary = Pick<GitOpsInsightSummary, 'tool' | 'health' | 'resourceHealthMode' | 'remoteDestination' | 'resourceHealthFromApi' | 'resourceHealthApiError'>
|
|
40
43
|
|
|
@@ -43,8 +46,9 @@ export type HealthSourceNoticeSummary = Pick<GitOpsInsightSummary, 'tool' | 'hea
|
|
|
43
46
|
// there), and when the host read Argo's verdicts from its API server there
|
|
44
47
|
// is nothing of Radar's to explain either.
|
|
45
48
|
export function healthSourceNoticeKind(summary: HealthSourceNoticeSummary | undefined): 'remote' | 'appTree' | null {
|
|
46
|
-
if (!summary
|
|
49
|
+
if (!summary) return null
|
|
47
50
|
if (summary.remoteDestination) return 'remote'
|
|
51
|
+
if (summary.tool !== 'argocd') return null
|
|
48
52
|
if (summary.resourceHealthMode === 'appTree' && !summary.resourceHealthFromApi && summary.health !== 'Healthy') return 'appTree'
|
|
49
53
|
return null
|
|
50
54
|
}
|
|
@@ -100,10 +104,14 @@ export function GitOpsHealthSourceNotice({
|
|
|
100
104
|
<Info className="mt-px h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
101
105
|
<span className="min-w-0">
|
|
102
106
|
{kind === 'remote' ? (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
summary?.tool === 'argocd' ? (
|
|
108
|
+
<>
|
|
109
|
+
{REMOTE_DESTINATION_NOTICE}
|
|
110
|
+
{remoteDestinationHint ? <> {remoteDestinationHint}</> : null}
|
|
111
|
+
</>
|
|
112
|
+
) : (
|
|
113
|
+
REMOTE_FLUX_TARGET_NOTICE
|
|
114
|
+
)
|
|
107
115
|
) : (
|
|
108
116
|
<>
|
|
109
117
|
{apiError ? (
|