@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
@@ -0,0 +1,759 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { RefObject } from 'react'
4
+ import { clsx } from 'clsx'
5
+ import { MultiSelectPicker } from '../ui/MultiSelectPicker'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import {
8
+ Boxes,
9
+ Pin,
10
+ Trash2,
11
+ Check,
12
+ ChevronDown,
13
+ RefreshCw,
14
+ SlidersHorizontal,
15
+ List,
16
+ GanttChart,
17
+ } from 'lucide-react'
18
+ import { SearchBox } from '../ui/SearchBox'
19
+ import { type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
20
+ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
21
+ import { pluralize } from '../../utils/pluralize'
22
+ import type { TimelineEvent, TimeRange } from '../../types'
23
+ import type { TimelineGrouping } from '../../utils/resource-hierarchy'
24
+ import type { ActivityFilterKey, ActivitySource, ActivityStats } from './timeline-filters'
25
+ import { activityKeysToSelection, computeActivityStats, selectionToActivityKeys } from './timeline-filters'
26
+ import type { TimelineSort } from './timeline-lane-sort'
27
+
28
+ // Swimlane view options, surfaced together in the "View" menu so the toolbar
29
+ // stays a single row. The list view omits these — its View menu shows filters only.
30
+ export interface TimelineViewOptions {
31
+ // Which lane ordering is active: 'importance' (default) | 'recent' | 'name'.
32
+ sort: { value: TimelineSort; onChange: (value: TimelineSort) => void }
33
+ // How lanes are grouped: 'app' (server app membership) | 'owner' (owner +
34
+ // topology parenting only) | 'flat' (every resource its own lane).
35
+ grouping: { value: TimelineGrouping; onChange: (value: TimelineGrouping) => void }
36
+ }
37
+
38
+ // The lane orderings, in the order they read in the menu (default first).
39
+ const SORT_OPTIONS: { value: TimelineSort; label: string; tooltip: string }[] = [
40
+ { value: 'importance', label: 'Importance', tooltip: 'Rank lanes by interestingness — health problems, activity, and variety float to the top.' },
41
+ { value: 'recent', label: 'Recent activity', tooltip: 'Order lanes by their most recent event in view — what just moved bubbles to the top.' },
42
+ { value: 'name', label: 'Name (A→Z)', tooltip: 'Alphabetical by lane name (app groups by their title), case-insensitive.' },
43
+ ]
44
+
45
+ // The 3-way grouping choices, in escalating-detail order (most grouped → flat).
46
+ const GROUPING_OPTIONS: { value: TimelineGrouping; label: string; tooltip: string }[] = [
47
+ { value: 'app', label: 'Application', tooltip: 'Group lanes into the applications defined by the server (workload grouping + evidence).' },
48
+ { value: 'owner', label: 'Workload', tooltip: 'Group by owning workload (Deployment→ReplicaSet→Pod, Service→Deployment).' },
49
+ { value: 'flat', label: 'None', tooltip: 'No grouping — every resource is its own lane (K8s Events still attach to their owner).' },
50
+ ]
51
+
52
+ export interface TimelineToolbarProps {
53
+ // Search
54
+ search: string
55
+ onSearchChange: (value: string) => void
56
+ searchScope?: ShortcutScope
57
+ searchShortcutId: string
58
+ searchClassName?: string
59
+
60
+ // Activity-type cells. Multi-select with union semantics: an empty array means
61
+ // "all". Counts derive from the full events array (or an explicit precomputed
62
+ // stats object) so both views show identical totals.
63
+ activityFilter: ActivityFilterKey[]
64
+ onActivityFilterChange: (keys: ActivityFilterKey[]) => void
65
+ events?: TimelineEvent[]
66
+ stats?: ActivityStats
67
+
68
+ // Deleted toggle — renders in the filter row (a scope filter past the
69
+ // divider), not the View menu.
70
+ showDeleted: boolean
71
+ onShowDeletedChange: (showDeleted: boolean) => void
72
+ // Pinned-only filter — only rendered when there are pinned rows at all.
73
+ pinnedCount?: number
74
+ pinnedOnly?: boolean
75
+ onPinnedOnlyChange?: (pinnedOnly: boolean) => void
76
+
77
+ // Kind filter — its own toolbar chip (between the activity control and View),
78
+ // not a View-menu option: it changes which events are visible, so it's a filter,
79
+ // not a view preference. Multi-select: an empty array means "all kinds".
80
+ kindFilter: string[]
81
+ onKindFilterChange: (kinds: string[]) => void
82
+ kindOptions: string[]
83
+
84
+ // Time-range dropdown — rendered only when the range props are supplied
85
+ // (retained mode omits them, so the dropdown disappears).
86
+ rangeOptions?: { value: TimeRange; label: string }[]
87
+ timeRange?: TimeRange
88
+ onTimeRangeChange?: (range: TimeRange) => void
89
+
90
+ // Right-side counts. Render what's given: `resources` is swimlane-only.
91
+ counts?: { events: number; resources?: number }
92
+ countsFiltered?: boolean
93
+
94
+ // View toggle
95
+ view?: 'list' | 'swimlane'
96
+ onViewChange?: (view: 'list' | 'swimlane') => void
97
+
98
+ // Manual refresh button. Radar's own timeline omits it (the view is live);
99
+ // hosts whose data is a one-shot load can wire it.
100
+ onRefresh?: () => void
101
+
102
+ // Swimlane-only view options (sort + grouping). Rendered inside the single
103
+ // "View" menu as labeled Sort and Group sections.
104
+ viewOptions?: TimelineViewOptions
105
+
106
+ // Legend toggle (swimlane-only). When supplied, a "Legend" button renders in the
107
+ // right-side controls; the host owns the shown/hidden state so the marker+health
108
+ // key stays on-demand rather than permanently occupying a toolbar row.
109
+ legend?: { shown: boolean; onToggle: () => void }
110
+ }
111
+
112
+ export function TimelineToolbar({
113
+ search,
114
+ onSearchChange,
115
+ searchScope = 'timeline',
116
+ searchShortcutId,
117
+ // Compact static width (single-row layout): the row is shared with the
118
+ // meta controls, so the search takes a fixed slot; focus and typing can
119
+ // never move the controls to its right.
120
+ searchClassName = 'w-44 shrink-0',
121
+ activityFilter,
122
+ onActivityFilterChange,
123
+ events,
124
+ stats: statsProp,
125
+ showDeleted,
126
+ onShowDeletedChange,
127
+ pinnedCount = 0,
128
+ pinnedOnly = false,
129
+ onPinnedOnlyChange,
130
+ kindFilter,
131
+ onKindFilterChange,
132
+ kindOptions,
133
+ rangeOptions,
134
+ timeRange,
135
+ onTimeRangeChange,
136
+ counts,
137
+ countsFiltered,
138
+ view,
139
+ onViewChange,
140
+ onRefresh,
141
+ viewOptions,
142
+ legend,
143
+ }: TimelineToolbarProps) {
144
+ const stats = statsProp ?? computeActivityStats(events)
145
+ const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
146
+ const showRange = !!rangeOptions && timeRange !== undefined && !!onTimeRangeChange
147
+
148
+ // Two-axis read of the carried keys: SOURCE (what stream) × PROBLEMS (only
149
+ // the severity slice of it). Both writers emit canonical key sets, so the
150
+ // URL/state stay in the shared ActivityFilterKey vocabulary.
151
+ const activitySel = activityKeysToSelection(activityFilter)
152
+ const setSource = (source: ActivitySource) =>
153
+ onActivityFilterChange(selectionToActivityKeys({ ...activitySel, source }))
154
+ const toggleProblems = () =>
155
+ onActivityFilterChange(selectionToActivityKeys({ ...activitySel, problemsOnly: !activitySel.problemsOnly }))
156
+ // The problems count follows the picked source — it previews exactly what
157
+ // the toggle would show, not a fixed global number.
158
+ const problemsCount =
159
+ activitySel.source === 'changes' ? stats.unhealthy
160
+ : activitySel.source === 'k8s_events' ? stats.warnings
161
+ : stats.warnings + stats.unhealthy
162
+
163
+ return (
164
+ // ONE control row: search + filter chips left, table controls
165
+ // right. The former two-row stack was 90% air on its second row. The left
166
+ // group scrolls within itself when the container is narrower than its
167
+ // intrinsic width (e.g. the ~800px resource-drawer embed).
168
+ // @container = inline-size CONTAINMENT (not just a query context): without
169
+ // it the row's intrinsic min-content width propagates up and widens the
170
+ // whole timeline pane past the viewport instead of the left group shrinking.
171
+ <div className="@container/toolbar border-b border-theme-border bg-theme-surface/50">
172
+ <div className="flex items-center gap-3 px-4 py-2">
173
+ {/* -m-1 p-1: the overflow-x-auto clip box would otherwise shave the
174
+ search input's 2px focus ring on the left/top edge; the padding gives
175
+ the ring room and the negative margin cancels the layout shift. The
176
+ Kinds popover is portaled so this scroll container can't clip it. */}
177
+ <div className="-m-1 flex min-w-0 items-center gap-2 overflow-x-auto p-1">
178
+ {/* Search — always open, compact static width, FIRST. The `/` shortcut
179
+ still focuses it (SearchBox owns the shortcut) and the clear × stays. */}
180
+ <SearchBox
181
+ value={search}
182
+ onChange={onSearchChange}
183
+ scope={searchScope}
184
+ shortcutId={searchShortcutId}
185
+ className={searchClassName}
186
+ placeholder="Search..."
187
+ />
188
+
189
+ {/* Activity filter — two orthogonal axes: a single-select SOURCE pick
190
+ (which stream) plus a PROBLEMS toggle (only the severity slice of
191
+ the picked source). Zero-count chips dim in place rather than
192
+ disappearing — position memory without visual weight. */}
193
+ <div role="radiogroup" aria-label="Activity source" className="flex shrink-0 items-center gap-1.5">
194
+ <SourceChip
195
+ active={activitySel.source === 'all'}
196
+ onClick={() => setSource('all')}
197
+ label="All"
198
+ count={stats.total}
199
+ tooltip="Everything — resource changes and K8s Events"
200
+ />
201
+ <SourceChip
202
+ active={activitySel.source === 'changes'}
203
+ onClick={() => setSource('changes')}
204
+ label="Changes"
205
+ count={stats.changes}
206
+ tooltip="Spec & status changes to the resource itself — creates, updates, deletes"
207
+ />
208
+ <SourceChip
209
+ active={activitySel.source === 'k8s_events'}
210
+ onClick={() => setSource('k8s_events')}
211
+ label="K8s Events"
212
+ count={stats.k8sEvents}
213
+ tooltip="Native Kubernetes Event objects — what the cluster reported: scheduling, image pulls, restarts (Normal + Warning)"
214
+ />
215
+ </div>
216
+ <ProblemsToggle
217
+ active={activitySel.problemsOnly}
218
+ count={problemsCount}
219
+ source={activitySel.source}
220
+ onClick={toggleProblems}
221
+ />
222
+
223
+ {/* Kinds filter — dashed "add a filter" chip beside the type chips. */}
224
+ <KindsMenu
225
+ kindFilter={kindFilter}
226
+ onKindFilterChange={onKindFilterChange}
227
+ kindOptions={kindOptions}
228
+ />
229
+
230
+ {/* Deleted is SCOPE, not a type: it filters which resources
231
+ exist, not which events show — so it sits past a divider, out of
232
+ the type-chip group. */}
233
+ <span className="mx-0.5 h-5 w-px shrink-0 bg-theme-border" aria-hidden />
234
+ <DeletedEventsToggle showDeleted={showDeleted} onChange={onShowDeletedChange} />
235
+
236
+ {/* Pinned-only — appears only once something is pinned; hidden chrome
237
+ otherwise. Covers pinned rows of both kinds (resources and apps). */}
238
+ {pinnedCount > 0 && onPinnedOnlyChange && (
239
+ <PinnedOnlyToggle pinnedOnly={pinnedOnly} pinnedCount={pinnedCount} onChange={onPinnedOnlyChange} />
240
+ )}
241
+
242
+ {/* Time range — omitted when an external control owns the range */}
243
+ {showRange && (
244
+ <select
245
+ value={timeRange}
246
+ onChange={(e) => onTimeRangeChange!(e.target.value as TimeRange)}
247
+ className="shrink-0 appearance-none bg-theme-elevated text-theme-text-primary text-sm rounded-lg px-3 py-2 border border-theme-border-light focus:outline-none focus:ring-2 focus:ring-skyhook-500"
248
+ >
249
+ {rangeOptions!.map((range) => (
250
+ <option key={range.value} value={range.value}>
251
+ {range.label}
252
+ </option>
253
+ ))}
254
+ </select>
255
+ )}
256
+ </div>
257
+
258
+ {/* META group — right-aligned: Showing · View (sort + group) · Legend ·
259
+ view toggle. Its position is width-driven, never interaction. */}
260
+ <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
261
+ {counts && (
262
+ <span className="min-w-0 truncate text-xs text-theme-text-tertiary">
263
+ {/* "Showing": the visible-slice count, worded apart from the
264
+ strip's loaded total so the two numbers don't read as one. */}
265
+ {counts.resources !== undefined && `Showing ${pluralize(counts.resources, 'resource')} · `}
266
+ {counts.resources === undefined && 'Showing '}
267
+ {pluralize(counts.events, 'event')}
268
+ {countsFiltered && ' (filtered)'}
269
+ </span>
270
+ )}
271
+
272
+ {/* View menu — Sort + Group as labeled sections behind one trigger:
273
+ the single control row has no room for two dropdowns that
274
+ each restate their value. */}
275
+ {viewOptions && <ViewMenu viewOptions={viewOptions} />}
276
+
277
+ {onRefresh && (
278
+ <Tooltip content="Refresh" position="bottom" wrapperClassName="shrink-0">
279
+ <button
280
+ type="button"
281
+ onClick={handleRefresh}
282
+ disabled={isRefreshAnimating}
283
+ aria-label="Refresh"
284
+ className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50"
285
+ >
286
+ <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
287
+ </button>
288
+ </Tooltip>
289
+ )}
290
+
291
+ {/* Legend toggle — the marker/health key is on-demand, not a
292
+ permanent toolbar row. Host owns the shown state. */}
293
+ {legend && (
294
+ <button
295
+ type="button"
296
+ onClick={legend.onToggle}
297
+ aria-pressed={legend.shown}
298
+ className={clsx(
299
+ 'shrink-0 rounded-lg border px-2.5 py-1.5 text-sm font-medium transition-colors',
300
+ legend.shown
301
+ ? 'border-theme-border bg-theme-hover text-theme-text-primary'
302
+ : 'border-theme-border-light text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
303
+ )}
304
+ >
305
+ Legend
306
+ </button>
307
+ )}
308
+
309
+ {/* View toggle — labeled, and LAST so it hugs the toolbar's right
310
+ edge. The rightmost slot is the only position that stays fixed
311
+ across the two views while the variable-width counts and menus
312
+ beside it change; an anchored control can be re-clicked without
313
+ re-aiming. */}
314
+ {onViewChange && (
315
+ <div className="flex shrink-0 items-center gap-1 bg-theme-elevated rounded-lg p-1">
316
+ <button
317
+ type="button"
318
+ onClick={() => onViewChange('list')}
319
+ className={clsx(
320
+ 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
321
+ view === 'list' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
322
+ )}
323
+ >
324
+ <List className="w-4 h-4" />
325
+ List
326
+ </button>
327
+ <button
328
+ type="button"
329
+ onClick={() => onViewChange('swimlane')}
330
+ className={clsx(
331
+ 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
332
+ view === 'swimlane' ? 'bg-theme-hover text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
333
+ )}
334
+ >
335
+ <GanttChart className="w-4 h-4" />
336
+ Timeline
337
+ </button>
338
+ </div>
339
+ )}
340
+ </div>
341
+ </div>
342
+ </div>
343
+ )
344
+ }
345
+
346
+ // One pill of the single-select SOURCE pick. A zero-count pill dims in place
347
+ // and goes inert: position memory without visual weight — chips that
348
+ // vanish make the row reflow and the user hunt.
349
+ function SourceChip({ active, onClick, label, count, tooltip }: {
350
+ active: boolean
351
+ onClick: () => void
352
+ label: string
353
+ count?: number
354
+ tooltip?: string
355
+ }) {
356
+ const dead = !active && count === 0
357
+ return (
358
+ <Tooltip content={tooltip} disabled={!tooltip || dead} position="bottom" wrapperClassName="shrink-0">
359
+ <button
360
+ type="button"
361
+ role="radio"
362
+ aria-checked={active}
363
+ onClick={dead ? undefined : onClick}
364
+ disabled={dead}
365
+ className={clsx(
366
+ 'flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-1.5 text-sm transition-colors',
367
+ active
368
+ ? 'border-theme-border-light bg-theme-hover font-semibold text-theme-text-primary'
369
+ : 'border-theme-border text-theme-text-secondary',
370
+ dead ? 'cursor-default opacity-45' : !active && 'hover:bg-theme-hover hover:text-theme-text-primary',
371
+ )}
372
+ >
373
+ <span>{label}</span>
374
+ {count !== undefined && (
375
+ <span className="tabular-nums text-xs text-theme-text-tertiary">{count.toLocaleString()}</span>
376
+ )}
377
+ </button>
378
+ </Tooltip>
379
+ )
380
+ }
381
+
382
+ // The severity slice of the picked source: Warning events, unhealthy/degraded
383
+ // changes, or both under "All". Engaged state is amber — the timeline's
384
+ // problem hue — and the count previews what the toggle would show.
385
+ function ProblemsToggle({ active, count, source, onClick }: {
386
+ active: boolean
387
+ count: number
388
+ source: ActivitySource
389
+ onClick: () => void
390
+ }) {
391
+ const dead = !active && count === 0
392
+ const tooltip =
393
+ source === 'changes' ? 'Only changes that left the resource unhealthy or degraded.'
394
+ : source === 'k8s_events' ? 'Only Warning-type events (e.g. ImagePullBackOff, FailedScheduling).'
395
+ : 'Only problem activity: Warning events and changes that left a resource unhealthy or degraded. Combines with the source pick.'
396
+ return (
397
+ <Tooltip content={tooltip} position="bottom" disabled={dead} wrapperClassName="shrink-0">
398
+ <button
399
+ type="button"
400
+ aria-pressed={active}
401
+ onClick={dead ? undefined : onClick}
402
+ disabled={dead}
403
+ className={clsx(
404
+ 'flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-1.5 text-sm transition-colors',
405
+ active
406
+ ? 'border-amber-400/60 bg-amber-100 font-semibold text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/50 dark:text-amber-300'
407
+ : 'border-theme-border text-theme-text-secondary',
408
+ dead ? 'cursor-default opacity-45' : !active && 'hover:bg-theme-hover hover:text-theme-text-primary',
409
+ )}
410
+ >
411
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
412
+ <span>Problems</span>
413
+ <span className={clsx('tabular-nums text-xs', active ? 'text-amber-700/80 dark:text-amber-400/80' : 'text-theme-text-tertiary')}>
414
+ {count.toLocaleString()}
415
+ </span>
416
+ </button>
417
+ </Tooltip>
418
+ )
419
+ }
420
+
421
+
422
+ interface KindsMenuProps {
423
+ kindFilter: string[]
424
+ onKindFilterChange: (kinds: string[]) => void
425
+ kindOptions: string[]
426
+ }
427
+
428
+ // The shared markup behind both the Sort and Group controls: a radiogroup of
429
+ // vertical check rows (identical structure; differ only in options/value).
430
+ function SegmentedRadioGroup<T extends string>({
431
+ label,
432
+ options,
433
+ value,
434
+ onChange,
435
+ }: {
436
+ label: string
437
+ options: { value: T; label: string; tooltip: string }[]
438
+ value: T
439
+ onChange: (value: T) => void
440
+ }) {
441
+ // Product menu idiom (see TopologyControls): vertical check rows, not inline
442
+ // segments — long labels ("Recent activity") stay one line instead of wrapping
443
+ // into uneven boxes.
444
+ return (
445
+ <div role="radiogroup" aria-label={label} className="-mx-1 flex flex-col">
446
+ {options.map((opt) => {
447
+ const active = value === opt.value
448
+ return (
449
+ <Tooltip key={opt.value} content={opt.tooltip} position="left" wrapperClassName="w-full">
450
+ <button
451
+ type="button"
452
+ role="radio"
453
+ aria-checked={active}
454
+ onClick={() => onChange(opt.value)}
455
+ className={clsx(
456
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors',
457
+ 'hover:bg-theme-hover focus:bg-theme-hover focus:outline-none',
458
+ active ? 'text-theme-text-primary font-medium' : 'text-theme-text-secondary',
459
+ )}
460
+ >
461
+ <Check className={clsx('h-3.5 w-3.5 shrink-0 text-skyhook-500', active ? 'opacity-100' : 'opacity-0')} />
462
+ <span className="truncate">{opt.label}</span>
463
+ </button>
464
+ </Tooltip>
465
+ )
466
+ })}
467
+ </div>
468
+ )
469
+ }
470
+
471
+ /**
472
+ * The "View" button + popover holding the Sort and Group sections (one
473
+ * trigger — the single control row has no room for two dropdowns that each
474
+ * restate their value; the sections inside are clearly labeled).
475
+ */
476
+ export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions }) {
477
+ const [open, setOpen] = useState(false)
478
+ const rootRef = useRef<HTMLDivElement | null>(null)
479
+ usePopoverDismiss(open, setOpen, rootRef)
480
+
481
+ return (
482
+ <div ref={rootRef} className="relative shrink-0">
483
+ <button
484
+ type="button"
485
+ aria-haspopup="menu"
486
+ aria-expanded={open}
487
+ onClick={() => setOpen((o) => !o)}
488
+ className={clsx(
489
+ 'flex items-center gap-1.5 rounded-lg border border-theme-border-light px-2.5 py-1.5 text-sm font-medium transition-colors',
490
+ open
491
+ ? 'bg-theme-elevated text-theme-text-primary'
492
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
493
+ )}
494
+ >
495
+ <SlidersHorizontal className="h-4 w-4" />
496
+ <span>View</span>
497
+ <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
498
+ </button>
499
+
500
+ {open && (
501
+ <div
502
+ role="menu"
503
+ aria-label="View options"
504
+ className="absolute right-0 top-full z-50 mt-1 min-w-[14rem] rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg"
505
+ >
506
+ <span className="block px-2 pb-1 text-[10px] font-bold uppercase tracking-[0.08em] text-theme-text-tertiary">Sort</span>
507
+ <SegmentedRadioGroup
508
+ label="Lane sort"
509
+ options={SORT_OPTIONS}
510
+ value={viewOptions.sort.value}
511
+ onChange={(v) => { viewOptions.sort.onChange(v); setOpen(false) }}
512
+ />
513
+ <span aria-hidden className="my-2 block h-px bg-theme-border" />
514
+ <span className="block px-2 pb-1 text-[10px] font-bold uppercase tracking-[0.08em] text-theme-text-tertiary">Group by</span>
515
+ <SegmentedRadioGroup
516
+ label="Lane grouping"
517
+ options={GROUPING_OPTIONS}
518
+ value={viewOptions.grouping.value}
519
+ onChange={(v) => { viewOptions.grouping.onChange(v); setOpen(false) }}
520
+ />
521
+ </div>
522
+ )}
523
+ </div>
524
+ )
525
+ }
526
+
527
+ // Pinned-only filter chip — same quiet/active pattern as the deleted toggle:
528
+ // engaged (non-default) state gets the accent treatment.
529
+ export function PinnedOnlyToggle({
530
+ pinnedOnly,
531
+ pinnedCount,
532
+ onChange,
533
+ }: {
534
+ pinnedOnly: boolean
535
+ pinnedCount: number
536
+ onChange: (pinnedOnly: boolean) => void
537
+ }) {
538
+ return (
539
+ <Tooltip
540
+ content={pinnedOnly
541
+ ? 'Showing pinned rows only. Click to show all rows again.'
542
+ : `Show only the ${pinnedCount} pinned row${pinnedCount === 1 ? '' : 's'} (resources and apps).`}
543
+ position="bottom"
544
+ >
545
+ <button
546
+ type="button"
547
+ aria-pressed={pinnedOnly}
548
+ aria-label={pinnedOnly ? 'Show all rows' : 'Show pinned rows only'}
549
+ onClick={() => onChange(!pinnedOnly)}
550
+ className={clsx(
551
+ 'flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-sm transition-colors',
552
+ pinnedOnly
553
+ ? 'border-accent bg-accent-muted text-accent-text'
554
+ : 'border-theme-border bg-theme-surface text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
555
+ )}
556
+ >
557
+ <Pin className="h-4 w-4" />
558
+ {pinnedOnly && <span className="text-xs font-medium">only</span>}
559
+ </button>
560
+ </Tooltip>
561
+ )
562
+ }
563
+
564
+ // Quiet icon toggle for delete-event visibility. Lives with the other filters
565
+ // (activity segments, Kinds) — it filters WHICH events are visible, so it does
566
+ // not belong in the View menu. Default (shown) renders quiet; the NON-default
567
+ // hidden state gets the active treatment, like any engaged filter.
568
+ export function DeletedEventsToggle({
569
+ showDeleted,
570
+ onChange,
571
+ }: {
572
+ showDeleted: boolean
573
+ onChange: (show: boolean) => void
574
+ }) {
575
+ return (
576
+ <Tooltip
577
+ content={showDeleted
578
+ ? 'Delete events are shown. Click to hide them — resources whose only events are deletions will disappear from the list.'
579
+ : 'Delete events are hidden (resources with only deletions are not listed). Click to show them.'}
580
+ position="bottom"
581
+ >
582
+ <button
583
+ type="button"
584
+ aria-pressed={!showDeleted}
585
+ aria-label={showDeleted ? 'Hide delete events' : 'Show delete events'}
586
+ onClick={() => onChange(!showDeleted)}
587
+ // Dashed pill like Kinds: Deleted is SCOPE (which resources exist),
588
+ // not an event type — the dashed family marks the scope controls.
589
+ className={clsx(
590
+ 'flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-1.5 text-sm transition-colors',
591
+ showDeleted
592
+ ? 'border-dashed border-theme-border-light text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
593
+ : 'border-accent bg-accent-muted text-accent-text',
594
+ )}
595
+ >
596
+ <Trash2 className="h-4 w-4" />
597
+ <span>{showDeleted ? 'Deleted' : 'Deleted hidden'}</span>
598
+ </button>
599
+ </Tooltip>
600
+ )
601
+ }
602
+
603
+ // Shared popover dismissal for the toolbar's menus (View, Kinds): outside
604
+ // pointerdown and Escape both close. Extracted so both behave identically.
605
+ function usePopoverDismiss(
606
+ open: boolean,
607
+ setOpen: (open: boolean) => void,
608
+ rootRef: RefObject<HTMLDivElement | null>,
609
+ ) {
610
+ useEffect(() => {
611
+ if (!open) return
612
+ const onDown = (e: PointerEvent) => {
613
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
614
+ }
615
+ const onKey = (e: KeyboardEvent) => {
616
+ if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
617
+ }
618
+ window.addEventListener('pointerdown', onDown)
619
+ window.addEventListener('keydown', onKey)
620
+ return () => {
621
+ window.removeEventListener('pointerdown', onDown)
622
+ window.removeEventListener('keydown', onKey)
623
+ }
624
+ }, [open, setOpen, rootRef])
625
+ }
626
+
627
+ /**
628
+ * The "Kinds" filter chip + popover. Same visual family and dismissal behaviour as
629
+ * the View menu, but it's a filter (changes which events are visible), so it owns
630
+ * its own badge = number of selected kinds, hidden when none. The popover body is
631
+ * the shared MultiSelectPicker — same search + Clear all / Select all + checkbox
632
+ * list + summary/Done shell as the namespace scope picker. Empty selection = no
633
+ * filter ("All kinds").
634
+ */
635
+ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuProps) {
636
+ const [open, setOpen] = useState(false)
637
+ const [search, setSearch] = useState('')
638
+ const rootRef = useRef<HTMLDivElement | null>(null)
639
+ const panelRef = useRef<HTMLDivElement | null>(null)
640
+ const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
641
+ const activeCount = kindFilter.length
642
+ const selected = useMemo(() => new Set(kindFilter), [kindFilter])
643
+
644
+ // The chip lives inside the filters row's horizontal scroll container, which
645
+ // clips absolutely-positioned children. Portal the popover to the body and pin
646
+ // it to the button with fixed coords so it escapes the clip. Dismiss must treat
647
+ // BOTH the chip and the portaled panel as "inside".
648
+ const PANEL_WIDTH = 256
649
+ useLayoutEffect(() => {
650
+ if (!open) { setAnchor(null); return }
651
+ const place = () => {
652
+ const r = rootRef.current?.getBoundingClientRect()
653
+ if (!r) return
654
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - PANEL_WIDTH - 8))
655
+ const spaceBelow = window.innerHeight - r.bottom - 8
656
+ const spaceAbove = r.top - 8
657
+ // scrollHeight = natural (uncapped) height, so measuring stays stable even
658
+ // after we've capped the rendered panel on a previous pass.
659
+ const panelH = panelRef.current?.scrollHeight ?? 0
660
+ // Near the viewport bottom the popover would open off-screen. Flip it above
661
+ // the chip when it doesn't fit below but has more room above; cap the height
662
+ // to the chosen gap so the list scrolls instead of clipping, and never let
663
+ // the flipped-up panel escape the top edge.
664
+ const flipUp = panelH > spaceBelow && spaceAbove > spaceBelow
665
+ const maxHeight = Math.max(120, flipUp ? spaceAbove : spaceBelow)
666
+ const top = flipUp
667
+ ? Math.max(4, r.top - 4 - Math.min(panelH, maxHeight))
668
+ : r.bottom + 4
669
+ setAnchor({ left, top, maxHeight })
670
+ }
671
+ place()
672
+ // Re-place after the panel mounts so the first open can measure its height and
673
+ // flip; the initial pass runs with panelH=0 (panel not yet in the DOM).
674
+ const raf = requestAnimationFrame(place)
675
+ window.addEventListener('scroll', place, true)
676
+ window.addEventListener('resize', place)
677
+ return () => {
678
+ cancelAnimationFrame(raf)
679
+ window.removeEventListener('scroll', place, true)
680
+ window.removeEventListener('resize', place)
681
+ }
682
+ }, [open])
683
+
684
+ useEffect(() => {
685
+ if (!open) return
686
+ const onDown = (e: PointerEvent) => {
687
+ const t = e.target as Node
688
+ if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
689
+ setOpen(false)
690
+ }
691
+ const onKey = (e: KeyboardEvent) => {
692
+ if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
693
+ }
694
+ window.addEventListener('pointerdown', onDown)
695
+ window.addEventListener('keydown', onKey)
696
+ return () => {
697
+ window.removeEventListener('pointerdown', onDown)
698
+ window.removeEventListener('keydown', onKey)
699
+ }
700
+ }, [open])
701
+
702
+ // Reset the filter whenever the popover closes by any path (Done, chip toggle,
703
+ // outside-click, Escape) so a reopen starts fresh — mirrors the namespace picker.
704
+ useEffect(() => {
705
+ if (!open) setSearch('')
706
+ }, [open])
707
+
708
+ return (
709
+ <div ref={rootRef} className="relative shrink-0">
710
+ <button
711
+ type="button"
712
+ aria-haspopup="menu"
713
+ aria-expanded={open}
714
+ onClick={() => setOpen((o) => !o)}
715
+ // Dashed pill: reads as "add a filter", distinct from the solid
716
+ // always-on activity pills beside it.
717
+ className={clsx(
718
+ 'flex items-center gap-1.5 rounded-full border border-dashed px-2.5 py-1.5 text-sm transition-colors',
719
+ open || activeCount > 0
720
+ ? 'border-theme-border bg-theme-hover text-theme-text-primary'
721
+ : 'border-theme-border-light text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
722
+ )}
723
+ >
724
+ <Boxes className="w-4 h-4" />
725
+ <span>Kinds</span>
726
+ {activeCount > 0 && (
727
+ <span className="inline-flex min-w-[1.25rem] justify-center rounded-full bg-accent px-1.5 text-xs font-medium text-white tabular-nums">
728
+ {activeCount}
729
+ </span>
730
+ )}
731
+ <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
732
+ </button>
733
+
734
+ {open && anchor && createPortal(
735
+ <div
736
+ ref={panelRef}
737
+ style={{ position: 'fixed', left: anchor.left, top: anchor.top, width: PANEL_WIDTH, maxHeight: anchor.maxHeight }}
738
+ className="z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg"
739
+ >
740
+ <MultiSelectPicker
741
+ items={kindOptions}
742
+ selected={selected}
743
+ onSelectionChange={(next) => onKindFilterChange([...next])}
744
+ onClearAll={() => onKindFilterChange([])}
745
+ onDone={() => setOpen(false)}
746
+ search={search}
747
+ onSearchChange={setSearch}
748
+ searchPlaceholder="Filter kinds"
749
+ summaryEmptyLabel="All kinds"
750
+ noItemsLabel="No kinds available."
751
+ clearAllDisabled={activeCount === 0}
752
+ clearAllAriaLabel="Clear kind selection"
753
+ />
754
+ </div>,
755
+ document.body,
756
+ )}
757
+ </div>
758
+ )
759
+ }