@skyhook-io/k8s-ui 1.18.1 → 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 (85) hide show
  1. package/README.md +8 -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/ResourceCompareView.tsx +6 -2
  6. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  7. package/src/components/issues/IssuesView.tsx +2 -2
  8. package/src/components/issues/diagnostic.ts +2 -0
  9. package/src/components/issues/severity.ts +1 -0
  10. package/src/components/logs/LogCore.tsx +13 -11
  11. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  12. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  13. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  15. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  16. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  17. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  18. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  19. package/src/components/resources/ResourcesView.tsx +238 -66
  20. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  21. package/src/components/resources/generic-status.ts +15 -0
  22. package/src/components/resources/printer-columns.test.ts +36 -0
  23. package/src/components/resources/printer-columns.ts +12 -1
  24. package/src/components/resources/readiness-gates.test.ts +95 -0
  25. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  26. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  27. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  28. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  29. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  30. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  31. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  32. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  33. package/src/components/resources/renderers/index.ts +3 -0
  34. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  35. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  36. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  37. package/src/components/resources/resource-utils-flagger.ts +76 -0
  38. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  39. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  40. package/src/components/resources/resource-utils.ts +80 -2
  41. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  42. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  43. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  44. package/src/components/shared/DetailShell.tsx +14 -3
  45. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  46. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  47. package/src/components/shared/EditableYamlView.tsx +18 -3
  48. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  49. package/src/components/shared/FilterRailPanel.tsx +62 -0
  50. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  51. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  52. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  53. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  54. package/src/components/shared/ManagedByChip.tsx +135 -2
  55. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  56. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  57. package/src/components/shared/SetImageDialog.tsx +3 -0
  58. package/src/components/topology/TopologyControls.tsx +1 -1
  59. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  60. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  61. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  62. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  63. package/src/components/ui/PageHeader.tsx +3 -2
  64. package/src/components/ui/SummaryTile.tsx +13 -3
  65. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  66. package/src/components/ui/Tooltip.tsx +11 -2
  67. package/src/components/ui/YamlReview.test.tsx +52 -0
  68. package/src/components/ui/YamlReview.tsx +7 -0
  69. package/src/components/ui/drawer-components.test.tsx +27 -0
  70. package/src/components/ui/drawer-components.tsx +22 -42
  71. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  72. package/src/components/workload/WorkloadView.tsx +57 -34
  73. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  74. package/src/hooks/useKeepInViewport.test.ts +27 -0
  75. package/src/hooks/useKeepInViewport.ts +87 -0
  76. package/src/types/core.ts +53 -1
  77. package/src/utils/api-resources.ts +1 -0
  78. package/src/utils/gitops-owner.ts +136 -8
  79. package/src/utils/gitops-write-guard.test.ts +332 -0
  80. package/src/utils/gitops-write-guard.ts +175 -11
  81. package/src/utils/index.ts +1 -0
  82. package/src/utils/read-actions.ts +34 -0
  83. package/src/utils/touch-intent.test.ts +39 -0
  84. package/src/utils/touch-intent.ts +35 -0
  85. package/vitest.config.ts +26 -0
