@skyhook-io/k8s-ui 1.14.8 → 1.14.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.8",
3
+ "version": "1.14.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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'
@@ -568,6 +569,11 @@ export const K8sResourceNode = memo(function K8sResourceNode({
568
569
  const policyStatus = nodeData.policyStatus as string | undefined
569
570
  const deploymentMembership = nodeData.deploymentMembership as 'runtime-only' | 'source-only' | undefined
570
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
571
577
 
572
578
  const Icon = getTopologyIcon(kind);
573
579
 
@@ -658,6 +664,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
658
664
  </span>
659
665
  </Tooltip>
660
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
+ )}
661
676
  {onToggleReplicaSets && (
662
677
  <Tooltip content={nodeData.replicaSetsCollapsed ? 'Show ReplicaSet' : 'Hide stable ReplicaSet'} position="right">
663
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
@@ -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 {