@skyhook-io/k8s-ui 1.14.7 → 1.14.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.7",
3
+ "version": "1.14.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { getSubtitle } from './GitOpsTreeGraph'
4
+ import type { GitOpsTreeNode } from '../../../types'
5
+
6
+ function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
7
+ return {
8
+ id: 'node-1',
9
+ ref: { kind: 'Service', name: 'podinfo', namespace: 'demo-flux' },
10
+ role: 'declared',
11
+ tool: 'fluxcd',
12
+ ...extras,
13
+ }
14
+ }
15
+
16
+ describe('getSubtitle', () => {
17
+ it('shows what a healthy resource exposes, not that it is healthy', () => {
18
+ // The status dot and the left border stripe already paint a healthy node
19
+ // green, so the word adds nothing and the ports are the only thing here the
20
+ // rest of the card can't say.
21
+ expect(getSubtitle(node({
22
+ sync: 'Synced',
23
+ health: 'Healthy',
24
+ info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
25
+ }))).toBe('Synced • ClusterIP :9898 +1 more')
26
+
27
+ expect(getSubtitle(node({
28
+ ref: { kind: 'Pod', name: 'podinfo-6b4f8c9d7-xk2mv', namespace: 'demo-flux' },
29
+ role: 'generated',
30
+ health: 'Healthy',
31
+ info: [{ name: 'Phase', value: 'Running' }],
32
+ }))).toBe('Running')
33
+
34
+ expect(getSubtitle(node({
35
+ ref: { kind: 'Ingress', name: 'podinfo', namespace: 'demo-flux' },
36
+ sync: 'Synced',
37
+ health: 'Healthy',
38
+ info: [{ name: 'Host', value: 'podinfo.example.com' }],
39
+ }))).toBe('Synced • podinfo.example.com')
40
+ })
41
+
42
+ it('keeps every health value the stripe colour cannot identify on its own', () => {
43
+ // Progressing and Suspended are both yellow, Degraded and Missing both red.
44
+ // Drop the word and the two become indistinguishable.
45
+ expect(getSubtitle(node({
46
+ sync: 'Synced',
47
+ health: 'Progressing',
48
+ info: [{ name: 'Service', value: 'LoadBalancer :80' }],
49
+ }))).toBe('Synced • Progressing • LoadBalancer :80')
50
+
51
+ expect(getSubtitle(node({
52
+ ref: { kind: 'Deployment', name: 'podinfo', namespace: 'demo-flux' },
53
+ sync: 'OutOfSync',
54
+ health: 'Degraded',
55
+ info: [{ name: 'Ready', value: '2/3' }],
56
+ }))).toBe('OutOfSync • Degraded • 2/3')
57
+
58
+ expect(getSubtitle(node({
59
+ health: 'Unknown',
60
+ info: [{ name: 'Service', value: 'ClusterIP :80' }],
61
+ }))).toBe('Unknown • ClusterIP :80')
62
+ })
63
+
64
+ it('still reads as Healthy when there is nothing to say instead', () => {
65
+ // Kinds infoFromTopology doesn't cover, and remote Argo destinations, reach
66
+ // the tree with no info line at all. Those must not lose their status line.
67
+ expect(getSubtitle(node({ sync: 'Synced', health: 'Healthy' }))).toBe('Synced • Healthy')
68
+ expect(getSubtitle(node({ health: 'Healthy' }))).toBe('Healthy')
69
+ expect(getSubtitle(node({ sync: 'OutOfSync', health: 'Missing' }))).toBe('OutOfSync • Missing')
70
+ })
71
+
72
+ it('lets lifecycle and grouping own the line outright', () => {
73
+ expect(getSubtitle(node({
74
+ role: 'group',
75
+ sync: 'Synced',
76
+ health: 'Healthy',
77
+ info: [{ name: 'Phase', value: 'Running' }],
78
+ }))).toBe('Click to expand')
79
+
80
+ expect(getSubtitle(node({
81
+ sync: 'Synced',
82
+ health: 'Healthy',
83
+ info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
84
+ data: { deletionTimestamp: '2026-09-11T13:09:43Z' },
85
+ }))).toBe('Pending deletion')
86
+ })
87
+
88
+ it('falls back to the namespace only when the node states nothing else', () => {
89
+ expect(getSubtitle(node())).toBe('demo-flux')
90
+ expect(getSubtitle(node({ ref: { kind: 'ClusterRole', name: 'podinfo', namespace: '' } }))).toBe('')
91
+ })
92
+ })
@@ -25,6 +25,7 @@ import { displayKind } from '../../../types'
25
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
26
26
  import { formatCompactAge } from '../../../utils/format'
27
27
  import { radarHealthNote } from '../health-provenance'
28
+ import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
28
29
  import { getTopologyIcon } from '../../../utils/resource-icons'
29
30
  import { Tooltip } from '../../ui/Tooltip'
