@skyhook-io/k8s-ui 1.16.2 → 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/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/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
- 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/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/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
|
@@ -77,7 +77,7 @@ describe('getCNPGObjectStoreStatus', () => {
|
|
|
77
77
|
it('is unknown, not healthy, when no server has reported', () => {
|
|
78
78
|
const s = getCNPGObjectStoreStatus(store({}))
|
|
79
79
|
expect(s.level).toBe('unknown')
|
|
80
|
-
expect(s.text).toBe('No
|
|
80
|
+
expect(s.text).toBe('No backup recorded')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
it('is unhealthy when any server is failing since its last success', () => {
|
|
@@ -600,7 +600,7 @@ export interface CNPGObjectStoreRecoveryWindow {
|
|
|
600
600
|
firstRecoverabilityPoint?: string
|
|
601
601
|
lastSuccessfulBackupTime?: string
|
|
602
602
|
lastFailedBackupTime?: string
|
|
603
|
-
/** A failure newer than the last success
|
|
603
|
+
/** A failure newer than the last success, or a failure and no success at all. */
|
|
604
604
|
failingSinceLastSuccess: boolean
|
|
605
605
|
}
|
|
606
606
|
|
|
@@ -641,7 +641,7 @@ export function getCNPGObjectStoreRecoveryWindows(resource: any): CNPGObjectStor
|
|
|
641
641
|
export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
|
|
642
642
|
const windows = getCNPGObjectStoreRecoveryWindows(resource)
|
|
643
643
|
if (windows.length === 0) {
|
|
644
|
-
return { text: 'No
|
|
644
|
+
return { text: 'No backup recorded', color: healthColors.unknown, level: 'unknown' }
|
|
645
645
|
}
|
|
646
646
|
if (windows.some((w) => w.failingSinceLastSuccess)) {
|
|
647
647
|
return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
@@ -649,7 +649,7 @@ export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
|
|
|
649
649
|
// Every timestamp on a recovery window is optional, so a server can be listed
|
|
650
650
|
// with no recovery point at all. The entry existing is not a backup existing.
|
|
651
651
|
if (!windows.some((w) => w.firstRecoverabilityPoint || w.lastSuccessfulBackupTime)) {
|
|
652
|
-
return { text: '
|
|
652
|
+
return { text: 'Recovery point not reported', color: healthColors.unknown, level: 'unknown' }
|
|
653
653
|
}
|
|
654
654
|
return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
|
|
655
655
|
}
|
|
@@ -141,7 +141,7 @@ describe('getCNPGObjectStoreStatus', () => {
|
|
|
141
141
|
// Every timestamp on a RecoveryWindow is optional, so the server can be
|
|
142
142
|
// listed while holding nothing restorable.
|
|
143
143
|
expect(getCNPGObjectStoreStatus({ status: { serverRecoveryWindow: { pg: {} } } }))
|
|
144
|
-
.toMatchObject({ text: '
|
|
144
|
+
.toMatchObject({ text: 'Recovery point not reported', level: 'unknown' })
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('reports recoverable once a real point exists', () => {
|
|
@@ -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.
|