@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.
Files changed (80) hide show
  1. package/package.json +4 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. 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.10",
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.0",
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 === node.kind && w.name === node.name && w.namespace === ns,
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 === parent.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(node.kind, group ?? ""),
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 workload = item.resource
2450
- ? workloads.find(
2451
- (candidate) =>
2452
- candidate.kind.toLowerCase() === item.resource!.kind.toLowerCase() &&
2453
- candidate.namespace === item.resource!.namespace &&
2454
- candidate.name === item.resource!.name,
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, backfilled by the backend from the builtin Kind→group table
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('secrets')
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={<>Findings cover only available inputs. Unavailable inputs: {missingInputs.join(', ')}.</>}
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
- // The per-finding message only earns a place when it adds something the line
807
- // doesn't already show. Normalize each message by removing its own resource
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={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="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
866
- <span className={`shrink-0 font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
867
- {r.namespace ? `${r.namespace} / ` : ''}
868
- {r.name}
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
- {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/f:opacity-100" />}
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
- // items-baseline so the smaller mono kind label shares a baseline with the
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 items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
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 on the node, returns pod coordinates for exec */
9
- createNodeDebugPod: (nodeName: string) => Promise<{
10
- podName: string
11
- namespace: string
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 cleanupDoneRef = useRef(false)
35
- // Stable refs for callbacks
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(async () => {
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
- createPod()
59
- return () => {
60
- if (!cleanupDoneRef.current) {
61
- cleanupDoneRef.current = true
62
- cleanupNodeDebugPodRef.current(nodeName).catch((err) => {
63
- console.warn('[NodeTerminal] Cleanup on unmount failed:', err)
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
- // Best-effort cleanup on page unload — uses keepalive so the browser
70
- // does not cancel the request when the page navigates away.
71
- useEffect(() => {
72
- const handleUnload = () => {
73
- if (!cleanupDoneRef.current) {
74
- cleanupDoneRef.current = true
75
- cleanupNodeDebugPodRef.current(nodeName).catch(() => {})
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
- window.addEventListener('beforeunload', handleUnload)
79
- return () => window.removeEventListener('beforeunload', handleUnload)
80
- }, [nodeName])
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 deploys to another cluster. Radar can't read its
13
- // resources from here at all, so nothing is derived. A standalone Radar
14
- // may add a pointer to Radar Cloud (the host passes it; embedded hosts
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 || summary.tool !== 'argocd') return null
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
- {REMOTE_DESTINATION_NOTICE}
105
- {remoteDestinationHint ? <> {remoteDestinationHint}</> : null}
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 ? (