30
31
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
@@ -592,6 +593,15 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
592
593
  // (ReplicaSets, Pods) are always Radar's read; marking each would be
593
594
  // noise.
594
595
  const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
596
+ // A Service's subtitle names its first port and counts the rest, so without a
597
+ // hover the ones behind "+1 more" can't be reached from the graph at all. Same
598
+ // affordance the topology graph's Service node carries, same renderer. Stood
599
+ // down when the card already explains an unhealthy verdict: that reason is
600
+ // what the hover should say, and only one tooltip is ever visible anyway.
601
+ const portsTooltip = kind === 'Service' && !cause
602
+ ? servicePortsTooltip((node.data?.ports as ServicePortEntry[] | undefined) ?? [])
603
+ : null
604
+ const subtitle = getSubtitle(node)
595
605
 
596
606
  const card = (
597
607
  <div
@@ -654,7 +664,13 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
654
664
  own page". The subtitle text alone wasn't enough — users were
655
665
  treating the count as an immutable fact rather than a button. */}
656
666
  <div className="mt-0.5 flex items-center gap-1 text-xs text-theme-text-secondary">
657
- <span className="truncate">{getSubtitle(node)}</span>
667
+ {portsTooltip ? (
668
+ <Tooltip content={portsTooltip} position="bottom" wrapperClassName="min-w-0">
669
+ <span className="cursor-help truncate">{subtitle}</span>
670
+ </Tooltip>
671
+ ) : (
672
+ <span className="truncate">{subtitle}</span>
673
+ )}
658
674
  {(node.role === 'group' || gitopsTool) && <ChevronRight className="ml-auto h-3 w-3 shrink-0 text-theme-text-tertiary" />}
659
675
  </div>
