@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 +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -53
- package/src/components/topology/K8sResourceNode.tsx +15 -0
- package/src/components/topology/TopologyGraph.tsx +88 -10
- package/src/utils/topology-neighborhood.ts +15 -2
- package/src/utils/workload-rollout.ts +68 -2
package/package.json
CHANGED
|
@@ -257,59 +257,9 @@ export function rolloutConditionTone(cond: { type?: string; status?: string }):
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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' :
|
|
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
|
-
|
|
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
|
-
//
|
|
465
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
271
|
-
edges:
|
|
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
|
-
|
|
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 {
|