@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.
Files changed (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  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-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. 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
+ })
@@ -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) {
@@ -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',
@@ -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', () => {
@@ -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
- // 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])
@@ -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
+ }