@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +53 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
-
//
|
|
2
|
-
//
|
|
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
|
+
}
|