@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,4 +1,5 @@
1
- import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
1
+ import { dedupeResourceRefs, omitResourceRefs, resourceRefKey } from '../../utils/resource-refs'
2
+ import { gapReasonText } from '../../utils/read-actions'
2
3
  import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
3
4
  import { Disclosure } from './Disclosure'
4
5
  import { createContext, useContext, useState, useSyncExternalStore } from 'react'
@@ -295,9 +296,9 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
295
296
  return inverted ? cond.status === 'False' : cond.status === 'True'
296
297
  }
297
298
 
298
- export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
299
+ export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown' | 'neutral'
299
300
 
300
- export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
301
+ export function defaultConditionTone(cond: { type?: string; status?: string }): Exclude<ConditionTone, 'neutral'> {
301
302
  if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
302
303
  return isConditionHealthy(cond) ? 'ok' : 'fail'
303
304
  }
@@ -342,6 +343,7 @@ export function ConditionsSection({
342
343
  const isOk = tone === 'ok'
343
344
  const isWarning = tone === 'warning'
344
345
  const isFail = tone === 'fail'
346
+ const isNeutral = tone === 'neutral'
345
347
  return (
346
348
  <div key={cond.type} className={clsx(
347
349
  'flex items-start py-1.5 pr-1 text-sm relative',
@@ -355,12 +357,14 @@ export function ConditionsSection({
355
357
  {/* Timeline dot */}
356
358
  <span className={clsx(
357
359
  'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
358
- isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
360
+ isNeutral ? 'bg-theme-elevated text-theme-text-tertiary'
361
+ : isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
359
362
  : tone === 'unknown' ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
360
363
  : isWarning ? 'bg-amber-500/25 text-amber-600 dark:text-amber-400 dark:bg-amber-500/35'
361
364
  : 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
362
365
  )}>
363
- {isOk ? <Check className="w-2 h-2" strokeWidth={4} />
366
+ {isNeutral ? <Minus className="w-2 h-2" strokeWidth={4} />
367
+ : isOk ? <Check className="w-2 h-2" strokeWidth={4} />
364
368
  : tone === 'unknown' ? <Minus className="w-2 h-2" strokeWidth={4} />
365
369
  : isWarning ? <AlertTriangle className="w-2 h-2" strokeWidth={4} />
366
370
  : <X className="w-2 h-2" strokeWidth={4} />}
@@ -369,7 +373,8 @@ export function ConditionsSection({
369
373
  <div className="min-w-0 flex-1 pl-2">
370
374
  <span className={clsx(
371
375
  'font-medium text-[13px]',
372
- isOk ? 'text-theme-text-primary'
376
+ isNeutral ? 'text-theme-text-secondary'
377
+ : isOk ? 'text-theme-text-primary'
373
378
  : tone === 'unknown' ? 'text-theme-text-secondary'
374
379
  : isWarning ? 'text-amber-600 dark:text-amber-400'
375
380
  : 'text-red-600 dark:text-red-400'
@@ -415,7 +420,7 @@ interface AlertBannerProps {
415
420
  icon?: React.ComponentType<{ className?: string }>
416
421
  title: string
417
422
  message?: React.ReactNode
418
- items?: string[]
423
+ items?: React.ReactNode[]
419
424
  children?: React.ReactNode
420
425
  /** A control at the end of the title line, e.g. a link to where the problem is handled. */
421
426
  action?: React.ReactNode
@@ -815,24 +820,57 @@ export type CopyHandler = (text: string, key: string) => void
815
820
  // RELATED RESOURCES SECTION
816
821
  // ============================================================================
817
822
 
818
- import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
823
+ import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage, ManagerAnswer } from '../../types'
819
824
  import { isChangeEvent, isK8sEvent } from '../../types'
820
- import { Link } from 'lucide-react'
825
+ import { Info, Link } from 'lucide-react'
821
826
  import { formatRelativeAgeTime } from '../../utils/format'
822
827
 
823
828
  interface RelatedResourcesSectionProps {
824
829
  relationships: Relationships | undefined
825
830
  onNavigate?: (ref: ResourceRef) => void
831
+ /** The URL plural of the object shown (e.g. `deployments`). */
832
+ kind?: string
833
+ /** The manager the resource's tracking metadata names, when every manager's
834
+ * list was read, that one's doesn't list the resource and another's does. */
835
+ trackedBy?: string
826
836
  }
827
837
 
828
- export function RelatedResourcesSection({ relationships, onNavigate }: RelatedResourcesSectionProps) {
838
+ // Kinds whose objects usually own others. An empty Children list for one of
839
+ // these is surprising, so it says it covered only the kinds Radar checked;
840
+ // for any other kind an empty list stays hidden, or nearly every leaf object
841
+ // would carry the note.
842
+ const KINDS_WITH_CHILDREN = new Set(['deployments', 'replicasets', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'services', 'rollouts'])
843
+
844
+ export function RelatedResourcesSection({ relationships, onNavigate, kind, trackedBy }: RelatedResourcesSectionProps) {
829
845
  if (!relationships) return null
830
846
 
831
847
  const configRefs = omitResourceRefs(relationships.configRefs ?? [], relationships.dependencies ?? [])
832
848
  const consumers = omitResourceRefs(relationships.consumers ?? [], relationships.dependents ?? [])
849
+ const alsoOwnedBy = omitResourceRefs(relationships.owners ?? [], relationships.owner ? [relationships.owner] : [])
850
+ const managesNote = membersNote(relationships.ownership?.manages, (relationships.manages?.length ?? 0) > 0)
851
+ const managers = dedupeResourceRefs(relationships.managers ?? [])
852
+ const managedByNote = managersNote(relationships.ownership, managers.length, trackedBy)
853
+ const unanswered = ownershipUnanswered(relationships.ownership)
854
+ const several = !relationships.owner && relationships.ownership?.primaryOwnerState === 'several'
855
+ const ownerMissing = relationships.owner || several ? undefined : ownerNote(relationships.ownership)
856
+ let childrenCoverage = childrenNote(relationships.ownership)
857
+ const noChildren = !relationships.children?.length && !relationships.olderRevisions?.length
858
+ if (noChildren && childrenCoverage?.hint && !childrenCoverage.text && kind && KINDS_WITH_CHILDREN.has(kind)) {
859
+ childrenCoverage = { ...childrenCoverage, text: 'No children among the kinds Radar checked.' }
860
+ }
833
861
 
834
862
  const hasRelationships =
835
863
  relationships.owner ||
864
+ unanswered ||
865
+ ownerMissing ||
866
+ several ||
867
+ childrenCoverage?.text ||
868
+ (relationships.olderRevisions && relationships.olderRevisions.length > 0) ||
869
+ alsoOwnedBy.length > 0 ||
870
+ managers.length > 0 ||
871
+ managedByNote.text ||
872
+ (relationships.manages && relationships.manages.length > 0) ||
873
+ managesNote ||
836
874
  relationships.deployment ||
837
875
  relationships.node ||
838
876
  (relationships.children && relationships.children.length > 0) ||
@@ -864,8 +902,20 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
864
902
  return (
865
903
  <Section title="Related Resources" icon={Link} defaultExpanded>
866
904
  <div className="space-y-3">
867
- {relationships.owner && (
868
- <RelationshipGroup label="Owner" refs={[relationships.owner]} onNavigate={onNavigate} />
905
+ {unanswered && <div className="text-xs text-theme-text-tertiary">{unanswered}</div>}
906
+ {(relationships.owner || ownerMissing) && (
907
+ <RelationshipGroup label="Owner" refs={relationships.owner ? [relationships.owner] : []} onNavigate={onNavigate} note={ownerMissing} />
908
+ )}
909
+ {(alsoOwnedBy.length > 0 || several) && (
910
+ <RelationshipGroup
911
+ label={relationships.owner || ownerMissing ? 'Also Owned By' : 'Owners'}
912
+ refs={dedupeResourceRefs(alsoOwnedBy)}
913
+ onNavigate={onNavigate}
914
+ note={several ? { text: alsoOwnedBy.length > 0 ? 'None of them is its controller.' : 'None of the owners it names can be shown.' } : undefined}
915
+ />
916
+ )}
917
+ {(managers.length > 0 || managedByNote.text) && (
918
+ <RelationshipGroup label={managersListOnly(relationships.ownership?.manager, managers) ? 'Listed By' : 'Managed By'} refs={managers} onNavigate={onNavigate} note={managedByNote} />
869
919
  )}
870
920
  {relationships.deployment && (
871
921
  <RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
@@ -873,8 +923,18 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
873
923
  {relationships.node && (
874
924
  <RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
875
925
  )}
876
- {relationships.children && relationships.children.length > 0 && (
877
- <RelationshipGroup label="Children" refs={dedupeResourceRefs(relationships.children)} onNavigate={onNavigate} />
926
+ {((relationships.children && relationships.children.length > 0) || childrenCoverage?.text || (relationships.olderRevisions && relationships.olderRevisions.length > 0)) && (
927
+ <RelationshipGroup
928
+ label="Children"
929
+ refs={dedupeResourceRefs(relationships.children ?? [])}
930
+ onNavigate={onNavigate}
931
+ note={childrenCoverage}
932
+ more={olderRevisions(relationships.olderRevisions)}
933
+ capped={relationships.ownership?.children?.truncated}
934
+ />
935
+ )}
936
+ {((relationships.manages && relationships.manages.length > 0) || managesNote) && (
937
+ <RelationshipGroup label="Manages" refs={dedupeResourceRefs(relationships.manages ?? [])} onNavigate={onNavigate} note={managesNote} capped={relationships.ownership?.manages?.truncated} />
878
938
  )}
879
939
  {relationships.services && relationships.services.length > 0 && (
880
940
  <RelationshipGroup label="Services" refs={dedupeResourceRefs(relationships.services)} onNavigate={onNavigate} />
@@ -947,23 +1007,220 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
947
1007
  )
948
1008
  }
949
1009
 
1010
+ // ownershipUnanswered says why Owner and Children couldn't be answered at all.
1011
+ function ownershipUnanswered(coverage: OwnershipCoverage | undefined): string | undefined {
1012
+ const reason = coverage?.unavailableReason
1013
+ if (!reason) return undefined
1014
+ if (reason === 'user_forbidden') return "Owner and children aren't shown: your access lets you open this object, but not list its kind here."
1015
+ return `Owner and children couldn't be read: ${gapReasonText(reason)}.`
1016
+ }
1017
+
1018
+ // ownerNote says why no owner is named, when there is an owner reference.
1019
+ function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1020
+ const ref = coverage?.ownerRef
1021
+ const named = ref?.kind && ref.name ? `${ref.kind} ${ref.name}` : 'the owner it names'
1022
+ switch (coverage?.primaryOwnerState) {
1023
+ case 'gone':
1024
+ return { text: `${capitalize(named)} no longer exists.` }
1025
+ case 'replaced':
1026
+ return { text: `${capitalize(named)} was deleted and recreated; this object names the earlier one.` }
1027
+ case 'unknown':
1028
+ return { text: coverage?.ownerReason ? `Couldn't check ${named} (${gapReasonText(coverage.ownerReason)}).` : `Couldn't check ${named}.` }
1029
+ case 'withheld':
1030
+ return { text: 'Not visible to you.' }
1031
+ case 'unresolvable':
1032
+ return { text: "Its owner reference can't be resolved in this cluster." }
1033
+ case 'excluded':
1034
+ return { text: "Its owner reference names an Event, which Radar doesn't follow as an owner." }
1035
+ }
1036
+ return undefined
1037
+ }
1038
+
1039
+ // childrenNote says what a Children list may be missing. Kinds Radar doesn't
1040
+ // collect are a hint, since most lists miss a few; kinds it couldn't check
1041
+ // for any other reason, a list cut short, and places the viewer may not read
1042
+ // are said outright.
1043
+ function childrenNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1044
+ const children = coverage?.children
1045
+ if (!children) return undefined
1046
+ const uncollected = new Set<string>()
1047
+ let someNamespaces = false
1048
+ let undiscovered = false
1049
+ const pressing = new Map<string, string>()
1050
+ for (const g of children.notChecked ?? []) {
1051
+ if (!g.kind) undiscovered = true
1052
+ else if (g.reason === 'radar_kind_not_collected') uncollected.add(g.kind)
1053
+ else if (g.reason === 'radar_namespace_not_collected') someNamespaces = true
1054
+ else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
1055
+ }
1056
+ const note: RelationshipNote = {}
1057
+ const hint: string[] = []
1058
+ if (uncollected.size > 0) hint.push(`Radar doesn't collect ${joinOr(Array.from(uncollected))} objects, so none of those are listed.`)
1059
+ if (someNamespaces) hint.push("Radar doesn't collect every namespace, so children in the others aren't listed.")
1060
+ // An API group whose discovery fails, often a metrics API with no server
1061
+ // behind it, leaves every answer with this gap; most such groups hold no
1062
+ // children, so it is a hint, not a warning.
1063
+ if (undiscovered) hint.push("Radar's API discovery missed some API groups, so children of their kinds aren't listed.")
1064
+ if (children.narrowed) hint.push('Only children you can view are listed.')
1065
+ if (hint.length > 0) note.hint = hint
1066
+ const text: string[] = []
1067
+ if (pressing.size > 0) text.push(`Couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}.`)
1068
+ if (children.truncated) text.push('More children than shown.')
1069
+ if (text.length > 0) note.text = text.join(' ')
1070
+ return note.hint || note.text ? note : undefined
1071
+ }
1072
+
1073
+ function capitalize(s: string): string {
1074
+ return s.charAt(0).toUpperCase() + s.slice(1)
1075
+ }
1076
+
1077
+ function joinOr(items: string[]): string {
1078
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
1079
+ }
1080
+
1081
+ // membersNote says what a GitOps manager's status records beyond the members
1082
+ // Manages lists; listed says whether it lists any.
1083
+ function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
1084
+ if (!manages) return undefined
1085
+ if (manages.membership === 'remote') return { text: "Its destination isn't this cluster as far as Radar can tell, so its members aren't listed." }
1086
+ if (manages.membership === 'unreadable') {
1087
+ const note: RelationshipNote = {}
1088
+ const text: string[] = []
1089
+ if (manages.unavailableReason) text.push(`Couldn't read the members its status records (${gapReasonText(manages.unavailableReason)}).`)
1090
+ if (manages.truncated) text.push('More members than shown.')
1091
+ if (text.length > 0) note.text = text.join(' ')
1092
+ if (listed) note.hint = ["Its status has no member list Radar can read now, so these are the resources the topology links to it."]
1093
+ else if (!manages.unavailableReason) note.hint = ["Its status has no member list Radar can read now."]
1094
+ return note
1095
+ }
1096
+ const extra: string[] = []
1097
+ if (manages.missing) extra.push(`${manages.missing} not in the cluster`)
1098
+ if (manages.withheld) extra.push(`${manages.withheld} you can't view`)
1099
+ if (manages.unknown) extra.push(`${manages.unknown} Radar can't check`)
1100
+ const parts: string[] = []
1101
+ if (extra.length > 0) parts.push(`Its status also lists ${extra.join(', ')}.`)
1102
+ if (manages.truncated) parts.push('More members than shown.')
1103
+ if (parts.length === 0 && !listed) parts.push('Its status records no members to show.')
1104
+ return parts.length > 0 ? { text: parts.join(' ') } : undefined
1105
+ }
1106
+
1107
+ export interface RelationshipNote {
1108
+ /** Shown under the group. */
1109
+ text?: string
1110
+ /** Lines shown on hovering an info mark beside the label. */
1111
+ hint?: string[]
1112
+ }
1113
+
950
1114
  interface RelationshipGroupProps {
951
1115
  label: string
952
1116
  refs: ResourceRef[]
953
1117
  onNavigate?: (ref: ResourceRef) => void
1118
+ note?: RelationshipNote
1119
+ /** Refs shown only on request, such as a Deployment's old ReplicaSets. */
1120
+ more?: MoreRefs
1121
+ /** The server cut the list short, so refs is not every one. */
1122
+ capped?: boolean
1123
+ }
1124
+
1125
+ interface MoreRefs {
1126
+ refs: ResourceRef[]
1127
+ /** What the refs are, as "older ReplicaSets" reads after a count. */
1128
+ label: string
1129
+ /** Why they're kept apart, shown on hovering the toggle. */
1130
+ hint: string
1131
+ }
1132
+
1133
+ // olderRevisions are the ReplicaSets the server keeps out of Children because
1134
+ // they only hold rollback history: listed there, ten of them would bury the
1135
+ // ones running Pods.
1136
+ function olderRevisions(refs: ResourceRef[] | undefined): MoreRefs | undefined {
1137
+ if (!refs || refs.length === 0) return undefined
1138
+ const unique = dedupeResourceRefs(refs)
1139
+ return {
1140
+ refs: unique,
1141
+ label: unique.length === 1 ? 'older ReplicaSet' : 'older ReplicaSets',
1142
+ hint: 'Earlier revisions kept for rollback. They run no Pods.',
1143
+ }
954
1144
  }
955
1145
 
956
1146
  const RELATIONSHIP_TRUNCATE_LIMIT = 10
957
1147
 
958
- export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
1148
+ // Managed By sits next to Owner and reads like it, so it says how they differ
1149
+ // and which direction its answer comes from.
1150
+ const MANAGED_BY_HINT = [
1151
+ 'Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.',
1152
+ "Kubernetes' garbage collector follows owner references, not these links.",
1153
+ ]
1154
+
1155
+ // managersListOnly: every manager shown is one whose list names the resource
1156
+ // while its metadata doesn't confirm it, which the chip calls Listed by.
1157
+ function managersListOnly(answer: ManagerAnswer | undefined, managers: ResourceRef[]): boolean {
1158
+ if (!answer || answer.via || !['listed', 'shared', 'disagree'].includes(answer.state) || !managers.length) return false
1159
+ const recorders = new Set((answer.direct.recorders ?? []).map(resourceRefKey))
1160
+ return managers.every(m => recorders.has(resourceRefKey(m)))
1161
+ }
1162
+
1163
+ // managersNote explains Managed By: where it comes from, more than one
1164
+ // manager, and the managers the search couldn't settle. With none listed, a
1165
+ // remote list naming the resource or a manager kind Radar couldn't search is
1166
+ // said outright, so an empty group doesn't read as "no GitOps manager". An
1167
+ // unreadable list stays in the hint: it may name any resource, so it would
1168
+ // otherwise show beside every one, and the controllers stamp tracking
1169
+ // metadata on what they apply, through which the topology names such a
1170
+ // manager anyway.
1171
+ function managersNote(coverage: OwnershipCoverage | undefined, listed: number, trackedBy?: string): RelationshipNote {
1172
+ const hint = [...MANAGED_BY_HINT]
1173
+ const open: string[] = []
1174
+ const coverageOf = coverage?.managers
1175
+ if (coverageOf?.unreadable) {
1176
+ const n = coverageOf.unreadable
1177
+ hint.push(n === 1 ? "1 manager's member list can't be read, so it may also list this resource." : `${n} managers' member lists can't be read, so they may also list this resource.`)
1178
+ }
1179
+ if (coverageOf?.remote) {
1180
+ const n = coverageOf.remote
1181
+ open.push(`${n} ${n === 1 ? 'manager lists' : 'managers list'} it for a cluster Radar can't confirm is this one`)
1182
+ }
1183
+ const pressing = new Map<string, string>()
1184
+ for (const g of coverageOf?.notChecked ?? []) {
1185
+ // An unserved manager kind under incomplete discovery reads as read_error
1186
+ // on every resource, like discovery's own gap.
1187
+ if (!g.kind) hint.push("Radar's API discovery missed some API groups, so managers of their kinds aren't checked.")
1188
+ else if (g.reason === 'read_error') hint.push(`Radar couldn't search ${g.kind} objects, so those managers aren't checked.`)
1189
+ else if (g.reason === 'radar_kind_not_collected') hint.push(`Radar doesn't collect ${g.kind} objects, so those managers aren't checked.`)
1190
+ else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
1191
+ }
1192
+ if (pressing.size > 0) open.push(`couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}`)
1193
+ const text: string[] = []
1194
+ if (trackedBy) text.push(`Its tracking label names ${trackedBy}, whose status doesn't list it.`)
1195
+ if (open.length > 0) {
1196
+ const sentence = `${capitalize(open.join('; '))}.`
1197
+ if (listed === 0) text.push(sentence)
1198
+ else hint.push(sentence)
1199
+ }
1200
+ if (listed > 1) text.push('More than one manager lists or links to it.')
1201
+ const note: RelationshipNote = { hint: [...new Set(hint)] }
1202
+ if (text.length > 0) note.text = text.join(' ')
1203
+ return note
1204
+ }
1205
+
1206
+ export function RelationshipGroup({ label, refs, onNavigate, note, more, capped }: RelationshipGroupProps) {
959
1207
  const [showAll, setShowAll] = useState(false)
960
- if (!refs || refs.length === 0) return null
1208
+ if ((!refs || refs.length === 0) && !note && !more) return null
961
1209
  const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
962
1210
  const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
963
1211
 
964
1212
  return (
965
1213
  <div>
966
- <div className="text-xs text-theme-text-tertiary mb-1">{label}{refs.length > 1 ? ` (${refs.length})` : ''}</div>
1214
+ <div className="text-xs text-theme-text-tertiary mb-1 flex items-center gap-1">
1215
+ <span>{label}{refs.length > 1 ? ` (${refs.length}${capped ? '+' : ''})` : ''}</span>
1216
+ {note?.hint && (
1217
+ <Tooltip content={<div className="space-y-0.5">{note.hint.map((line) => <div key={line}>{line}</div>)}</div>} delay={150} className="max-h-64 overflow-y-auto">
1218
+ <button type="button" className="inline-flex rounded text-theme-text-tertiary hover:text-theme-text-secondary" aria-label={note.hint.join(' ')}>
1219
+ <Info className="w-3 h-3" aria-hidden="true" />
1220
+ </button>
1221
+ </Tooltip>
1222
+ )}
1223
+ </div>
967
1224
  <div className="flex flex-wrap gap-1">
968
1225
  {visibleRefs.map((resourceRef, i) => (
969
1226
  <ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
@@ -973,10 +1230,37 @@ export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroup
973
1230
  onClick={() => setShowAll(true)}
974
1231
  className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
975
1232
  >
976
- Show all {refs.length}
1233
+ {capped ? `Show the ${refs.length} listed` : `Show all ${refs.length}`}
977
1234
  </button>
978
1235
  )}
979
1236
  </div>
1237
+ {more && <MoreRefsToggle more={more} onNavigate={onNavigate} />}
1238
+ {note?.text && <div className="mt-1 text-xs text-theme-text-tertiary">{note.text}</div>}
1239
+ </div>
1240
+ )
1241
+ }
1242
+
1243
+ function MoreRefsToggle({ more, onNavigate }: { more: MoreRefs; onNavigate?: (ref: ResourceRef) => void }) {
1244
+ const [open, setOpen] = useState(false)
1245
+ const { panelId, buttonProps } = useDisclosure(open)
1246
+ return (
1247
+ <div className="mt-1">
1248
+ <button
1249
+ {...buttonProps}
1250
+ onClick={() => setOpen(!open)}
1251
+ title={more.hint}
1252
+ className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
1253
+ >
1254
+ <CollapseChevron open={open} className="w-3 h-3" />
1255
+ {open ? `Hide ${more.label}` : `Show ${more.refs.length} ${more.label}`}
1256
+ </button>
1257
+ <Collapse open={open} id={panelId} mountLazily>
1258
+ <div className="flex flex-wrap gap-1 mt-1">
1259
+ {more.refs.map((resourceRef, i) => (
1260
+ <ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
1261
+ ))}
1262
+ </div>
1263
+ </Collapse>
980
1264
  </div>
981
1265
  )
982
1266
  }
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToString } from 'react-dom/server'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { RelatedResourcesSection } from './drawer-components'
7
+ import type { Relationships } from '../../types'
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
10
+
11
+ const rs = (name: string) => ({ group: 'apps', kind: 'ReplicaSet', namespace: 'shop', name })
12
+ let container: HTMLDivElement | undefined
13
+ afterEach(() => {
14
+ container?.remove()
15
+ container = undefined
16
+ })
17
+
18
+ function mount(relationships: Relationships) {
19
+ container = document.createElement('div')
20
+ document.body.appendChild(container)
21
+ const root = createRoot(container)
22
+ act(() => root.render(<RelatedResourcesSection relationships={relationships} />))
23
+ return container
24
+ }
25
+
26
+ describe('older ReplicaSets', () => {
27
+ it('lists the ReplicaSets running Pods, and the old revisions only on request', () => {
28
+ const el = mount({ children: [rs('web-7d4f')], olderRevisions: [rs('web-5c9a'), rs('web-6b1e')] })
29
+ const toggle = Array.from(el.querySelectorAll('button')).find((b) => b.textContent?.includes('older ReplicaSets'))
30
+ expect(toggle?.textContent).toBe('Show 2 older ReplicaSets')
31
+ expect(toggle?.getAttribute('aria-expanded')).toBe('false')
32
+ expect(el.textContent).toContain('web-7d4f')
33
+ expect(el.textContent).not.toContain('web-5c9a')
34
+ act(() => toggle?.click())
35
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true')
36
+ expect(toggle?.textContent).toBe('Hide older ReplicaSets')
37
+ expect(el.textContent).toContain('web-5c9a')
38
+ expect(el.textContent).toContain('web-6b1e')
39
+ })
40
+
41
+ it('shows Children for old revisions alone, and nothing extra without them', () => {
42
+ expect(renderToString(<RelatedResourcesSection relationships={{ olderRevisions: [rs('web-5c9a')] }} />)).toContain('Show 1 older ReplicaSet')
43
+ expect(renderToString(<RelatedResourcesSection relationships={{ children: [rs('web-7d4f')] }} />)).not.toContain('older ReplicaSet')
44
+ })
45
+ })
@@ -35,3 +35,76 @@ describe('workload staged-policy projection', () => {
35
35
  expect(html).not.toContain('Network policies')
36
36
  })
37
37
  })
38
+
39
+
40
+ describe('workload GitOps manager', () => {
41
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
42
+ const render = (relationships: Parameters<typeof WorkloadView>[0]['relationships']) =>
43
+ renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} relationships={relationships} expanded={false} />)
44
+
45
+ it('shows the header chip alone when its manager is the only one whose status lists the workload', () => {
46
+ const html = render({ managedBy: [app], managers: [app], ownership: { children: { complete: true } } })
47
+ expect(html).toContain('Managed by')
48
+ expect(html).not.toContain('Managed By')
49
+ })
50
+
51
+ it('keeps Managed By when the search left managers it could not settle', () => {
52
+ const html = render({ managedBy: [app], managers: [app], ownership: { children: { complete: true }, managers: { remote: 1 } } })
53
+ expect(html).toContain('Managed By')
54
+ expect(html).toContain('Managed by')
55
+ })
56
+
57
+ it('keeps Managed By beside a chip whose manager its status does not list', () => {
58
+ const hub = { ...app, name: 'hub' }
59
+ const html = render({ managedBy: [app], managers: [hub], ownership: { children: { complete: true } } })
60
+ expect(html).toContain('Managed By')
61
+ expect(html).toContain('Tracked by')
62
+ expect(html).toContain("Its tracking label names Application argocd/shop, whose status doesn&#x27;t list it.")
63
+ })
64
+
65
+ it('names a manager known only by its instance label without a namespace', () => {
66
+ const hub = { ...app, name: 'hub' }
67
+ const html = render({ managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'shop' }], managers: [hub], ownership: { children: { complete: true } } })
68
+ expect(html).toContain("Its tracking label names Application shop, whose status doesn&#x27;t list it.")
69
+ })
70
+ })
71
+
72
+ describe('manager answer group projection', () => {
73
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
74
+ const object = { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }
75
+ const answer: import('../../types/core').ManagerAnswer = { state: 'listed', lead: app, direct: { object, recorders: [app] } }
76
+ const render = (manager: typeof answer, extra: Partial<import('../../types/core').Relationships> = {}) =>
77
+ renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} expanded={false}
78
+ relationships={{ managers: [app], ownership: { children: { complete: true }, manager }, ...extra }} />)
79
+ // The group is titled the way the chip words the answer.
80
+ const group = (html: string) => html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : null
81
+
82
+ it.each(['agree', 'listed'] as const)('hides only the exact single %s recorder', state => {
83
+ const html = render({ ...answer, state })
84
+ expect(html).toContain(state === 'agree' ? 'Managed by' : 'Listed by')
85
+ expect(group(html)).toBeNull()
86
+ })
87
+ it.each(['claimed', 'unknown', 'none'] as const)('keeps Managed By for %s', state => {
88
+ expect(group(render({ ...answer, state }))).toBe('Managed By')
89
+ })
90
+ it.each(['disagree', 'shared'] as const)('keeps the group for %s, titled Listed By', state => {
91
+ expect(group(render({ ...answer, state }))).toBe('Listed By')
92
+ })
93
+ it('keeps Managed By with via', () => {
94
+ expect(render({ ...answer, via: { object, recorders: [app] } })).toContain('Managed By')
95
+ })
96
+ it('keeps extra non-recorded managers', () => {
97
+ expect(render(answer, { managers: [app, { kind: 'Certificate', group: 'cert-manager.io', namespace: 'demo', name: 'tls' }] })).toContain('Managed By')
98
+ })
99
+ it('keeps a different single manager', () => {
100
+ expect(render(answer, { managers: [{ ...app, name: 'other' }] })).toContain('Managed By')
101
+ })
102
+ it('keeps the coverage note', () => {
103
+ expect(group(render(answer, { ownership: { children: { complete: true }, manager: answer, managers: { unreadable: 1 } } }))).toBe('Listed By')
104
+ })
105
+ it('uses native Helm’s existing chip', () => {
106
+ const helm = { kind: 'HelmRelease', group: '', namespace: 'demo', name: 'chart' }
107
+ const html = render({ state: 'claimed', lead: helm, direct: { object, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording' }] } })
108
+ expect(html).toContain('Managed by Helm')
109
+ })
110
+ })
@@ -0,0 +1,96 @@
1
+ // @vitest-environment jsdom
2
+ import { StrictMode, act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import type { TimelineEvent } from '../../types'
6
+ import { WorkloadView } from './WorkloadView'
7
+
8
+ vi.mock('../timeline/TimelineSwimlanes', () => ({ TimelineSwimlanes: () => null }))
9
+
10
+ const resource = { apiVersion: 'batch/v1', kind: 'CronJob', metadata: { name: 'nightly', namespace: 'demo', uid: 'cronjob-1' }, spec: { schedule: '0 * * * *' } }
11
+ const row = (id: string, minutesAgo: number): TimelineEvent => ({
12
+ id, uid: 'cronjob-1', kind: 'CronJob', apiVersion: 'batch/v1', namespace: 'demo', name: 'nightly',
13
+ source: 'k8s_event', eventType: 'Warning', reason: 'InvalidSchedule', message: `report ${id}`,
14
+ timestamp: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
15
+ })
16
+ const focus = { id: 'target' }
17
+ let root: Root
18
+ let container: HTMLDivElement
19
+ const scroll = vi.fn()
20
+ beforeEach(() => {
21
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
22
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
23
+ vi.stubGlobal('CSS', { escape: (id: string) => id })
24
+ HTMLElement.prototype.scrollIntoView = scroll
25
+ scroll.mockClear()
26
+ container = document.createElement('div')
27
+ document.body.append(container)
28
+ root = createRoot(container)
29
+ })
30
+ afterEach(async () => {
31
+ await act(async () => root.unmount())
32
+ container.remove()
33
+ vi.unstubAllGlobals()
34
+ })
35
+
36
+ async function render(events: TimelineEvent[], options: { truncated?: boolean; loadOlder?: () => void; loadingOlder?: boolean; error?: Error; target?: { id: string } } = {}) {
37
+ await act(async () => root.render(<StrictMode><WorkloadView
38
+ kind="cronjobs" namespace="demo" name="nightly" resource={resource} onBack={() => {}}
39
+ expanded activeTab="timeline" allEvents={events} historyScoped timelineFocusEvent={options.target ?? focus}
40
+ historyTruncated={options.truncated} onLoadOlderHistory={options.loadOlder}
41
+ loadingOlderHistory={options.loadingOlder} olderHistoryError={options.error}
42
+ /></StrictMode>))
43
+ }
44
+
45
+ it('scrolls to the exact report even among warnings with the same reason', async () => {
46
+ await render([row('newest', 1), row('target', 5), row('oldest', 10)])
47
+ const target = container.querySelector('[data-event-id="target"]')
48
+ expect(target).not.toBeNull()
49
+ expect(target?.textContent).toContain('report target')
50
+ expect(target?.querySelector('.ring-2')).not.toBeNull()
51
+ expect(scroll).toHaveBeenCalled()
52
+ expect(container.textContent).not.toContain('not available')
53
+ })
54
+
55
+ it('loads older pages until the report appears, without duplicate requests in Strict Mode', async () => {
56
+ const loadOlder = vi.fn()
57
+ await render([row('newest', 1)], { truncated: true, loadOlder })
58
+ expect(loadOlder).toHaveBeenCalledTimes(1)
59
+ expect(container.textContent).toContain('Finding the reported error')
60
+ await render([row('newest', 1), row('middle', 2)], { truncated: true, loadOlder })
61
+ expect(loadOlder).toHaveBeenCalledTimes(2)
62
+ await render([row('newest', 1), row('target', 5)], { truncated: true, loadOlder })
63
+ expect(loadOlder).toHaveBeenCalledTimes(2)
64
+ expect(container.querySelector('[data-event-id="target"] .ring-2')).not.toBeNull()
65
+ })
66
+
67
+ it('explains an absent report and stops when history is exhausted', async () => {
68
+ const loadOlder = vi.fn()
69
+ await render([row('newest', 1)], { loadOlder })
70
+ expect(loadOlder).not.toHaveBeenCalled()
71
+ expect(container.textContent).toContain('not available in the retained timeline history')
72
+ })
73
+
74
+ it('does not retry failed paging automatically', async () => {
75
+ const loadOlder = vi.fn()
76
+ await render([row('newest', 1)], { truncated: true, loadOlder, error: new Error('unavailable') })
77
+ expect(loadOlder).not.toHaveBeenCalled()
78
+ expect(container.textContent).toContain("some history couldn't be loaded")
79
+ })
80
+
81
+ it('lets the user select another event without a refresh forcing the original selection back', async () => {
82
+ const events = [row('newest', 1), row('target', 5)]
83
+ await render(events)
84
+ await act(async () => container.querySelector<HTMLElement>('[data-event-id="newest"] .cursor-pointer')!.click())
85
+ await render([...events, row('older', 10)])
86
+ expect(container.querySelector('[data-event-id="newest"] .ring-2')).not.toBeNull()
87
+ expect(container.querySelector('[data-event-id="target"] .ring-2')).toBeNull()
88
+ })
89
+
90
+ it('honors a new click on the same report', async () => {
91
+ const events = [row('newest', 1), row('target', 5)]
92
+ await render(events)
93
+ await act(async () => container.querySelector<HTMLElement>('[data-event-id="newest"] .cursor-pointer')!.click())
94
+ await render(events, { target: { id: 'target' } })
95
+ expect(container.querySelector('[data-event-id="target"] .ring-2')).not.toBeNull()
96
+ })