660
676
  {chips.length > 0 && (
@@ -755,7 +771,21 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
755
771
  return chips
756
772
  }
757
773
 
758
- function getSubtitle(node: GitOpsTreeNode): string {
774
+ // The subtitle is the node's one line of prose, and sync, health and the
775
+ // backend's info line all want it. It can't be won on precedence: the backend
776
+ // derives a health for every node it can build an info line for, so a rule that
777
+ // only reaches info when health is absent never reaches it at all, and a
778
+ // Service's ports, a Pod's phase and an Ingress's host stay invisible.
779
+ //
780
+ // So all three share the line, and "Healthy" is the part that yields when the
781
+ // space is contested. It is the one health value the node already states without
782
+ // words — healthToTopology maps it onto the green status dot and the green left
783
+ // stripe, one value to one colour. Progressing and Suspended are both yellow and
784
+ // Degraded and Missing are both red, so for those the word is the only thing
785
+ // that tells them apart and it keeps its place. "Healthy" gives up the line only
786
+ // when there is a concrete info line to spend it on, so a node with nothing to
787
+ // say instead still reads as Healthy.
788
+ export function getSubtitle(node: GitOpsTreeNode): string {
759
789
  if (node.role === 'group') {
760
790
  // Action-oriented copy invites the click; "collapsed" alone reads as
761
791
  // a state description, not an affordance.
@@ -764,10 +794,10 @@ function getSubtitle(node: GitOpsTreeNode): string {
764
794
  if (isNodeTerminating(node)) {
765
795
  return 'Pending deletion'
766
796
  }
767
- if (node.sync || node.health) {
768
- return [node.sync, node.health].filter(Boolean).join(' • ')
769
- }
770
- if (node.info?.[0]?.value) return node.info[0].value
797
+ const info = node.info?.[0]?.value
798
+ const health = node.health === 'Healthy' && info ? undefined : node.health
799
+ const parts = [node.sync, health, info].filter(Boolean)
800
+ if (parts.length > 0) return parts.join(' • ')
771
801
  return node.ref.namespace || ''
772
802
  }
773
803
 
@@ -257,59 +257,9 @@ export function rolloutConditionTone(cond: { type?: string; status?: string }):
257
257
  }
258
258
  }
259
259
 
260
- /** Every CanaryStep variant Argo defines; raw JSON is unreadable in a step list. */
261
- export function canaryStepLabel(step: any): string {
262
- if (!step || typeof step !== 'object') return 'Unknown step'
263
-
264
- if (step.setWeight !== undefined) return `Set weight: ${step.setWeight}%`
265
-
266
- if (step.pause !== undefined) {
267
- return step.pause?.duration ? `Pause: ${step.pause.duration}` : 'Pause: until promoted'
268
- }
269
-
270
- if (step.analysis) {
271
- const templates = (step.analysis.templates || [])
272
- .map((t: any) => t.templateName || t.clusterTemplateName)
273
- .filter(Boolean)
274
- return templates.length > 0 ? `Analysis: ${templates.join(', ')}` : 'Analysis'
275
- }
276
-
277
- if (step.experiment) {
278
- const templates = (step.experiment.templates || []).map((t: any) => t.name).filter(Boolean)
279
- const duration = step.experiment.duration ? ` for ${step.experiment.duration}` : ''
280
- return templates.length > 0
281
- ? `Experiment: ${templates.join(', ')}${duration}`
282
- : `Experiment${duration}`
283
- }
284
-
285
- if (step.setCanaryScale) {
286
- const { weight, replicas, matchTrafficWeight } = step.setCanaryScale
287
- if (matchTrafficWeight) return 'Set canary scale: match traffic weight'
288
- if (replicas !== undefined) return `Set canary scale: ${replicas} replicas`
289
- if (weight !== undefined) return `Set canary scale: ${weight}%`
290
- return 'Set canary scale'
291
- }
292
-
293
- if (step.setHeaderRoute) {
294
- const { name, match } = step.setHeaderRoute
295
- // An empty match list is how a header route is torn down again.
296
- if (!match || match.length === 0) return `Remove header route${name ? `: ${name}` : ''}`
297
- const headers = match.map((m: any) => m.headerName).filter(Boolean)
298
- return `Header route${name ? ` ${name}` : ''}${headers.length ? `: ${headers.join(', ')}` : ''}`
299
- }
300
-
301
- if (step.setMirrorRoute) {
302
- const { name, match, percentage } = step.setMirrorRoute
303
- if (!match || match.length === 0) return `Remove mirror route${name ? `: ${name}` : ''}`
304
- const pct = percentage !== undefined ? ` (${percentage}%)` : ''
305
- return `Mirror route${name ? ` ${name}` : ''}${pct}`
306
- }
307
-
308
- if (step.plugin) return `Plugin: ${step.plugin.name || 'unnamed'}`
309
-
310
- const key = Object.keys(step)[0]
311
- return key ? `Unrecognized step: ${key}` : 'Unknown step'
312
- }
260
+ // canaryStepLabel lives in utils/workload-rollout.ts (not here) so that
261
+ // module doesn't have to import a renderer component just for step text.
262
+ export { canaryStepLabel } from '../../../utils/workload-rollout'
313
263
 
314
264
  /** AnalysisTemplate/ClusterAnalysisTemplate references on either a canary
315
265
  * step's analysis.templates[] array, or a single Experiment spec.analyses[]
@@ -16,6 +16,7 @@ import { ownershipOf } from '../../utils/topology-neighborhood'
16
16
  import { midTruncate } from '../../utils/format'
17
17
  import { getTopologyIcon } from '../../utils/resource-icons'
18
18
  import { Tooltip } from '../ui/Tooltip'
19
+ import { Badge } from '../ui/Badge'
19
20
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
20
21
  import { SEVERITY_TEXT_CLASS } from '../checks/severity'
21
22
  import argoCdLogo from '../../assets/gitops/argocd.png'
@@ -173,8 +174,10 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
173
174
  // a multi-port Service's other ports are visible without opening the detail
174
175
  // page. Formatting (hide targetPort when it matches port) mirrors
175
176
  // ServicePortCards in ServiceRenderer.tsx so a Service's ports read the same
176
- // whether glanced at in the graph or opened in the full resource view.
177
- function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
177
+ // whether glanced at in the graph or opened in the full resource view. Exported
178
+ // because the GitOps resource tree's Service node summarises ports the same way
179
+ // and needs the same hover to reach the ones the summary drops.
180
+ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
178
181
  if (ports.length < 2) return null;
179
182
  return (
180
183
  <div className="max-w-xs space-y-0.5">
@@ -566,6 +569,11 @@ export const K8sResourceNode = memo(function K8sResourceNode({
566
569
  const policyStatus = nodeData.policyStatus as string | undefined
567
570
  const deploymentMembership = nodeData.deploymentMembership as 'runtime-only' | 'source-only' | undefined
568
571
  const deploymentSourceLabel = nodeData.deploymentSourceLabel as string | undefined
572
+ // Argo Rollouts canary/stable (or blue-green active/preview) traffic role,
573
+ // set server-side on Pod/ReplicaSet/Service nodes owned by or matched to a
574
+ // Rollout. canary/preview are the "being tested" side, stable/active the
575
+ // "serving" side — tone only tells the two apart, it carries no other meaning.
576
+ const trafficRole = nodeData.trafficRole as 'canary' | 'stable' | 'active' | 'preview' | undefined
569
577
 
570
578
  const Icon = getTopologyIcon(kind);
571
579
 
@@ -656,6 +664,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
656
664
  </span>
657
665
  </Tooltip>
658
666
  )}
667
+ {trafficRole && (
668
+ <Badge
669
+ tone={trafficRole === 'canary' || trafficRole === 'preview' ? 'accent1' : 'accent2'}
670
+ size="sm"
671
+ className="!text-[9px] !px-1 !py-0.5 normal-case tracking-normal"
672
+ >
673
+ {trafficRole[0].toUpperCase() + trafficRole.slice(1)}
674
+ </Badge>
675
+ )}
659
676
  {onToggleReplicaSets && (
660
677
  <Tooltip content={nodeData.replicaSetsCollapsed ? 'Show ReplicaSet' : 'Hide stable ReplicaSet'} position="right">
661
678
  <button
@@ -69,21 +69,32 @@ const EDGE_LEGEND: { label: string; color: string }[] = [
69
69
  // Memoized edge style cache to avoid creating new objects on every render
70
70
  const edgeStyleCache = new Map<string, React.CSSProperties>()
71
71
 
72
- function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean, partial: boolean): React.CSSProperties {
73
- const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}-${partial}`
72
+ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boolean, animated: boolean, partial: boolean, isRolloutTrafficEdge: boolean): React.CSSProperties {
73
+ const cacheKey = `${type}-${isTrafficView}-${isTrafficEdge}-${animated}-${partial}-${isRolloutTrafficEdge}`
74
74
  let style = edgeStyleCache.get(cacheKey)
75
75
  if (!style) {
76
76
  const edgeColor = getEdgeColor(type, isTrafficView)
77
+ const dashed = (isTrafficView && isTrafficEdge && animated) || (isRolloutTrafficEdge && animated)
77
78
  style = {
78
79
  stroke: edgeColor,
79
80
  strokeWidth: isTrafficView ? 2 : 1.5,
80
- strokeDasharray: partial ? '6 3' : isTrafficView && isTrafficEdge && animated ? '5 5' : undefined,
81
+ strokeDasharray: partial ? '6 3' : dashed ? '5 5' : undefined,
81
82
  }
82
83
  edgeStyleCache.set(cacheKey, style)
83
84
  }
84
85
  return style
85
86
  }
86
87
 
88
+ // A Rollout canary/stable (weighted) or blue-green active/preview Service
89
+ // edge — set server-side via a fixed label vocabulary (pkg/topology
90
+ // builder.go's "Check Rollouts" block). These carry a live traffic split
91
+ // worth animating even outside the separate Network Flow view, since that
92
+ // view doesn't build Rollout nodes/edges at all — this is the only place
93
+ // they render.
94
+ function isRolloutTrafficEdgeLabel(label: TopologyEdge['label']): boolean {
95
+ return typeof label === 'string' && (label === 'Active' || label === 'Preview' || label.startsWith('Canary') || label.startsWith('Stable'))
96
+ }
97
+
87
98
  // Reachability outcome → edge color/dash, set by the Reachability view via
88
99
  // TopologyEdge.reachOutcome (distinct from policyEffect, a NetworkPolicy concept).
89
100
  // Honest + DISTINCT: "blocked" (a CONSEQUENCE - downstream of a real break) is a gray
@@ -126,6 +137,7 @@ function buildEdges(
126
137
  nodeCount?: number,
127
138
  groupLevels?: Map<string, GroupDisplayLevel>,
128
139
  smartDefaultActive = false,
140
+ nodes?: TopologyNode[],
129
141
  ): Edge[] {
130
142
  const edges: Edge[] = []
131
143
  const seenEdgeIds = new Set<string>() // O(1) duplicate detection
@@ -144,6 +156,21 @@ function buildEdges(
144
156
  }
145
157
  }
146
158
 
159
+ // nodeId -> trafficRole, so a Rollout->ReplicaSet or ReplicaSet/Rollout->Pod
160
+ // ownership edge can animate too when it leads to a canary/stable/active/
161
+ // preview node - the "active DAG" should read as a continuous path from the
162
+ // Service all the way down to the pods actually serving that role, not stop
163
+ // at the Rollout.
164
+ const nodeTrafficRoleById = new Map<string, string>()
165
+ if (nodes) {
166
+ for (const n of nodes) {
167
+ const role = (n.data as Record<string, unknown> | undefined)?.trafficRole
168
+ if (typeof role === 'string' && role) {
169
+ nodeTrafficRoleById.set(n.id, role)
170
+ }
171
+ }
172
+ }
173
+
147
174
  for (const edge of topologyEdges) {
148
175
  let source = edge.source
149
176
  let target = edge.target
@@ -176,8 +203,22 @@ function buildEdges(
176
203
  const reach = edge.reachOutcome
177
204
  const edgeColor = reach ? (REACH_COLORS[reach] || '#94a3b8') : getEdgeColor(edge.type, isTrafficView)
178
205
  const isTrafficEdge = edge.type === 'routes-to' || edge.type === 'exposes'
206
+ // Exposes: detected via the fixed label vocabulary (Canary/Stable/Active/
207
+ // Preview) set server-side on Service->Rollout edges. Manages: the SAME
208
+ // path continued down through Rollout->ReplicaSet and ReplicaSet/Rollout->
209
+ // Pod ownership edges, detected via the target node's own trafficRole
210
+ // (also set server-side) rather than a label, since an ownership edge
211
+ // carries no label today and doesn't need one just for this.
212
+ const isRolloutTrafficEdge =
213
+ (edge.type === 'exposes' && isRolloutTrafficEdgeLabel(edge.label)) ||
214
+ (edge.type === 'manages' && nodeTrafficRoleById.has(edge.target))
179
215
  // A reachability edge never animates (a dashed "blocked" must not look like flow).
180
- const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach && !edge.partial
216
+ // Rollout canary/stable/active/preview edges animate regardless of view mode —
217
+ // they only exist in the resources-view topology, so gating on isTrafficView
218
+ // (the separate Network Flow view) would mean they never animate at all.
219
+ const animated = enableAnimations && !reach && !edge.partial && (
220
+ (isTrafficView && isTrafficEdge) || isRolloutTrafficEdge
221
+ )
181
222
 
182
223
  edges.push({
183
224
  id: edgeId,
@@ -197,7 +238,7 @@ function buildEdges(
197
238
  width: 12,
198
239
  height: 12,
199
240
  },
200
- style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated, edge.partial === true),
241
+ style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated, edge.partial === true, isRolloutTrafficEdge),
201
242
  })
202
243
  }
203
244
 
@@ -429,11 +470,25 @@ export function TopologyGraph({
429
470
  restarts: number
430
471
  containers: number
431
472
  status?: HealthStatus
473
+ // Present only when the group spans more than one owner (e.g. a
474
+ // Rollout's canary + stable ReplicaSets) — the specific edge
475
+ // source(s) that actually own this pod, from the backend's own
476
+ // per-pod owner resolution. See pkg/topology/builder.go's
477
+ // ownerKeyToSourceIDs.
478
+ ownerIds?: string[]
479
+ // The group's own trafficRole (podGroupNode.data.trafficRole) is only
480
+ // set when every pod agrees — this is each pod's OWN role, so a mixed
481
+ // canary/stable group still badges correctly once expanded.
482
+ trafficRole?: string
432
483
  }>
433
484
 
434
485
  // Find edges pointing to this pod group
435
486
  const edgesToGroup = topoEdges.filter(e => e.target === podGroupId)
436
487
  const sourceIds = edgesToGroup.map(e => e.source)
488
+ // Homogeneous per build — resources-view feeds `manages` ownership
489
+ // edges, traffic-view feeds `routes-to` Service edges — so any surviving
490
+ // edge's type applies to the whole group.
491
+ const edgeType = edgesToGroup[0]?.type ?? 'routes-to'
437
492
 
438
493
  // Remove the PodGroup node and its edges
439
494
  const newNodes = topoNodes.filter(n => n.id !== podGroupId)
@@ -457,17 +512,33 @@ export function TopologyGraph({
457
512
  phase: pod.phase,
458
513
  restarts: pod.restarts,
459
514
  containers: pod.containers,
515
+ trafficRole: pod.trafficRole,
460
516
  expandedFromGroup: podGroupId, // Track which group this came from
461
517
  },
462
518
  })
463
519
 
464
- // Add edges from all sources to this pod
465
- for (const sourceId of sourceIds) {
520
+ // A group with a single owner has every source apply to every pod —
521
+ // the common case. A mixed-owner group (pod.ownerIds present) instead
522
+ // connects each pod only to the source(s) that are actually its own
523
+ // owner, so e.g. a canary pod doesn't end up drawn as owned by the
524
+ // stable ReplicaSet too.
525
+ const podSourceIds = pod.ownerIds?.filter(id => sourceIds.includes(id)) ?? sourceIds
526
+ // A mixed-owner group's shared sources (e.g. a Rollout, reached via
527
+ // both a canary and a stable edge) can't be told apart by source id
528
+ // alone once collapsed — both edges point at the same node id, just
529
+ // with different labels. The pod's own trafficRole is unambiguous, so
530
+ // an ownership edge derives its label from that directly rather than
531
+ // trying to match back to one specific original edge.
532
+ const label = edgeType === 'manages' && pod.trafficRole
533
+ ? pod.trafficRole[0].toUpperCase() + pod.trafficRole.slice(1)
534
+ : undefined
535
+ for (const sourceId of podSourceIds) {
466
536
  newEdges.push({
467
537
  id: `${sourceId}-to-${podId}`,
468
538
  source: sourceId,
469
539
  target: podId,
470
- type: 'routes-to' as const,
540
+ type: edgeType,
541
+ ...(label ? { label } : {}),
471
542
  })
472
543
  }
473
544
  }
@@ -796,7 +867,8 @@ export function TopologyGraph({
796
867
  nodeToGroup,
797
868
  nodesWithHandlers.length,
798
869
  groupLevels,
799
- smartDefaultActive
870
+ smartDefaultActive,
871
+ workingNodes
800
872
  )
801
873
  setEdges(builtEdges)
802
874
  }
@@ -845,7 +917,13 @@ export function TopologyGraph({
845
917
  })
846
918
  return changed ? next : prev
847
919
  })
848
- setEdges(prev => (prev.length === 0 ? prev : buildEdges(workingEdges, collapsedGroups, groupMapRef.current ?? new Map(), groupingMode, isTrafficView, undefined, prev.length, groupLevels, false)))
920
+ // nodeCount must be a real NODE count (buildEdges gates animations on it
921
+ // for the large-graph performance safeguard) — workingNodes.length, not
922
+ // the previous EDGES array's length. A tree-shaped graph commonly has
923
+ // fewer edges than nodes, so using edge count here could report "under
924
+ // the threshold" and re-enable animations on a graph that's actually
925
+ // over it.
926
+ setEdges(prev => (prev.length === 0 ? prev : buildEdges(workingEdges, collapsedGroups, groupMapRef.current ?? new Map(), groupingMode, isTrafficView, undefined, workingNodes.length, groupLevels, false, workingNodes)))
849
927
  // layoutEpoch is a dep so this re-applies AFTER any in-flight ELK layout lands -
850
928
  // a stale layout closure can't leave the canvas painted with pre-probe styles.
851
929
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1,10 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
- import { readFileSync } from 'fs'
4
- import { join } from 'path'
5
3
  import { Collapse, CollapseChevron, disclosurePanelId } from './Collapse'
6
4
  import {
7
- CSS_EASE,
8
5
  DURATION_DISCLOSURE,
9
6
  TRANSITION_CHEVRON,
10
7
  TRANSITION_DISCLOSURE,
@@ -62,13 +59,3 @@ describe('CollapseChevron', () => {
62
59
  expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
63
60
  })
64
61
  })
65
-
66
- describe('motion tokens stay in step with the stylesheet', () => {
67
- it('.issue-details-motion mirrors DURATION_DISCLOSURE and CSS_EASE', () => {
68
- const css = readFileSync(join(__dirname, '../../theme/components.css'), 'utf8')
69
- const m = css.match(/\.issue-details-motion \{[^}]*transition: grid-template-rows (\d+)ms ([^;]+);/)
70
- expect(m, 'legacy class present').not.toBeNull()
71
- expect(Number(m![1])).toBe(DURATION_DISCLOSURE)
72
- expect(m![2].replace(/\s+/g, '')).toBe(CSS_EASE.replace(/\s+/g, ''))
73
- })
74
- })
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { Disclosure } from './Disclosure'
4
+
5
+ describe('Disclosure', () => {
6
+ it('wires the header to the panel and keeps closed content mounted', () => {
7
+ const html = renderToString(<Disclosure summary="More">hidden-copy</Disclosure>)
8
+ expect(html).toContain('aria-expanded="false"')
9
+ const id = html.match(/aria-controls="([^"]+)"/)?.[1]
10
+ expect(id).toBeTruthy()
11
+ expect(html).toContain(`id="${id}"`)
12
+ expect(html).toContain('hidden-copy')
13
+ expect(html).toMatch(/inert=""|inert>/)
14
+ })
15
+
16
+ it('honours defaultOpen and a controlled open', () => {
17
+ expect(renderToString(<Disclosure summary="s" defaultOpen>x</Disclosure>)).toContain('aria-expanded="true"')
18
+ expect(renderToString(<Disclosure summary="s" open onOpenChange={() => {}}>x</Disclosure>)).toContain('aria-expanded="true"')
19
+ expect(renderToString(<Disclosure summary="s" open={false} defaultOpen>x</Disclosure>)).toContain('aria-expanded="false"')
20
+ })
21
+
22
+ it('caret follows the header color unless pinned', () => {
23
+ expect(renderToString(<Disclosure summary="s">x</Disclosure>)).not.toContain('text-theme-text-tertiary')
24
+ expect(renderToString(<Disclosure summary="s" inheritColor={false}>x</Disclosure>)).toContain('text-theme-text-tertiary')
25
+ })
26
+ })
@@ -0,0 +1,69 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
+
5
+ // Disclosure — in-flow expand/collapse with the app-standard motion; the
6
+ // replacement for native <details>/<summary> on primary paths. Native details
7
+ // snaps open with no transition and its marker ignores the caret language;
8
+ // this pairs a button header (aria-expanded / aria-controls via useDisclosure)
9
+ // with CollapseChevron and an animated Collapse panel. Shared by Radar and
10
+ // Radar Cloud so both open alike.
11
+ //
12
+ // Content stays mounted while closed (Collapse's default), exactly like native
13
+ // details — server-rendered markup and in-page search still see the collapsed
14
+ // copy. Sites whose panel polls or holds an editor should use
15
+ // <Collapse unmountOnExit> directly instead.
16
+ //
17
+ // Uncontrolled by default (`defaultOpen`); pass `open` + `onOpenChange` to
18
+ // control it from above (a form that keys other state on the disclosure).
19
+ // The chevron takes currentColor from the header by default: a summary's
20
+ // caret should read as part of its text, including on hover.
21
+ export function Disclosure({
22
+ summary,
23
+ children,
24
+ className,
25
+ summaryClassName,
26
+ chevronClassName = 'h-3.5 w-3.5',
27
+ inheritColor = true,
28
+ defaultOpen = false,
29
+ open: controlledOpen,
30
+ onOpenChange,
31
+ }: {
32
+ summary: ReactNode
33
+ children: ReactNode
34
+ /** Classes for the wrapper around header and panel. */
35
+ className?: string
36
+ /** Classes for the header button (layout, size and color of the summary). */
37
+ summaryClassName?: string
38
+ chevronClassName?: string
39
+ /** False pins the caret to the tertiary grey regardless of the header's color. */
40
+ inheritColor?: boolean
41
+ defaultOpen?: boolean
42
+ open?: boolean
43
+ onOpenChange?: (open: boolean) => void
44
+ }) {
45
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
46
+ const open = controlledOpen ?? uncontrolledOpen
47
+ const disclosure = useDisclosure(open)
48
+ const toggle = () => {
49
+ const next = !open
50
+ if (controlledOpen === undefined) setUncontrolledOpen(next)
51
+ onOpenChange?.(next)
52
+ }
53
+ return (
54
+ <div className={className}>
55
+ <button
56
+ type="button"
57
+ {...disclosure.buttonProps}
58
+ onClick={toggle}
59
+ className={clsx('flex w-full cursor-pointer select-none items-center gap-1.5 text-left', summaryClassName)}
60
+ >
61
+ <CollapseChevron open={open} inheritColor={inheritColor} className={chevronClassName} />
62
+ {summary}
63
+ </button>
64
+ <Collapse open={open} id={disclosure.panelId}>
65
+ {children}
66
+ </Collapse>
67
+ </div>
68
+ )
69
+ }
@@ -27,6 +27,7 @@ export { HealthRing } from './HealthRing'
27
27
  export { MetricsChart, MetricsSparkline } from './MetricsChart'
