@skyhook-io/k8s-ui 1.18.0 → 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -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/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- 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/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- 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.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- 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/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- 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-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -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 +21 -8
- 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.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- 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/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- 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 +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- 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/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -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 +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
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
|
|
@@ -162,6 +163,8 @@ export interface CloudConnectCapability {
|
|
|
162
163
|
|
|
163
164
|
export interface FeatureCapabilities {
|
|
164
165
|
helmSourceIdentity?: boolean
|
|
166
|
+
resourceReadCoverage?: boolean
|
|
167
|
+
searchCoverage?: boolean
|
|
165
168
|
yamlReview?: boolean
|
|
166
169
|
yamlSchemas?: boolean
|
|
167
170
|
workloadImages?: boolean
|
|
@@ -175,6 +178,7 @@ export interface FeatureCapabilities {
|
|
|
175
178
|
gitopsWriteEvidence?: boolean
|
|
176
179
|
serviceEndpointSlices?: boolean
|
|
177
180
|
trafficRecords?: boolean
|
|
181
|
+
clusterScopedPlaceholder?: boolean
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
// DeploymentMode is the closed set of topologies Radar can run in.
|
|
@@ -652,10 +656,23 @@ export interface Relationships {
|
|
|
652
656
|
sourceResourceVersion?: string
|
|
653
657
|
mirrors?: ResourceRef[]
|
|
654
658
|
}
|
|
655
|
-
owner?: ResourceRef
|
|
659
|
+
owner?: ResourceRef // The owner Kubernetes acts through: the controller reference, else the only reference
|
|
660
|
+
owners?: ResourceRef[] // Every owner reference that resolves to an existing owner
|
|
656
661
|
deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
|
|
657
662
|
managedBy?: ResourceRef[] // Topmost meaningful manager(s): GitOps controller (ArgoCD Application / Flux Kustomization / Flux HelmRelease), Helm release, or the topmost K8s owner. Synthesized server-side; replaces client-side detectGitOpsOwner.
|
|
658
|
-
children?: ResourceRef[]
|
|
663
|
+
children?: ResourceRef[] // Objects whose owner references name this one, less olderRevisions
|
|
664
|
+
// Children kept apart because they only keep history: ReplicaSets that run
|
|
665
|
+
// no Pods and aren't their owner's current revision. ownership.children
|
|
666
|
+
// covers both lists together.
|
|
667
|
+
olderRevisions?: ResourceRef[]
|
|
668
|
+
// Management without an owner reference: GitOps member lists, class
|
|
669
|
+
// bindings, spec and status references. Never ownership or garbage collection.
|
|
670
|
+
managers?: ResourceRef[]
|
|
671
|
+
manages?: ResourceRef[]
|
|
672
|
+
/** How complete the owner-reference and recorded-membership answers are.
|
|
673
|
+
* Absent: no completeness is claimed, whether because the server answers
|
|
674
|
+
* Owner and Children from graph edges or because the resource is an Event. */
|
|
675
|
+
ownership?: OwnershipCoverage
|
|
659
676
|
services?: ResourceRef[]
|
|
660
677
|
ingresses?: ResourceRef[]
|
|
661
678
|
gateways?: ResourceRef[]
|
|
@@ -682,6 +699,101 @@ export interface Relationships {
|
|
|
682
699
|
resourceClaims?: ResourceRef[] // For Pods: DRA ResourceClaims (direct + template-generated)
|
|
683
700
|
}
|
|
684
701
|
|
|
702
|
+
/** A kind Radar couldn't read, in namespace or (without one) in several or all of them, or the cluster for a cluster-scoped kind; reason is a readreason code. No kind stands for kinds discovery didn't list; no group is the core group. */
|
|
703
|
+
export interface OwnershipGap {
|
|
704
|
+
group?: string
|
|
705
|
+
kind?: string
|
|
706
|
+
namespace?: string
|
|
707
|
+
reason: string
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export interface OwnershipCoverage {
|
|
711
|
+
/** The owner reference's state when no owner is named: gone, replaced, withheld, unknown, unresolvable, excluded (it names an Event) or several. */
|
|
712
|
+
primaryOwnerState?: string
|
|
713
|
+
/** The owner the reference names when it is gone, replaced or unknown. */
|
|
714
|
+
ownerRef?: ResourceRef
|
|
715
|
+
/** A readreason code saying why an unknown owner couldn't be checked. */
|
|
716
|
+
ownerReason?: string
|
|
717
|
+
children: {
|
|
718
|
+
/** Children are every child the viewer may read. */
|
|
719
|
+
complete: boolean
|
|
720
|
+
notChecked?: OwnershipGap[]
|
|
721
|
+
truncated?: boolean
|
|
722
|
+
/** The viewer may read only some of the places children could be. */
|
|
723
|
+
narrowed?: boolean
|
|
724
|
+
}
|
|
725
|
+
/** For a GitOps manager whose status lists its members. */
|
|
726
|
+
manages?: {
|
|
727
|
+
membership: 'recorded' | 'remote' | 'unreadable'
|
|
728
|
+
/** A readreason code when the member list couldn't be read, as opposed to the status recording none. */
|
|
729
|
+
unavailableReason?: string
|
|
730
|
+
missing?: number
|
|
731
|
+
withheld?: number
|
|
732
|
+
unknown?: number
|
|
733
|
+
truncated?: boolean
|
|
734
|
+
}
|
|
735
|
+
/** Set when the search for the GitOps managers whose status lists this resource, for `managers`, couldn't settle every one the viewer may read. */
|
|
736
|
+
managers?: {
|
|
737
|
+
/** Managers with a member list Radar can't read, any of which may list this resource. */
|
|
738
|
+
unreadable?: number
|
|
739
|
+
/** Managers listing this resource's coordinates for a cluster Radar can't confirm is this one. */
|
|
740
|
+
remote?: number
|
|
741
|
+
notChecked?: OwnershipGap[]
|
|
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
|
|
745
|
+
/** A readreason code when Radar couldn't answer at all. */
|
|
746
|
+
unavailableReason?: string
|
|
747
|
+
}
|
|
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
|
+
|
|
685
797
|
// Parsed X.509 certificate metadata (from backend cert parsing)
|
|
686
798
|
export interface CertificateInfo {
|
|
687
799
|
subject: string
|
|
@@ -1212,6 +1324,7 @@ export interface InstallChartRequest {
|
|
|
1212
1324
|
|
|
1213
1325
|
// ArtifactHub chart with rich metadata
|
|
1214
1326
|
export interface ArtifactHubChart {
|
|
1327
|
+
official?: boolean
|
|
1215
1328
|
packageId: string
|
|
1216
1329
|
name: string
|
|
1217
1330
|
version: string
|
|
@@ -1234,6 +1347,9 @@ export interface ArtifactHubChart {
|
|
|
1234
1347
|
|
|
1235
1348
|
// ArtifactHub repository info
|
|
1236
1349
|
export interface ArtifactHubRepository {
|
|
1350
|
+
organizationDisplayName?: string
|
|
1351
|
+
userAlias?: string
|
|
1352
|
+
displayName?: string
|
|
1237
1353
|
name: string
|
|
1238
1354
|
url: string
|
|
1239
1355
|
official?: boolean
|
|
@@ -1451,7 +1567,7 @@ export interface AggregatedFlow {
|
|
|
1451
1567
|
|
|
1452
1568
|
// Cluster info for traffic detection
|
|
1453
1569
|
export interface TrafficClusterInfo {
|
|
1454
|
-
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
|
|
1455
1571
|
cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
|
|
1456
1572
|
dataplaneV2: boolean
|
|
1457
1573
|
clusterName?: string
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseFluxInventory } from './gitops'
|
|
3
|
+
|
|
4
|
+
// Mirrors TestParseFluxInventoryID in pkg/gitops/helpers_test.go: the
|
|
5
|
+
// Kustomization renderer and the Go tree read the same inventory IDs.
|
|
6
|
+
describe('parseFluxInventory', () => {
|
|
7
|
+
it('reads a colon in an RBAC name, written as a double underscore', () => {
|
|
8
|
+
expect(
|
|
9
|
+
parseFluxInventory([
|
|
10
|
+
{ id: '_system__metrics-server_rbac.authorization.k8s.io_ClusterRole', v: 'v1' },
|
|
11
|
+
{ id: 'kube-system_cert-manager__leaderelection_rbac.authorization.k8s.io_Role', v: 'v1' },
|
|
12
|
+
]),
|
|
13
|
+
).toEqual([
|
|
14
|
+
{ group: 'rbac.authorization.k8s.io', kind: 'ClusterRole', namespace: '', name: 'system:metrics-server' },
|
|
15
|
+
{ group: 'rbac.authorization.k8s.io', kind: 'Role', namespace: 'kube-system', name: 'cert-manager:leaderelection' },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps single underscores in a name and the empty core group', () => {
|
|
20
|
+
expect(parseFluxInventory([{ id: 'ns_my_weird_name__ConfigMap' }])).toEqual([
|
|
21
|
+
{ group: '', kind: 'ConfigMap', namespace: 'ns', name: 'my_weird_name' },
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/types/gitops.ts
CHANGED
|
@@ -446,9 +446,7 @@ export function argoApplicationSetConditionsToGitOpsStatus(
|
|
|
446
446
|
export function parseFluxInventory(entries: Array<{ id: string; v?: string }>): ManagedResource[] {
|
|
447
447
|
return entries
|
|
448
448
|
.map(entry => {
|
|
449
|
-
// Format: namespace_name_group_kind
|
|
450
|
-
// The challenge is that namespace, name, and group can all contain underscores
|
|
451
|
-
// But the format is always: ns_name_group_kind where kind is the last part
|
|
449
|
+
// Format: namespace_name_group_kind; only the name can hold an underscore.
|
|
452
450
|
const id = entry.id || ''
|
|
453
451
|
const parts = id.split('_')
|
|
454
452
|
|
|
@@ -460,9 +458,11 @@ export function parseFluxInventory(entries: Array<{ id: string; v?: string }>):
|
|
|
460
458
|
// Kind is always last, group is second-to-last
|
|
461
459
|
const kind = parts[parts.length - 1]
|
|
462
460
|
const group = parts[parts.length - 2]
|
|
463
|
-
// Namespace is first, name is everything in between
|
|
461
|
+
// Namespace is first, name is everything in between. Flux writes a colon
|
|
462
|
+
// in an RBAC object's name as a double underscore (system:metrics-server
|
|
463
|
+
// is system__metrics-server) and reads every "__" back as a colon.
|
|
464
464
|
const namespace = parts[0]
|
|
465
|
-
const name = parts.slice(1, -2).join('_')
|
|
465
|
+
const name = parts.slice(1, -2).join('_').replaceAll('__', ':')
|
|
466
466
|
|
|
467
467
|
// Validate required fields
|
|
468
468
|
if (!kind || !name) {
|
package/src/types/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './core'
|
|
2
|
+
export * from './read-coverage'
|
|
2
3
|
export * from './fetch-error'
|
|
3
4
|
export * from './gitops'
|
|
4
5
|
export * from './gitops-tree'
|
|
@@ -6,3 +7,5 @@ export * from './gitops-insights'
|
|
|
6
7
|
export * from './rbac'
|
|
7
8
|
export * from './capacity'
|
|
8
9
|
export * from './policy'
|
|
10
|
+
export * from './read-coverage'
|
|
11
|
+
export * from './resource-coverage'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Coverage of a Radar read, as Radar's pkg/resourceapi serializes it. Codes
|
|
2
|
+
// come from Radar's pkg/readreason.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A known, finite set of `count` members. `names` lists all of them, sorted,
|
|
6
|
+
* when there are few enough for the response; otherwise it is absent and which
|
|
7
|
+
* members are in the set is unknown.
|
|
8
|
+
*/
|
|
9
|
+
export interface ReadNameSet {
|
|
10
|
+
count: number
|
|
11
|
+
names?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How far a gap reaches along one axis when it can't list it: `all` of the
|
|
16
|
+
* answer's scope, or an `unknown` part of it. A value a client doesn't know
|
|
17
|
+
* reads as unknown.
|
|
18
|
+
*/
|
|
19
|
+
export type ReadExtent = 'all' | 'unknown' | (string & {})
|
|
20
|
+
|
|
21
|
+
/** A gap's set along one axis: listed like a ReadNameSet, or an extent. */
|
|
22
|
+
export type ReadGapSet<T> = { count: number; names?: T[]; extent?: undefined } | { extent: ReadExtent; count?: undefined; names?: undefined }
|
|
23
|
+
|
|
24
|
+
/** A kind; an omitted group is the core group. */
|
|
25
|
+
export interface ReadKindRef {
|
|
26
|
+
kind: string
|
|
27
|
+
group?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadObjectRef {
|
|
31
|
+
kind: string
|
|
32
|
+
group?: string
|
|
33
|
+
namespace?: string
|
|
34
|
+
name: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Part of an answer Radar did not read, and why. A gap covers every
|
|
39
|
+
* combination of its axes. An axis that applies is always present, listed or
|
|
40
|
+
* as an extent; an absent axis doesn't apply to the gap (namespaces on a
|
|
41
|
+
* cluster-scoped kind). Within one answer a namespace is in at most one gap.
|
|
42
|
+
* `action` is what the code calls for, so a code the client doesn't know still
|
|
43
|
+
* says what resolves it.
|
|
44
|
+
*/
|
|
45
|
+
export interface ReadGap {
|
|
46
|
+
code: string
|
|
47
|
+
action?: string
|
|
48
|
+
kinds?: ReadGapSet<ReadKindRef>
|
|
49
|
+
namespaces?: ReadGapSet<string>
|
|
50
|
+
objects?: { count: number; names?: ReadObjectRef[] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Why an implicitly broad read covers less than everything. */
|
|
54
|
+
export type ReadNarrowing = 'user_access' | 'namespace_pin' | 'namespace_collection' | 'kind_collection' | (string & {})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A search's coverage: `partial` when something the query named couldn't be
|
|
58
|
+
* searched or a read failed; `narrowedBy` when a broad query searched only
|
|
59
|
+
* what the caller's access and Radar's collection allow.
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchCoverage {
|
|
62
|
+
status: 'complete' | 'partial'
|
|
63
|
+
narrowedBy?: ReadNarrowing[]
|
|
64
|
+
gaps: ReadGap[]
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReadGap, ReadNameSet, ReadNarrowing } from './read-coverage'
|
|
2
|
+
|
|
3
|
+
export interface ResourceReadScope {
|
|
4
|
+
type: 'cluster' | 'all_namespaces' | 'namespaces'
|
|
5
|
+
namespaces?: ReadNameSet
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single-kind read's coverage. `status` is relative to `scope`: the
|
|
10
|
+
* namespaces the request named, or what remained of every namespace after
|
|
11
|
+
* `narrowedBy` (the caller's access, Radar's pin, what Radar collects) narrowed it.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResourceReadCoverage {
|
|
14
|
+
status: 'complete' | 'partial'
|
|
15
|
+
scope: ResourceReadScope
|
|
16
|
+
narrowedBy?: ReadNarrowing[]
|
|
17
|
+
/** What Radar read; on every successful answer, never on a failed read's coverage. */
|
|
18
|
+
read?: ResourceReadScope
|
|
19
|
+
gaps: ReadGap[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ResourceCoverage = ResourceReadCoverage | { status: 'unknown' }
|
|
23
|
+
|
|
24
|
+
export interface ResourceListResponse<T> {
|
|
25
|
+
items: T[]
|
|
26
|
+
coverage: ResourceReadCoverage
|
|
27
|
+
/** The lowercased plural from the request path, not a Kubernetes Kind. */
|
|
28
|
+
kind?: string
|
|
29
|
+
/** Omitted for the core group. */
|
|
30
|
+
group?: string
|
|
31
|
+
columns?: import('../components/resources/printer-columns').PrinterColumnDef[] | null
|
|
32
|
+
cells?: Record<string, unknown[]> | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A failed resource read's body, on REST and in MCP results alike. */
|
|
36
|
+
export interface ResourceReadErrorBody {
|
|
37
|
+
error: string
|
|
38
|
+
error_code: string
|
|
39
|
+
coverage?: ResourceReadCoverage
|
|
40
|
+
}
|
|
@@ -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',
|
package/src/utils/format.test.ts
CHANGED
|
@@ -50,6 +50,13 @@ describe('parseMemoryToBytes', () => {
|
|
|
50
50
|
expect(parseMemoryToBytes('1E3')).toBe(1000)
|
|
51
51
|
expect(formatMemoryString('1e9')).toBe('954 MiB')
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
it('reads sub-unit suffixes as fractions of a byte', () => {
|
|
55
|
+
expect(parseMemoryToBytes('1181116006400m')).toBeCloseTo(1181116006.4)
|
|
56
|
+
expect(parseMemoryToBytes('1000000u')).toBeCloseTo(1)
|
|
57
|
+
expect(parseMemoryToBytes('1000000000n')).toBeCloseTo(1)
|
|
58
|
+
expect(formatMemoryString('18360985190400m')).toBe('17.1 GiB')
|
|
59
|
+
})
|
|
53
60
|
})
|
|
54
61
|
|
|
55
62
|
describe('parseQuantityToNumber', () => {
|
package/src/utils/format.ts
CHANGED
|
@@ -141,8 +141,13 @@ export function parseMemoryToBytes(memString: string): number {
|
|
|
141
141
|
'Ei': 1024 ** 6,
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Decimal suffixes (powers of 1000)
|
|
144
|
+
// Decimal suffixes (powers of 1000). The sub-unit ones are real inputs too:
|
|
145
|
+
// a request written as "1288490188800m" is valid, and a sum that includes one
|
|
146
|
+
// keeps the milli scale, so a whole cluster's total serializes with "m".
|
|
145
147
|
const decimalSuffixes: Record<string, number> = {
|
|
148
|
+
'n': 1000 ** -3,
|
|
149
|
+
'u': 1000 ** -2,
|
|
150
|
+
'm': 1000 ** -1,
|
|
146
151
|
'k': 1000,
|
|
147
152
|
'K': 1000,
|
|
148
153
|
'M': 1000 ** 2,
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
import type { Relationships } from '../types/core'
|
|
4
4
|
|
|
5
|
-
import { gitOpsOwnerFromRelationships } from './gitops-owner'
|
|
5
|
+
import { gitOpsOwnerFromRelationships, managerListing } from './gitops-owner'
|
|
6
6
|
|
|
7
7
|
describe('gitOpsOwnerFromRelationships', () => {
|
|
8
8
|
it('returns null when relationships is undefined', () => {
|
|
@@ -93,3 +93,44 @@ describe('gitOpsOwnerFromRelationships', () => {
|
|
|
93
93
|
})
|
|
94
94
|
})
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
describe('managerListing', () => {
|
|
98
|
+
const owner = { tool: 'argocd' as const, kind: 'applications' as const, namespace: 'argocd', name: 'billing' }
|
|
99
|
+
const app = (name: string) => ({ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name })
|
|
100
|
+
|
|
101
|
+
it('confirms an owner that is the only manager listing the resource', () => {
|
|
102
|
+
expect(managerListing(owner, [app('billing')], true, true)).toEqual({ state: 'confirmed' })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('confirms an instance-label owner whose namespace is not known yet', () => {
|
|
106
|
+
expect(managerListing({ ...owner, namespace: '' }, [app('billing')], true, true)).toEqual({ state: 'confirmed' })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('reports a conflict when other managers list the resource and the owner does not', () => {
|
|
110
|
+
expect(managerListing(owner, [app('hub')], true, true)).toEqual({
|
|
111
|
+
state: 'conflict',
|
|
112
|
+
listedBy: [{ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'hub' }],
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('says nothing when the owner is one of several managers', () => {
|
|
117
|
+
expect(managerListing(owner, [app('billing'), app('hub')], true, true)).toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('says nothing without managers to compare', () => {
|
|
121
|
+
expect(managerListing(owner, undefined, true, true)).toBeUndefined()
|
|
122
|
+
expect(managerListing(owner, [], true, true)).toBeUndefined()
|
|
123
|
+
expect(managerListing(null, [app('billing')], true, true)).toBeUndefined()
|
|
124
|
+
expect(managerListing(owner, [{ kind: 'Deployment', group: 'apps', namespace: 'x', name: 'y' }], true, true)).toBeUndefined()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('claims nothing for an owner not verified in this cluster', () => {
|
|
128
|
+
expect(managerListing(owner, [app('billing')], false, true)).toBeUndefined()
|
|
129
|
+
expect(managerListing(owner, [app('hub')], false, true)).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('claims nothing when the search left managers unsettled, since a listed one may be a topology link', () => {
|
|
133
|
+
expect(managerListing(owner, [app('billing')], true, false)).toBeUndefined()
|
|
134
|
+
expect(managerListing(owner, [app('hub')], true, false)).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -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])
|
|
@@ -54,3 +52,155 @@ export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
|
|
|
54
52
|
return null
|
|
55
53
|
}
|
|
56
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How the managers whose status lists a resource (relationships.managers) bear
|
|
58
|
+
* on the GitOps owner its own tracking metadata names. `confirmed`: that owner
|
|
59
|
+
* is the only manager listing it. `conflict`: GitOps managers list it and the
|
|
60
|
+
* owner isn't one of them. Undefined when there is nothing to compare, the
|
|
61
|
+
* owner is one of several, the owner isn't verified in this cluster, or the
|
|
62
|
+
* search left managers unsettled (`settled` false): then a listed manager may
|
|
63
|
+
* be a topology link rather than a status list Radar read, and the owner's own
|
|
64
|
+
* list may be the one it couldn't read.
|
|
65
|
+
*/
|
|
66
|
+
export type ManagerListing = { state: 'confirmed' } | { state: 'conflict'; listedBy: GitOpsOwnerRef[] }
|
|
67
|
+
|
|
68
|
+
export function managerListing(owner: GitOpsOwnerRef | null | undefined, managers: ResourceRef[] | undefined, verified: boolean, settled: boolean): ManagerListing | undefined {
|
|
69
|
+
if (!owner || !managers?.length || !verified || !settled) return undefined
|
|
70
|
+
const listed = managers.map(gitOpsOwnerFromRef).filter((m): m is GitOpsOwnerRef => m !== null)
|
|
71
|
+
if (listed.length === 0) return undefined
|
|
72
|
+
// An Argo CD owner named only by its instance label has no namespace yet.
|
|
73
|
+
const isOwner = (m: GitOpsOwnerRef) => m.tool === owner.tool && m.kind === owner.kind && m.name === owner.name && (!owner.namespace || m.namespace === owner.namespace)
|
|
74
|
+
if (!listed.some(isOwner)) return { state: 'conflict', listedBy: listed }
|
|
75
|
+
return listed.length === 1 && managers.length === 1 ? { state: 'confirmed' } : undefined
|
|
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
|
+
}
|