@@ -0,0 +1,87 @@
1
+ import { useLayoutEffect, useEffect, type RefObject } from 'react'
2
+
3
+ /** Keeps an open menu or popover horizontally inside the visible area: the
4
+ * viewport, narrowed by any ancestor that clips horizontally (a content
5
+ * column beside a nav rail clips what slides under the rail). Anchored menus
6
+ * open at their trigger's edge with a fixed minimum width, so on a narrow
7
+ * screen a trigger near the edge pushes the menu out of view. After each
8
+ * layout, and on resize while open, this shifts the element back with the
9
+ * `translate` property (which moves it whichever side it's anchored to), and
10
+ * narrows it only if it can't fit. Don't use it on an element whose own
11
+ * motion animates `translate`: the inline value would override it.
12
+ *
13
+ * Runs after every render while `active`, so a menu whose content changes
14
+ * width (a filter narrowing a list) is re-measured. */
15
+ export function useKeepInViewport(ref: RefObject<HTMLElement | null>, active: boolean, margin = 8): void {
16
+ useLayoutEffect(() => {
17
+ if (active) keepInView(ref.current, margin)
18
+ })
19
+ useEffect(() => {
20
+ if (!active) return
21
+ const onResize = () => keepInView(ref.current, margin)
22
+ window.addEventListener('resize', onResize)
23
+ return () => window.removeEventListener('resize', onResize)
24
+ }, [ref, active, margin])
25
+ }
26
+
27
+ // Width overrides this hook applied, keyed by element, with the inline values
28
+ // they replaced, so a later measurement can put back exactly what the caller set
29
+ // (React won't re-apply an inline style whose value it thinks is unchanged).
30
+ const sizedBy = new WeakMap<HTMLElement, { minWidth: string; maxWidth: string }>()
31
+
32
+ function keepInView(el: HTMLElement | null, margin: number): void {
33
+ if (!el) return
34
+ // Measure and move with transitions off. Under reduced motion every
35
+ // element gets a near-zero transition on all properties, and a transitioned
36
+ // element reports its pre-change box in the same frame: a menu just moved
37
+ // from left 0 to its trigger still measured at 0, and was pushed off its
38
+ // trigger by the margin.
39
+ const transition = el.style.transition
40
+ el.style.transition = 'none'
41
+ place(el, margin)
42
+ el.getBoundingClientRect() // apply the move before transitions return
43
+ el.style.transition = transition
44
+ }
45
+
46
+ function place(el: HTMLElement, margin: number): void {
47
+ el.style.translate = ''
48
+ const saved = sizedBy.get(el)
49
+ if (saved) {
50
+ el.style.minWidth = saved.minWidth
51
+ el.style.maxWidth = saved.maxWidth
52
+ sizedBy.delete(el)
53
+ }
54
+ const [boundLeft, boundRight] = visibleBounds(el, margin)
55
+ const room = boundRight - boundLeft
56
+ // Only a menu that can't fit gets resized; otherwise the caller's own
57
+ // minimum and maximum widths stand.
58
+ if (el.offsetWidth > room) {
59
+ sizedBy.set(el, { minWidth: el.style.minWidth, maxWidth: el.style.maxWidth })
60
+ el.style.minWidth = '0'
61
+ el.style.maxWidth = `${room}px`
62
+ }
63
+ const { left, right } = el.getBoundingClientRect()
64
+ const shift = keepInViewportShift(left, right, boundLeft, boundRight)
65
+ if (shift !== 0) el.style.translate = `${shift}px 0`
66
+ }
67
+
68
+ function visibleBounds(el: HTMLElement, margin: number): [number, number] {
69
+ let left = margin
70
+ let right = document.documentElement.clientWidth - margin
71
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
72
+ if (getComputedStyle(p).overflowX === 'visible') continue
73
+ const r = p.getBoundingClientRect()
74
+ left = Math.max(left, r.left + margin)
75
+ right = Math.min(right, r.right - margin)
76
+ }
77
+ return [left, Math.max(left, right)]
78
+ }
79
+
80
+ /** Horizontal shift (px) that brings [left, right] inside [boundLeft,
81
+ * boundRight], preferring the left edge when the element is wider. */
82
+ export function keepInViewportShift(left: number, right: number, boundLeft: number, boundRight: number): number {
83
+ let shift = 0
84
+ if (right > boundRight) shift = boundRight - right
85
+ if (left + shift < boundLeft) shift = boundLeft - left
86
+ return shift
87
+ }
package/src/types/core.ts CHANGED
@@ -126,6 +126,7 @@ export interface Capabilities {
126
126
  helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values)
127
127
  nodeWrite: boolean // Node write operations (cordon, uncordon, drain)
128
128
  workloadWrites?: WorkloadWritePermissions // Workload patch permissions (restart/scale controls)
129
+ mcpExecTools?: boolean // Full MCP registration; does not grant the caller exec permission
129
130
  mcpReadOnly?: boolean // Full MCP endpoint is disabled; read-only endpoints remain available
130
131
  mcpEnabled: boolean // MCP server is running