28
28
  export * from './drawer-components'
29
29
  export { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from './Collapse'
30
+ export { Disclosure } from './Disclosure'
30
31
  export { ResourceBar } from './ResourceBar'
31
32
  export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
32
33
  export { InClusterConsentDialog } from './InClusterConsentDialog'
@@ -142,28 +142,6 @@
142
142
  padding: 0.75rem;
143
143
  }
144
144
 
145
- /* ── ISSUE ROW DETAILS ── */
146
-
147
- /* Legacy: prefer <Collapse unmountOnExit> (ui/Collapse.tsx). Kept for any
148
- straggler; timing mirrors DURATION_DISCLOSURE / CSS_EASE in
149
- utils/animation.ts — the source-hygiene test keeps them equal. */
150
- .issue-details-motion {
151
- display: grid;
152
- grid-template-rows: 0fr;
153
- overflow: hidden;
154
- transition: grid-template-rows 300ms cubic-bezier(0.32, 0.72, 0, 1);
155
- }
156
-
157
- .issue-details-motion-open {
158
- grid-template-rows: 1fr;
159
- }
160
-
161
- @starting-style {
162
- .issue-details-motion-open {
163
- grid-template-rows: 0fr;
164
- }
165
- }
166
-
167
145
  /* ── SUBTLE BORDERS ── */
