@skyhook-io/k8s-ui 1.16.1 → 1.16.3
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/README.md +22 -0
- package/package.json +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/dock/BottomDock.tsx +8 -1
- package/src/components/dock/DockContext.tsx +8 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
- package/src/components/dock/LocalTerminalTab.tsx +56 -18
- package/src/components/dock/TerminalTab.tsx +2 -4
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/LogCore.theme.test.tsx +77 -0
- package/src/components/logs/LogCore.tsx +23 -2
- package/src/components/logs/LogsViewer.tsx +5 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/ui/SelectMenu.test.tsx +25 -0
- package/src/components/ui/SelectMenu.tsx +11 -9
- package/src/components/ui/YamlReview.test.tsx +40 -0
- package/src/components/ui/YamlReview.tsx +22 -1
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/utils/log-level.ts +14 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
|
1
|
+
import { useState, useMemo, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
|
2
2
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
3
3
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
4
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
AlertCircle,
|
|
7
7
|
CheckCircle,
|
|
8
8
|
Clock,
|
|
9
|
+
Eye,
|
|
10
|
+
Info,
|
|
9
11
|
RefreshCw,
|
|
10
12
|
Plus,
|
|
11
13
|
Trash2,
|
|
@@ -104,6 +106,16 @@ export interface TimelineListProps {
|
|
|
104
106
|
// explicitly because the visible row count no longer reflects the source cap.
|
|
105
107
|
isTruncated?: boolean
|
|
106
108
|
truncationMessage?: string
|
|
109
|
+
// Routine activity toggle, shown when `onShowRoutineChange` is set.
|
|
110
|
+
// `routineEvents` are the routine rows the host holds: left out of `events`
|
|
111
|
+
// while hidden, included while shown. Counted here through the active filters.
|
|
112
|
+
showRoutine?: boolean
|
|
113
|
+
onShowRoutineChange?: (show: boolean) => void
|
|
114
|
+
routineEvents?: TimelineEvent[]
|
|
115
|
+
// Rows for which this returns true fold into one card per resource per time
|
|
116
|
+
// group, whatever their reason. The host uses it for problems on pods and
|
|
117
|
+
// other child resources, so a crash-looping pod is one card, not dozens.
|
|
118
|
+
foldPerResource?: (event: TimelineEvent) => boolean
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
@@ -115,7 +127,7 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|
|
115
127
|
{ value: 'all', label: 'All' },
|
|
116
128
|
]
|
|
117
129
|
|
|
118
|
-
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, rangeOptions = TIME_RANGES, hideRangeSelector = false, showDeleted: showDeletedProp, onShowDeletedChange, search: searchProp, onSearchChange, activityFilter: activityFilterProp, onActivityFilterChange, kindFilter: kindFilterProp, onKindFilterChange, onVisibleWindowChange, scrollToMs, compact = false, selectedEventId, onSelectEvent, truncatedAt, isTruncated, truncationMessage }: TimelineListProps) {
|
|
130
|
+
export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, rangeOptions = TIME_RANGES, hideRangeSelector = false, showDeleted: showDeletedProp, onShowDeletedChange, search: searchProp, onSearchChange, activityFilter: activityFilterProp, onActivityFilterChange, kindFilter: kindFilterProp, onKindFilterChange, onVisibleWindowChange, scrollToMs, compact = false, selectedEventId, onSelectEvent, truncatedAt, isTruncated, truncationMessage, showRoutine = false, onShowRoutineChange, routineEvents, foldPerResource }: TimelineListProps) {
|
|
119
131
|
const [searchInternal, setSearchInternal] = useState('')
|
|
120
132
|
const searchTerm = searchProp ?? searchInternal
|
|
121
133
|
const setSearchTerm = onSearchChange ?? setSearchInternal
|
|
@@ -174,16 +186,20 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
174
186
|
const kindOptions = useMemo(() => mergeKindOptions(seenKinds), [seenKinds])
|
|
175
187
|
|
|
176
188
|
// Filter activity through the shared predicates so list + swimlane can't drift.
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
const matchesListFilters = useCallback((item: TimelineEvent) => {
|
|
190
|
+
if (!matchesActivityFilter(item, activityTypeFilter)) return false
|
|
191
|
+
if (kindFilter.length > 0 && !kindFilter.includes(item.kind)) return false
|
|
192
|
+
if (!showDeleted && item.eventType === 'delete') return false
|
|
193
|
+
if (!matchesTimelineSearch(item, debouncedSearchTerm)) return false
|
|
194
|
+
return true
|
|
195
|
+
}, [activityTypeFilter, kindFilter, debouncedSearchTerm, showDeleted])
|
|
196
|
+
const filteredActivity = useMemo(() => (events ? events.filter(matchesListFilters) : []), [events, matchesListFilters])
|
|
197
|
+
// Counted through the same filters, so the number is exactly what the toggle adds or removes.
|
|
198
|
+
const routineCount = useMemo(
|
|
199
|
+
() => (routineEvents ? routineEvents.filter(matchesListFilters).length : 0),
|
|
200
|
+
[routineEvents, matchesListFilters],
|
|
201
|
+
)
|
|
202
|
+
const canShowRoutine = !showRoutine && routineCount > 0 && !!onShowRoutineChange
|
|
187
203
|
|
|
188
204
|
// Aggregated event group type
|
|
189
205
|
type AggregatedItem = {
|
|
@@ -195,6 +211,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
195
211
|
last: TimelineEvent
|
|
196
212
|
count: number
|
|
197
213
|
reason: string
|
|
214
|
+
} | {
|
|
215
|
+
type: 'folded'
|
|
216
|
+
// Stable across live updates, so an opened fold stays open as rows arrive.
|
|
217
|
+
key: string
|
|
218
|
+
latest: TimelineEvent
|
|
219
|
+
// Newest first.
|
|
220
|
+
others: TimelineEvent[]
|
|
198
221
|
}
|
|
199
222
|
|
|
200
223
|
// Aggregate repeated events for the same resource with the same reason
|
|
@@ -206,6 +229,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
206
229
|
const singleEvents: TimelineEvent[] = []
|
|
207
230
|
|
|
208
231
|
for (const item of items) {
|
|
232
|
+
if (foldPerResource?.(item)) {
|
|
233
|
+
const key = `fold:${item.kind}:${item.namespace}:${item.name}`
|
|
234
|
+
const existing = groups.get(key) || []
|
|
235
|
+
existing.push(item)
|
|
236
|
+
groups.set(key, existing)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
209
239
|
// Only aggregate K8s Warning events or changes with a specific reason
|
|
210
240
|
const reason = item.reason || ''
|
|
211
241
|
const shouldAggregate = (
|
|
@@ -227,8 +257,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
227
257
|
const result: AggregatedItem[] = []
|
|
228
258
|
|
|
229
259
|
// Process aggregated groups
|
|
230
|
-
for (const events of groups.
|
|
231
|
-
if (events.length >= 2) {
|
|
260
|
+
for (const [key, events] of groups.entries()) {
|
|
261
|
+
if (events.length >= 2 && key.startsWith('fold:')) {
|
|
262
|
+
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
|
263
|
+
result.push({ type: 'folded', key, latest: events[0], others: events.slice(1) })
|
|
264
|
+
} else if (events.length >= 2) {
|
|
232
265
|
// Sort by time (oldest first)
|
|
233
266
|
events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
234
267
|
result.push({
|
|
@@ -250,8 +283,9 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
250
283
|
|
|
251
284
|
// Sort all by most recent (last event time)
|
|
252
285
|
result.sort((a, b) => {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
286
|
+
const newest = (x: AggregatedItem) => x.type === 'aggregated' ? x.last : x.type === 'folded' ? x.latest : x.item
|
|
287
|
+
const timeA = new Date(newest(a).timestamp).getTime()
|
|
288
|
+
const timeB = new Date(newest(b).timestamp).getTime()
|
|
255
289
|
return timeB - timeA
|
|
256
290
|
})
|
|
257
291
|
|
|
@@ -323,7 +357,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
323
357
|
if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
|
|
324
358
|
|
|
325
359
|
return groups
|
|
326
|
-
}, [filteredActivity, compact])
|
|
360
|
+
}, [filteredActivity, compact, foldPerResource])
|
|
327
361
|
|
|
328
362
|
// Visible-window reporting: rows carry their time span in data attributes;
|
|
329
363
|
// on scroll (rAF-throttled) and whenever the row set changes, the span of
|
|
@@ -397,6 +431,18 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
397
431
|
card?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
398
432
|
}, [selectedEventId, groupedActivity])
|
|
399
433
|
|
|
434
|
+
const renderActivityCard = (item: TimelineEvent) => (
|
|
435
|
+
<ActivityCard
|
|
436
|
+
item={item}
|
|
437
|
+
expanded={expandedItem === item.id}
|
|
438
|
+
onToggle={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
|
439
|
+
onResourceClick={onResourceClick}
|
|
440
|
+
compact={compact}
|
|
441
|
+
selected={selectedEventId === item.id}
|
|
442
|
+
onSelect={() => onSelectEvent?.(selectedEventId === item.id ? null : item.id)}
|
|
443
|
+
/>
|
|
444
|
+
)
|
|
445
|
+
|
|
400
446
|
return (
|
|
401
447
|
<div className="flex flex-col h-full w-full">
|
|
402
448
|
{!compact && (
|
|
@@ -422,6 +468,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
422
468
|
/>
|
|
423
469
|
)}
|
|
424
470
|
|
|
471
|
+
|
|
472
|
+
{onShowRoutineChange && (
|
|
473
|
+
<RoutineActivityToggle shown={showRoutine} count={routineCount} onChange={onShowRoutineChange} />
|
|
474
|
+
)}
|
|
475
|
+
|
|
425
476
|
{/* Timeline content */}
|
|
426
477
|
<div className="flex-1 overflow-auto" ref={scrollRef} onScroll={onVisibleWindowChange ? handleListScroll : undefined}>
|
|
427
478
|
{isLoading ? (
|
|
@@ -433,11 +484,42 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
433
484
|
const activeFilters = describeActiveFilters({ search: searchTerm, activityFilter: activityTypeFilter, kindFilter, showDeleted })
|
|
434
485
|
return (
|
|
435
486
|
<>
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
487
|
+
{canShowRoutine ? (
|
|
488
|
+
<>
|
|
489
|
+
<p className="text-lg">All matching activity is routine</p>
|
|
490
|
+
<p className="text-sm mt-2 max-w-md text-center">
|
|
491
|
+
{pluralize(routineCount, 'routine event')} {routineCount === 1 ? 'matches' : 'match'}. Warnings and failures would show here.
|
|
492
|
+
</p>
|
|
493
|
+
<div className="mt-4 flex items-center gap-2">
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
onClick={() => onShowRoutineChange?.(true)}
|
|
497
|
+
className="btn-brand flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm"
|
|
498
|
+
>
|
|
499
|
+
<Eye className="w-3.5 h-3.5" />
|
|
500
|
+
Show routine activity
|
|
501
|
+
</button>
|
|
502
|
+
{activeFilters && (
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
onClick={clearAllFilters}
|
|
506
|
+
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-lg text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary transition-colors"
|
|
507
|
+
>
|
|
508
|
+
<X className="w-3.5 h-3.5" />
|
|
509
|
+
Clear filters
|
|
510
|
+
</button>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
</>
|
|
514
|
+
) : (
|
|
515
|
+
<>
|
|
516
|
+
<p className="text-lg">No activity found</p>
|
|
517
|
+
<p className="text-sm mt-2">
|
|
518
|
+
{activeFilters || 'Activity will appear here when cluster changes occur'}
|
|
519
|
+
</p>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
{activeFilters && !canShowRoutine && (
|
|
441
523
|
<button
|
|
442
524
|
type="button"
|
|
443
525
|
onClick={clearAllFilters}
|
|
@@ -478,7 +560,17 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
478
560
|
{/* Activity list */}
|
|
479
561
|
<div className="space-y-2 ml-6 border-l-2 border-theme-border pl-4">
|
|
480
562
|
{group.items.map((aggItem) => (
|
|
481
|
-
aggItem.type === '
|
|
563
|
+
aggItem.type === 'folded' ? (
|
|
564
|
+
<div
|
|
565
|
+
key={aggItem.key}
|
|
566
|
+
data-event-id={aggItem.latest.id}
|
|
567
|
+
data-ts-from={new Date(aggItem.others[aggItem.others.length - 1].timestamp).getTime()}
|
|
568
|
+
data-ts-to={new Date(aggItem.latest.timestamp).getTime()}
|
|
569
|
+
>
|
|
570
|
+
{renderActivityCard(aggItem.latest)}
|
|
571
|
+
<FoldedProblems latest={aggItem.latest} others={aggItem.others} renderCard={renderActivityCard} />
|
|
572
|
+
</div>
|
|
573
|
+
) : aggItem.type === 'aggregated' ? (
|
|
482
574
|
<div
|
|
483
575
|
key={`agg-${aggItem.first.id}-${aggItem.last.id}`}
|
|
484
576
|
data-event-id={aggItem.first.id}
|
|
@@ -719,6 +811,76 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
|
|
|
719
811
|
)
|
|
720
812
|
}
|
|
721
813
|
|
|
814
|
+
const ROUTINE_ACTIVITY_INFO =
|
|
815
|
+
'The everyday steps Kubernetes takes on its own: pods being created, scheduled and started, replica sets scaling, and jobs that a CronJob ran successfully. Hidden by default to keep the list short. Warnings, failures and changes to your deployments, cron jobs and other top-level resources always show.'
|
|
816
|
+
|
|
817
|
+
// Its own row under the toolbar: the filter row has no width to spare, and the
|
|
818
|
+
// hide must stay visible at every width.
|
|
819
|
+
function RoutineActivityToggle({ shown, count, onChange }: { shown: boolean; count: number; onChange: (shown: boolean) => void }) {
|
|
820
|
+
return (
|
|
821
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-secondary">
|
|
822
|
+
<label className="flex cursor-pointer items-center gap-1.5">
|
|
823
|
+
<input
|
|
824
|
+
type="checkbox"
|
|
825
|
+
checked={shown}
|
|
826
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
827
|
+
className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
|
|
828
|
+
/>
|
|
829
|
+
Show routine activity
|
|
830
|
+
</label>
|
|
831
|
+
<Tooltip content={ROUTINE_ACTIVITY_INFO} delay={150} position="bottom" wrapperClassName="shrink-0">
|
|
832
|
+
<button type="button" aria-label="About routine activity" className="flex cursor-default rounded text-theme-text-tertiary/70 hover:text-theme-text-secondary">
|
|
833
|
+
<Info className="h-3.5 w-3.5" />
|
|
834
|
+
</button>
|
|
835
|
+
</Tooltip>
|
|
836
|
+
<span className="tabular-nums text-theme-text-tertiary">
|
|
837
|
+
· {count > 0 ? `${count.toLocaleString()} ${shown ? 'shown' : 'hidden'}` : shown ? 'none match' : 'none hidden'}
|
|
838
|
+
</span>
|
|
839
|
+
</div>
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// What the rest of a folded group holds, in the words its rows use: each K8s
|
|
844
|
+
// event reason with its count (BackOff ×8), then the status changes.
|
|
845
|
+
function describeFolded(others: TimelineEvent[]): string {
|
|
846
|
+
const reasons = new Map<string, number>()
|
|
847
|
+
let changes = 0
|
|
848
|
+
for (const e of others) {
|
|
849
|
+
// A K8s event row already stands for `count` occurrences.
|
|
850
|
+
if (e.source === 'k8s_event' && e.reason) reasons.set(e.reason, (reasons.get(e.reason) ?? 0) + (e.count ?? 1))
|
|
851
|
+
else changes++
|
|
852
|
+
}
|
|
853
|
+
const parts = [...reasons.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => (n > 1 ? `${r} ×${n}` : r))
|
|
854
|
+
if (changes > 0) parts.push(pluralize(changes, 'status change'))
|
|
855
|
+
return parts.join(', ')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The rest of a child resource's problems in one time group, behind one line
|
|
859
|
+
// under its newest card, so a crash-looping pod doesn't fill the list.
|
|
860
|
+
function FoldedProblems({ latest, others, renderCard }: { latest: TimelineEvent; others: TimelineEvent[]; renderCard: (item: TimelineEvent) => ReactNode }) {
|
|
861
|
+
const [open, setOpen] = useState(false)
|
|
862
|
+
return (
|
|
863
|
+
<div className="pl-3">
|
|
864
|
+
<button
|
|
865
|
+
type="button"
|
|
866
|
+
onClick={() => setOpen(!open)}
|
|
867
|
+
aria-expanded={open}
|
|
868
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
869
|
+
>
|
|
870
|
+
<CollapseChevron open={open} inheritColor className="h-3.5 w-3.5" />
|
|
871
|
+
{others.length} more on this {latest.kind}: {describeFolded(others)}
|
|
872
|
+
</button>
|
|
873
|
+
<Collapse open={open} unmountOnExit>
|
|
874
|
+
<div className="mt-2 space-y-2 border-l-2 border-theme-border pl-3">{others.map((e) => (
|
|
875
|
+
<div key={e.id} data-event-id={e.id} data-ts-from={new Date(e.timestamp).getTime()} data-ts-to={new Date(e.timestamp).getTime()}>
|
|
876
|
+
{renderCard(e)}
|
|
877
|
+
</div>
|
|
878
|
+
))}</div>
|
|
879
|
+
</Collapse>
|
|
880
|
+
</div>
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
722
884
|
// Component for aggregated repeated events (e.g., multiple OOMKilled)
|
|
723
885
|
interface AggregatedActivityCardProps {
|
|
724
886
|
first: TimelineEvent
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
5
|
+
import type { TimelineEvent } from '../../types'
|
|
6
|
+
import { TimelineList } from './TimelineList'
|
|
7
|
+
|
|
8
|
+
const NOW = Date.now()
|
|
9
|
+
const crash = (id: string, secondsAgo: number, over: Partial<TimelineEvent> = {}): TimelineEvent => ({
|
|
10
|
+
id,
|
|
11
|
+
timestamp: new Date(NOW - secondsAgo * 1000).toISOString(),
|
|
12
|
+
source: 'informer',
|
|
13
|
+
kind: 'Pod',
|
|
14
|
+
namespace: 'default',
|
|
15
|
+
name: 'api-1',
|
|
16
|
+
eventType: 'update',
|
|
17
|
+
healthState: 'unhealthy',
|
|
18
|
+
...over,
|
|
19
|
+
})
|
|
20
|
+
const ROWS = [crash('newest', 10), crash('older-1', 20), crash('older-2', 30)]
|
|
21
|
+
const foldAll = () => true
|
|
22
|
+
|
|
23
|
+
let container: HTMLDivElement
|
|
24
|
+
let root: Root
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
28
|
+
container = document.createElement('div')
|
|
29
|
+
root = createRoot(container)
|
|
30
|
+
})
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
act(() => root.unmount())
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const render = (events: TimelineEvent[]) =>
|
|
36
|
+
act(async () => root.render(<TimelineList events={events} isLoading={false} foldPerResource={foldAll} />))
|
|
37
|
+
const foldButton = () => [...container.querySelectorAll('button')].find((b) => /more on this Pod/.test(b.textContent ?? ''))!
|
|
38
|
+
const isOpen = () => foldButton().getAttribute('aria-expanded') === 'true'
|
|
39
|
+
const click = () => act(async () => foldButton().click())
|
|
40
|
+
|
|
41
|
+
it('starts closed and toggles on click', async () => {
|
|
42
|
+
await render(ROWS)
|
|
43
|
+
expect(isOpen()).toBe(false)
|
|
44
|
+
await click()
|
|
45
|
+
expect(isOpen()).toBe(true)
|
|
46
|
+
await click()
|
|
47
|
+
expect(isOpen()).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('stays open as newer rows for the same resource arrive', async () => {
|
|
51
|
+
await render(ROWS)
|
|
52
|
+
await click()
|
|
53
|
+
await render([crash('even-newer', 1), ...ROWS])
|
|
54
|
+
expect(isOpen()).toBe(true)
|
|
55
|
+
expect(foldButton().textContent).toContain('3 more on this Pod')
|
|
56
|
+
})
|
|
@@ -86,3 +86,101 @@ describe('TimelineSwimlanes controlled vs fallback', () => {
|
|
|
86
86
|
expect(html).toContain('>View<')
|
|
87
87
|
})
|
|
88
88
|
})
|
|
89
|
+
|
|
90
|
+
describe('TimelineList routine activity toggle', () => {
|
|
91
|
+
const at = (id: string, over: Partial<TimelineEvent>): TimelineEvent => ({
|
|
92
|
+
...EVENTS[0], id, timestamp: new Date(NOW).toISOString(), ...over,
|
|
93
|
+
})
|
|
94
|
+
const ROUTINE: TimelineEvent[] = [
|
|
95
|
+
at('pod-started', { kind: 'Pod', name: 'web-1', source: 'k8s_event', eventType: 'Normal', reason: 'Started' }),
|
|
96
|
+
at('pod-pulled', { kind: 'Pod', name: 'web-2', source: 'k8s_event', eventType: 'Normal', reason: 'Pulled' }),
|
|
97
|
+
at('rs-scaled', { kind: 'ReplicaSet', name: 'web-5f86', eventType: 'update' }),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
it('shows the checkbox with the hidden count and the (i) explanation', () => {
|
|
101
|
+
const html = renderToString(
|
|
102
|
+
<TimelineList events={EVENTS} isLoading={false} routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
|
|
103
|
+
)
|
|
104
|
+
expect(html).toContain('Show routine activity')
|
|
105
|
+
expect(html).toContain('type="checkbox"')
|
|
106
|
+
expect(html).toContain('· <!-- -->3 hidden')
|
|
107
|
+
expect(html).toContain('aria-label="About routine activity"')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('counts only hidden rows the active filters would show', () => {
|
|
111
|
+
const html = renderToString(
|
|
112
|
+
<TimelineList
|
|
113
|
+
events={EVENTS}
|
|
114
|
+
isLoading={false}
|
|
115
|
+
kindFilter={['Pod']}
|
|
116
|
+
onKindFilterChange={() => {}}
|
|
117
|
+
routineEvents={ROUTINE}
|
|
118
|
+
onShowRoutineChange={() => {}}
|
|
119
|
+
/>,
|
|
120
|
+
)
|
|
121
|
+
expect(html).toContain('2 routine events<!-- --> <!-- -->match')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('says none hidden when nothing matching is routine', () => {
|
|
125
|
+
const html = renderToString(
|
|
126
|
+
<TimelineList events={EVENTS} isLoading={false} search="web" onSearchChange={() => {}} routineEvents={[]} onShowRoutineChange={() => {}} />,
|
|
127
|
+
)
|
|
128
|
+
expect(html).toContain('· <!-- -->none hidden')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('reads shown, with the count, once the user opts in', () => {
|
|
132
|
+
const html = renderToString(
|
|
133
|
+
<TimelineList events={[...EVENTS, ...ROUTINE]} isLoading={false} showRoutine routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
|
|
134
|
+
)
|
|
135
|
+
expect(html).toContain('checked=""')
|
|
136
|
+
expect(html).toContain('· <!-- -->3 shown')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('explains an empty list whose matching rows are all routine', () => {
|
|
140
|
+
const html = renderToString(
|
|
141
|
+
<TimelineList events={[]} isLoading={false} routineEvents={ROUTINE} onShowRoutineChange={() => {}} />,
|
|
142
|
+
)
|
|
143
|
+
expect(html).toContain('All matching activity is routine')
|
|
144
|
+
expect(html).toContain('3 routine events<!-- --> <!-- -->match<!-- -->. Warnings and failures would show here.')
|
|
145
|
+
expect(html).toContain('Show routine activity')
|
|
146
|
+
expect(html).not.toContain('No activity found')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('has no toggle when the host does not offer it', () => {
|
|
150
|
+
const html = renderToString(<TimelineList events={EVENTS} isLoading={false} />)
|
|
151
|
+
expect(html).not.toContain('routine activity')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('folds a resource\'s rows under its newest card, describing the rest', () => {
|
|
155
|
+
const crash = (id: string, minutesAgo: number, over: Partial<TimelineEvent>) =>
|
|
156
|
+
at(id, { kind: 'Pod', name: 'api-1', timestamp: new Date(NOW - minutesAgo * 60_000).toISOString(), ...over })
|
|
157
|
+
const rows = [
|
|
158
|
+
crash('newest', 0, { eventType: 'update', healthState: 'unhealthy', owner: { kind: 'ReplicaSet', name: 'api-5f86' } }),
|
|
159
|
+
crash('b1', 1, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff' }),
|
|
160
|
+
crash('b2', 2, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff' }),
|
|
161
|
+
crash('u1', 3, { eventType: 'update', healthState: 'degraded' }),
|
|
162
|
+
]
|
|
163
|
+
const folded = renderToString(<TimelineList events={rows} isLoading={false} foldPerResource={(e) => e.kind === 'Pod'} />)
|
|
164
|
+
expect(folded).toContain('3<!-- --> more on this <!-- -->Pod<!-- -->: <!-- -->BackOff ×2, 1 status change')
|
|
165
|
+
expect(folded).toContain('deploy<!-- -->/</span>api')
|
|
166
|
+
expect(folded).toContain('data-event-id="newest"')
|
|
167
|
+
const plain = renderToString(<TimelineList events={rows} isLoading={false} />)
|
|
168
|
+
expect(plain).not.toContain('more on this')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('counts each event\'s occurrences in the fold summary', () => {
|
|
172
|
+
const crash = (id: string, minutesAgo: number, over: Partial<TimelineEvent>) =>
|
|
173
|
+
at(id, { kind: 'Pod', name: 'api-1', timestamp: new Date(NOW - minutesAgo * 60_000).toISOString(), ...over })
|
|
174
|
+
const rows = [
|
|
175
|
+
crash('newest', 0, { eventType: 'update', healthState: 'unhealthy' }),
|
|
176
|
+
crash('backoff', 1, { source: 'k8s_event', eventType: 'Warning', reason: 'BackOff', count: 43 }),
|
|
177
|
+
]
|
|
178
|
+
const html = renderToString(<TimelineList events={rows} isLoading={false} foldPerResource={() => true} />)
|
|
179
|
+
expect(html).toContain('BackOff ×43')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('keeps the (i) explanation reachable by keyboard', () => {
|
|
183
|
+
const html = renderToString(<TimelineList events={EVENTS} isLoading={false} routineEvents={[]} onShowRoutineChange={() => {}} />)
|
|
184
|
+
expect(html).toContain('<button type="button" aria-label="About routine activity"')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './shared'
|
|
2
2
|
export * from './DiffViewer'
|
|
3
3
|
export type { ActivityTypeFilter, ActivityFilterKey } from './timeline-filters'
|
|
4
|
+
export { isTimelineProblem } from './timeline-filters'
|
|
4
5
|
export type { TimelineSort } from './timeline-lane-sort'
|
|
5
6
|
export * from './TimelineList'
|
|
6
7
|
export * from './TimelineSwimlanes'
|
|
@@ -69,6 +69,13 @@ export function selectionToActivityKeys(sel: ActivitySelection): ActivityFilterK
|
|
|
69
69
|
return []
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// A row the Problems chip counts. Hosts that hide routine activity must never
|
|
73
|
+
// hide these, so the Problems count and the list stay truthful.
|
|
74
|
+
const PROBLEM_KEYS = selectionToActivityKeys({ source: 'all', problemsOnly: true })
|
|
75
|
+
export function isTimelineProblem(event: TimelineEvent): boolean {
|
|
76
|
+
return matchesActivityFilter(event, PROBLEM_KEYS)
|
|
77
|
+
}
|
|
78
|
+
|
|
72
79
|
// Inverse of selectionToActivityKeys for the six canonical states. Legacy
|
|
73
80
|
// multi-select key sets (pre-two-axis URLs) fall back to the widest reading —
|
|
74
81
|
// showing more than a stale link intended beats silently hiding activity.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { SelectMenu } from './SelectMenu'
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
8
|
+
|
|
9
|
+
it('keeps one option tabbable when the options shrink while the menu is open', async () => {
|
|
10
|
+
const element = document.createElement('div')
|
|
11
|
+
document.body.append(element)
|
|
12
|
+
const root = createRoot(element)
|
|
13
|
+
const render = (options: { value: string; label: string }[]) =>
|
|
14
|
+
root.render(<SelectMenu value="c" onChange={() => {}} options={options} ariaLabel="Source" />)
|
|
15
|
+
await act(async () => render([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }]))
|
|
16
|
+
await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!.click() })
|
|
17
|
+
expect(element.querySelectorAll('[role="option"]')).toHaveLength(3)
|
|
18
|
+
|
|
19
|
+
await act(async () => render([{ value: 'a', label: 'A' }]))
|
|
20
|
+
const tabbable = element.querySelectorAll('[role="option"][tabindex="0"]')
|
|
21
|
+
expect(tabbable).toHaveLength(1)
|
|
22
|
+
expect(tabbable[0].textContent).toBe('A')
|
|
23
|
+
await act(async () => root.unmount())
|
|
24
|
+
element.remove()
|
|
25
|
+
})
|
|
@@ -55,7 +55,9 @@ export function SelectMenu({
|
|
|
55
55
|
if (!normalized) return options
|
|
56
56
|
return options.filter((option) => `${option.label} ${option.description ?? ''}`.toLowerCase().includes(normalized))
|
|
57
57
|
}, [options, query])
|
|
58
|
-
|
|
58
|
+
// Options can be replaced while the menu is open, leaving the highlight past
|
|
59
|
+
// the end; without the clamp no option is tabbable and Tab skips the list.
|
|
60
|
+
const activeIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
|
|
59
61
|
|
|
60
62
|
const focusTabbableOption = () => {
|
|
61
63
|
listRef.current?.querySelector<HTMLElement>('[role="option"][tabindex="0"]')?.focus()
|
|
@@ -153,7 +155,9 @@ export function SelectMenu({
|
|
|
153
155
|
ref={triggerRef}
|
|
154
156
|
id={id}
|
|
155
157
|
type="button"
|
|
156
|
-
|
|
158
|
+
// The fixed label would otherwise hide the visible value from screen
|
|
159
|
+
// readers, which a native select announces.
|
|
160
|
+
aria-label={selected ? `${ariaLabel}: ${selected.label}` : ariaLabel}
|
|
157
161
|
aria-describedby={ariaDescribedBy}
|
|
158
162
|
aria-haspopup="listbox"
|
|
159
163
|
aria-expanded={open}
|
|
@@ -216,11 +220,11 @@ export function SelectMenu({
|
|
|
216
220
|
onKeyDown={(event) => {
|
|
217
221
|
if (event.key === 'Enter' && filteredOptions.length > 0) {
|
|
218
222
|
event.preventDefault()
|
|
219
|
-
selectOption(filteredOptions[
|
|
223
|
+
selectOption(filteredOptions[activeIndex].value)
|
|
220
224
|
} else if (event.key === 'ArrowDown') {
|
|
221
225
|
event.preventDefault()
|
|
222
226
|
const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
|
|
223
|
-
optionElements?.[
|
|
227
|
+
optionElements?.[activeIndex]?.focus()
|
|
224
228
|
}
|
|
225
229
|
}}
|
|
226
230
|
aria-label={searchPlaceholder}
|
|
@@ -230,7 +234,7 @@ export function SelectMenu({
|
|
|
230
234
|
aria-expanded="true"
|
|
231
235
|
aria-activedescendant={
|
|
232
236
|
filteredOptions.length > 0
|
|
233
|
-
? `${listboxId}-option-${
|
|
237
|
+
? `${listboxId}-option-${activeIndex}`
|
|
234
238
|
: undefined
|
|
235
239
|
}
|
|
236
240
|
placeholder={searchPlaceholder}
|
|
@@ -283,15 +287,13 @@ export function SelectMenu({
|
|
|
283
287
|
role="option"
|
|
284
288
|
aria-selected={active}
|
|
285
289
|
aria-disabled={option.disabled || undefined}
|
|
286
|
-
tabIndex={
|
|
287
|
-
searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
|
|
288
|
-
}
|
|
290
|
+
tabIndex={index === activeIndex ? 0 : -1}
|
|
289
291
|
onClick={() => selectOption(option.value)}
|
|
290
292
|
onFocus={() => setHighlightedIndex(index)}
|
|
291
293
|
className={clsx(
|
|
292
294
|
'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors',
|
|
293
295
|
option.disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover hover:text-theme-text-primary',
|
|
294
|
-
searchPlaceholder && index ===
|
|
296
|
+
searchPlaceholder && index === activeIndex && 'bg-theme-hover text-theme-text-primary',
|
|
295
297
|
!searchPlaceholder && 'whitespace-nowrap'
|
|
296
298
|
)}
|
|
297
299
|
>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { YamlReview } from './YamlReview'
|
|
4
|
+
|
|
5
|
+
const doc = { index: 0, kind: 'ConfigMap', name: 'app', namespace: 'shop', status: 'ok' as const, action: 'update' as const, reviewedResourceVersion: '42' }
|
|
6
|
+
|
|
7
|
+
function review(changedSinceReview?: boolean, applyError?: string) {
|
|
8
|
+
return renderToString(
|
|
9
|
+
<YamlReview
|
|
10
|
+
submittedYaml="kind: ConfigMap"
|
|
11
|
+
documents={[doc]}
|
|
12
|
+
changedSinceReview={changedSinceReview}
|
|
13
|
+
applyError={applyError}
|
|
14
|
+
onBack={() => {}}
|
|
15
|
+
onApply={() => {}}
|
|
16
|
+
/>,
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('YamlReview after a failed apply', () => {
|
|
21
|
+
it('says the review now shows the latest version when the resource changed under it', () => {
|
|
22
|
+
const html = review(true, 'resource changed after review; review the latest version before applying')
|
|
23
|
+
expect(html).toContain('This resource changed after your review')
|
|
24
|
+
expect(html).toContain('apply again')
|
|
25
|
+
// Said once: the raw API message would repeat the notice.
|
|
26
|
+
expect(html).not.toContain('review the latest version before applying')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps an unrelated apply error beside the notice', () => {
|
|
30
|
+
const html = review(true, 'admission webhook "policy.example" denied the request')
|
|
31
|
+
expect(html).toContain('This resource changed after your review')
|
|
32
|
+
expect(html).toContain('denied the request')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('says nothing extra when the resource did not change', () => {
|
|
36
|
+
expect(review(false, 'forbidden')).not.toContain('changed after your review')
|
|
37
|
+
expect(review(false, 'forbidden')).toContain('forbidden')
|
|
38
|
+
expect(review()).not.toContain('changed after your review')
|
|
39
|
+
})
|
|
40
|
+
})
|