@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
|
@@ -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
|
+
}
|