@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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- 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 +33 -4
- 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 +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- 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 +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -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 +66 -2
- 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/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 +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- 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={
|
|
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={
|
|
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}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
})
|
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
|
+
}
|
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
|
+
})
|
|
@@ -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
})
|