168
146
 
169
147
  .table-divide-subtle > * + * {
@@ -265,10 +265,23 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
265
265
  }
266
266
  }
267
267
 
268
+ const keptNodes = topology.nodes.filter((n) => keep.has(n.id))
269
+ // A shortcut edge (Rollout->Pod, CronJob->Pod, ...) exists to bridge the gap
270
+ // left when its intermediate kind is filtered out. The main view drops it
271
+ // once that kind reappears in the user's visible-kinds toggle; this
272
+ // neighborhood has no such toggle, so the equivalent check is whether a
273
+ // node of that kind is actually present in the resulting subgraph.
274
+ const presentKinds = new Set(keptNodes.map((n) => n.kind))
275
+ const keptEdges = topology.edges.filter((e) => {
276
+ if (!keep.has(e.source) || !keep.has(e.target)) return false
277
+ if (e.skipIfKindVisible && presentKinds.has(e.skipIfKindVisible as NodeKind)) return false
278
+ return true
279
+ })
280
+
268
281
  return {
269
282
  ...topology,
270
- nodes: topology.nodes.filter((n) => keep.has(n.id)),
271
- edges: topology.edges.filter((e) => keep.has(e.source) && keep.has(e.target)),
283
+ nodes: keptNodes,
284
+ edges: keptEdges,
272
285
  warnings: [
273
286
  ...(topology.warnings ?? []),
274
287
  ...Array.from(cappedSources).map((sourceId) => {
@@ -1,5 +1,59 @@
1
1
  import type { WorkloadPodInfo } from '../types/core'
2
2
 
3
+ /** Every CanaryStep variant Argo defines; raw JSON is unreadable in a step list. */
4
+ export function canaryStepLabel(step: any): string {
5
+ if (!step || typeof step !== 'object') return 'Unknown step'
6
+
7
+ if (step.setWeight !== undefined) return `Set weight: ${step.setWeight}%`
8
+
9
+ if (step.pause !== undefined) {
10
+ return step.pause?.duration ? `Pause: ${step.pause.duration}` : 'Pause: until promoted'
11
+ }
12
+
13
+ if (step.analysis) {
14
+ const templates = (step.analysis.templates || [])
15
+ .map((t: any) => t.templateName || t.clusterTemplateName)
16
+ .filter(Boolean)
17
+ return templates.length > 0 ? `Analysis: ${templates.join(', ')}` : 'Analysis'
18
+ }
19
+
20
+ if (step.experiment) {
21
+ const templates = (step.experiment.templates || []).map((t: any) => t.name).filter(Boolean)
22
+ const duration = step.experiment.duration ? ` for ${step.experiment.duration}` : ''
23
+ return templates.length > 0
24
+ ? `Experiment: ${templates.join(', ')}${duration}`
25
+ : `Experiment${duration}`
26
+ }
27
+
28
+ if (step.setCanaryScale) {
29
+ const { weight, replicas, matchTrafficWeight } = step.setCanaryScale
30
+ if (matchTrafficWeight) return 'Set canary scale: match traffic weight'
31
+ if (replicas !== undefined) return `Set canary scale: ${replicas} replicas`
32
+ if (weight !== undefined) return `Set canary scale: ${weight}%`
33
+ return 'Set canary scale'
34
+ }
35
+
36
+ if (step.setHeaderRoute) {
37
+ const { name, match } = step.setHeaderRoute
38
+ // An empty match list is how a header route is torn down again.
39
+ if (!match || match.length === 0) return `Remove header route${name ? `: ${name}` : ''}`
40
+ const headers = match.map((m: any) => m.headerName).filter(Boolean)
41
+ return `Header route${name ? ` ${name}` : ''}${headers.length ? `: ${headers.join(', ')}` : ''}`
42
+ }
43
+
44
+ if (step.setMirrorRoute) {
45
+ const { name, match, percentage } = step.setMirrorRoute
46
+ if (!match || match.length === 0) return `Remove mirror route${name ? `: ${name}` : ''}`
47
+ const pct = percentage !== undefined ? ` (${percentage}%)` : ''
48
+ return `Mirror route${name ? ` ${name}` : ''}${pct}`
49
+ }
50
+
51
+ if (step.plugin) return `Plugin: ${step.plugin.name || 'unnamed'}`
52
+
53
+ const key = Object.keys(step)[0]
54
+ return key ? `Unrecognized step: ${key}` : 'Unknown step'
55
+ }
56
+
3
57
  export type WorkloadRolloutPhase =
4
58
  | 'idle'
5
59
  | 'applying'
@@ -225,10 +279,22 @@ export function getArgoRolloutStepNumber(resource: any): number | null {
225
279
  return Math.min(Math.max(currentIndex + 1, 1), steps.length)
226
280
  }
227
281
 
282
+ // Mirrors pkg/health/workload_rollout.go's argoStepDetail exactly (same
283
+ // output string, same step-type coverage via canaryStepLabel) - the two are
284
+ // checked against the same golden fixture
285
+ // (pkg/health/testdata/workload_rollout_vectors.json), so a change to one
286
+ // without the other silently breaks cross-language parity rather than
287
+ // failing loudly.
228
288
  function argoDetail(resource: any, updated: number, desired: number, available: number): string {
289
+ const steps = resource?.spec?.strategy?.canary?.steps || []
229
290
  const stepNumber = getArgoRolloutStepNumber(resource)
230
- const step = stepNumber === null ? '' : `Step ${stepNumber} · `
231
- return `${step}${updated}/${desired} updated · ${available} available`
291
+ const step = stepNumber === null ? '' : `Step ${stepNumber}`
292
+ const currentStep = stepNumber === null ? undefined : steps[stepNumber - 1]
293
+ const label = currentStep ? ` (${canaryStepLabel(currentStep)})` : ''
294
+ const weight = resource?.status?.canary?.weights?.canary?.weight
295
+ const weightSuffix = typeof weight === 'number' ? ` · ${weight}% canary traffic` : ''
296
+ const prefix = stepNumber === null ? '' : `${step}${label} · `
297
+ return `${prefix}${updated}/${desired} updated · ${available} available${weightSuffix}`
232
298
  }
233
299
 
234
300
  function replicaDetail(updated: number, desired: number, available: number): string {