@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 +1 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -53
- package/src/components/topology/K8sResourceNode.tsx +19 -2
- package/src/components/topology/TopologyGraph.tsx +88 -10
- package/src/components/ui/Collapse.test.tsx +0 -13
- package/src/components/ui/Disclosure.test.tsx +26 -0
- package/src/components/ui/Disclosure.tsx +69 -0
- package/src/components/ui/index.ts +1 -0
- package/src/theme/components.css +0 -22
- package/src/utils/topology-neighborhood.ts +15 -2
- package/src/utils/workload-rollout.ts +68 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (
|
|
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
|
-
|
|
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'
|
|
@@ -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
|
-
|
|
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' :
|
|
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
|
|
@@ -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'
|
package/src/theme/components.css
CHANGED
|
@@ -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:
|
|
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 {
|