131
132
  // Karpenter discovery and NodePool read state. Optional on the wire for the
@@ -177,6 +178,7 @@ export interface FeatureCapabilities {
177
178
  gitopsWriteEvidence?: boolean
178
179
  serviceEndpointSlices?: boolean
179
180
  trafficRecords?: boolean
181
+ clusterScopedPlaceholder?: boolean
180
182
  }
181
183
 
182
184
  // DeploymentMode is the closed set of topologies Radar can run in.
@@ -738,10 +740,60 @@ export interface OwnershipCoverage {
738
740
  remote?: number
739
741
  notChecked?: OwnershipGap[]
740
742
  }
743
+ /** What manages this resource, from its own evidence or its nearest controller ancestor's. Absent means no answer (a Radar that predates it, or unavailableReason), never "none". */
744
+ manager?: ManagerAnswer
741
745
  /** A readreason code when Radar couldn't answer at all. */
742
746
  unavailableReason?: string
743
747
  }
744
748
 
749
+ /** What manages a resource: the managers whose status lists name it and what its metadata claims. It names no winner the evidence doesn't support. */
750
+ export interface ManagerAnswer {
751
+ /** none: no evidence in what Radar collects and the viewer may read, never "unmanaged". */
752
+ state: 'shared' | 'disagree' | 'agree' | 'listed' | 'claimed' | 'unknown' | 'none'
753
+ /** The manager to show first, never a verdict: the recorder for agree/listed; for shared, the one recorder a claim names, if exactly one; for claimed, the manager every claim names, if they agree. Its namespace is "" when it comes from a claim that names none. */
754
+ lead?: ResourceRef
755
+ /** The evidence on the resource itself. */
756
+ direct: ManagerEvidence
757
+ /** The nearest controller ancestor's evidence that settles it when the resource's own doesn't, or, when nothing does and the resource makes no claim that isn't copied, the nearest ancestor that makes one; state and lead then come from it. */
758
+ via?: ManagerEvidence
759
+ /** Other objects the walk passed that make claims, in chain order. When via is a fallback to the nearest claim, claims farther up stay here. */
760
+ between?: ManagerEvidence[]
761
+ /** Why a walk that found no settling evidence stopped short. Absent when evidence settled it first, or the walk reached the chain's top. */
762
+ chainEnd?: 'unfollowed' | 'cycle' | 'truncated'
763
+ /** The controller reference the walk couldn't follow, when chainEnd is unfollowed. */
764
+ stop?: {
765
+ at: ResourceRef
766
+ /** As primaryOwnerState: gone, replaced, withheld, unknown, unresolvable or excluded. */
767
+ state: string
768
+ /** The owner the reference names when it is gone, replaced or unknown. */
769
+ owner?: ResourceRef
770
+ /** A readreason code when the owner is unknown. */
771
+ reason?: string
772
+ }
773
+ /** The viewer's access is restricted, so the evidence is the part it may read. */
774
+ narrowed?: boolean
775
+ }
776
+
777
+ export interface ManagerEvidence {
778
+ object: ResourceRef
779
+ /** Local managers whose status lists name it. */
780
+ recorders?: ResourceRef[]
781
+ /** Managers its own metadata names. */
782
+ claims?: ManagerClaim[]
783
+ }
784
+
785
+ /** source, check, and the answer's state and chainEnd may gain values in newer Radars: show an unfamiliar one generically and never treat it as confirming. */
786
+ export interface ManagerClaim {
787
+ /** Its namespace is "" when the metadata doesn't say. */
788
+ manager: ResourceRef
789
+ source: 'flux-helm-labels' | 'flux-kustomize-labels' | 'argo-tracking-id' | 'argo-instance-label' | 'helm-release'
790
+ check: 'recorded' | 'omitted' | 'unreadable' | 'remote' | 'missing' | 'not-recording' | 'unresolved' | 'withheld' | 'unchecked'
791
+ /** An Argo CD tracking ID naming another object. */
792
+ copied?: boolean
793
+ /** Copied, or an instance label or Helm release annotation on an object a controller owns: it doesn't stop the walk. One that isn't copied can still answer when nothing on the chain settles it. */
794
+ weak?: boolean
795
+ }
796
+
745
797
  // Parsed X.509 certificate metadata (from backend cert parsing)
