@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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  3. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  4. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  5. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  6. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  7. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  8. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  9. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  10. package/src/components/cnpg/index.ts +14 -0
  11. package/src/components/cnpg/primitives.tsx +134 -0
  12. package/src/components/cnpg/relations.test.ts +304 -0
  13. package/src/components/cnpg/relations.ts +417 -0
  14. package/src/components/cnpg/workspace.test.ts +285 -0
  15. package/src/components/cnpg/workspace.ts +700 -0
  16. package/src/components/issues/IssuesView.tsx +10 -1
  17. package/src/components/issues/types.ts +24 -0
  18. package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
  19. package/src/components/logs/useLogBuffer.test.ts +17 -0
  20. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  21. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  22. package/src/components/resources/ResourcesView.tsx +5 -1
  23. package/src/components/resources/index.ts +1 -1
  24. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  25. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  26. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  27. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  28. package/src/components/resources/status-overclaim.test.ts +1 -1
  29. package/src/components/timeline/TimelineList.tsx +185 -23
  30. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  31. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  32. package/src/components/timeline/index.ts +1 -0
  33. package/src/components/timeline/timeline-filters.ts +7 -0
  34. package/src/components/workload/WorkloadView.tsx +96 -5
  35. package/src/components/workload/index.ts +1 -0
  36. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  37. package/src/index.ts +3 -0
  38. 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 backups yet')
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 — the window has stopped advancing. */
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 backups yet', color: healthColors.unknown, level: 'unknown' }
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: 'No recovery point', color: healthColors.unknown, level: 'unknown' }
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: 'No recovery point', level: 'unknown' })
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 filteredActivity = useMemo(() => {
178
- if (!events) return []
179
- return events.filter((item) => {
180
- if (!matchesActivityFilter(item, activityTypeFilter)) return false
181
- if (kindFilter.length > 0 && !kindFilter.includes(item.kind)) return false
182
- if (!showDeleted && item.eventType === 'delete') return false
183
- if (!matchesTimelineSearch(item, debouncedSearchTerm)) return false
184
- return true
185
- })
186
- }, [events, activityTypeFilter, kindFilter, debouncedSearchTerm, showDeleted])
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.values()) {
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 timeA = a.type === 'aggregated' ? new Date(a.last.timestamp).getTime() : new Date(a.item.timestamp).getTime()
254
- const timeB = b.type === 'aggregated' ? new Date(b.last.timestamp).getTime() : new Date(b.item.timestamp).getTime()
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
- <p className="text-lg">No activity found</p>
437
- <p className="text-sm mt-2">
438
- {activeFilters || 'Activity will appear here when cluster changes occur'}
439
- </p>
440
- {activeFilters && (
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 === 'aggregated' ? (
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.