@skyhook-io/k8s-ui 1.18.0 → 1.18.1

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 (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. package/src/utils/resource-coverage.ts +117 -0
@@ -43,7 +43,7 @@ import type { NavigateToResource } from '../../utils/navigation'
43
43
  import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
44
44
  import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
45
45
  import { TopologyGraph } from '../topology/TopologyGraph'
46
- import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
46
+ import { gitOpsOwnerFromRelationships, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
47
47
  import { gitOpsRouteForResource } from '../../utils/gitops-route'
48
48
  import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
49
49
  import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
@@ -57,7 +57,7 @@ import {
57
57
  import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
58
58
  import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
59
59
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
60
- import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
60
+ import { gitOpsOwnerKindLabel, HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
61
61
  import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
62
62
  import { Badge, type BadgeSeverity } from '../ui/Badge'
63
63
  import { Tooltip } from '../ui/Tooltip'
@@ -216,6 +216,8 @@ interface WorkloadViewProps {
216
216
  /** Timeline events the history lanes are built from: this workload's own
217
217
  * history, or events for its namespace from a host that has no scoped query. */
218
218
  allEvents?: TimelineEvent[]
219
+ /** Reveals one event, loading older pages if needed. Use a new object for each navigation request. */
220
+ timelineFocusEvent?: { id: string }
219
221
  /** Older history exists beyond allEvents. */
220
222
  historyTruncated?: boolean
221
223
  /** Loads the next older page of history; omit when the host can't page. */
@@ -469,6 +471,7 @@ export function WorkloadView({
469
471
  refetch: refetchProp,
470
472
  // Timeline
471
473
  allEvents,
474
+ timelineFocusEvent,
472
475
  historyTruncated = false,
473
476
  onLoadOlderHistory,
474
477
  loadingOlderHistory = false,
@@ -723,6 +726,18 @@ export function WorkloadView({
723
726
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
724
727
  const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
725
728
  const gitopsOwner = resolvedGitOpsOwner ?? relationshipGitOpsOwner
729
+ const listing = useMemo(
730
+ () => managerListing(gitopsOwner, relationships?.managers, gitOpsOwnerVerified, !relationships?.ownership?.managers),
731
+ [gitopsOwner, relationships?.managers, relationships?.ownership?.managers, gitOpsOwnerVerified],
732
+ )
733
+ // When the chip's manager is the only one whose status lists the resource,
734
+ // the chip says it all: Related Resources would repeat it.
735
+ const rendererRelationships = useMemo(
736
+ () => (relationships && listing?.state === 'confirmed' ? { ...relationships, managers: undefined } : relationships),
737
+ [relationships, listing],
738
+ )
739
+ // When they disagree, Related Resources says which manager the label names.
740
+ const trackedBy = listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
726
741
  // When the resource itself is a portal GitOps CR (Application, Kustomization,
727
742
  // HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
728
743
  // the drawer's renderer is thorough but the tab has the tree + insights +
@@ -1022,7 +1037,7 @@ export function WorkloadView({
1022
1037
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
1023
1038
  {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
1024
1039
  <div className="mt-1 flex flex-wrap items-center gap-1.5">
1025
- {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} />}
1040
+ {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} />}
1026
1041
  {helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
1027
1042
  {gitOpsResourcePath && onNavigateGitOpsPath && (
1028
1043
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
@@ -1109,7 +1124,8 @@ export function WorkloadView({
1109
1124
  <ResourceRendererDispatch
1110
1125
  resource={selectedResource}
1111
1126
  data={resource}
1112
- relationships={relationships}
1127
+ relationships={rendererRelationships}
1128
+ trackedBy={trackedBy}
1113
1129
  certificateInfo={certificateInfo}
1114
1130
  hpaDiagnosis={hpaDiagnosis}
1115
1131
  scalerDiagnostics={scalerDiagnostics}
@@ -1207,7 +1223,7 @@ export function WorkloadView({
1207
1223
  </Tooltip>
1208
1224
  ),
1209
1225
  gitopsOwner && (
1210
- <ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} variant="block" />
1226
+ <ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
1211
1227
  ),
1212
1228
  helmOwner && (
1213
1229
  <HelmManagedByChip key="helm" owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
@@ -1322,7 +1338,8 @@ export function WorkloadView({
1322
1338
  <InfoTab
1323
1339
  resource={resource}
1324
1340
  selectedResource={selectedResource}
1325
- relationships={relationships}
1341
+ relationships={rendererRelationships}
1342
+ trackedBy={trackedBy}
1326
1343
  certificateInfo={certificateInfo}
1327
1344
  hpaDiagnosis={hpaDiagnosis}
1328
1345
  scalerDiagnostics={scalerDiagnostics}
@@ -1383,6 +1400,7 @@ export function WorkloadView({
1383
1400
  )}
1384
1401
  {effectiveTab === 'timeline' && (
1385
1402
  <EventsTab
1403
+ focusEvent={timelineFocusEvent}
1386
1404
  events={resourceEvents}
1387
1405
  isLoading={eventsLoading}
1388
1406
  truncated={historyTruncated}
@@ -1688,6 +1706,7 @@ function OwnershipHeading({
1688
1706
  // ============================================================================
1689
1707
 
1690
1708
  function EventsTab({
1709
+ focusEvent,
1691
1710
  events,
1692
1711
  isLoading,
1693
1712
  truncated,
@@ -1703,6 +1722,7 @@ function EventsTab({
1703
1722
  topology,
1704
1723
  onResourceClick,
1705
1724
  }: {
1725
+ focusEvent?: { id: string }
1706
1726
  events: TimelineEvent[]
1707
1727
  isLoading: boolean
1708
1728
  truncated?: boolean
@@ -1798,6 +1818,22 @@ function EventsTab({
1798
1818
  onSelectEvent(id)
1799
1819
  }, [onSelectEvent])
1800
1820
 
1821
+ const focusedRequest = useRef<typeof focusEvent>(undefined)
1822
+ const requestedPage = useRef<{ focus: typeof focusEvent; events: TimelineEvent[] } | undefined>(undefined)
1823
+ const focusFound = Boolean(focusEvent && cleanEvents.some((event) => event.id === focusEvent.id))
1824
+ useEffect(() => {
1825
+ if (!focusEvent || focusedRequest.current === focusEvent || isLoading) return
1826
+ if (focusFound) {
1827
+ handleSelect(focusEvent.id)
1828
+ focusedRequest.current = focusEvent
1829
+ } else if (truncated && onLoadOlder && !loadingOlder && !olderError && !error) {
1830
+ // Strict Mode can replay this effect before the loading prop updates.
1831
+ if (requestedPage.current?.focus === focusEvent && requestedPage.current.events === events) return
1832
+ requestedPage.current = { focus: focusEvent, events }
1833
+ onLoadOlder()
1834
+ }
1835
+ }, [focusEvent, focusFound, events, isLoading, truncated, onLoadOlder, loadingOlder, olderError, error, handleSelect])
1836
+
1801
1837
  if (isLoading) {
1802
1838
  return (
1803
1839
  <div className="flex items-center justify-center h-full text-theme-text-tertiary">
@@ -1827,6 +1863,15 @@ function EventsTab({
1827
1863
 
1828
1864
  return (
1829
1865
  <div className="h-full flex flex-col overflow-hidden">
1866
+ {focusEvent && !focusFound && (
1867
+ <div className="shrink-0 border-b border-theme-border px-4 py-2 text-xs text-theme-text-secondary" role="status">
1868
+ {olderError || error
1869
+ ? "Couldn't find the reported error because some history couldn't be loaded."
1870
+ : truncated && onLoadOlder
1871
+ ? 'Finding the reported error in older history…'
1872
+ : 'The reported error is not available in the retained timeline history.'}
1873
+ </div>
1874
+ )}
1830
1875
  {error && (
1831
1876
  <div className="shrink-0 flex flex-wrap items-center gap-x-2 border-b border-theme-border bg-theme-base px-4 py-1.5 text-xs" role="alert">
1832
1877
  <span className="text-[var(--color-error)]" title={error.message}>
@@ -1932,6 +1977,7 @@ function InfoTab({
1932
1977
  resource,
1933
1978
  selectedResource,
1934
1979
  relationships,
1980
+ trackedBy,
1935
1981
  certificateInfo,
1936
1982
  hpaDiagnosis,
1937
1983
  scalerDiagnostics,
@@ -1968,6 +2014,7 @@ function InfoTab({
1968
2014
  resource: any
1969
2015
  selectedResource: SelectedResource
1970
2016
  relationships?: Relationships
2017
+ trackedBy?: string
1971
2018
  certificateInfo?: any
1972
2019
  hpaDiagnosis?: HPADiagnosis
1973
2020
  scalerDiagnostics?: ScalerDiagnosis[]
@@ -2022,6 +2069,7 @@ function InfoTab({
2022
2069
  resource={selectedResource}
2023
2070
  data={resource}
2024
2071
  relationships={relationships}
2072
+ trackedBy={trackedBy}
2025
2073
  certificateInfo={certificateInfo}
2026
2074
  hpaDiagnosis={hpaDiagnosis}
2027
2075
  scalerDiagnostics={scalerDiagnostics}
@@ -1,6 +1,8 @@
1
1
  export { useAnimatedUnmount } from './useAnimatedUnmount'
2
2
  export { useDebouncedValue } from './useDebouncedValue'
3
3
  export { useRefreshAnimation } from './useRefreshAnimation'
4
+ export { useMediaQuery } from './useMediaQuery'
5
+ export { usePhoneLayout, PHONE_MEDIA_QUERY } from './usePhoneLayout'
4
6
  export {
5
7
  KeyboardShortcutProvider,
6
8
  useRegisterShortcut,
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ // Subscribe to a CSS media query. SSR-safe (returns false before mount) and
4
+ // updates on viewport changes.
5
+ export function useMediaQuery(query: string): boolean {
6
+ const [matches, setMatches] = useState(() => {
7
+ if (typeof window === 'undefined' || !window.matchMedia) return false
8
+ return window.matchMedia(query).matches
9
+ })
10
+
11
+ useEffect(() => {
12
+ if (typeof window === 'undefined' || !window.matchMedia) return
13
+ const mql = window.matchMedia(query)
14
+ const onChange = () => setMatches(mql.matches)
15
+ onChange()
16
+ mql.addEventListener('change', onChange)
17
+ return () => mql.removeEventListener('change', onChange)
18
+ }, [query])
19
+
20
+ return matches
21
+ }
@@ -0,0 +1,12 @@
1
+ import { useMediaQuery } from './useMediaQuery'
2
+
3
+ // A phone: a narrow screen whose primary pointer is touch. A desktop browser
4
+ // narrowed below this width keeps the desktop layout (and its content floor),
5
+ // since a wider window can show the full UI; browser dev-tools device mode
6
+ // emulates touch and gets the phone layout. Keep in sync with the `phone:`
7
+ // variant in theme/tailwind-theme.css.
8
+ export const PHONE_MEDIA_QUERY = '(max-width: 639px) and (pointer: coarse)'
9
+
10
+ export function usePhoneLayout(): boolean {
11
+ return useMediaQuery(PHONE_MEDIA_QUERY)
12
+ }
@@ -6,6 +6,11 @@
6
6
  * Import after variables.css.
7
7
  */
8
8
 
9
+ /* `phone:` styles apply on a narrow touch-first screen only (see
10
+ * PHONE_MEDIA_QUERY in hooks/usePhoneLayout.ts). A narrowed desktop window
11
+ * keeps the desktop layout. */
12
+ @custom-variant phone (@media (max-width: 639px) and (pointer: coarse));
13
+
9
14
  @theme inline {
10
15
  /* Backgrounds */
11
16
  --color-theme-sidebar: var(--bg-sidebar);
package/src/types/core.ts CHANGED
@@ -162,6 +162,8 @@ export interface CloudConnectCapability {
162
162
 
163
163
  export interface FeatureCapabilities {
164
164
  helmSourceIdentity?: boolean
165
+ resourceReadCoverage?: boolean
166
+ searchCoverage?: boolean
165
167
  yamlReview?: boolean
166
168
  yamlSchemas?: boolean
167
169
  workloadImages?: boolean
@@ -652,10 +654,23 @@ export interface Relationships {
652
654
  sourceResourceVersion?: string
653
655
  mirrors?: ResourceRef[]
654
656
  }
655
- owner?: ResourceRef
657
+ owner?: ResourceRef // The owner Kubernetes acts through: the controller reference, else the only reference
658
+ owners?: ResourceRef[] // Every owner reference that resolves to an existing owner
656
659
  deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
657
660
  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[]
661
+ children?: ResourceRef[] // Objects whose owner references name this one, less olderRevisions
662
+ // Children kept apart because they only keep history: ReplicaSets that run
663
+ // no Pods and aren't their owner's current revision. ownership.children
664
+ // covers both lists together.
665
+ olderRevisions?: ResourceRef[]
666
+ // Management without an owner reference: GitOps member lists, class
667
+ // bindings, spec and status references. Never ownership or garbage collection.
668
+ managers?: ResourceRef[]
669
+ manages?: ResourceRef[]
670
+ /** How complete the owner-reference and recorded-membership answers are.
671
+ * Absent: no completeness is claimed, whether because the server answers
672
+ * Owner and Children from graph edges or because the resource is an Event. */
673
+ ownership?: OwnershipCoverage
659
674
  services?: ResourceRef[]
660
675
  ingresses?: ResourceRef[]
661
676
  gateways?: ResourceRef[]
@@ -682,6 +697,51 @@ export interface Relationships {
682
697
  resourceClaims?: ResourceRef[] // For Pods: DRA ResourceClaims (direct + template-generated)
683
698
  }
684
699
 
700
+ /** 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. */
701
+ export interface OwnershipGap {
702
+ group?: string
703
+ kind?: string
704
+ namespace?: string
705
+ reason: string
706
+ }
707
+
708
+ export interface OwnershipCoverage {
709
+ /** The owner reference's state when no owner is named: gone, replaced, withheld, unknown, unresolvable, excluded (it names an Event) or several. */
710
+ primaryOwnerState?: string
711
+ /** The owner the reference names when it is gone, replaced or unknown. */
712
+ ownerRef?: ResourceRef
713
+ /** A readreason code saying why an unknown owner couldn't be checked. */
714
+ ownerReason?: string
715
+ children: {
716
+ /** Children are every child the viewer may read. */
717
+ complete: boolean
718
+ notChecked?: OwnershipGap[]
719
+ truncated?: boolean
720
+ /** The viewer may read only some of the places children could be. */
721
+ narrowed?: boolean
722
+ }
723
+ /** For a GitOps manager whose status lists its members. */
724
+ manages?: {
725
+ membership: 'recorded' | 'remote' | 'unreadable'
726
+ /** A readreason code when the member list couldn't be read, as opposed to the status recording none. */
727
+ unavailableReason?: string
728
+ missing?: number
729
+ withheld?: number
730
+ unknown?: number
731
+ truncated?: boolean
732
+ }
733
+ /** Set when the search for the GitOps managers whose status lists this resource, for `managers`, couldn't settle every one the viewer may read. */
734
+ managers?: {
735
+ /** Managers with a member list Radar can't read, any of which may list this resource. */
736
+ unreadable?: number
737
+ /** Managers listing this resource's coordinates for a cluster Radar can't confirm is this one. */
738
+ remote?: number
739
+ notChecked?: OwnershipGap[]
740
+ }
741
+ /** A readreason code when Radar couldn't answer at all. */
742
+ unavailableReason?: string
743
+ }
744
+
685
745
  // Parsed X.509 certificate metadata (from backend cert parsing)
686
746
  export interface CertificateInfo {
687
747
  subject: string
@@ -1212,6 +1272,7 @@ export interface InstallChartRequest {
1212
1272
 
1213
1273
  // ArtifactHub chart with rich metadata
1214
1274
  export interface ArtifactHubChart {
1275
+ official?: boolean
1215
1276
  packageId: string
1216
1277
  name: string
1217
1278
  version: string
@@ -1234,6 +1295,9 @@ export interface ArtifactHubChart {
1234
1295
 
1235
1296
  // ArtifactHub repository info
1236
1297
  export interface ArtifactHubRepository {
1298
+ organizationDisplayName?: string
1299
+ userAlias?: string
1300
+ displayName?: string
1237
1301
  name: string
1238
1302
  url: string
1239
1303
  official?: boolean
@@ -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
+ }
@@ -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
+ })
@@ -54,3 +54,25 @@ export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
54
54
  return null
55
55
  }
56
56
  }
57
+
58
+ /**
59
+ * How the managers whose status lists a resource (relationships.managers) bear
60
+ * on the GitOps owner its own tracking metadata names. `confirmed`: that owner
61
+ * is the only manager listing it. `conflict`: GitOps managers list it and the
62
+ * owner isn't one of them. Undefined when there is nothing to compare, the
63
+ * owner is one of several, the owner isn't verified in this cluster, or the
64
+ * search left managers unsettled (`settled` false): then a listed manager may
65
+ * be a topology link rather than a status list Radar read, and the owner's own
66
+ * list may be the one it couldn't read.
67
+ */
68
+ export type ManagerListing = { state: 'confirmed' } | { state: 'conflict'; listedBy: GitOpsOwnerRef[] }
69
+
70
+ export function managerListing(owner: GitOpsOwnerRef | null | undefined, managers: ResourceRef[] | undefined, verified: boolean, settled: boolean): ManagerListing | undefined {
71
+ if (!owner || !managers?.length || !verified || !settled) return undefined
72
+ const listed = managers.map(gitOpsOwnerFromRef).filter((m): m is GitOpsOwnerRef => m !== null)
73
+ if (listed.length === 0) return undefined
74
+ // An Argo CD owner named only by its instance label has no namespace yet.
75
+ const isOwner = (m: GitOpsOwnerRef) => m.tool === owner.tool && m.kind === owner.kind && m.name === owner.name && (!owner.namespace || m.namespace === owner.namespace)
76
+ if (!listed.some(isOwner)) return { state: 'conflict', listedBy: listed }
77
+ return listed.length === 1 && managers.length === 1 ? { state: 'confirmed' } : undefined
78
+ }
@@ -35,5 +35,7 @@ export * from './network-policy'
35
35
  export * from './cnpg-backup'
36
36
  export * from './url-path'
37
37
  export * from './resource-refs'
38
+ export * from './resource-coverage'
39
+ export * from './read-actions'
38
40
 
39
41
  export * from './grant'
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { canonicalReadCode, READ_ACTIONS, readActionFor } from './read-actions'
6
+
7
+ const FIXTURE = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../pkg/readreason/testdata/actions.json')
8
+
9
+ describe('read actions', () => {
10
+ it('mirror the server table exactly', () => {
11
+ expect(READ_ACTIONS).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8')))
12
+ })
13
+ it('maps an incomplete findings scan to changing the request', () => {
14
+ expect(READ_ACTIONS.findings_scan_incomplete).toBe('change_request')
15
+ })
16
+ it('read the spellings older Radars sent as the codes they became', () => {
17
+ expect(canonicalReadCode('kind_sync_failed')).toBe('kind_sync_timed_out')
18
+ expect(canonicalReadCode('rbac_denied')).toBe('user_forbidden')
19
+ expect(readActionFor('kind_sync_failed')).toBe('wait')
20
+ expect(readActionFor('rbac_denied')).toBe('user_access')
21
+ })
22
+ })