@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -1,26 +1,14 @@
1
1
  /**
2
- * Shared timeline components and utilities.
2
+ * Shared timeline utilities.
3
3
  *
4
- * These components are used by both TimelineSwimlanes.tsx and the EventsTab in WorkloadView.
5
- * They ensure consistent behavior for time formatting, zoom controls, legends, and markers.
4
+ * Time formatting, timeline geometry, and health-span computation used by both
5
+ * TimelineSwimlanes.tsx and the EventsTab in WorkloadView. Extracted so the two
6
+ * views can't drift on how they format axis labels or derive health spans.
6
7
  */
7
8
 
8
- import { clsx } from 'clsx'
9
- import { ZoomIn, ZoomOut } from 'lucide-react'
10
9
  import type { TimelineEvent } from '../../types/core'
11
- import { isChangeEvent, isHistoricalEvent } from '../../types/core'
12
- import { isProblematicEvent } from '../../utils/resource-hierarchy'
13
-
14
- // ============================================================================
15
- // Constants
16
- // ============================================================================
17
-
18
- /**
19
- * Available zoom levels in hours.
20
- * These control how much time is visible on the timeline.
21
- */
22
- export const ZOOM_LEVELS = [0.5, 1, 2, 6, 12, 24, 48, 168] as const
23
- export type ZoomLevel = (typeof ZOOM_LEVELS)[number]
10
+ import { isChangeEvent, isDeploymentLikeWorkloadKind } from '../../types/core'
11
+ import { isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
24
12
 
25
13
  // ============================================================================
26
14
  // Time Formatting Utilities
@@ -50,428 +38,6 @@ export function formatFullTime(date: Date): string {
50
38
  return date.toLocaleString()
51
39
  }
52
40
 
53
- /**
54
- * Format a relative time string (e.g., "5m ago", "2h ago").
55
- */
56
- export function formatRelativeTime(timestamp: string): string {
57
- const diff = Date.now() - new Date(timestamp).getTime()
58
- const mins = Math.floor(diff / 60000)
59
- if (mins < 1) return 'just now'
60
- if (mins < 60) return `${mins}m ago`
61
- const hours = Math.floor(mins / 60)
62
- if (hours < 24) return `${hours}h ago`
63
- return `${Math.floor(hours / 24)}d ago`
64
- }
65
-
66
- /**
67
- * Format zoom level for display (e.g., "1h", "6h", "24h", "7d").
68
- */
69
- export function formatZoomLevel(hours: number): string {
70
- if (hours < 1) return `${Math.round(hours * 60)}m`
71
- if (hours < 24) return `${hours}h`
72
- return `${Math.round(hours / 24)}d`
73
- }
74
-
75
- // ============================================================================
76
- // Shared Components
77
- // ============================================================================
78
-
79
- /**
80
- * Legend item with hover tooltip explaining what a color means.
81
- */
82
- interface LegendItemProps {
83
- color: string
84
- label: string
85
- description: string
86
- dashed?: boolean
87
- }
88
-
89
- export function LegendItem({ color, label, description, dashed }: LegendItemProps) {
90
- return (
91
- <span className="relative flex items-center gap-1 group cursor-help">
92
- <span className={clsx(
93
- 'w-2 h-2 rounded-full',
94
- dashed ? 'border border-dashed border-current bg-transparent' : color
95
- )} />
96
- <span>{label}</span>
97
- <span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-theme-base text-theme-text-primary rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity duration-75 shadow-lg border border-theme-border-light">
98
- {description}
99
- </span>
100
- </span>
101
- )
102
- }
103
-
104
- /**
105
- * Legend for event dot markers (used in both swimlanes and detail view).
106
- */
107
- export function EventDotLegend() {
108
- return (
109
- <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
110
- <LegendItem color="bg-green-500" label="Created" description="Resource was created" />
111
- <LegendItem color="bg-blue-500" label="Modified" description="Resource was modified" />
112
- <LegendItem color="bg-red-500" label="Deleted" description="Resource was deleted" />
113
- <LegendItem color="bg-amber-500" label="Warning" description="Warning event or error condition" />
114
- <LegendItem color="" label="Historical" description="Reconstructed from metadata" dashed />
115
- </div>
116
- )
117
- }
118
-
119
- /**
120
- * Legend for health span bars (colored bars showing health state over time).
121
- */
122
- export function HealthSpanLegend() {
123
- return (
124
- <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
125
- <span className="flex items-center gap-1">
126
- <span className="w-4 h-2 rounded-sm bg-green-500/60 dark:bg-green-600/60" />
127
- <span>Healthy</span>
128
- </span>
129
- <span className="flex items-center gap-1">
130
- <span className="w-4 h-2 rounded-sm bg-blue-500/60 dark:bg-blue-500/60" />
131
- <span>Rolling</span>
132
- </span>
133
- <span className="flex items-center gap-1">
134
- <span className="w-4 h-2 rounded-sm bg-amber-500/60 dark:bg-[#b8861e]" />
135
- <span>Degraded</span>
136
- </span>
137
- <span className="flex items-center gap-1">
138
- <span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
139
- <span>Unhealthy</span>
140
- </span>
141
- <span className="flex items-center gap-1">
142
- <span className="w-4 h-2 rounded-sm bg-sky-500/60 dark:bg-sky-500/60" />
143
- <span>Idle</span>
144
- </span>
145
- </div>
146
- )
147
- }
148
-
149
- /**
150
- * Zoom controls for the timeline (zoom in/out buttons + current level display).
151
- */
152
- interface ZoomControlsProps {
153
- zoom: ZoomLevel
154
- onZoomIn: () => void
155
- onZoomOut: () => void
156
- canZoomIn: boolean
157
- canZoomOut: boolean
158
- }
159
-
160
- export function ZoomControls({ zoom, onZoomIn, onZoomOut, canZoomIn, canZoomOut }: ZoomControlsProps) {
161
- return (
162
- <div className="flex items-center gap-1 text-theme-text-tertiary">
163
- <button
164
- onClick={onZoomOut}
165
- disabled={!canZoomOut}
166
- className="p-1.5 hover:bg-theme-elevated rounded disabled:opacity-30"
167
- title="Zoom out (show more time)"
168
- >
169
- <ZoomOut className="w-4 h-4" />
170
- </button>
171
- <span className="text-xs min-w-[3ch] text-center">{formatZoomLevel(zoom)}</span>
172
- <button
173
- onClick={onZoomIn}
174
- disabled={!canZoomIn}
175
- className="p-1.5 hover:bg-theme-elevated rounded disabled:opacity-30"
176
- title="Zoom in (show less time)"
177
- >
178
- <ZoomIn className="w-4 h-4" />
179
- </button>
180
- </div>
181
- )
182
- }
183
-
184
- /**
185
- * Quick zoom level selector (5m, 30m, 1h, 6h, 24h buttons).
186
- */
187
- interface QuickZoomSelectorProps {
188
- zoom: ZoomLevel
189
- onZoomChange: (level: ZoomLevel) => void
190
- }
191
-
192
- export function QuickZoomSelector({ zoom, onZoomChange }: QuickZoomSelectorProps) {
193
- const quickLevels: ZoomLevel[] = [0.5, 1, 6, 24]
194
- const labels: Record<number, string> = { 0.5: '30m', 1: '1h', 6: '6h', 24: '24h' }
195
-
196
- return (
197
- <div className="flex items-center gap-0.5 bg-theme-elevated rounded-lg p-0.5">
198
- {quickLevels.map(level => (
199
- <button
200
- key={level}
201
- onClick={() => onZoomChange(level)}
202
- className={clsx(
203
- 'px-2 py-1 text-xs rounded transition-colors',
204
- zoom === level
205
- ? 'bg-theme-surface text-theme-text-primary shadow-sm'
206
- : 'text-theme-text-tertiary hover:text-theme-text-secondary'
207
- )}
208
- >
209
- {labels[level]}
210
- </button>
211
- ))}
212
- </div>
213
- )
214
- }
215
-
216
- /**
217
- * Event marker dot for timeline visualization.
218
- */
219
- interface EventMarkerProps {
220
- event: TimelineEvent
221
- x: number
222
- selected?: boolean
223
- onClick: () => void
224
- dimmed?: boolean
225
- small?: boolean
226
- }
227
-
228
- export function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarkerProps) {
229
- const isChange = isChangeEvent(event)
230
- const isProblematic = isProblematicEvent(event)
231
- const isHistorical = isHistoricalEvent(event)
232
-
233
- const getMarkerStyle = () => {
234
- if (isHistorical) {
235
- if (isProblematic) {
236
- return 'bg-amber-500/20 border-2 border-dashed border-amber-500/60'
237
- }
238
- if (isChange) {
239
- switch (event.eventType) {
240
- case 'add':
241
- return 'bg-green-500/20 border-2 border-dashed border-green-500/60'
242
- case 'delete':
243
- return 'bg-red-500/20 border-2 border-dashed border-red-500/60'
244
- case 'update':
245
- return 'bg-blue-500/20 border-2 border-dashed border-blue-500/60'
246
- }
247
- }
248
- return 'bg-theme-hover/30 border-2 border-dashed border-theme-border-light'
249
- }
250
-
251
- if (isProblematic) {
252
- return dimmed ? 'bg-amber-500/50' : 'bg-amber-500'
253
- }
254
- if (isChange) {
255
- switch (event.eventType) {
256
- case 'add':
257
- return dimmed ? 'bg-green-500/50' : 'bg-green-500'
258
- case 'delete':
259
- return dimmed ? 'bg-red-500/50' : 'bg-red-500'
260
- case 'update':
261
- return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
262
- }
263
- }
264
- return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
265
- }
266
-
267
- const getOperationLabel = () => {
268
- if (isProblematic) {
269
- return `⚠ ${event.reason || 'Warning'}`
270
- }
271
- if (isChange) {
272
- switch (event.eventType) {
273
- case 'add': return '● Created'
274
- case 'delete': return '● Deleted'
275
- case 'update': return '● Modified'
276
- default: return '● Changed'
277
- }
278
- }
279
- if (event.reason) {
280
- return `● ${event.reason}`
281
- }
282
- return '● Event'
283
- }
284
-
285
- const tooltipLines: string[] = []
286
- tooltipLines.push(getOperationLabel())
287
- if (event.message) {
288
- const msg = event.message.length > 60 ? event.message.slice(0, 60) + '...' : event.message
289
- tooltipLines.push(msg)
290
- }
291
- tooltipLines.push(formatRelativeTime(event.timestamp))
292
- if (isHistorical) tooltipLines.push('(from metadata)')
293
-
294
- const tooltipText = tooltipLines.join(' · ')
295
-
296
- return (
297
- <button
298
- className={clsx(
299
- 'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 rounded-full transition-all group',
300
- small ? 'w-2.5 h-2.5' : 'w-3 h-3',
301
- getMarkerStyle(),
302
- selected ? 'ring-2 ring-white ring-offset-2 ring-offset-theme-base scale-150' : 'hover:scale-125',
303
- dimmed ? 'z-5' : isHistorical ? 'z-5' : 'z-10'
304
- )}
305
- style={{ left: `${x}%` }}
306
- onClick={(e) => {
307
- e.stopPropagation()
308
- onClick()
309
- }}
310
- >
311
- <span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-theme-base text-theme-text-primary rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity duration-75">
312
- {tooltipText}
313
- </span>
314
- </button>
315
- )
316
- }
317
-
318
- /**
319
- * "Now" marker line that shows the current time on the timeline.
320
- */
321
- interface NowMarkerProps {
322
- /** X position as percentage (0-100) */
323
- x: number
324
- /** Label to show next to the line */
325
- label?: string
326
- /** Additional CSS classes for positioning */
327
- className?: string
328
- }
329
-
330
- export function NowMarker({ x, label = 'now', className }: NowMarkerProps) {
331
- if (x < 0 || x > 100) return null
332
-
333
- return (
334
- <div
335
- className={clsx(
336
- 'absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-10 pointer-events-none',
337
- className
338
- )}
339
- style={{ left: `${x}%` }}
340
- >
341
- <span className="absolute -top-4 left-1/2 -translate-x-1/2 text-xs text-purple-500 font-medium">
342
- {label}
343
- </span>
344
- </div>
345
- )
346
- }
347
-
348
- /**
349
- * Time axis with tick marks and labels.
350
- */
351
- interface TimeAxisProps {
352
- startTime: number
353
- endTime: number
354
- tickCount?: number
355
- /** CSS class for label column width (e.g., "w-64") */
356
- labelColumnClass?: string
357
- }
358
-
359
- export function TimeAxis({ startTime, endTime, tickCount = 8, labelColumnClass = 'w-64' }: TimeAxisProps) {
360
- const windowMs = endTime - startTime
361
- const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
362
- const t = startTime + (windowMs * i) / tickCount
363
- return { time: t, label: formatAxisTime(new Date(t)) }
364
- })
365
-
366
- const timeToX = (ts: number) => ((ts - startTime) / windowMs) * 100
367
-
368
- return (
369
- <div className="flex">
370
- <div className={clsx('shrink-0 bg-theme-surface/50 border-r border-theme-border', labelColumnClass)} />
371
- <div className="flex-1 relative h-6 bg-theme-elevated/30">
372
- {ticks.map((tick, i) => {
373
- const x = timeToX(tick.time)
374
- return (
375
- <div
376
- key={i}
377
- className="absolute top-0 flex flex-col items-center"
378
- style={{ left: `${x}%`, transform: 'translateX(-50%)' }}
379
- >
380
- <div className="h-2 w-px bg-theme-border" />
381
- <span className="text-xs text-theme-text-tertiary">{tick.label}</span>
382
- </div>
383
- )
384
- })}
385
- </div>
386
- </div>
387
- )
388
- }
389
-
390
- /**
391
- * Health span bar showing a health state over a time range.
392
- */
393
- interface HealthSpanProps {
394
- health: 'healthy' | 'rolling' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' | string
395
- left: number // percentage
396
- width: number // percentage
397
- title?: string
398
- /** Show indicator that resource was created before visible window */
399
- createdBefore?: Date
400
- }
401
-
402
- export function HealthSpan({ health, left, width, title, createdBefore }: HealthSpanProps) {
403
- if (width <= 0) return null
404
-
405
- const getHealthColor = () => {
406
- switch (health) {
407
- case 'healthy':
408
- return 'bg-green-500/60 dark:bg-green-600/60'
409
- case 'rolling':
410
- return 'bg-blue-500/60 dark:bg-blue-500/60'
411
- case 'degraded':
412
- return 'bg-amber-500/60 dark:bg-[#b8861e]'
413
- case 'unhealthy':
414
- return 'bg-red-500/60 dark:bg-red-500/60'
415
- case 'neutral':
416
- // intentional/idle (suspended, scaled-to-0) — sky, calm
417
- return 'bg-sky-500/60 dark:bg-sky-500/60'
418
- default:
419
- // Unknown or other states
420
- return 'bg-gray-400/40'
421
- }
422
- }
423
-
424
- return (
425
- <div
426
- className={clsx(
427
- 'absolute top-1 bottom-1 rounded-sm group',
428
- getHealthColor()
429
- )}
430
- style={{ left: `${left}%`, width: `${width}%` }}
431
- title={title}
432
- >
433
- {createdBefore && left === 0 && (
434
- <span className="absolute left-0.5 top-1/2 -translate-y-1/2 text-[9px] text-black/70 dark:text-black/60 whitespace-nowrap pointer-events-none group-hover:text-black/90 dark:group-hover:text-black/80">
435
- ← {formatCreatedBefore(createdBefore)}
436
- </span>
437
- )}
438
- </div>
439
- )
440
- }
441
-
442
- /**
443
- * Format the "created before" date for display.
444
- * Shows relative time for recent dates, or short date for older ones.
445
- */
446
- function formatCreatedBefore(date: Date): string {
447
- const now = Date.now()
448
- const diffMs = now - date.getTime()
449
- const diffHours = diffMs / (1000 * 60 * 60)
450
- const diffDays = diffHours / 24
451
-
452
- if (diffDays < 1) {
453
- return `${Math.round(diffHours)}h ago`
454
- } else if (diffDays < 7) {
455
- return `${Math.round(diffDays)}d ago`
456
- } else {
457
- return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
458
- }
459
- }
460
-
461
- /**
462
- * Calculate visible time range from zoom level and optional pan offset.
463
- */
464
- export function calculateTimeRange(
465
- zoom: ZoomLevel,
466
- now: number,
467
- panOffset: number = 0
468
- ): { start: number; end: number; windowMs: number } {
469
- const windowMs = zoom * 60 * 60 * 1000
470
- const end = now - panOffset
471
- const start = end - windowMs
472
- return { start, end, windowMs }
473
- }
474
-
475
41
  /**
476
42
  * Convert a timestamp to X position (0-100) within a time range.
477
43
  */