746
798
  export interface CertificateInfo {
747
799
  subject: string
@@ -1515,7 +1567,7 @@ export interface AggregatedFlow {
1515
1567
 
1516
1568
  // Cluster info for traffic detection
1517
1569
  export interface TrafficClusterInfo {
1518
- platform: string // rke2, gke, eks, aks, minikube, kind, docker-desktop, openshift, rancher, generic
1570
+ platform: string // nkp, rke2, gke, eks, aks, nutanix, minikube, kind, docker-desktop, openshift, rancher, generic
1519
1571
  cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
1520
1572
  dataplaneV2: boolean
1521
1573
  clusterName?: string
@@ -216,6 +216,7 @@ export function formatGroupName(group: string): string {
216
216
  'logstash.k8s.elastic.co': 'Elastic',
217
217
  'jaegertracing.io': 'Jaeger',
218
218
  'opentelemetry.io': 'OpenTelemetry',
219
+ 'flagger.app': 'Flagger',
219
220
  'projectcalico.org': 'Calico',
220
221
  'crd.projectcalico.org': 'Calico',
221
222
  'projectcontour.io': 'Contour',
@@ -1,12 +1,8 @@
1
- // Map a resource's server-side `Relationships.managedBy` to a navigable
2
- // GitOps owner ref, used by the drawer to render the "Managed by <app>" chip.
3
- //
4
- // The detection (label/annotation parsing, Argo tracking-id decoding, Flux
5
- // label inspection, Helm release annotation, owner-chain walk) lives server-
6
- // side in pkg/topology.SynthesizeManagedBy. This file is now a thin mapper
7
- // from the structured ref into the discriminated union the chip + router need.
1
+ // Maps server manager evidence to drawer copy and navigation. Older Radars
2
+ // without the answer retain their managedBy projection.
8
3
 
9
- import type { Relationships, ResourceRef } from '../types/core'
4
+ import type { ManagerAnswer, ManagerClaim, ManagerEvidence, Relationships, ResourceRef } from '../types/core'
5
+ import { dedupeResourceRefs, resourceRefKey } from './resource-refs'
10
6
 
11
7
  // GitOpsOwnerRef is a discriminated union — the tool determines which kind is
12
8
  // valid. Modeling it this way prevents callers (and consumers of the returned
@@ -36,6 +32,8 @@ const FLUX_HELM_GROUP = 'helm.toolkit.fluxcd.io'
36
32
  export function gitOpsOwnerFromRelationships(
37
33
  rel: Relationships | undefined | null,
38
34
  ): GitOpsOwnerRef | null {
35
+ const answer = rel?.ownership?.manager
36
+ if (answer) return answer.lead ? gitOpsOwnerFromRef(answer.lead) : null
39
37
  const refs = rel?.managedBy
40
38
  if (!refs || refs.length === 0) return null
41
39
  return gitOpsOwnerFromRef(refs[0])
@@ -76,3 +74,133 @@ export function managerListing(owner: GitOpsOwnerRef | null | undefined, manager
76
74
  if (!listed.some(isOwner)) return { state: 'conflict', listedBy: listed }
77
75
  return listed.length === 1 && managers.length === 1 ? { state: 'confirmed' } : undefined
78
76
  }
77
+
78
+
79
+ export function activeManagerClaims(evidence: ManagerEvidence): ManagerClaim[] {
80
+ const claims = (evidence.claims ?? []).filter(c => !c.copied)
81
+ const hasGitOps = Boolean(evidence.recorders?.length) || claims.some(c => c.source !== 'helm-release')
82
+ return hasGitOps ? claims.filter(c => c.source !== 'helm-release') : claims
83
+ }
84
+
85
+ export function managerCandidates(answer: ManagerAnswer): ResourceRef[] {
86
+ const evidence = answer.via ?? answer.direct
87
+ const recorders = evidence.recorders ?? []
88
+ const qualified = [...recorders, ...(answer.lead ? [answer.lead] : []), ...activeManagerClaims(evidence).map(c => c.manager).filter(r => r.namespace)]
89
+ // A claim without a namespace names the qualified manager of its kind and
90
+ // name the answer already holds, when there is exactly one.
91
+ const claims = activeManagerClaims(evidence).map(c => {
92
+ const ref = c.manager
93
+ if (ref.namespace) return ref
94
+ const matches = dedupeResourceRefs(qualified.filter(r => r.kind === ref.kind && r.group === ref.group && r.name === ref.name))
95
+ return matches.length === 1 ? matches[0] : ref
96
+ })
97
+ return dedupeResourceRefs([...(answer.lead ? [answer.lead] : []), ...claims, ...recorders])
98
+ }
99
+
100
+ export function managerRefLabel(ref: ResourceRef): string {
101
+ return `${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ''}${ref.name}`
102
+ }
103
+
104
+ export function managerChipPresentation(answer: ManagerAnswer): { prefix: string; label: string; via?: string; tooltip: string; warning: boolean } | null {
105
+ if (answer.state === 'unknown' || answer.state === 'none') return null
106
+ const evidence = answer.via ?? answer.direct
107
+ const candidates = managerCandidates(answer)
108
+ const names = (refs: ResourceRef[]) => refs.map(managerRefLabel).join(' and ')
109
+ const claims = activeManagerClaims(evidence)
110
+ const claimed = dedupeResourceRefs(claims.map(c => c.manager))
111
+ const recorders = evidence.recorders ?? []
112
+ // A list alone records what a manager compared or applied, which can be an
113
+ // object it doesn't sync (a copy carrying another's tracking ID, a CRD a
114
+ // HelmRelease skips): only a list and the object's metadata agreeing says
115
+ // it manages the object.
116
+ const prefix = answer.state === 'agree' ? 'Managed by' : answer.state === 'shared' || answer.state === 'listed' ? 'Listed by' : 'Tracked by'
117
+ const listed = answer.lead && recorders.some(r => resourceRefKey(r) === resourceRefKey(answer.lead!))
118
+ ? dedupeResourceRefs([answer.lead, ...recorders]) : recorders
119
+ const displayed = answer.state === 'disagree' ? claimed : answer.state === 'shared' ? listed : candidates
120
+ const label = displayed.map(r => r.namespace ? `${r.namespace}/${r.name}` : r.name).join(' and ')
121
+ const reasons: Record<ManagerClaim['check'], string> = {
122
+ recorded: 'its status lists this resource',
123
+ omitted: "its status doesn't list this resource",
124
+ unreadable: "Radar can't read its status",
125
+ remote: "Radar can't confirm it deploys to this cluster",
126
+ missing: 'no such manager here',
127
+ 'not-recording': 'Helm records an installation, rather than continuously reconciling it',
128
+ unresolved: 'Radar found no manager of that name listing this resource; it may be managed from another cluster',
129
+ withheld: "you can't view it",
130
+ unchecked: "Radar couldn't check its status",
131
+ }
132
+ const subject = answer.via ? managerRefLabel(evidence.object) : 'this resource'
133
+ const details: string[] = []
134
+ const whose = answer.via ? `${managerRefLabel(evidence.object)}'s` : "This resource's"
135
+ // A newer Radar may send a check this UI doesn't know.
136
+ const why = (c: ManagerClaim, object: string) => c.check === 'missing'
137
+ ? `there's no such ${c.manager.kind} here`
138
+ : (reasons[c.check] ?? "Radar couldn't confirm its status lists this resource").replace('manager of', `${c.manager.kind} of`).replace('this resource', object)
139
+ // Tracked by names what metadata says, which Radar hasn't confirmed: one
140
+ // claim says why in the same sentence.
141
+ const single = answer.state === 'claimed' && claims.length === 1 && claims[0].check !== 'recorded' ? claims[0] : null
142
+ if (single) {
143
+ details.push(`${whose} ${single.source.includes('label') ? 'label' : 'annotation'} names ${managerRefLabel(single.manager)}, but ${why(single, answer.via ? subject : 'it')}.`)
144
+ } else if (answer.state === 'claimed') {
145
+ details.push(`${whose} labels or annotations name ${names(claimed)}; Radar hasn't confirmed ${claimed.length === 1 ? 'it manages' : 'they manage'} it.`)
146
+ }
147
+ for (const c of claims) {
148
+ if (c.check === 'recorded' || c === single) continue
149
+ details.push(`${managerRefLabel(c.manager)}: ${why(c, subject)}.`)
150
+ }
151
+ if (recorders.length) {
152
+ // A claim its list confirms adds nothing but the agreement.
153
+ const confirmed = claims.some(c => c.check === 'recorded')
154
+ details.push(`${names(recorders)} ${recorders.length === 1 ? 'lists' : 'list'} ${subject} in ${recorders.length === 1 ? 'its status' : 'their statuses'}${confirmed ? `, and ${answer.via ? 'its' : 'this resource\'s'} tracking metadata names ${recorders.length === 1 ? 'it' : 'one of them'}` : ''}.`)
155
+ }
156
+ if (answer.via) details.push(`Through ${managerRefLabel(answer.via.object)}.`)
157
+ // Claims the answer passed on the way stay visible, with or without Via.
158
+ {
159
+ for (const e of [...(answer.via ? [answer.direct] : []), ...(answer.between ?? [])]) {
160
+ for (const c of e.claims ?? []) {
161
+ if (c.copied) continue
162
+ details.push(`${managerRefLabel(e.object)}: its own ${c.source.includes('label') ? 'label' : 'annotation'} names ${managerRefLabel(c.manager)}.`)
163
+ }
164
+ }
165
+ }
166
+ if (answer.narrowed) details.push('This answer covers what you can view.')
167
+ return { prefix, label, via: answer.via?.object.kind, tooltip: details.join(' '), warning: answer.state === 'disagree' }
168
+ }
169
+
170
+ /**
171
+ * A candidate manager of an answer, with what names it: its list, its
172
+ * metadata, or both. found: Radar read the manager, so a link to it opens it;
173
+ * a claim of a manager that is missing, withheld or unresolved isn't.
174
+ */
175
+ export type ManagerChoice = { ref: ResourceRef; listed: boolean; claimed: boolean; found: boolean }
176
+
177
+ /** Every candidate manager of an answer, lead first, each with what names it. */
178
+ export function managerChoices(answer: ManagerAnswer): ManagerChoice[] {
179
+ const evidence = answer.via ?? answer.direct
180
+ const recorders = new Set((evidence.recorders ?? []).map(resourceRefKey))
181
+ const candidates = managerCandidates(answer)
182
+ // A manager is found when any claim naming it read it; a check this UI
183
+ // doesn't know never counts as one.
184
+ const read: Array<string> = ['recorded', 'omitted', 'unreadable', 'remote']
185
+ const claimed = new Set<string>()
186
+ const found = new Set<string>()
187
+ for (const c of activeManagerClaims(evidence)) {
188
+ const ref = c.manager
189
+ const key = resourceRefKey(ref.namespace ? ref : candidates.find(r => r.kind === ref.kind && r.group === ref.group && r.name === ref.name) ?? ref)
190
+ claimed.add(key)
191
+ if (read.includes(c.check)) found.add(key)
192
+ }
193
+ return candidates.map(ref => {
194
+ const key = resourceRefKey(ref)
195
+ const listed = recorders.has(key)
196
+ return { ref, listed, claimed: claimed.has(key), found: listed || found.has(key) }
197
+ })
198
+ }
199
+
200
+ export function managerChipCoversGroup(relationships: Relationships): boolean {
201
+ const answer = relationships.ownership?.manager
202
+ if (!answer || !['agree', 'listed'].includes(answer.state) || answer.via || relationships.ownership?.managers) return false
203
+ const recorders = answer.direct.recorders ?? []
204
+ const managers = relationships.managers ?? []
205
+ return recorders.length === 1 && managers.length === 1 && resourceRefKey(recorders[0]) === resourceRefKey(managers[0])
206
+ }