@skyhook-io/k8s-ui 1.16.2 → 1.17.0

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 (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. package/src/utils/resource-refs.ts +30 -0
@@ -1,11 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState, useId } from 'react'
2
- import {
3
- AlertTriangle,
4
- ExternalLink,
5
- Loader2,
6
- RefreshCw,
7
- X,
8
- } from 'lucide-react'
2
+ import { Loader2, RefreshCw, X } from 'lucide-react'
9
3
  import { clsx } from 'clsx'
10
4
 
11
5
  import type {
@@ -16,11 +10,33 @@ import type {
16
10
  } from '../../types/core'
17
11
  import { DialogPortal } from '../ui/DialogPortal'
18
12
  import { Badge } from '../ui/Badge'
13
+ import {
14
+ canConfirmGitOpsWrite,
15
+ gitOpsWriteGuardKey,
16
+ type GitOpsWrite,
17
+ type GitOpsWriteGuard,
18
+ } from '../../utils/gitops-write-guard'
19
+ import { GitOpsWriteWarning } from './GitOpsWriteWarning'
19
20
 
20
- export interface ManagedImageSource {
21
- type: 'GitOps' | 'Helm'
22
- label: string
23
- onOpen?: () => void
21
+ /** The GitOps write guard for the object whose Pod template the dialog
22
+ * writes, evaluated by the host: for {@link SET_IMAGE_WRITES} until the
23
+ * containers are known, then for {@link setImageWrites} of the containers
24
+ * {@link onSelectionChange} reports. */
25
+ export interface SetImageOwnership {
26
+ guard: GitOpsWriteGuard
27
+ onOpenOwner?: () => void
28
+ /** The containers the dialog lists and the ones being changed, so the
29
+ * guard can judge exactly those (null until the containers load). */
30
+ onSelectionChange?: (selection: SetImageSelection | null) => void
31
+ /** {@link setImageSelectionKey} of the selection `guard` was evaluated
32
+ * for. When set, Update waits until it matches the dialog's current
33
+ * selection. */
34
+ selectionKey?: string
35
+ }
36
+
37
+ export interface SetImageSelection {
38
+ containers: Pick<WorkloadContainerImage, 'type' | 'name'>[]
39
+ changed: Pick<WorkloadContainerImage, 'type' | 'name'>[]
24
40
  }
25
41
 
26
42
  export interface SetImageDialogProps {
@@ -28,7 +44,8 @@ export interface SetImageDialogProps {
28
44
  workloadLabel: string
29
45
  workloadName: string
30
46
  workloadResource: string
31
- managedSources?: ManagedImageSource[]
47
+ /** Undefined while the host can't verify ownership; the dialog then blocks. */
48
+ ownership?: SetImageOwnership
32
49
  pending?: boolean
33
50
  onClose: () => void
34
51
  onLoad: () => Promise<WorkloadImageInventory>
@@ -83,31 +100,49 @@ export function reconcileRefreshedImageDrafts(
83
100
  return { drafts: nextDrafts, changedCurrentKeys }
84
101
  }
85
102
 
103
+ export function setImageSelectionKey(selection: SetImageSelection | null): string {
104
+ if (!selection) return ''
105
+ const rows = (refs: SetImageSelection['containers']) => refs.map((c) => [c.type, c.name])
106
+ return JSON.stringify([rows(selection.containers), rows(selection.changed)])
107
+ }
108
+
109
+ /** One write per container, by name: the guard's verdict for exactly the
110
+ * images being changed. */
111
+ export function setImageWrites(containers: Pick<WorkloadContainerImage, 'type' | 'name'>[]): GitOpsWrite[] {
112
+ return containers.map((container) => ({
113
+ scope: 'spec',
114
+ paths: [`spec.template.spec.${container.type === 'initContainer' ? 'initContainers' : 'containers'}[name=${container.name}].image`],
115
+ description: container.name,
116
+ }))
117
+ }
118
+
119
+ // Before the containers load, the guard covers every container image.
120
+ export const SET_IMAGE_WRITES: GitOpsWrite[] = [
121
+ {
122
+ scope: 'spec',
123
+ paths: [
124
+ 'spec.template.spec.containers[*].image',
125
+ 'spec.template.spec.initContainers[*].image',
126
+ ],
127
+ description: 'Container images',
128
+ },
129
+ ]
130
+
86
131
  export function canSubmitImageUpdates({
87
132
  updateCount,
88
133
  hasEmptyImage,
89
- managed,
90
- ownershipResolved,
91
- acknowledged,
134
+ ownershipConfirmable,
92
135
  busy,
93
136
  loadFailed = false,
94
137
  }: {
95
138
  updateCount: number
96
139
  hasEmptyImage: boolean
97
- managed: boolean
98
- ownershipResolved: boolean
99
- acknowledged: boolean
140
+ /** Ownership resolved and, when it's managed, acknowledged. */
141
+ ownershipConfirmable: boolean
100
142
  busy: boolean
101
143
  loadFailed?: boolean
102
144
  }): boolean {
103
- return (
104
- updateCount > 0 &&
105
- !hasEmptyImage &&
106
- ownershipResolved &&
107
- (!managed || acknowledged) &&
108
- !busy &&
109
- !loadFailed
110
- )
145
+ return updateCount > 0 && !hasEmptyImage && ownershipConfirmable && !busy && !loadFailed
111
146
  }
112
147
 
113
148
  export function describeImageUpdateBehavior(
@@ -140,7 +175,7 @@ export function SetImageDialog({
140
175
  workloadLabel,
141
176
  workloadName,
142
177
  workloadResource,
143
- managedSources,
178
+ ownership,
144
179
  pending = false,
145
180
  onClose,
146
181
  onLoad,
@@ -155,7 +190,9 @@ export function SetImageDialog({
155
190
  const [submitting, setSubmitting] = useState(false)
156
191
  const [loadError, setLoadError] = useState<string | null>(null)
157
192
  const [submitError, setSubmitError] = useState<string | null>(null)
158
- const [acknowledged, setAcknowledged] = useState(false)
193
+ // The guard key the acknowledgment was given for: a different verdict
194
+ // (fresh evidence, other containers, a changed policy) asks again.
195
+ const [ackedKey, setAckedKey] = useState('')
159
196
  const [changedCurrentKeys, setChangedCurrentKeys] = useState<string[]>([])
160
197
  const onLoadRef = useRef(onLoad)
161
198
  const loadRequestRef = useRef(0)
@@ -216,7 +253,7 @@ export function SetImageDialog({
216
253
  setDrafts({})
217
254
  setChangedCurrentKeys([])
218
255
  setSubmitError(null)
219
- setAcknowledged(false)
256
+ setAckedKey('')
220
257
  void loadInventory()
221
258
  }, [open, workloadLabel, workloadName, workloadResource])
222
259
 
@@ -229,13 +266,27 @@ export function SetImageDialog({
229
266
  (container) => !(drafts[containerKey(container)] ?? '').trim(),
230
267
  ) ?? false
231
268
  const busy = pending || submitting
232
- const ownershipResolved = managedSources !== undefined
269
+ const ownershipResolved = ownership !== undefined
270
+ const onSelectionChange = ownership?.onSelectionChange
271
+ const selectionKey = setImageSelectionKey(
272
+ inventory ? { containers: inventory.containers, changed: updates } : null,
273
+ )
274
+ useEffect(() => {
275
+ if (!onSelectionChange) return
276
+ const [containers, changed] = selectionKey ? (JSON.parse(selectionKey) as [string[][], string[][]]) : [null, null]
277
+ const toRefs = (rows: string[][]) => rows.map(([type, name]) => ({ type: type as WorkloadContainerImage['type'], name }))
278
+ onSelectionChange(containers && changed ? { containers: toRefs(containers), changed: toRefs(changed) } : null)
279
+ }, [onSelectionChange, selectionKey])
280
+ const guard = ownership?.guard
281
+ // The host evaluates the guard after it hears of an edit; until then the
282
+ // verdict on screen is for other containers.
283
+ const guardCurrent = ownership?.selectionKey === undefined || ownership.selectionKey === selectionKey
284
+ const guardKey = gitOpsWriteGuardKey(guard)
285
+ const acknowledged = Boolean(guardKey) && ackedKey === guardKey
233
286
  const canSubmit = canSubmitImageUpdates({
234
287
  updateCount: updates.length,
235
288
  hasEmptyImage,
236
- managed: (managedSources?.length ?? 0) > 0,
237
- ownershipResolved,
238
- acknowledged,
289
+ ownershipConfirmable: guardCurrent && canConfirmGitOpsWrite(guard, acknowledged),
239
290
  busy,
240
291
  loadFailed: Boolean(loadError),
241
292
  })
@@ -378,57 +429,14 @@ export function SetImageDialog({
378
429
  {describeImageUpdateBehavior(inventory.behavior)}
379
430
  </div>
380
431
 
381
- {managedSources && managedSources.length > 0 && (
382
- <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-3">
383
- <div className="flex items-start gap-2">
384
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
385
- <div className="min-w-0 flex-1">
386
- <p className="text-xs font-medium text-amber-800 dark:text-amber-300">
387
- The declared source may overwrite this live change
388
- </p>
389
- <p className="mt-1 text-xs text-theme-text-secondary">
390
- Managed by{' '}
391
- {managedSources
392
- .map((source) => `${source.type} ${source.label}`)
393
- .join(' and ')}
394
- . Update the declared source for a durable change; use
395
- this action only as a break-glass operation.
396
- </p>
397
- <div className="mt-2 flex flex-wrap gap-2">
398
- {managedSources
399
- .filter((source) => source.onOpen)
400
- .map((source) => (
401
- <button
402
- key={`${source.type}-${source.label}`}
403
- type="button"
404
- onClick={source.onOpen}
405
- className="inline-flex items-center gap-1 text-xs font-medium text-skyhook-600 hover:underline dark:text-skyhook-400"
406
- >
407
- {source.type === 'GitOps'
408
- ? 'View GitOps owner'
409
- : 'View Helm release'}
410
- <ExternalLink className="h-3 w-3" />
411
- </button>
412
- ))}
413
- </div>
414
- <label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-theme-text-primary">
415
- <input
416
- type="checkbox"
417
- checked={acknowledged}
418
- onChange={(event) =>
419
- setAcknowledged(event.target.checked)
420
- }
421
- disabled={busy}
422
- className="mt-0.5 h-3.5 w-3.5 accent-skyhook-500"
423
- />
424
- <span>
425
- I understand the declared source may overwrite this live
426
- change.
427
- </span>
428
- </label>
429
- </div>
430
- </div>
431
- </div>
432
+ {guard && (
433
+ <GitOpsWriteWarning
434
+ guard={guard}
435
+ acknowledged={acknowledged}
436
+ onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : '')}
437
+ onOpenOwner={ownership?.onOpenOwner}
438
+ disabled={busy}
439
+ />
432
440
  )}
433
441
 
434
442
  <ContainerImageGroup
@@ -5,7 +5,8 @@ export {
5
5
  DrainPlanDialog, DrainPlanContent, canConfirmDrain, planMatches, emptyDirPodsAtRisk, DEFAULT_DRAIN_DIALOG_OPTIONS,
6
6
  type DrainPlan, type DrainPlanPod, type DrainOutcome, type DrainDialogOptions,
7
7
  } from './DrainPlanDialog'
8
- export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
8
+ export { SetImageDialog, SET_IMAGE_WRITES, setImageWrites, setImageSelectionKey, type SetImageOwnership, type SetImageSelection, type SetImageDialogProps } from './SetImageDialog'
9
+ export { GitOpsWriteWarning, type GitOpsWriteWarningProps } from './GitOpsWriteWarning'
9
10
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
10
11
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
11
12
  export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
@@ -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,
@@ -33,20 +35,7 @@ import type { NavigateToResource } from '../../utils/navigation'
33
35
  import { builtinGroupForKind } from '../../utils/api-resources'
34
36
  import { kindToPluralWithGroup, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
35
37
  import { pluralize } from '../../utils/pluralize'
36
-
37
- /** Format resource age (e.g., "3d", "5h", "10m") */
38
- function formatResourceAge(createdAt: string): string {
39
- const diff = Date.now() - new Date(createdAt).getTime()
40
- const mins = Math.floor(diff / 60000)
41
- if (mins < 1) return '<1m'
42
- if (mins < 60) return `${mins}m`
43
- const hours = Math.floor(mins / 60)
44
- if (hours < 24) return `${hours}h`
45
- const days = Math.floor(hours / 24)
46
- if (days < 30) return `${days}d`
47
- const months = Math.floor(days / 30)
48
- return `${months}mo`
49
- }
38
+ import { formatCompactAge } from '../../utils/format'
50
39
 
51
40
  export type { ActivityTypeFilter, ActivityFilterKey }
52
41
 
@@ -104,6 +93,16 @@ export interface TimelineListProps {
104
93
  // explicitly because the visible row count no longer reflects the source cap.
105
94
  isTruncated?: boolean
106
95
  truncationMessage?: string
96
+ // Routine activity toggle, shown when `onShowRoutineChange` is set.
97
+ // `routineEvents` are the routine rows the host holds: left out of `events`
98
+ // while hidden, included while shown. Counted here through the active filters.
99
+ showRoutine?: boolean
100
+ onShowRoutineChange?: (show: boolean) => void
101
+ routineEvents?: TimelineEvent[]
102
+ // Rows for which this returns true fold into one card per resource per time
103
+ // group, whatever their reason. The host uses it for problems on pods and
104
+ // other child resources, so a crash-looping pod is one card, not dozens.
105
+ foldPerResource?: (event: TimelineEvent) => boolean
107
106
  }
108
107
 
109
108
  const TIME_RANGES: { value: TimeRange; label: string }[] = [
@@ -115,7 +114,7 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
115
114
  { value: 'all', label: 'All' },
116
115
  ]
117
116
 
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) {
117
+ 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
118
  const [searchInternal, setSearchInternal] = useState('')
120
119
  const searchTerm = searchProp ?? searchInternal
121
120
  const setSearchTerm = onSearchChange ?? setSearchInternal
@@ -174,16 +173,20 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
174
173
  const kindOptions = useMemo(() => mergeKindOptions(seenKinds), [seenKinds])
175
174
 
176
175
  // 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])
176
+ const matchesListFilters = useCallback((item: TimelineEvent) => {
177
+ if (!matchesActivityFilter(item, activityTypeFilter)) return false
178
+ if (kindFilter.length > 0 && !kindFilter.includes(item.kind)) return false
179
+ if (!showDeleted && item.eventType === 'delete') return false
180
+ if (!matchesTimelineSearch(item, debouncedSearchTerm)) return false
181
+ return true
182
+ }, [activityTypeFilter, kindFilter, debouncedSearchTerm, showDeleted])
183
+ const filteredActivity = useMemo(() => (events ? events.filter(matchesListFilters) : []), [events, matchesListFilters])
184
+ // Counted through the same filters, so the number is exactly what the toggle adds or removes.
185
+ const routineCount = useMemo(
186
+ () => (routineEvents ? routineEvents.filter(matchesListFilters).length : 0),
187
+ [routineEvents, matchesListFilters],
188
+ )
189
+ const canShowRoutine = !showRoutine && routineCount > 0 && !!onShowRoutineChange
187
190
 
188
191
  // Aggregated event group type
189
192
  type AggregatedItem = {
@@ -195,6 +198,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
195
198
  last: TimelineEvent
196
199
  count: number
197
200
  reason: string
201
+ } | {
202
+ type: 'folded'
203
+ // Stable across live updates, so an opened fold stays open as rows arrive.
204
+ key: string
205
+ latest: TimelineEvent
206
+ // Newest first.
207
+ others: TimelineEvent[]
198
208
  }
199
209
 
200
210
  // Aggregate repeated events for the same resource with the same reason
@@ -206,6 +216,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
206
216
  const singleEvents: TimelineEvent[] = []
207
217
 
208
218
  for (const item of items) {
219
+ if (foldPerResource?.(item)) {
220
+ const key = `fold:${item.kind}:${item.namespace}:${item.name}`
221
+ const existing = groups.get(key) || []
222
+ existing.push(item)
223
+ groups.set(key, existing)
224
+ continue
225
+ }
209
226
  // Only aggregate K8s Warning events or changes with a specific reason
210
227
  const reason = item.reason || ''
211
228
  const shouldAggregate = (
@@ -227,8 +244,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
227
244
  const result: AggregatedItem[] = []
228
245
 
229
246
  // Process aggregated groups
230
- for (const events of groups.values()) {
231
- if (events.length >= 2) {
247
+ for (const [key, events] of groups.entries()) {
248
+ if (events.length >= 2 && key.startsWith('fold:')) {
249
+ events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
250
+ result.push({ type: 'folded', key, latest: events[0], others: events.slice(1) })
251
+ } else if (events.length >= 2) {
232
252
  // Sort by time (oldest first)
233
253
  events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
234
254
  result.push({
@@ -250,8 +270,9 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
250
270
 
251
271
  // Sort all by most recent (last event time)
252
272
  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()
273
+ const newest = (x: AggregatedItem) => x.type === 'aggregated' ? x.last : x.type === 'folded' ? x.latest : x.item
274
+ const timeA = new Date(newest(a).timestamp).getTime()
275
+ const timeB = new Date(newest(b).timestamp).getTime()
255
276
  return timeB - timeA
256
277
  })
257
278
 
@@ -323,7 +344,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
323
344
  if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
324
345
 
325
346
  return groups
326
- }, [filteredActivity, compact])
347
+ }, [filteredActivity, compact, foldPerResource])
327
348
 
328
349
  // Visible-window reporting: rows carry their time span in data attributes;
329
350
  // on scroll (rAF-throttled) and whenever the row set changes, the span of
@@ -397,6 +418,18 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
397
418
  card?.scrollIntoView({ behavior: 'smooth', block: 'center' })
398
419
  }, [selectedEventId, groupedActivity])
399
420
 
421
+ const renderActivityCard = (item: TimelineEvent) => (
422
+ <ActivityCard
423
+ item={item}
424
+ expanded={expandedItem === item.id}
425
+ onToggle={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
426
+ onResourceClick={onResourceClick}
427
+ compact={compact}
428
+ selected={selectedEventId === item.id}
429
+ onSelect={() => onSelectEvent?.(selectedEventId === item.id ? null : item.id)}
430
+ />
431
+ )
432
+
400
433
  return (
401
434
  <div className="flex flex-col h-full w-full">
402
435
  {!compact && (
@@ -422,6 +455,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
422
455
  />
423
456
  )}
424
457
 
458
+
459
+ {onShowRoutineChange && (
460
+ <RoutineActivityToggle shown={showRoutine} count={routineCount} onChange={onShowRoutineChange} />
461
+ )}
462
+
425
463
  {/* Timeline content */}
426
464
  <div className="flex-1 overflow-auto" ref={scrollRef} onScroll={onVisibleWindowChange ? handleListScroll : undefined}>
427
465
  {isLoading ? (
@@ -433,11 +471,42 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
433
471
  const activeFilters = describeActiveFilters({ search: searchTerm, activityFilter: activityTypeFilter, kindFilter, showDeleted })
434
472
  return (
435
473
  <>
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 && (
474
+ {canShowRoutine ? (
475
+ <>
476
+ <p className="text-lg">All matching activity is routine</p>
477
+ <p className="text-sm mt-2 max-w-md text-center">
478
+ {pluralize(routineCount, 'routine event')} {routineCount === 1 ? 'matches' : 'match'}. Warnings and failures would show here.
479
+ </p>
480
+ <div className="mt-4 flex items-center gap-2">
481
+ <button
482
+ type="button"
483
+ onClick={() => onShowRoutineChange?.(true)}
484
+ className="btn-brand flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm"
485
+ >
486
+ <Eye className="w-3.5 h-3.5" />
487
+ Show routine activity
488
+ </button>
489
+ {activeFilters && (
490
+ <button
491
+ type="button"
492
+ onClick={clearAllFilters}
493
+ 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"
494
+ >
495
+ <X className="w-3.5 h-3.5" />
496
+ Clear filters
497
+ </button>
498
+ )}
499
+ </div>
500
+ </>
501
+ ) : (
502
+ <>
503
+ <p className="text-lg">No activity found</p>
504
+ <p className="text-sm mt-2">
505
+ {activeFilters || 'Activity will appear here when cluster changes occur'}
506
+ </p>
507
+ </>
508
+ )}
509
+ {activeFilters && !canShowRoutine && (
441
510
  <button
442
511
  type="button"
443
512
  onClick={clearAllFilters}
@@ -478,7 +547,17 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
478
547
  {/* Activity list */}
479
548
  <div className="space-y-2 ml-6 border-l-2 border-theme-border pl-4">
480
549
  {group.items.map((aggItem) => (
481
- aggItem.type === 'aggregated' ? (
550
+ aggItem.type === 'folded' ? (
551
+ <div
552
+ key={aggItem.key}
553
+ data-event-id={aggItem.latest.id}
554
+ data-ts-from={new Date(aggItem.others[aggItem.others.length - 1].timestamp).getTime()}
555
+ data-ts-to={new Date(aggItem.latest.timestamp).getTime()}
556
+ >
557
+ {renderActivityCard(aggItem.latest)}
558
+ <FoldedProblems latest={aggItem.latest} others={aggItem.others} renderCard={renderActivityCard} />
559
+ </div>
560
+ ) : aggItem.type === 'aggregated' ? (
482
561
  <div
483
562
  key={`agg-${aggItem.first.id}-${aggItem.last.id}`}
484
563
  data-event-id={aggItem.first.id}
@@ -644,7 +723,7 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
644
723
  {item.createdAt && (
645
724
  <Tooltip content={`Created: ${new Date(item.createdAt).toLocaleString()}`}>
646
725
  <span className="text-xs text-theme-text-quaternary">
647
- • {formatResourceAge(item.createdAt)} old
726
+ • {formatCompactAge(item.createdAt)} old
648
727
  </span>
649
728
  </Tooltip>
650
729
  )}
@@ -719,6 +798,76 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
719
798
  )
720
799
  }
721
800
 
801
+ const ROUTINE_ACTIVITY_INFO =
802
+ '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.'
803
+
804
+ // Its own row under the toolbar: the filter row has no width to spare, and the
805
+ // hide must stay visible at every width.
806
+ function RoutineActivityToggle({ shown, count, onChange }: { shown: boolean; count: number; onChange: (shown: boolean) => void }) {
807
+ return (
808
+ <div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-secondary">
809
+ <label className="flex cursor-pointer items-center gap-1.5">
810
+ <input
811
+ type="checkbox"
812
+ checked={shown}
813
+ onChange={(e) => onChange(e.target.checked)}
814
+ className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
815
+ />
816
+ Show routine activity
817
+ </label>
818
+ <Tooltip content={ROUTINE_ACTIVITY_INFO} delay={150} position="bottom" wrapperClassName="shrink-0">
819
+ <button type="button" aria-label="About routine activity" className="flex cursor-default rounded text-theme-text-tertiary/70 hover:text-theme-text-secondary">
820
+ <Info className="h-3.5 w-3.5" />
821
+ </button>
822
+ </Tooltip>
823
+ <span className="tabular-nums text-theme-text-tertiary">
824
+ · {count > 0 ? `${count.toLocaleString()} ${shown ? 'shown' : 'hidden'}` : shown ? 'none match' : 'none hidden'}
825
+ </span>
826
+ </div>
827
+ )
828
+ }
829
+
830
+ // What the rest of a folded group holds, in the words its rows use: each K8s
831
+ // event reason with its count (BackOff ×8), then the status changes.
832
+ function describeFolded(others: TimelineEvent[]): string {
833
+ const reasons = new Map<string, number>()
834
+ let changes = 0
835
+ for (const e of others) {
836
+ // A K8s event row already stands for `count` occurrences.
837
+ if (e.source === 'k8s_event' && e.reason) reasons.set(e.reason, (reasons.get(e.reason) ?? 0) + (e.count ?? 1))
838
+ else changes++
839
+ }
840
+ const parts = [...reasons.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => (n > 1 ? `${r} ×${n}` : r))
841
+ if (changes > 0) parts.push(pluralize(changes, 'status change'))
842
+ return parts.join(', ')
843
+ }
844
+
845
+ // The rest of a child resource's problems in one time group, behind one line
846
+ // under its newest card, so a crash-looping pod doesn't fill the list.
847
+ function FoldedProblems({ latest, others, renderCard }: { latest: TimelineEvent; others: TimelineEvent[]; renderCard: (item: TimelineEvent) => ReactNode }) {
848
+ const [open, setOpen] = useState(false)
849
+ return (
850
+ <div className="pl-3">
851
+ <button
852
+ type="button"
853
+ onClick={() => setOpen(!open)}
854
+ aria-expanded={open}
855
+ 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"
856
+ >
857
+ <CollapseChevron open={open} inheritColor className="h-3.5 w-3.5" />
858
+ {others.length} more on this {latest.kind}: {describeFolded(others)}
859
+ </button>
860
+ <Collapse open={open} unmountOnExit>
861
+ <div className="mt-2 space-y-2 border-l-2 border-theme-border pl-3">{others.map((e) => (
862
+ <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()}>
863
+ {renderCard(e)}
864
+ </div>
865
+ ))}</div>
866
+ </Collapse>
867
+ </div>
868
+ )
869
+ }
870
+
722
871
  // Component for aggregated repeated events (e.g., multiple OOMKilled)
723
872
  interface AggregatedActivityCardProps {
724
873
  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
+ })