@skyhook-io/k8s-ui 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. package/src/utils/resource-icons.ts +29 -2
@@ -1,18 +1,301 @@
1
- import { Server, GitBranch, AlertTriangle, Network } from 'lucide-react'
1
+ import { useState } from 'react'
2
+ import { Server, GitBranch, Network, Activity, Play, FastForward, SkipForward, RotateCcw, Undo2 } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection } from '../../ui/drawer-components'
4
+ import { AlertBanner, Section, PropertyList, Property, ConditionsSection, PodTemplateSection, type ConditionTone } from '../../ui/drawer-components'
5
+ import { Tooltip } from '../../ui/Tooltip'
6
+ import { ConfirmDialog } from '../../ui/ConfirmDialog'
4
7
  import { formatAge } from '../resource-utils'
5
8
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
6
9
 
10
+ export type RolloutAction = 'abort' | 'retry' | 'promote' | 'promote-full' | 'skip-step'
11
+
12
+ export interface RolloutCapabilities {
13
+ abort: boolean
14
+ retry: boolean
15
+ promote: boolean
16
+ promoteFull: boolean
17
+ skipStep: boolean
18
+ rollback: boolean
19
+ restart: boolean
20
+ strategy: string
21
+ terminating: boolean
22
+ }
23
+
24
+ export interface RolloutActionConfirm {
25
+ title: string
26
+ message: string
27
+ }
28
+
29
+ export interface RolloutActionSpec {
30
+ action: RolloutAction
31
+ label: string
32
+ pendingLabel: string
33
+ icon: typeof Play
34
+ hint: string
35
+ blocked?: string
36
+ destructive?: boolean
37
+ // Set on the verbs that shift production traffic in one click.
38
+ confirm?: RolloutActionConfirm
39
+ }
40
+
7
41
  interface RolloutRendererProps {
8
42
  data: any
43
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
44
+ capabilities?: RolloutCapabilities
45
+ onAction?: (action: RolloutAction) => void
46
+ pendingAction?: RolloutAction | null
47
+ }
48
+
49
+ // A denied capability omits the verb entirely; `blocked` covers wrong-state only.
50
+ export function rolloutActions(data: any, capabilities?: RolloutCapabilities): RolloutActionSpec[] {
51
+ if (!capabilities) return []
52
+
53
+ const status = data?.status || {}
54
+ const phase = status.phase || 'Unknown'
55
+ const steps = data?.spec?.strategy?.canary?.steps || []
56
+ const isAborted = status.abort === true
57
+ const isSettled = phase === 'Healthy' && !isAborted
58
+ const stepsRemaining = steps.length > 0 && (status.currentStepIndex ?? 0) < steps.length
59
+ // Mid-analysis a canary is Progressing with nothing paused, and promote advances
60
+ // the step rather than clearing a pause — so gating on Paused alone hides it.
61
+ const stepAnalysis = status.canary?.currentStepAnalysisRunStatus?.status
62
+ const analysisInFlight =
63
+ stepAnalysis === 'Running' || stepAnalysis === 'Pending' || stepAnalysis === 'Inconclusive'
64
+ const promotable = phase === 'Paused' || analysisInFlight
65
+
66
+ const actions: RolloutActionSpec[] = []
67
+ if (capabilities.promote) {
68
+ actions.push({
69
+ action: 'promote',
70
+ label: 'Promote',
71
+ pendingLabel: 'Promoting…',
72
+ icon: Play,
73
+ hint: 'Clear the current pause, or advance past the running analysis, and continue',
74
+ // An aborted Rollout keeps an Inconclusive analysis status, so promotable stays
75
+ // true; Argo's own promote reports success there and advances nothing.
76
+ blocked: isAborted
77
+ ? 'Retry the rollout first'
78
+ : !promotable
79
+ ? 'Rollout is not paused and no analysis is running'
80
+ : undefined,
81
+ })
82
+ }
83
+ if (capabilities.promoteFull) {
84
+ actions.push({
85
+ action: 'promote-full',
86
+ label: 'Promote full',
87
+ pendingLabel: 'Promoting…',
88
+ icon: FastForward,
89
+ hint: 'Skip every remaining step, pause, and analysis — emergency hotfix path',
90
+ blocked: isSettled ? 'Nothing left to promote' : isAborted ? 'Retry the rollout first' : undefined,
91
+ confirm: {
92
+ title: 'Promote fully?',
93
+ message:
94
+ 'Every remaining step, pause, and analysis is skipped and the new version goes to 100% of production traffic immediately.',
95
+ },
96
+ })
97
+ }
98
+ if (capabilities.skipStep) {
99
+ actions.push({
100
+ action: 'skip-step',
101
+ label: 'Skip step',
102
+ pendingLabel: 'Skipping…',
103
+ icon: SkipForward,
104
+ hint: 'Advance one canary step without waiting for it',
105
+ blocked: !stepsRemaining ? 'No canary step left to skip' : isAborted ? 'Retry the rollout first' : undefined,
106
+ })
107
+ }
108
+ if (capabilities.retry && isAborted) {
109
+ actions.push({
110
+ action: 'retry',
111
+ label: 'Retry',
112
+ pendingLabel: 'Retrying…',
113
+ icon: RotateCcw,
114
+ hint: 'Clear the abort and resume the rollout from its current step',
115
+ })
116
+ }
117
+ if (capabilities.abort && !isAborted) {
118
+ actions.push({
119
+ action: 'abort',
120
+ label: 'Abort',
121
+ pendingLabel: 'Aborting…',
122
+ icon: Undo2,
123
+ hint: 'Shift all traffic back to the stable revision and stop progressing',
124
+ blocked: isSettled ? 'Rollout is fully promoted — roll back instead' : undefined,
125
+ destructive: true,
126
+ confirm: {
127
+ title: 'Abort rollout?',
128
+ message:
129
+ 'All production traffic shifts back to the stable revision and the rollout stops progressing.',
130
+ },
131
+ })
132
+ }
133
+ return actions
134
+ }
135
+
136
+ export interface RolloutProblem {
137
+ color: 'red' | 'yellow'
138
+ message: string
139
+ }
140
+
141
+ export function rolloutProblems(data: any): RolloutProblem[] {
142
+ const status = data?.status || {}
143
+ const phase = status.phase || 'Unknown'
144
+ const conditions: any[] = status.conditions || []
145
+ const problems: RolloutProblem[] = []
146
+
147
+ if (status.abort === true) {
148
+ problems.push({ color: 'red', message: status.message || 'Rollout was aborted' })
149
+ }
150
+ if (phase === 'Degraded') {
151
+ problems.push({ color: 'red', message: status.message || 'Rollout is degraded' })
152
+ }
153
+
154
+ const progressDeadline = conditions.find(
155
+ (c) => c.type === 'Progressing' && c.status === 'False' && c.reason === 'ProgressDeadlineExceeded'
156
+ )
157
+ if (progressDeadline) {
158
+ problems.push({ color: 'red', message: progressDeadline.message || 'Progress deadline exceeded' })
159
+ }
160
+
161
+ const invalidSpec = conditions.find((c) => c.type === 'InvalidSpec' && c.status === 'True')
162
+ if (invalidSpec) {
163
+ problems.push({ color: 'red', message: invalidSpec.message || 'Invalid rollout spec' })
164
+ }
165
+
166
+ if (phase === 'Paused' && !status.abort) {
167
+ const reasons = (status.pauseConditions || []).map((pc: any) => {
168
+ const since = pc.startTime ? ` (since ${formatAge(pc.startTime)})` : ''
169
+ return `${pc.reason || 'Unknown'}${since}`
170
+ })
171
+ problems.push({
172
+ color: 'yellow',
173
+ message: reasons.length > 0 ? `Rollout is paused: ${reasons.join('; ')}` : 'Rollout is paused',
174
+ })
175
+ }
176
+
177
+ for (const run of rolloutAnalysisRuns(status)) {
178
+ if (run.status === 'Inconclusive' || run.status === 'Failed' || run.status === 'Error') {
179
+ problems.push({
180
+ color: run.status === 'Inconclusive' ? 'yellow' : 'red',
181
+ message: `${run.label} ${run.status.toLowerCase()} (${run.name})${run.message ? `: ${run.message}` : ''}`,
182
+ })
183
+ }
184
+ }
185
+
186
+ // An aborted Rollout is also Degraded, and both read status.message — so the
187
+ // same sentence arrives twice. Keep the first, which carries the abort framing.
188
+ const seen = new Set<string>()
189
+ return problems.filter((p) => {
190
+ if (seen.has(p.message)) return false
191
+ seen.add(p.message)
192
+ return true
193
+ })
194
+ }
195
+
196
+ // Argo inverts polarity on two condition types: Paused=False and InvalidSpec=False
197
+ // are the good states, which the generic True-is-healthy rule scores as failing.
198
+ export function rolloutConditionTone(cond: { type?: string; status?: string }): ConditionTone | undefined {
199
+ if (cond.status !== 'True' && cond.status !== 'False') return undefined
200
+ const isTrue = cond.status === 'True'
201
+ switch (cond.type) {
202
+ case 'Paused':
203
+ return isTrue ? 'warning' : 'ok'
204
+ case 'InvalidSpec':
205
+ return isTrue ? 'fail' : 'ok'
206
+ default:
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ /** Every CanaryStep variant Argo defines; raw JSON is unreadable in a step list. */
212
+ export function canaryStepLabel(step: any): string {
213
+ if (!step || typeof step !== 'object') return 'Unknown step'
214
+
215
+ if (step.setWeight !== undefined) return `Set weight: ${step.setWeight}%`
216
+
217
+ if (step.pause !== undefined) {
218
+ return step.pause?.duration ? `Pause: ${step.pause.duration}` : 'Pause: until promoted'
219
+ }
220
+
221
+ if (step.analysis) {
222
+ const templates = (step.analysis.templates || [])
223
+ .map((t: any) => t.templateName || t.clusterTemplateName)
224
+ .filter(Boolean)
225
+ return templates.length > 0 ? `Analysis: ${templates.join(', ')}` : 'Analysis'
226
+ }
227
+
228
+ if (step.experiment) {
229
+ const templates = (step.experiment.templates || []).map((t: any) => t.name).filter(Boolean)
230
+ const duration = step.experiment.duration ? ` for ${step.experiment.duration}` : ''
231
+ return templates.length > 0
232
+ ? `Experiment: ${templates.join(', ')}${duration}`
233
+ : `Experiment${duration}`
234
+ }
235
+
236
+ if (step.setCanaryScale) {
237
+ const { weight, replicas, matchTrafficWeight } = step.setCanaryScale
238
+ if (matchTrafficWeight) return 'Set canary scale: match traffic weight'
239
+ if (replicas !== undefined) return `Set canary scale: ${replicas} replicas`
240
+ if (weight !== undefined) return `Set canary scale: ${weight}%`
241
+ return 'Set canary scale'
242
+ }
243
+
244
+ if (step.setHeaderRoute) {
245
+ const { name, match } = step.setHeaderRoute
246
+ // An empty match list is how a header route is torn down again.
247
+ if (!match || match.length === 0) return `Remove header route${name ? `: ${name}` : ''}`
248
+ const headers = match.map((m: any) => m.headerName).filter(Boolean)
249
+ return `Header route${name ? ` ${name}` : ''}${headers.length ? `: ${headers.join(', ')}` : ''}`
250
+ }
251
+
252
+ if (step.setMirrorRoute) {
253
+ const { name, match, percentage } = step.setMirrorRoute
254
+ if (!match || match.length === 0) return `Remove mirror route${name ? `: ${name}` : ''}`
255
+ const pct = percentage !== undefined ? ` (${percentage}%)` : ''
256
+ return `Mirror route${name ? ` ${name}` : ''}${pct}`
257
+ }
258
+
259
+ if (step.plugin) return `Plugin: ${step.plugin.name || 'unnamed'}`
260
+
261
+ const key = Object.keys(step)[0]
262
+ return key ? `Unrecognized step: ${key}` : 'Unknown step'
9
263
  }
10
264
 
11
- export function RolloutRenderer({ data }: RolloutRendererProps) {
265
+ /** Populated analysis slots, in the order the controller runs them. */
266
+ export function rolloutAnalysisRuns(
267
+ status: any
268
+ ): Array<{ label: string; name?: string; status?: string; message?: string }> {
269
+ return [
270
+ { label: 'Step analysis', ...(status?.canary?.currentStepAnalysisRunStatus || {}) },
271
+ { label: 'Background analysis', ...(status?.canary?.currentBackgroundAnalysisRunStatus || {}) },
272
+ { label: 'Pre-promotion analysis', ...(status?.blueGreen?.prePromotionAnalysisRunStatus || {}) },
273
+ { label: 'Post-promotion analysis', ...(status?.blueGreen?.postPromotionAnalysisRunStatus || {}) },
274
+ ].filter((run) => run.name)
275
+ }
276
+
277
+ function analysisStatusClass(status?: string): string {
278
+ switch (status) {
279
+ case 'Successful':
280
+ return 'status-healthy'
281
+ case 'Running':
282
+ case 'Pending':
283
+ return 'status-degraded'
284
+ case 'Inconclusive':
285
+ return 'status-alert'
286
+ case 'Failed':
287
+ case 'Error':
288
+ return 'status-unhealthy'
289
+ default:
290
+ return 'status-unknown'
291
+ }
292
+ }
293
+
294
+ export function RolloutRenderer({ data, onNavigate, capabilities, onAction, pendingAction }: RolloutRendererProps) {
295
+ const [confirming, setConfirming] = useState<RolloutActionSpec | null>(null)
12
296
  const status = data.status || {}
13
297
  const spec = data.spec || {}
14
298
  const phase = status.phase || 'Unknown'
15
- const conditions = status.conditions || []
16
299
 
17
300
  const canaryStrategy = spec.strategy?.canary
18
301
  const blueGreenStrategy = spec.strategy?.blueGreen
@@ -34,46 +317,9 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
34
317
  })()
35
318
  : null
36
319
 
37
- // Problem detection
38
- const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
39
-
40
- // Aborted rollout detection — must come before generic paused check
41
- if (status.abort === true) {
42
- problems.push({ color: 'red', message: status.message || 'Rollout was aborted' })
43
- }
44
-
45
- if (phase === 'Degraded') {
46
- problems.push({ color: 'red', message: status.message || 'Rollout is degraded' })
47
- }
48
-
49
- const progressDeadlineCond = conditions.find(
50
- (c: any) => c.type === 'Progressing' && c.status === 'False' && c.reason === 'ProgressDeadlineExceeded'
51
- )
52
- if (progressDeadlineCond) {
53
- problems.push({ color: 'red', message: progressDeadlineCond.message || 'Progress deadline exceeded' })
54
- }
55
-
56
- const invalidSpecCond = conditions.find(
57
- (c: any) => c.type === 'InvalidSpec' && c.status === 'True'
58
- )
59
- if (invalidSpecCond) {
60
- problems.push({ color: 'red', message: invalidSpecCond.message || 'Invalid rollout spec' })
61
- }
62
-
63
- // Pause conditions — show specific reasons instead of generic "Rollout is paused"
64
- const pauseConditions: Array<{ reason: string; startTime?: string }> = status.pauseConditions || []
65
- if (phase === 'Paused' && !status.abort) {
66
- if (pauseConditions.length > 0) {
67
- const reasons = pauseConditions.map((pc: any) => {
68
- const reason = pc.reason || 'Unknown'
69
- const since = pc.startTime ? ` (since ${formatAge(pc.startTime)})` : ''
70
- return `${reason}${since}`
71
- })
72
- problems.push({ color: 'yellow', message: `Rollout is paused: ${reasons.join('; ')}` })
73
- } else {
74
- problems.push({ color: 'yellow', message: 'Rollout is paused' })
75
- }
76
- }
320
+ const analysisRuns = rolloutAnalysisRuns(status)
321
+ const problems = rolloutProblems(data)
322
+ const actions = onAction ? rolloutActions(data, capabilities) : []
77
323
 
78
324
  // Phase badge color
79
325
  const phaseColor = (() => {
@@ -88,46 +334,59 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
88
334
 
89
335
  return (
90
336
  <>
91
- {/* Problem alerts */}
92
337
  {problems.map((problem, i) => (
93
- <div
338
+ <AlertBanner
94
339
  key={i}
95
- className={clsx(
96
- 'mb-4 p-3 border rounded-lg',
97
- problem.color === 'red'
98
- ? 'bg-red-500/10 border-red-500/30'
99
- : 'bg-yellow-500/10 border-yellow-500/30'
100
- )}
101
- >
102
- <div className="flex items-start gap-2">
103
- <AlertTriangle
104
- className={clsx(
105
- 'w-4 h-4 mt-0.5 shrink-0',
106
- problem.color === 'red' ? 'text-red-400' : 'text-yellow-400'
107
- )}
108
- />
109
- <div className="flex-1 min-w-0">
110
- <div
111
- className={clsx(
112
- 'text-sm font-medium',
113
- problem.color === 'red' ? 'text-red-400' : 'text-yellow-400'
114
- )}
115
- >
116
- {problem.color === 'red' ? 'Issue Detected' : 'Warning'}
117
- </div>
118
- <div
119
- className={clsx(
120
- 'text-xs mt-1',
121
- problem.color === 'red' ? 'text-red-300/80' : 'text-yellow-300/80'
122
- )}
123
- >
124
- {problem.message}
125
- </div>
126
- </div>
127
- </div>
128
- </div>
340
+ variant={problem.color === 'red' ? 'error' : 'warning'}
341
+ title={problem.color === 'red' ? 'Issue Detected' : 'Warning'}
342
+ message={problem.message}
343
+ />
129
344
  ))}
130
345
 
346
+ {actions.length > 0 && (
347
+ <div className="mb-4 flex flex-wrap items-center gap-2">
348
+ {actions.map((spec) => {
349
+ const { action, label, pendingLabel, icon: Icon, hint, blocked, destructive, confirm } = spec
350
+ const pending = pendingAction === action
351
+ const disabled = Boolean(blocked) || pendingAction != null || capabilities?.terminating
352
+ return (
353
+ <Tooltip key={action} content={capabilities?.terminating ? 'Rollout is being deleted' : blocked || hint} delay={150}>
354
+ <button
355
+ onClick={() => (confirm ? setConfirming(spec) : onAction?.(action))}
356
+ disabled={disabled}
357
+ className={clsx(
358
+ 'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',
359
+ destructive
360
+ ? 'border border-red-400/50 text-red-400 enabled:hover:bg-red-500/10'
361
+ : 'btn-brand-muted'
362
+ )}
363
+ >
364
+ <Icon className={clsx('h-3.5 w-3.5', pending && 'animate-pulse')} />
365
+ {pending ? pendingLabel : label}
366
+ </button>
367
+ </Tooltip>
368
+ )
369
+ })}
370
+ </div>
371
+ )}
372
+
373
+ {confirming?.confirm && (
374
+ <ConfirmDialog
375
+ open
376
+ onClose={() => setConfirming(null)}
377
+ onConfirm={() => {
378
+ const action = confirming.action
379
+ setConfirming(null)
380
+ onAction?.(action)
381
+ }}
382
+ title={confirming.confirm.title}
383
+ message={confirming.confirm.message}
384
+ details={`${data.metadata?.namespace}/${data.metadata?.name}`}
385
+ confirmLabel={confirming.label}
386
+ variant={confirming.destructive ? 'danger' : 'warning'}
387
+ />
388
+ )}
389
+
131
390
  {/* Status section */}
132
391
  <Section title="Status" icon={Server}>
133
392
  <PropertyList>
@@ -164,12 +423,19 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
164
423
  )}
165
424
  <Property label="Canary Service" value={canaryStrategy.canaryService} />
166
425
  <Property label="Stable Service" value={canaryStrategy.stableService} />
426
+ {status.canary?.currentExperiment && (
427
+ <Property label="Running Experiment" value={status.canary.currentExperiment} />
428
+ )}
167
429
  </>
168
430
  ) : blueGreenStrategy ? (
169
431
  <>
170
432
  <Property label="Strategy" value="Blue-Green" />
171
433
  <Property label="Active Service" value={blueGreenStrategy.activeService} />
172
434
  <Property label="Preview Service" value={blueGreenStrategy.previewService} />
435
+ {/* The selectors are what each Service actually serves right now — a
436
+ rolled-back revision sits in preview until it is promoted. */}
437
+ <Property label="Active Selector" value={status.blueGreen?.activeSelector} />
438
+ <Property label="Preview Selector" value={status.blueGreen?.previewSelector} />
173
439
  <Property
174
440
  label="Auto Promote"
175
441
  value={
@@ -178,6 +444,15 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
178
444
  : undefined
179
445
  }
180
446
  />
447
+ {blueGreenStrategy.autoPromotionSeconds !== undefined && (
448
+ <Property
449
+ label="Auto Promote After"
450
+ value={`${blueGreenStrategy.autoPromotionSeconds}s`}
451
+ />
452
+ )}
453
+ {status.blueGreen?.scaleUpPreviewCheckPoint && (
454
+ <Property label="Preview" value="Scaled up, awaiting promotion" />
455
+ )}
181
456
  </>
182
457
  ) : (
183
458
  <Property label="Strategy" value="Unknown" />
@@ -256,6 +531,40 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
256
531
  </Section>
257
532
  )}
258
533
 
534
+ {analysisRuns.length > 0 && (
535
+ <Section title="Analysis" icon={Activity}>
536
+ <PropertyList>
537
+ {analysisRuns.map((run) => (
538
+ <Property
539
+ key={run.label}
540
+ label={run.label}
541
+ value={
542
+ <span className="flex items-center gap-2">
543
+ <span className={clsx('badge', analysisStatusClass(run.status))}>{run.status || 'Unknown'}</span>
544
+ {onNavigate ? (
545
+ <button
546
+ onClick={() =>
547
+ onNavigate({
548
+ kind: 'AnalysisRun',
549
+ namespace: data?.metadata?.namespace ?? '',
550
+ name: run.name!,
551
+ })
552
+ }
553
+ className="font-mono text-xs text-brand hover:underline"
554
+ >
555
+ {run.name}
556
+ </button>
557
+ ) : (
558
+ <span className="font-mono text-xs text-theme-text-secondary">{run.name}</span>
559
+ )}
560
+ </span>
561
+ }
562
+ />
563
+ ))}
564
+ </PropertyList>
565
+ </Section>
566
+ )}
567
+
259
568
  {/* Canary Steps visual */}
260
569
  {isCanary && steps.length > 0 && (
261
570
  <Section title={`Canary Steps (${steps.length})`} defaultExpanded>
@@ -265,17 +574,7 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
265
574
  const isCurrent = currentStepIndex !== undefined && index === currentStepIndex
266
575
  const isPending = currentStepIndex === undefined || index > currentStepIndex
267
576
 
268
- const stepLabel = (() => {
269
- if (step.setWeight !== undefined) return `Set Weight: ${step.setWeight}%`
270
- if (step.pause !== undefined) {
271
- if (step.pause.duration) return `Pause: ${step.pause.duration}`
272
- return 'Pause: manual'
273
- }
274
- // Handle other step types generically
275
- const key = Object.keys(step)[0]
276
- if (key) return `${key}: ${JSON.stringify(step[key])}`
277
- return 'Unknown step'
278
- })()
577
+ const stepLabel = canaryStepLabel(step)
279
578
 
280
579
  return (
281
580
  <div
@@ -324,7 +623,7 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
324
623
  </Section>
325
624
 
326
625
  {/* Conditions section */}
327
- <ConditionsSection conditions={status.conditions} />
626
+ <ConditionsSection conditions={status.conditions} getConditionTone={rolloutConditionTone} />
328
627
  </>
329
628
  )
330
629
  }