@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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/checks/types.ts +2 -13
- 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/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +12 -9
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +2 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.tsx +21 -7
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -7
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +17 -33
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -20
- package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineList.tsx +187 -38
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- 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/topology/TopologyGraph.tsx +5 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/drawer-components.test.tsx +66 -1
- package/src/components/ui/drawer-components.tsx +43 -29
- package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.tsx +216 -48
- 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/types/capacity.ts +10 -0
- package/src/types/core.ts +32 -1
- package/src/utils/format.test.ts +40 -2
- package/src/utils/format.ts +5 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/log-level.ts +14 -0
- package/src/utils/navigation.test.ts +13 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/resource-hierarchy.test.ts +32 -0
- package/src/utils/resource-hierarchy.ts +28 -2
- package/src/utils/resource-refs.test.ts +23 -0
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
|
178
|
-
if (!
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
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
|
|
254
|
-
const
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 === '
|
|
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
|
-
• {
|
|
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
|
+
})
|