@skyhook-io/k8s-ui 1.18.0 → 1.18.1

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 (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. package/src/utils/resource-coverage.ts +117 -0
@@ -815,24 +815,57 @@ export type CopyHandler = (text: string, key: string) => void
815
815
  // RELATED RESOURCES SECTION
816
816
  // ============================================================================
817
817
 
818
- import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
818
+ import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage } from '../../types'
819
819
  import { isChangeEvent, isK8sEvent } from '../../types'
820
- import { Link } from 'lucide-react'
820
+ import { Info, Link } from 'lucide-react'
821
821
  import { formatRelativeAgeTime } from '../../utils/format'
822
822
 
823
823
  interface RelatedResourcesSectionProps {
824
824
  relationships: Relationships | undefined
825
825
  onNavigate?: (ref: ResourceRef) => void
826
+ /** The URL plural of the object shown (e.g. `deployments`). */
827
+ kind?: string
828
+ /** The manager the resource's tracking metadata names, when every manager's
829
+ * list was read, that one's doesn't list the resource and another's does. */
830
+ trackedBy?: string
826
831
  }
827
832
 
828
- export function RelatedResourcesSection({ relationships, onNavigate }: RelatedResourcesSectionProps) {
833
+ // Kinds whose objects usually own others. An empty Children list for one of
834
+ // these is surprising, so it says it covered only the kinds Radar checked;
835
+ // for any other kind an empty list stays hidden, or nearly every leaf object
836
+ // would carry the note.
837
+ const KINDS_WITH_CHILDREN = new Set(['deployments', 'replicasets', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'services', 'rollouts'])
838
+
839
+ export function RelatedResourcesSection({ relationships, onNavigate, kind, trackedBy }: RelatedResourcesSectionProps) {
829
840
  if (!relationships) return null
830
841
 
831
842
  const configRefs = omitResourceRefs(relationships.configRefs ?? [], relationships.dependencies ?? [])
832
843
  const consumers = omitResourceRefs(relationships.consumers ?? [], relationships.dependents ?? [])
844
+ const alsoOwnedBy = omitResourceRefs(relationships.owners ?? [], relationships.owner ? [relationships.owner] : [])
845
+ const managesNote = membersNote(relationships.ownership?.manages, (relationships.manages?.length ?? 0) > 0)
846
+ const managers = dedupeResourceRefs(relationships.managers ?? [])
847
+ const managedByNote = managersNote(relationships.ownership, managers.length, trackedBy)
848
+ const unanswered = ownershipUnanswered(relationships.ownership)
849
+ const several = !relationships.owner && relationships.ownership?.primaryOwnerState === 'several'
850
+ const ownerMissing = relationships.owner || several ? undefined : ownerNote(relationships.ownership)
851
+ let childrenCoverage = childrenNote(relationships.ownership)
852
+ const noChildren = !relationships.children?.length && !relationships.olderRevisions?.length
853
+ if (noChildren && childrenCoverage?.hint && !childrenCoverage.text && kind && KINDS_WITH_CHILDREN.has(kind)) {
854
+ childrenCoverage = { ...childrenCoverage, text: 'No children among the kinds Radar checked.' }
855
+ }
833
856
 
834
857
  const hasRelationships =
835
858
  relationships.owner ||
859
+ unanswered ||
860
+ ownerMissing ||
861
+ several ||
862
+ childrenCoverage?.text ||
863
+ (relationships.olderRevisions && relationships.olderRevisions.length > 0) ||
864
+ alsoOwnedBy.length > 0 ||
865
+ managers.length > 0 ||
866
+ managedByNote.text ||
867
+ (relationships.manages && relationships.manages.length > 0) ||
868
+ managesNote ||
836
869
  relationships.deployment ||
837
870
  relationships.node ||
838
871
  (relationships.children && relationships.children.length > 0) ||
@@ -864,8 +897,20 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
864
897
  return (
865
898
  <Section title="Related Resources" icon={Link} defaultExpanded>
866
899
  <div className="space-y-3">
867
- {relationships.owner && (
868
- <RelationshipGroup label="Owner" refs={[relationships.owner]} onNavigate={onNavigate} />
900
+ {unanswered && <div className="text-xs text-theme-text-tertiary">{unanswered}</div>}
901
+ {(relationships.owner || ownerMissing) && (
902
+ <RelationshipGroup label="Owner" refs={relationships.owner ? [relationships.owner] : []} onNavigate={onNavigate} note={ownerMissing} />
903
+ )}
904
+ {(alsoOwnedBy.length > 0 || several) && (
905
+ <RelationshipGroup
906
+ label={relationships.owner || ownerMissing ? 'Also Owned By' : 'Owners'}
907
+ refs={dedupeResourceRefs(alsoOwnedBy)}
908
+ onNavigate={onNavigate}
909
+ note={several ? { text: alsoOwnedBy.length > 0 ? 'None of them is its controller.' : 'None of the owners it names can be shown.' } : undefined}
910
+ />
911
+ )}
912
+ {(managers.length > 0 || managedByNote.text) && (
913
+ <RelationshipGroup label="Managed By" refs={managers} onNavigate={onNavigate} note={managedByNote} />
869
914
  )}
870
915
  {relationships.deployment && (
871
916
  <RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
@@ -873,8 +918,18 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
873
918
  {relationships.node && (
874
919
  <RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
875
920
  )}
876
- {relationships.children && relationships.children.length > 0 && (
877
- <RelationshipGroup label="Children" refs={dedupeResourceRefs(relationships.children)} onNavigate={onNavigate} />
921
+ {((relationships.children && relationships.children.length > 0) || childrenCoverage?.text || (relationships.olderRevisions && relationships.olderRevisions.length > 0)) && (
922
+ <RelationshipGroup
923
+ label="Children"
924
+ refs={dedupeResourceRefs(relationships.children ?? [])}
925
+ onNavigate={onNavigate}
926
+ note={childrenCoverage}
927
+ more={olderRevisions(relationships.olderRevisions)}
928
+ capped={relationships.ownership?.children?.truncated}
929
+ />
930
+ )}
931
+ {((relationships.manages && relationships.manages.length > 0) || managesNote) && (
932
+ <RelationshipGroup label="Manages" refs={dedupeResourceRefs(relationships.manages ?? [])} onNavigate={onNavigate} note={managesNote} capped={relationships.ownership?.manages?.truncated} />
878
933
  )}
879
934
  {relationships.services && relationships.services.length > 0 && (
880
935
  <RelationshipGroup label="Services" refs={dedupeResourceRefs(relationships.services)} onNavigate={onNavigate} />
@@ -947,23 +1002,245 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
947
1002
  )
948
1003
  }
949
1004
 
1005
+ // ownershipUnanswered says why Owner and Children couldn't be answered at all.
1006
+ function ownershipUnanswered(coverage: OwnershipCoverage | undefined): string | undefined {
1007
+ const reason = coverage?.unavailableReason
1008
+ if (!reason) return undefined
1009
+ if (reason === 'user_forbidden') return "Owner and children aren't shown: your access lets you open this object, but not list its kind here."
1010
+ return `Owner and children couldn't be read: ${gapReasonText(reason)}.`
1011
+ }
1012
+
1013
+ // ownerNote says why no owner is named, when there is an owner reference.
1014
+ function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1015
+ const ref = coverage?.ownerRef
1016
+ const named = ref?.kind && ref.name ? `${ref.kind} ${ref.name}` : 'the owner it names'
1017
+ switch (coverage?.primaryOwnerState) {
1018
+ case 'gone':
1019
+ return { text: `${capitalize(named)} no longer exists.` }
1020
+ case 'replaced':
1021
+ return { text: `${capitalize(named)} was deleted and recreated; this object names the earlier one.` }
1022
+ case 'unknown':
1023
+ return { text: coverage?.ownerReason ? `Couldn't check ${named} (${gapReasonText(coverage.ownerReason)}).` : `Couldn't check ${named}.` }
1024
+ case 'withheld':
1025
+ return { text: 'Not visible to you.' }
1026
+ case 'unresolvable':
1027
+ return { text: "Its owner reference can't be resolved in this cluster." }
1028
+ case 'excluded':
1029
+ return { text: "Its owner reference names an Event, which Radar doesn't follow as an owner." }
1030
+ }
1031
+ return undefined
1032
+ }
1033
+
1034
+ // childrenNote says what a Children list may be missing. Kinds Radar doesn't
1035
+ // collect are a hint, since most lists miss a few; kinds it couldn't check
1036
+ // for any other reason, a list cut short, and places the viewer may not read
1037
+ // are said outright.
1038
+ function childrenNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1039
+ const children = coverage?.children
1040
+ if (!children) return undefined
1041
+ const uncollected = new Set<string>()
1042
+ let someNamespaces = false
1043
+ let undiscovered = false
1044
+ const pressing = new Map<string, string>()
1045
+ for (const g of children.notChecked ?? []) {
1046
+ if (!g.kind) undiscovered = true
1047
+ else if (g.reason === 'radar_kind_not_collected') uncollected.add(g.kind)
1048
+ else if (g.reason === 'radar_namespace_not_collected') someNamespaces = true
1049
+ else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
1050
+ }
1051
+ const note: RelationshipNote = {}
1052
+ const hint: string[] = []
1053
+ if (uncollected.size > 0) hint.push(`Radar doesn't collect ${joinOr(Array.from(uncollected))} objects, so none of those are listed.`)
1054
+ if (someNamespaces) hint.push("Radar doesn't collect every namespace, so children in the others aren't listed.")
1055
+ // An API group whose discovery fails, often a metrics API with no server
1056
+ // behind it, leaves every answer with this gap; most such groups hold no
1057
+ // children, so it is a hint, not a warning.
1058
+ if (undiscovered) hint.push("Radar's API discovery missed some API groups, so children of their kinds aren't listed.")
1059
+ if (children.narrowed) hint.push('Only children you can view are listed.')
1060
+ if (hint.length > 0) note.hint = hint
1061
+ const text: string[] = []
1062
+ if (pressing.size > 0) text.push(`Couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}.`)
1063
+ if (children.truncated) text.push('More children than shown.')
1064
+ if (text.length > 0) note.text = text.join(' ')
1065
+ return note.hint || note.text ? note : undefined
1066
+ }
1067
+
1068
+ function capitalize(s: string): string {
1069
+ return s.charAt(0).toUpperCase() + s.slice(1)
1070
+ }
1071
+
1072
+ function joinOr(items: string[]): string {
1073
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
1074
+ }
1075
+
1076
+ function gapReasonText(reason: string): string {
1077
+ switch (reason) {
1078
+ case 'radar_kind_not_collected':
1079
+ case 'radar_namespace_not_collected':
1080
+ return "Radar doesn't collect it"
1081
+ case 'kind_sync_pending':
1082
+ return 'still loading'
1083
+ case 'kind_sync_timed_out':
1084
+ return 'loading is overdue'
1085
+ case 'radar_forbidden':
1086
+ case 'radar_credentials_rejected':
1087
+ return 'Radar may not list it'
1088
+ case 'kind_not_watchable':
1089
+ return "its API can't be watched"
1090
+ case 'user_permission_check_failed':
1091
+ return "your access couldn't be checked"
1092
+ case 'user_forbidden':
1093
+ return 'you may not list it'
1094
+ case 'kind_unknown':
1095
+ return "Radar's API discovery doesn't list its kind or version"
1096
+ case 'object_not_found':
1097
+ return "Radar doesn't have it yet"
1098
+ case 'cluster_connecting':
1099
+ return 'Radar is still connecting'
1100
+ case 'cluster_disconnected':
1101
+ return 'Radar lost its connection'
1102
+ case 'connection_changed':
1103
+ return "Radar's connection changed meanwhile"
1104
+ default:
1105
+ return 'the read failed'
1106
+ }
1107
+ }
1108
+
1109
+ // membersNote says what a GitOps manager's status records beyond the members
1110
+ // Manages lists; listed says whether it lists any.
1111
+ function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
1112
+ if (!manages) return undefined
1113
+ if (manages.membership === 'remote') return { text: "Its destination isn't this cluster as far as Radar can tell, so its members aren't listed." }
1114
+ if (manages.membership === 'unreadable') {
1115
+ const note: RelationshipNote = {}
1116
+ const text: string[] = []
1117
+ if (manages.unavailableReason) text.push(`Couldn't read the members its status records (${gapReasonText(manages.unavailableReason)}).`)
1118
+ if (manages.truncated) text.push('More members than shown.')
1119
+ if (text.length > 0) note.text = text.join(' ')
1120
+ if (listed) note.hint = ["Its status has no member list Radar can read now, so these are the resources the topology links to it."]
1121
+ else if (!manages.unavailableReason) note.hint = ["Its status has no member list Radar can read now."]
1122
+ return note
1123
+ }
1124
+ const extra: string[] = []
1125
+ if (manages.missing) extra.push(`${manages.missing} not in the cluster`)
1126
+ if (manages.withheld) extra.push(`${manages.withheld} you can't view`)
1127
+ if (manages.unknown) extra.push(`${manages.unknown} Radar can't check`)
1128
+ const parts: string[] = []
1129
+ if (extra.length > 0) parts.push(`Its status also lists ${extra.join(', ')}.`)
1130
+ if (manages.truncated) parts.push('More members than shown.')
1131
+ if (parts.length === 0 && !listed) parts.push('Its status records no members to show.')
1132
+ return parts.length > 0 ? { text: parts.join(' ') } : undefined
1133
+ }
1134
+
1135
+ export interface RelationshipNote {
1136
+ /** Shown under the group. */
1137
+ text?: string
1138
+ /** Lines shown on hovering an info mark beside the label. */
1139
+ hint?: string[]
1140
+ }
1141
+
950
1142
  interface RelationshipGroupProps {
951
1143
  label: string
952
1144
  refs: ResourceRef[]
953
1145
  onNavigate?: (ref: ResourceRef) => void
1146
+ note?: RelationshipNote
1147
+ /** Refs shown only on request, such as a Deployment's old ReplicaSets. */
1148
+ more?: MoreRefs
1149
+ /** The server cut the list short, so refs is not every one. */
1150
+ capped?: boolean
1151
+ }
1152
+
1153
+ interface MoreRefs {
1154
+ refs: ResourceRef[]
1155
+ /** What the refs are, as "older ReplicaSets" reads after a count. */
1156
+ label: string
1157
+ /** Why they're kept apart, shown on hovering the toggle. */
1158
+ hint: string
1159
+ }
1160
+
1161
+ // olderRevisions are the ReplicaSets the server keeps out of Children because
1162
+ // they only hold rollback history: listed there, ten of them would bury the
1163
+ // ones running Pods.
1164
+ function olderRevisions(refs: ResourceRef[] | undefined): MoreRefs | undefined {
1165
+ if (!refs || refs.length === 0) return undefined
1166
+ const unique = dedupeResourceRefs(refs)
1167
+ return {
1168
+ refs: unique,
1169
+ label: unique.length === 1 ? 'older ReplicaSet' : 'older ReplicaSets',
1170
+ hint: 'Earlier revisions kept for rollback. They run no Pods.',
1171
+ }
954
1172
  }
955
1173
 
956
1174
  const RELATIONSHIP_TRUNCATE_LIMIT = 10
957
1175
 
958
- export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
1176
+ // Managed By sits next to Owner and reads like it, so it says how they differ
1177
+ // and which direction its answer comes from.
1178
+ const MANAGED_BY_HINT = [
1179
+ 'Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.',
1180
+ "Kubernetes' garbage collector follows owner references, not these links.",
1181
+ ]
1182
+
1183
+ // managersNote explains Managed By: where it comes from, more than one
1184
+ // manager, and the managers the search couldn't settle. With none listed, a
1185
+ // remote list naming the resource or a manager kind Radar couldn't search is
1186
+ // said outright, so an empty group doesn't read as "no GitOps manager". An
1187
+ // unreadable list stays in the hint: it may name any resource, so it would
1188
+ // otherwise show beside every one, and the controllers stamp tracking
1189
+ // metadata on what they apply, through which the topology names such a
1190
+ // manager anyway.
1191
+ function managersNote(coverage: OwnershipCoverage | undefined, listed: number, trackedBy?: string): RelationshipNote {
1192
+ const hint = [...MANAGED_BY_HINT]
1193
+ const open: string[] = []
1194
+ const coverageOf = coverage?.managers
1195
+ if (coverageOf?.unreadable) {
1196
+ const n = coverageOf.unreadable
1197
+ 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.`)
1198
+ }
1199
+ if (coverageOf?.remote) {
1200
+ const n = coverageOf.remote
1201
+ open.push(`${n} ${n === 1 ? 'manager lists' : 'managers list'} it for a cluster Radar can't confirm is this one`)
1202
+ }
1203
+ const pressing = new Map<string, string>()
1204
+ for (const g of coverageOf?.notChecked ?? []) {
1205
+ // An unserved manager kind under incomplete discovery reads as read_error
1206
+ // on every resource, like discovery's own gap.
1207
+ if (!g.kind) hint.push("Radar's API discovery missed some API groups, so managers of their kinds aren't checked.")
1208
+ else if (g.reason === 'read_error') hint.push(`Radar couldn't search ${g.kind} objects, so those managers aren't checked.`)
1209
+ else if (g.reason === 'radar_kind_not_collected') hint.push(`Radar doesn't collect ${g.kind} objects, so those managers aren't checked.`)
1210
+ else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
1211
+ }
1212
+ if (pressing.size > 0) open.push(`couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}`)
1213
+ const text: string[] = []
1214
+ if (trackedBy) text.push(`Its tracking label names ${trackedBy}, whose status doesn't list it.`)
1215
+ if (open.length > 0) {
1216
+ const sentence = `${capitalize(open.join('; '))}.`
1217
+ if (listed === 0) text.push(sentence)
1218
+ else hint.push(sentence)
1219
+ }
1220
+ if (listed > 1) text.push('More than one manager lists or links to it.')
1221
+ const note: RelationshipNote = { hint: [...new Set(hint)] }
1222
+ if (text.length > 0) note.text = text.join(' ')
1223
+ return note
1224
+ }
1225
+
1226
+ export function RelationshipGroup({ label, refs, onNavigate, note, more, capped }: RelationshipGroupProps) {
959
1227
  const [showAll, setShowAll] = useState(false)
960
- if (!refs || refs.length === 0) return null
1228
+ if ((!refs || refs.length === 0) && !note && !more) return null
961
1229
  const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
962
1230
  const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
963
1231
 
964
1232
  return (
965
1233
  <div>
966
- <div className="text-xs text-theme-text-tertiary mb-1">{label}{refs.length > 1 ? ` (${refs.length})` : ''}</div>
1234
+ <div className="text-xs text-theme-text-tertiary mb-1 flex items-center gap-1">
1235
+ <span>{label}{refs.length > 1 ? ` (${refs.length}${capped ? '+' : ''})` : ''}</span>
1236
+ {note?.hint && (
1237
+ <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">
1238
+ <button type="button" className="inline-flex rounded text-theme-text-tertiary hover:text-theme-text-secondary" aria-label={note.hint.join(' ')}>
1239
+ <Info className="w-3 h-3" aria-hidden="true" />
1240
+ </button>
1241
+ </Tooltip>
1242
+ )}
1243
+ </div>
967
1244
  <div className="flex flex-wrap gap-1">
968
1245
  {visibleRefs.map((resourceRef, i) => (
969
1246
  <ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
@@ -973,10 +1250,37 @@ export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroup
973
1250
  onClick={() => setShowAll(true)}
974
1251
  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
1252
  >
976
- Show all {refs.length}
1253
+ {capped ? `Show the ${refs.length} listed` : `Show all ${refs.length}`}
977
1254
  </button>
978
1255
  )}
979
1256
  </div>
1257
+ {more && <MoreRefsToggle more={more} onNavigate={onNavigate} />}
1258
+ {note?.text && <div className="mt-1 text-xs text-theme-text-tertiary">{note.text}</div>}
1259
+ </div>
1260
+ )
1261
+ }
1262
+
1263
+ function MoreRefsToggle({ more, onNavigate }: { more: MoreRefs; onNavigate?: (ref: ResourceRef) => void }) {
1264
+ const [open, setOpen] = useState(false)
1265
+ const { panelId, buttonProps } = useDisclosure(open)
1266
+ return (
1267
+ <div className="mt-1">
1268
+ <button
1269
+ {...buttonProps}
1270
+ onClick={() => setOpen(!open)}
1271
+ title={more.hint}
1272
+ className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
1273
+ >
1274
+ <CollapseChevron open={open} className="w-3 h-3" />
1275
+ {open ? `Hide ${more.label}` : `Show ${more.refs.length} ${more.label}`}
1276
+ </button>
1277
+ <Collapse open={open} id={panelId} mountLazily>
1278
+ <div className="flex flex-wrap gap-1 mt-1">
1279
+ {more.refs.map((resourceRef, i) => (
1280
+ <ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
1281
+ ))}
1282
+ </div>
1283
+ </Collapse>
980
1284
  </div>
981
1285
  )
982
1286
  }
@@ -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,36 @@ 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
+ })
@@ -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
+ })