@@ -495,12 +61,11 @@ export interface HealthSpanResult {
495
61
  * Rollout signals include:
496
62
  * - Diff summary contains "updated:" (indicating replica count or image changes)
497
63
  * - Diff summary mentions image changes
498
- * - Event is for Deployment/StatefulSet/DaemonSet with degraded health
64
+ * - Event is for a deployment-like workload with degraded health
499
65
  */
500
66
  function isRolloutEvent(event: TimelineEvent): boolean {
501
- // Only workload kinds can have rollouts
502
- const rolloutKinds = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'ReplicaSet'])
503
- if (!rolloutKinds.has(event.kind)) return false
67
+ // Only deployment-like workload controllers can have rollout transitions.
68
+ if (!isDeploymentLikeWorkloadKind(event.kind)) return false
504
69
 
505
70
  // Check diff summary for rollout signals
506
71
  if (event.diff?.summary) {
@@ -544,8 +109,16 @@ export function buildHealthSpans(
544
109
  events: TimelineEvent[],
545
110
  startTime: number,
546
111
  now: number,
547
- allEvents?: TimelineEvent[]
112
+ allEvents?: TimelineEvent[],
113
+ // The lane's own resource. Lanes aggregate descendant events (a CronJob lane
114
+ // carries its Jobs' and Pods' events); without this, the FIRST child cleanup
115
+ // delete in the stream would truncate the whole lane's existence — routine
116
+ // cron churn erased the parent's health strip. Only a delete of the lane's
117
+ // own resource ends it; child deletes neither end it nor change its health.
118
+ ownResource?: { kind: string; namespace: string; name: string }
548
119
  ): HealthSpanResult {
120
+ const isOwn = (e: TimelineEvent) =>
121
+ !ownResource || (e.kind === ownResource.kind && e.namespace === ownResource.namespace && e.name === ownResource.name)
549
122
  // Use allEvents if provided to get createdAt from K8s Events too
550
123
  const eventsForMetadata = allEvents ?? events
551
124
 
@@ -564,12 +137,19 @@ export function buildHealthSpans(
564
137
  : undefined
565
138
 
566
139
  // Check for delete event to know when resource stopped existing
567
- const deleteEvent = sorted.find(e => e.eventType === 'delete')
140
+ const deleteEvent = sorted.find(e => e.eventType === 'delete' && isOwn(e))
568
141
 
569
142
  // Determine when the resource "existed"
570
143
  // - If we have createdAt, use it
571
144
  // - Otherwise, assume it existed before the time window
572
- const existsFrom = createdAtTimestamp ?? startTime
145
+ // Birth clamp: when metadata createdAt is absent but the first observed
146
+ // change IS the creation (add / reason "created"), the resource was born
147
+ // then — the window-start fallback painted hours of 'unknown' before a cron
148
+ // pod existed. Only a resource whose first event is a later mutation keeps
149
+ // the "existed before the window" assumption.
150
+ const firstChange = sorted[0]
151
+ const bornAtFirstEvent = !!firstChange && (firstChange.eventType === 'add' || firstChange.reason === 'created')
152
+ const existsFrom = createdAtTimestamp ?? (bornAtFirstEvent ? new Date(firstChange.timestamp).getTime() : startTime)
573
153
  const createdBeforeWindow = createdAtTimestamp ? createdAtTimestamp < startTime : false
574
154
 
575
155
  // If deleted, it stops existing at that point
@@ -587,6 +167,7 @@ export function buildHealthSpans(
587
167
  const ts = new Date(evt.timestamp).getTime()
588
168
  if (ts < existsFrom) continue // Resource didn't exist yet
589
169
  if (ts > existsUntil) continue // Resource was deleted
170
+ if (evt.eventType === 'delete' && !isOwn(evt)) continue // Child cleanup — not this lane's death, not a health change
590
171
 
591
172
  // Use getEffectiveHealthState to distinguish rollouts from unexpected degradation
592
173
  const newHealth = getEffectiveHealthState(evt)
@@ -620,3 +201,207 @@ export function buildHealthSpans(
620
201
 
621
202
  return { spans, createdAt: createdAtTimestamp, createdBeforeWindow }
622
203
  }
204
+
205
+ // ============================================================================
206
+ // State-family aggregate health sweep
207
+ //
208
+ // A collapsed parent / app-group strip must NOT be a latest-event-wins blend
209
+ // across its members — one member's fresh event would repaint the whole strip.
210
+ // Instead: compute each member's honest spans, overlay them into time slices,
211
+ // and per slice decide from the SET of states present. States group into
212
+ // families; a slice whose present states all fall in ONE family paints that
213
+ // family's dominant state; families that DISAGREE paint a neutral 'mixed'
214
+ // texture (reads as "not a health state — expand to see who").
215
+ // ============================================================================
216
+
217
+ /** One member's honest health timeline within the window: non-overlapping spans
218
+ * in chronological order (buildHealthSpans output for a single resource). */
219
+ export interface MemberHealthSpans {
220
+ name: string
221
+ spans: { start: number; end: number; health: string }[]
222
+ }
223
+
224
+ /** A painted segment of an aggregate lane's health strip. Either a real health
225
+ * state (a uniform-family slice) or 'mixed' (families disagree). Carries the
226
+ * per-state member attribution the tooltip renders. */
227
+ export interface AggregateHealthSegment {
228
+ start: number
229
+ end: number
230
+ /** 'healthy' | 'idle' | 'degraded' | 'unhealthy' | 'rolling' | 'mixed' | … */
231
+ health: string
232
+ mixed: boolean
233
+ /** Distinct members present (alive) in this slice. */
234
+ total: number
235
+ /** Member names grouped by their raw state in this slice. */
236
+ byState: Record<string, string[]>
237
+ }
238
+
239
+ // State families. OK = benign-or-quiet; BAD = something wrong; ROLLING = a
240
+ // deploy in flight (its own family — a rollout beside a healthy peer is neither
241
+ // uniformly OK nor broken, so the slice reads MIXED). A benign healthy+idle
242
+ // slice is one family (OK) → NOT mixed: the founder's key case.
243
+ const OK_STATES = new Set(['healthy', 'idle', 'neutral', 'unknown'])
244
+ const BAD_STATES = new Set(['degraded', 'unhealthy'])
245
+ const ROLLING_STATES = new Set(['rolling'])
246
+
247
+ type HealthFamily = 'ok' | 'bad' | 'rolling'
248
+
249
+ function healthFamily(state: string): HealthFamily {
250
+ if (BAD_STATES.has(state)) return 'bad'
251
+ if (ROLLING_STATES.has(state)) return 'rolling'
252
+ if (OK_STATES.has(state)) return 'ok'
253
+ // Any unrecognized vocabulary word is treated as quiet, not a false alarm,
254
+ // so it joins the OK family.
255
+ return 'ok'
256
+ }
257
+
258
+ /** Gather an aggregate lane's LEAF resources (a Deployment's Pods, a CronJob's
259
+ * Jobs' Pods) and compute each one's honest single-resource health spans.
260
+ * Leaves are the units that carry real health; an intermediate node's health is
261
+ * just its children's. Seeds sweepAggregateHealth for a collapsed parent /
262
+ * app-group strip. A leaf with no observed spans is omitted (nothing to sweep). */
263
+ export function buildLaneMemberSpans(
264
+ lane: ResourceLane,
265
+ startTime: number,
266
+ now: number,
267
+ ): MemberHealthSpans[] {
268
+ const leaves: ResourceLane[] = []
269
+ const walk = (l: ResourceLane): void => {
270
+ if (!l.children?.length) { leaves.push(l); return }
271
+ for (const c of l.children) walk(c)
272
+ }
273
+ walk(lane)
274
+ const out: MemberHealthSpans[] = []
275
+ for (const leaf of leaves) {
276
+ const changeEvents = leaf.events.filter((e) => isChangeEvent(e))
277
+ const { spans } = buildHealthSpans(changeEvents, startTime, now, leaf.events, {
278
+ kind: leaf.kind, namespace: leaf.namespace, name: leaf.name,
279
+ })
280
+ if (spans.length > 0) out.push({ name: leaf.name, spans })
281
+ }
282
+ return out
283
+ }
284
+
285
+ /** OK-family dominant state: the majority state by member count; ties → healthy
286
+ * (the founder rule — a split OK slice reads as the reassuring state). */
287
+ function dominantOkState(byState: Record<string, string[]>): string {
288
+ let maxCount = 0
289
+ for (const s of Object.keys(byState)) maxCount = Math.max(maxCount, byState[s].length)
290
+ const top = Object.keys(byState).filter((s) => byState[s].length === maxCount)
291
+ return top.length === 1 ? top[0] : 'healthy'
292
+ }
293
+
294
+ function paintSlice(
295
+ start: number, end: number, byState: Record<string, string[]>, total: number,
296
+ ): AggregateHealthSegment {
297
+ const families = new Set<HealthFamily>()
298
+ for (const state of Object.keys(byState)) families.add(healthFamily(state))
299
+ if (families.size > 1) return { start, end, health: 'mixed', mixed: true, total, byState }
300
+ const family = [...families][0]
301
+ const health =
302
+ family === 'bad' ? (byState['unhealthy'] ? 'unhealthy' : 'degraded')
303
+ : family === 'rolling' ? 'rolling'
304
+ : dominantOkState(byState)
305
+ return { start, end, health, mixed: false, total, byState }
306
+ }
307
+
308
+ function sameSegment(a: AggregateHealthSegment, b: AggregateHealthSegment): boolean {
309
+ if (a.health !== b.health || a.mixed !== b.mixed || a.total !== b.total) return false
310
+ const ak = Object.keys(a.byState)
311
+ if (ak.length !== Object.keys(b.byState).length) return false
312
+ for (const k of ak) {
313
+ const bv = b.byState[k]
314
+ if (!bv || bv.length !== a.byState[k].length) return false
315
+ const bs = new Set(bv)
316
+ for (const n of a.byState[k]) if (!bs.has(n)) return false
317
+ }
318
+ return true
319
+ }
320
+
321
+ /** Interval sweep over member spans → aggregate health segments. At each slice:
322
+ * the SET of present member states → set of families. One family → paint that
323
+ * family's dominant state (unhealthy>degraded for BAD; OK paints the majority,
324
+ * ties→healthy; ROLLING→rolling). Families disagree → 'mixed'. Adjacent slices
325
+ * with identical result (state + attribution) merge. Empty slices (no member
326
+ * alive) paint nothing. Pure. */
327
+ export function sweepAggregateHealth(
328
+ members: MemberHealthSpans[],
329
+ startTime: number,
330
+ endTime: number,
331
+ ): AggregateHealthSegment[] {
332
+ const bounds = new Set<number>()
333
+ for (const m of members) {
334
+ for (const s of m.spans) {
335
+ if (s.start > startTime && s.start < endTime) bounds.add(s.start)
336
+ if (s.end > startTime && s.end < endTime) bounds.add(s.end)
337
+ }
338
+ }
339
+ const edges = [startTime, ...[...bounds].sort((a, b) => a - b), endTime]
340
+ const raw: AggregateHealthSegment[] = []
341
+ for (let i = 0; i < edges.length - 1; i++) {
342
+ const t0 = edges[i]
343
+ const t1 = edges[i + 1]
344
+ if (t1 <= t0) continue
345
+ const mid = (t0 + t1) / 2
346
+ const byState: Record<string, string[]> = {}
347
+ let total = 0
348
+ for (const m of members) {
349
+ const span = m.spans.find((s) => s.start <= mid && mid < s.end)
350
+ if (!span) continue
351
+ total++
352
+ ;(byState[span.health] ??= []).push(m.name)
353
+ }
354
+ if (total === 0) continue
355
+ raw.push(paintSlice(t0, t1, byState, total))
356
+ }
357
+ const merged: AggregateHealthSegment[] = []
358
+ for (const seg of raw) {
359
+ const prev = merged[merged.length - 1]
360
+ if (prev && prev.end === seg.start && sameSegment(prev, seg)) prev.end = seg.end
361
+ else merged.push({ ...seg })
362
+ }
363
+ return merged
364
+ }
365
+
366
+ /** Cap a name list at 3 + "+N more" for tooltip density. */
367
+ function capNames(list: string[]): string {
368
+ if (list.length <= 3) return list.join(', ')
369
+ return `${list.slice(0, 3).join(', ')}, +${list.length - 3} more`
370
+ }
371
+
372
+ function collectFamilyNames(
373
+ byState: Record<string, string[]>, family: HealthFamily,
374
+ ): { names: string[]; dominant: string } {
375
+ const names: string[] = []
376
+ let hasUnhealthy = false
377
+ for (const [state, ns] of Object.entries(byState)) {
378
+ if (healthFamily(state) !== family) continue
379
+ names.push(...ns)
380
+ if (state === 'unhealthy') hasUnhealthy = true
381
+ }
382
+ const dominant = family === 'bad' ? (hasUnhealthy ? 'unhealthy' : 'degraded')
383
+ : family === 'rolling' ? 'rolling' : 'healthy'
384
+ return { names, dominant }
385
+ }
386
+
387
+ /** Attribution-bearing tooltip for an aggregate strip segment. Examples:
388
+ * uniform → `healthy · 23/23`; mixed → `mixed · 2/23 unhealthy: a, b · 21 healthy/idle`. */
389
+ export function formatAggregateHealthTooltip(seg: AggregateHealthSegment): string {
390
+ if (!seg.mixed) {
391
+ const paintedCount = (seg.byState[seg.health] ?? []).length
392
+ const parts = [`${seg.health} · ${paintedCount}/${seg.total}`]
393
+ // Any other states in the same (uniform) family, for honesty about the split.
394
+ for (const state of Object.keys(seg.byState).filter((s) => s !== seg.health).sort()) {
395
+ parts.push(`${seg.byState[state].length} ${state}`)
396
+ }
397
+ return parts.join(' · ')
398
+ }
399
+ const parts = ['mixed']
400
+ const bad = collectFamilyNames(seg.byState, 'bad')
401
+ if (bad.names.length) parts.push(`${bad.names.length}/${seg.total} ${bad.dominant}: ${capNames(bad.names)}`)
402
+ const rolling = collectFamilyNames(seg.byState, 'rolling')
403
+ if (rolling.names.length) parts.push(`${rolling.names.length}/${seg.total} rolling: ${capNames(rolling.names)}`)
404
+ const ok = collectFamilyNames(seg.byState, 'ok')
405
+ if (ok.names.length) parts.push(`${ok.names.length} healthy/idle`)
406
+ return parts.join(' · ')
407
+ }