@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.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared timeline
|
|
2
|
+
* Shared timeline utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
|
64
|
+
* - Event is for a deployment-like workload with degraded health
|
|
499
65
|
*/
|
|
500
66
|
function isRolloutEvent(event: TimelineEvent): boolean {
|
|
501
|
-
// Only workload
|
|
502
|
-
|
|
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
|
-
|
|
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
|
+
}
|