@skyhook-io/k8s-ui 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  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.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. 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
@@ -175,8 +176,10 @@ export interface FeatureCapabilities {
175
176
  cnpgWorkspace?: boolean
176
177
  cnpgProtectionSetup?: boolean
177
178
  gitopsWriteEvidence?: boolean
179
+ agentSigninCheck?: boolean
178
180
  serviceEndpointSlices?: boolean
179
181
  trafficRecords?: boolean
182
+ clusterScopedPlaceholder?: boolean
180
183
  }
181
184
 
182
185
  // DeploymentMode is the closed set of topologies Radar can run in.
@@ -738,10 +741,60 @@ export interface OwnershipCoverage {
738
741
  remote?: number
739
742
  notChecked?: OwnershipGap[]
740
743
  }
744
+ /** 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". */
745
+ manager?: ManagerAnswer
741
746
  /** A readreason code when Radar couldn't answer at all. */
742
747
  unavailableReason?: string
743
748
  }
744
749
 
750
+ /** 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. */
751
+ export interface ManagerAnswer {
752
+ /** none: no evidence in what Radar collects and the viewer may read, never "unmanaged". */
753
+ state: 'shared' | 'disagree' | 'agree' | 'listed' | 'claimed' | 'unknown' | 'none'
754
+ /** 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. */
755
+ lead?: ResourceRef
756
+ /** The evidence on the resource itself. */
757
+ direct: ManagerEvidence
758
+ /** 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. */
759
+ via?: ManagerEvidence
760
+ /** 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. */
761
+ between?: ManagerEvidence[]
762
+ /** Why a walk that found no settling evidence stopped short. Absent when evidence settled it first, or the walk reached the chain's top. */
763
+ chainEnd?: 'unfollowed' | 'cycle' | 'truncated'
764
+ /** The controller reference the walk couldn't follow, when chainEnd is unfollowed. */
765
+ stop?: {
766
+ at: ResourceRef
767
+ /** As primaryOwnerState: gone, replaced, withheld, unknown, unresolvable or excluded. */
768
+ state: string
769
+ /** The owner the reference names when it is gone, replaced or unknown. */
770
+ owner?: ResourceRef
771
+ /** A readreason code when the owner is unknown. */
772
+ reason?: string
773
+ }
774
+ /** The viewer's access is restricted, so the evidence is the part it may read. */
775
+ narrowed?: boolean
776
+ }
777
+
778
+ export interface ManagerEvidence {
779
+ object: ResourceRef
780
+ /** Local managers whose status lists name it. */
781
+ recorders?: ResourceRef[]
782
+ /** Managers its own metadata names. */
783
+ claims?: ManagerClaim[]
784
+ }
785
+
786
+ /** 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. */
787
+ export interface ManagerClaim {
788
+ /** Its namespace is "" when the metadata doesn't say. */
789
+ manager: ResourceRef
790
+ source: 'flux-helm-labels' | 'flux-kustomize-labels' | 'argo-tracking-id' | 'argo-instance-label' | 'helm-release'
791
+ check: 'recorded' | 'omitted' | 'unreadable' | 'remote' | 'missing' | 'not-recording' | 'unresolved' | 'withheld' | 'unchecked'
792
+ /** An Argo CD tracking ID naming another object. */
793
+ copied?: boolean
794
+ /** 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. */
795
+ weak?: boolean
796
+ }
797
+
745
798
  // Parsed X.509 certificate metadata (from backend cert parsing)
746
799
  export interface CertificateInfo {
747
800
  subject: string
@@ -1515,7 +1568,7 @@ export interface AggregatedFlow {
1515
1568
 
1516
1569
  // Cluster info for traffic detection
1517
1570
  export interface TrafficClusterInfo {
1518
- platform: string // rke2, gke, eks, aks, minikube, kind, docker-desktop, openshift, rancher, generic
1571
+ platform: string // nkp, rke2, gke, eks, aks, nutanix, minikube, kind, docker-desktop, openshift, rancher, generic
1519
1572
  cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
1520
1573
  dataplaneV2: boolean
1521
1574
  clusterName?: string
@@ -124,6 +124,14 @@ export interface GitOpsIssue {
124
124
  rawMessage?: string
125
125
  refs?: GitOpsInsightRef[]
126
126
  action?: string
127
+ /** Optional presentation split; action always retains the complete guidance. */
128
+ actionPrimary?: string
129
+ actionSecondaryTitle?: string
130
+ actionSecondary?: string[]
131
+ // Set when syncing would prune every managed
132
+ // resource or take resources over from another Application. Shown as a
133
+ // warning ahead of a failed operation's cause; resource rows defer sync advice to it.
134
+ hazard?: string
127
135
  // Plain-English root cause when the message matched a recognized error
128
136
  // pattern. Empty for unrecognized messages — UI falls back to the raw message.
129
137
  cause?: 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
+ }