@skyhook-io/radar-app 1.16.2 → 1.17.0

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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -3,40 +3,48 @@ import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmission
3
3
  import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
4
4
  import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
5
5
  import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
6
- import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
6
+ import { useMemo, useEffect, useCallback, useRef, useState, type ReactNode } from 'react'
7
7
  import { useQueries, useQueryClient } from '@tanstack/react-query'
8
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
8
+ import { Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
9
9
  import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
10
10
  import { clsx } from 'clsx'
11
11
  import { Terminal, Stethoscope } from 'lucide-react'
12
12
  import {
13
13
  WorkloadView as BaseWorkloadView,
14
+ isApiGroup,
14
15
  EditableYamlView,
15
16
  FetchResult,
16
17
  Section,
17
18
  type WorkloadTabType,
19
+ type WorkloadExtraTab,
18
20
  type RendererOverrides,
19
21
  type GitOpsOwnerRef,
20
- type GitOpsStatus,
21
22
  type HelmOwnerRef,
22
23
  type AppRow,
23
24
  type ResourceOwnershipContext,
24
25
  type ServingResourceDetail,
25
26
  type AuditFinding,
27
+ dedupeResourceRefs,
26
28
  gitOpsRouteForOwner,
27
29
  gitOpsOwnerFromRelationships,
28
- getGitOpsResourceStatus,
29
30
  isDiagnoseKind,
30
31
  isRolloutKind,
31
32
  canSetWorkloadImages,
32
33
  isCoreBatchJob,
33
- type ManagedImageSource,
34
+ type SetImageOwnership,
35
+ SET_IMAGE_WRITES,
36
+ setImageWrites,
37
+ setImageSelectionKey,
38
+ gitOpsWriteEvidencePaths,
39
+ type SetImageSelection,
34
40
  type WorkloadImageTarget,
35
41
  } from '@skyhook-io/k8s-ui'
36
42
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
37
43
  import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
38
44
  import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
39
45
  import { useHistoryPaging } from './historyPaging'
46
+ import { findInheritedGitOpsLookupRef, useResolvedGitOpsOwner } from '../../hooks/useResolvedGitOpsOwner'
47
+ import { useGitOpsWriteGuard } from '../../hooks/useGitOpsWriteGuard'
40
48
  import {
41
49
  kindToPlural,
42
50
  kindToPluralWithGroup,
@@ -95,6 +103,7 @@ import { RightsizingPanel } from '../resource/RightsizingStrip'
95
103
  import { WorkloadCostTab } from '../cost/WorkloadCostTab'
96
104
  import { isOpenCostWorkloadKind } from '../cost/kinds'
97
105
  import { isRadarFeatureUnsupported } from '../../api/radarFeatures'
106
+ import { useTrafficAvailable } from '../../api/traffic'
98
107
  import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
99
108
  import { AuditAlerts, getRadarUpgradeRequirement, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
100
109
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
@@ -148,6 +157,9 @@ import {
148
157
  CNPGSubscriptionRenderer,
149
158
  } from '../resources/renderers/CNPGDeclarativeRenderer'
150
159
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
160
+ import { renderCNPGSummary } from '../cnpg/CNPGSummaryHost'
161
+ import { CNPGClusterLogs } from '../cnpg/CNPGClusterLogs'
162
+ import { cnpgDetailKindFor, cnpgDetailPath } from '../cnpg/routes'
151
163
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
152
164
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
153
165
  import { useCompareLauncher } from '../compare/useCompareLauncher'
@@ -277,6 +289,17 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
277
289
  )
278
290
  }
279
291
 
292
+ const cnpgPlural = cnpgDetailKindFor(kind, group)
293
+ if (cnpgPlural) {
294
+ const params = new URLSearchParams(searchParams)
295
+ params.delete('apiGroup')
296
+ const tab = params.get('tab')
297
+ if (cnpgPlural === 'clusters' && (tab === 'timeline' || tab === 'events')) params.set('tab', 'activity')
298
+ const base = cnpgDetailPath({ plural: cnpgPlural, namespace, name })
299
+ const qs = params.toString()
300
+ return <Navigate replace to={qs ? `${base}?${qs}` : base} state={location.state} />
301
+ }
302
+
280
303
  return (
281
304
  <WorkloadView
282
305
  kind={kind}
@@ -289,6 +312,10 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
289
312
  )
290
313
  }
291
314
 
315
+ // The kinds the traffic map draws as a node of their own: those that own
316
+ // pods, which it groups under them.
317
+ const TRAFFIC_WORKLOAD_PLURALS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts', 'cronjobs', 'jobs'])
318
+
292
319
  // ============================================================================
293
320
  // WORKLOAD VIEW WRAPPER — injects data fetching hooks
294
321
  // ============================================================================
@@ -312,6 +339,8 @@ interface WorkloadViewProps {
312
339
  initialTab?: 'detail' | 'yaml'
313
340
  group?: string
314
341
  pushTabHistory?: boolean
342
+ breadcrumb?: ReactNode
343
+ extraTabs?: WorkloadExtraTab[]
315
344
  }
316
345
 
317
346
  interface ImageTargetOwnershipContext {
@@ -322,7 +351,6 @@ interface ImageTargetOwnershipContext {
322
351
  }
323
352
  target: WorkloadImageTarget
324
353
  response: ResourceWithRelationships<Record<string, unknown>>
325
- inheritedResponse?: ResourceWithRelationships<Record<string, unknown>>
326
354
  }
327
355
 
328
356
  function useActionsBarProps(
@@ -536,11 +564,14 @@ export function WorkloadView({
536
564
  ...rest
537
565
  }: WorkloadViewProps) {
538
566
  const [searchParams, setSearchParams] = useSearchParams()
567
+ const location = useLocation()
539
568
  const apiKind = kindToPluralWithGroup(kindProp, rest.group ?? '')
540
569
  const queryClient = useQueryClient()
541
570
  const [imageTargetOwnership, setImageTargetOwnership] =
542
571
  useState<ImageTargetOwnershipContext | null>(null)
543
572
  const imageOwnershipRequestRef = useRef(0)
573
+ // The image guard's evidence is read only once the user opens the dialog.
574
+ const [imageGuardRequested, setImageGuardRequested] = useState(false)
544
575
 
545
576
  // Tab state from URL query param — migrate legacy tab names
546
577
  const rawTab = searchParams.get('tab')
@@ -561,9 +592,9 @@ export function WorkloadView({
561
592
  } else {
562
593
  params.set('tab', tab)
563
594
  }
564
- setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
595
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory, state: location.state })
565
596
  },
566
- [pushTabHistory, searchParams, setSearchParams],
597
+ [pushTabHistory, searchParams, setSearchParams, location.state],
567
598
  )
568
599
 
569
600
  const selectedRunKey = searchParams.get('run') ?? ''
@@ -655,125 +686,33 @@ export function WorkloadView({
655
686
  : null,
656
687
  [apiKind, imageTargetOwnership, name, namespace],
657
688
  )
658
- const relationshipGitopsOwner = useMemo(
659
- () => gitOpsOwnerFromRelationships(relationships),
660
- [relationships],
661
- )
662
- const inheritedGitOpsLookupRef = useMemo(
663
- () =>
664
- findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, {
665
- kind: apiKind,
666
- namespace,
667
- name,
668
- group: rest.group,
669
- }),
670
- [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
671
- )
672
- const inheritedGitOpsResponse = useResourceWithRelationships<any>(
673
- inheritedGitOpsLookupRef ? kindToPluralWithGroup(inheritedGitOpsLookupRef.kind, inheritedGitOpsLookupRef.group ?? '') : '',
674
- inheritedGitOpsLookupRef?.namespace ?? '',
675
- inheritedGitOpsLookupRef?.name ?? '',
676
- inheritedGitOpsLookupRef?.group,
677
- )
678
- const inheritedGitopsOwner = useMemo(
679
- () => gitOpsOwnerFromRelationships(inheritedGitOpsResponse.data?.relationships),
680
- [inheritedGitOpsResponse.data?.relationships],
681
- )
682
- const relationshipHelmOwner = useMemo(
683
- () =>
684
- nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace),
685
- [relationships, resource?.metadata?.namespace, namespace],
686
- )
687
- const inheritedHelmOwner = useMemo(
688
- () =>
689
- nativeHelmOwnerFromRelationships(
690
- inheritedGitOpsResponse.data?.relationships,
691
- inheritedGitOpsResponse.data?.resource?.metadata?.namespace ?? namespace,
692
- ),
693
- [
694
- inheritedGitOpsResponse.data?.relationships,
695
- inheritedGitOpsResponse.data?.resource?.metadata?.namespace,
696
- namespace,
697
- ],
698
- )
699
- const rawGitopsOwner = relationshipGitopsOwner ?? inheritedGitopsOwner
700
- const gitOpsSourceResource = relationshipGitopsOwner
701
- ? resource
702
- : inheritedGitOpsResponse.data?.resource
703
- const helmOwner = relationshipHelmOwner ?? inheritedHelmOwner
704
- const helmSourceResource = relationshipHelmOwner
705
- ? resource
706
- : inheritedGitOpsResponse.data?.resource
707
- const targetRelationshipGitopsOwner = useMemo(
708
- () => gitOpsOwnerFromRelationships(activeImageTargetOwnership?.response.relationships),
709
- [activeImageTargetOwnership?.response.relationships],
710
- )
711
- const targetInheritedGitopsOwner = useMemo(
712
- () =>
713
- gitOpsOwnerFromRelationships(
714
- activeImageTargetOwnership?.inheritedResponse?.relationships,
715
- ),
716
- [activeImageTargetOwnership?.inheritedResponse?.relationships],
717
- )
718
- const targetRawGitopsOwner = targetRelationshipGitopsOwner ?? targetInheritedGitopsOwner
719
- const targetRelationshipHelmOwner = nativeHelmOwnerFromRelationships(
720
- activeImageTargetOwnership?.response.relationships,
721
- activeImageTargetOwnership?.target.namespace ?? namespace,
722
- )
723
- const targetInheritedHelmOwner = nativeHelmOwnerFromRelationships(
724
- activeImageTargetOwnership?.inheritedResponse?.relationships,
725
- activeImageTargetOwnership?.target.namespace ?? namespace,
726
- )
727
- const targetHelmOwner = targetRelationshipHelmOwner ?? targetInheritedHelmOwner
728
- const shouldResolveArgoOwner =
729
- (rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace) ||
730
- (targetRawGitopsOwner?.tool === 'argocd' && !targetRawGitopsOwner.namespace)
731
- const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', {
732
- enabled: shouldResolveArgoOwner,
689
+ const workloadOwnership = useResolvedGitOpsOwner({
690
+ kind: apiKind,
691
+ group: rest.group,
692
+ namespace,
693
+ name,
694
+ relationships,
695
+ resource,
696
+ })
697
+ const {
698
+ owner: gitopsOwner,
699
+ ownerObject: gitopsOwnerObject,
700
+ ownerVerified: gitOpsOwnerVerified,
701
+ ownerPending: gitOpsOwnerPending,
702
+ ownerSource: gitOpsOwnerSource,
703
+ ownerStatus: gitOpsOwnerStatus,
704
+ helmOwner,
705
+ helmSource: helmOwnerSource,
706
+ } = workloadOwnership
707
+ const imageTargetOwnershipResolution = useResolvedGitOpsOwner({
708
+ kind: activeImageTargetOwnership?.target.resource ?? '',
709
+ group: activeImageTargetOwnership?.target.group,
710
+ namespace: activeImageTargetOwnership?.target.namespace ?? '',
711
+ name: activeImageTargetOwnership?.target.name ?? '',
712
+ relationships: activeImageTargetOwnership?.response.relationships,
713
+ resource: activeImageTargetOwnership?.response.resource,
714
+ enabled: Boolean(activeImageTargetOwnership),
733
715
  })
734
- const gitopsOwner = useMemo(
735
- () => resolveGitOpsOwner(rawGitopsOwner, argoApplications),
736
- [rawGitopsOwner, argoApplications],
737
- )
738
- const targetGitopsOwner = useMemo(
739
- () => resolveGitOpsOwner(targetRawGitopsOwner, argoApplications),
740
- [argoApplications, targetRawGitopsOwner],
741
- )
742
- const gitopsOwnerGroup = gitopsOwner ? gitOpsOwnerGroup(gitopsOwner) : ''
743
- const shouldFetchGitOpsOwner = Boolean(gitopsOwner?.namespace)
744
- const gitopsOwnerQuery = useResource<any>(
745
- shouldFetchGitOpsOwner ? gitopsOwner!.kind : '',
746
- gitopsOwner?.namespace ?? '',
747
- gitopsOwner?.name ?? '',
748
- gitopsOwnerGroup,
749
- )
750
- const targetGitopsOwnerGroup = targetGitopsOwner ? gitOpsOwnerGroup(targetGitopsOwner) : ''
751
- const shouldFetchTargetGitOpsOwner = Boolean(
752
- activeImageTargetOwnership && targetGitopsOwner?.namespace,
753
- )
754
- const targetGitopsOwnerQuery = useResource<Record<string, unknown>>(
755
- shouldFetchTargetGitOpsOwner ? targetGitopsOwner!.kind : '',
756
- targetGitopsOwner?.namespace ?? '',
757
- targetGitopsOwner?.name ?? '',
758
- targetGitopsOwnerGroup,
759
- { enabled: shouldFetchTargetGitOpsOwner },
760
- )
761
- const gitOpsOwnerStatus = useMemo(
762
- () => deriveGitOpsOwnerStatus(gitopsOwner, gitopsOwnerQuery.data),
763
- [gitopsOwner, gitopsOwnerQuery.data],
764
- )
765
- const gitOpsOwnerVerified = Boolean(gitopsOwner?.namespace && gitopsOwnerQuery.data)
766
- const gitOpsOwnerPending = Boolean(
767
- gitopsOwner?.namespace && gitopsOwnerQuery.isLoading && !gitopsOwnerQuery.data,
768
- )
769
- const gitOpsOwnerSource = useMemo(
770
- () => describeGitOpsOwnerSource(rawGitopsOwner, gitOpsSourceResource),
771
- [rawGitopsOwner, gitOpsSourceResource],
772
- )
773
- const helmOwnerSource = useMemo(
774
- () => describeHelmOwnerSource(helmOwner, helmSourceResource),
775
- [helmOwner, helmSourceResource],
776
- )
777
716
 
778
717
  // Fetch topology for hierarchy building (only when expanded). Polled like
779
718
  // useTrace's "drawer feeling live" pattern — without this, a resource
@@ -970,6 +909,23 @@ export function WorkloadView({
970
909
  (path: string) => navigateRouter(path),
971
910
  [navigateRouter],
972
911
  )
912
+ // Offered for the kinds the traffic map draws as one node, and only once a
913
+ // traffic source is known to be available on this cluster. A Job something
914
+ // controls (a CronJob, a JobSet) is not one: the map groups its pods under
915
+ // that owner, so it is offered only once the Job is known to have none.
916
+ const isJob = apiKind.toLowerCase() === 'jobs'
917
+ const controlledJob = isJob && (!resource ||
918
+ (resource.metadata?.ownerReferences ?? []).some((ref: { controller?: boolean }) => ref.controller))
919
+ const trafficKind = !!namespace && TRAFFIC_WORKLOAD_PLURALS.has(apiKind.toLowerCase()) && !controlledJob
920
+ const trafficAvailable = useTrafficAvailable(trafficKind)
921
+ const openLiveTraffic = useCallback(() => {
922
+ const params = new URLSearchParams()
923
+ const namespaces = searchParams.get('namespaces')
924
+ if (namespaces) params.set('namespaces', namespaces)
925
+ params.set('focus', `${namespace}/${name}`)
926
+ navigateRouter({ pathname: '/traffic', search: params.toString() })
927
+ }, [navigateRouter, searchParams, namespace, name])
928
+ const offerLiveTraffic = trafficKind && trafficAvailable
973
929
  // Drawer TraceSummary CTA → open the full resource view ON the Reachability tab.
974
930
  // The generic onExpand navigates to the workload path but drops the query, so we
975
931
  // navigate directly to that path WITH ?tab=reachability - the deeplink the
@@ -994,6 +950,10 @@ export function WorkloadView({
994
950
  const loadImagesWithTargetOwnership = useCallback(
995
951
  async (params: { kind: string; namespace: string; name: string }) => {
996
952
  const request = ++imageOwnershipRequestRef.current
953
+ setImageGuardRequested(true)
954
+ // Each open and refresh re-reads the evidence: the owner's sync policy
955
+ // or ignore rules may have changed since the dialog last opened.
956
+ void queryClient.invalidateQueries({ queryKey: ['gitops-write-evidence'] })
997
957
  const inventory = await baseActionsBarProps.onLoadImages!(params)
998
958
  const targetDiffers =
999
959
  inventory.target.resource.toLowerCase() !== params.kind.toLowerCase() ||
@@ -1039,14 +999,17 @@ export function WorkloadView({
1039
999
  group: inventory.target.group,
1040
1000
  },
1041
1001
  )
1042
- const inheritedResponse = inheritedRef
1043
- ? await fetchRelationships(
1044
- kindToPluralWithGroup(inheritedRef.kind, inheritedRef.group ?? ''),
1045
- inheritedRef.namespace,
1046
- inheritedRef.name,
1047
- inheritedRef.group,
1048
- )
1049
- : undefined
1002
+ // Warm the cache the target's ownership resolution reads, so the
1003
+ // dialog doesn't open on a still-pending inherited lookup. A failed
1004
+ // read surfaces there as unknown ownership, not as a failed load.
1005
+ if (inheritedRef) {
1006
+ await fetchRelationships(
1007
+ kindToPluralWithGroup(inheritedRef.kind, inheritedRef.group ?? ''),
1008
+ inheritedRef.namespace,
1009
+ inheritedRef.name,
1010
+ inheritedRef.group,
1011
+ ).catch(() => undefined)
1012
+ }
1050
1013
  if (request === imageOwnershipRequestRef.current) {
1051
1014
  setImageTargetOwnership({
1052
1015
  root: {
@@ -1056,52 +1019,74 @@ export function WorkloadView({
1056
1019
  },
1057
1020
  target: inventory.target,
1058
1021
  response,
1059
- inheritedResponse,
1060
1022
  })
1061
1023
  }
1062
1024
  return inventory
1063
1025
  },
1064
1026
  [baseActionsBarProps.onLoadImages, queryClient, setImageTargetOwnership],
1065
1027
  )
1066
- const imageGitopsOwner = activeImageTargetOwnership ? targetGitopsOwner : gitopsOwner
1067
- const imageHelmOwner = activeImageTargetOwnership ? targetHelmOwner : helmOwner
1068
- const imageGitopsOwnerData = activeImageTargetOwnership
1069
- ? targetGitopsOwnerQuery.data
1070
- : gitopsOwnerQuery.data
1071
- const managedImageSources = useMemo<ManagedImageSource[] | undefined>(() => {
1072
- if (!activeImageTargetOwnership && inheritedGitOpsLookupRef && (inheritedGitOpsResponse.isPending || inheritedGitOpsResponse.isError)) {
1073
- return undefined
1074
- }
1075
- const sources: ManagedImageSource[] = []
1076
- if (imageGitopsOwner) {
1077
- sources.push({
1078
- type: 'GitOps',
1079
- label: imageGitopsOwner.namespace
1080
- ? `${imageGitopsOwner.namespace}/${imageGitopsOwner.name}`
1081
- : imageGitopsOwner.name,
1082
- onOpen: imageGitopsOwnerData
1083
- ? () => handleOpenGitOpsResource(imageGitopsOwner)
1028
+ const imageOwnershipSource = activeImageTargetOwnership ? imageTargetOwnershipResolution : workloadOwnership
1029
+ const imageGuardTarget = activeImageTargetOwnership
1030
+ ? {
1031
+ kind: activeImageTargetOwnership.target.kind,
1032
+ group: activeImageTargetOwnership.target.group,
1033
+ namespace: activeImageTargetOwnership.target.namespace,
1034
+ name: activeImageTargetOwnership.target.name,
1035
+ }
1036
+ : { kind: resource?.kind ?? pluralToKind(apiKind), group: effectiveGroup ?? '', namespace, name }
1037
+ // Once the dialog lists the containers, ask about each by name (so the
1038
+ // evidence can't predate the list) and judge the ones being changed.
1039
+ const [imageSelection, setImageSelection] = useState<SetImageSelection | null>(null)
1040
+ const imageWrites = useMemo(
1041
+ () =>
1042
+ imageSelection
1043
+ ? setImageWrites(imageSelection.changed.length ? imageSelection.changed : imageSelection.containers)
1044
+ : SET_IMAGE_WRITES,
1045
+ [imageSelection],
1046
+ )
1047
+ const imageEvidencePaths = useMemo(
1048
+ () => (imageSelection ? gitOpsWriteEvidencePaths(setImageWrites(imageSelection.containers)) : undefined),
1049
+ [imageSelection],
1050
+ )
1051
+ const { guard: imageGuard, ownership: imageGuardOwnership } = useGitOpsWriteGuard({
1052
+ target: imageGuardTarget,
1053
+ writes: imageWrites,
1054
+ evidencePaths: imageEvidencePaths,
1055
+ ownership: imageOwnershipSource,
1056
+ relationshipsUnavailable: activeImageTargetOwnership
1057
+ ? !activeImageTargetOwnership.response.relationships
1058
+ : Boolean(resource) && !relationships,
1059
+ enabled: imageGuardRequested,
1060
+ })
1061
+ const {
1062
+ owner: imageOwner,
1063
+ helmOwner: imageHelmOwner,
1064
+ ownerVerified: imageOwnerVerified,
1065
+ } = imageGuardOwnership
1066
+ const imageOwnership = useMemo<SetImageOwnership | undefined>(() => {
1067
+ if (!imageGuard) return undefined
1068
+ const owner = imageOwner
1069
+ const helmOwner = imageHelmOwner
1070
+ return {
1071
+ guard: imageGuard,
1072
+ onSelectionChange: setImageSelection,
1073
+ selectionKey: setImageSelectionKey(imageSelection),
1074
+ onOpenOwner: owner
1075
+ ? imageOwnerVerified
1076
+ ? () => handleOpenGitOpsResource(owner)
1077
+ : undefined
1078
+ : helmOwner
1079
+ ? () => handleOpenHelmRelease(helmOwner)
1084
1080
  : undefined,
1085
- })
1086
- }
1087
- if (imageHelmOwner) {
1088
- sources.push({
1089
- type: 'Helm',
1090
- label: `${imageHelmOwner.namespace}/${imageHelmOwner.name}`,
1091
- onOpen: () => handleOpenHelmRelease(imageHelmOwner),
1092
- })
1093
1081
  }
1094
- return sources
1095
1082
  }, [
1096
1083
  handleOpenGitOpsResource,
1097
1084
  handleOpenHelmRelease,
1098
- activeImageTargetOwnership,
1099
- imageGitopsOwner,
1100
- imageGitopsOwnerData,
1085
+ imageGuard,
1086
+ imageOwner,
1101
1087
  imageHelmOwner,
1102
- inheritedGitOpsLookupRef,
1103
- inheritedGitOpsResponse.isError,
1104
- inheritedGitOpsResponse.isPending,
1088
+ imageOwnerVerified,
1089
+ imageSelection,
1105
1090
  ])
1106
1091
  const actionsBarProps = useMemo(
1107
1092
  () => ({
@@ -1111,12 +1096,12 @@ export function WorkloadView({
1111
1096
  : undefined,
1112
1097
  onCompareTo,
1113
1098
  onCompareAcrossClusters,
1114
- managedImageSources,
1099
+ imageOwnership,
1115
1100
  }),
1116
1101
  [
1117
1102
  baseActionsBarProps,
1118
1103
  loadImagesWithTargetOwnership,
1119
- managedImageSources,
1104
+ imageOwnership,
1120
1105
  onCompareTo,
1121
1106
  onCompareAcrossClusters,
1122
1107
  ],
@@ -1264,6 +1249,9 @@ export function WorkloadView({
1264
1249
  onSelectRun={handleSelectedRunChange}
1265
1250
  />
1266
1251
  )}
1252
+ renderSummary={({ apiKind: ak, namespace: ns, name: n, resource: res, context, onNavigate }) =>
1253
+ renderCNPGSummary({ apiKind: ak, namespace: ns, name: n, group: effectiveGroup, resource: res, context, onNavigate })
1254
+ }
1267
1255
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
1268
1256
  supportsBatchExecution(k, apiKind, effectiveGroup, res?.apiVersion) &&
1269
1257
  res ? (
@@ -1373,7 +1361,7 @@ export function WorkloadView({
1373
1361
  )}
1374
1362
  hasOperationalIssues={hasOperationalIssues}
1375
1363
  operationalIssuesPending={issuesPending}
1376
- onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
1364
+ onOpenGitOpsResource={gitopsOwnerObject ? handleOpenGitOpsResource : undefined}
1377
1365
  resolvedGitOpsOwner={gitopsOwner}
1378
1366
  gitOpsOwnerVerified={gitOpsOwnerVerified}
1379
1367
  gitOpsOwnerPending={gitOpsOwnerPending}
@@ -1383,6 +1371,7 @@ export function WorkloadView({
1383
1371
  helmOwnerSource={helmOwnerSource}
1384
1372
  onOpenHelmRelease={handleOpenHelmRelease}
1385
1373
  onNavigateGitOpsPath={handleNavigateGitOpsPath}
1374
+ onOpenLiveTraffic={offerLiveTraffic ? openLiveTraffic : undefined}
1386
1375
  />
1387
1376
  <CreateResourceDialog
1388
1377
  open={duplicateDialogOpen}
@@ -1406,7 +1395,7 @@ export function WorkloadView({
1406
1395
 
1407
1396
  function collectServingRefs(relationships: Relationships | undefined): ResourceRef[] {
1408
1397
  if (!relationships) return []
1409
- return dedupeRefs([
1398
+ return dedupeResourceRefs([
1410
1399
  ...(relationships.services ?? []),
1411
1400
  ...(relationships.ingresses ?? []),
1412
1401
  ...(relationships.gateways ?? []),
@@ -1414,45 +1403,6 @@ function collectServingRefs(relationships: Relationships | undefined): ResourceR
1414
1403
  ])
1415
1404
  }
1416
1405
 
1417
- function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
1418
- const seen = new Set<string>()
1419
- return refs.filter((ref) => {
1420
- const key = `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
1421
- if (seen.has(key)) return false
1422
- seen.add(key)
1423
- return true
1424
- })
1425
- }
1426
-
1427
- function resolveGitOpsOwner(
1428
- owner: GitOpsOwnerRef | null,
1429
- argoApplications: any[] | undefined,
1430
- ): GitOpsOwnerRef | null {
1431
- if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
1432
- const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
1433
- if (matches.length !== 1) return owner
1434
- const namespace = matches[0]?.metadata?.namespace
1435
- return namespace ? { ...owner, namespace } : owner
1436
- }
1437
-
1438
- export function findInheritedGitOpsLookupRef(
1439
- relationships: Relationships | undefined,
1440
- directOwner: GitOpsOwnerRef | null,
1441
- current: ResourceRef,
1442
- ): ResourceRef | null {
1443
- if (directOwner) return null
1444
- const inheritedManagerRefs = (relationships?.managedBy ?? []).filter(
1445
- (ref) => !gitOpsOwnerFromRelationships({ managedBy: [ref] }) && !isNativeHelmManager(ref),
1446
- )
1447
- const candidates = [
1448
- relationships?.deployment,
1449
- ...inheritedManagerRefs,
1450
- relationships?.owner,
1451
- ].filter(Boolean) as ResourceRef[]
1452
-
1453
- return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
1454
- }
1455
-
1456
1406
  const POD_OWNERSHIP_WORKLOAD_KINDS = new Set([
1457
1407
  'deployments',
1458
1408
  'statefulsets',
@@ -1532,89 +1482,6 @@ function sameWorkload(
1532
1482
  )
1533
1483
  }
1534
1484
 
1535
- function nativeHelmOwnerFromRelationships(
1536
- relationships: Relationships | undefined,
1537
- fallbackNamespace: string,
1538
- ): HelmOwnerRef | null {
1539
- const ref = relationships?.managedBy?.[0]
1540
- if (!ref || !isNativeHelmManager(ref)) return null
1541
- return {
1542
- namespace: ref.namespace || fallbackNamespace,
1543
- name: ref.name,
1544
- }
1545
- }
1546
-
1547
- function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
1548
- return (
1549
- kindToPluralWithGroup(ref.kind, ref.group ?? '') ===
1550
- kindToPluralWithGroup(current.kind, current.group ?? '') &&
1551
- ref.namespace === current.namespace &&
1552
- ref.name === current.name &&
1553
- (ref.group ?? '') === (current.group ?? '')
1554
- )
1555
- }
1556
-
1557
- function isNativeHelmManager(ref: ResourceRef): boolean {
1558
- return ref.kind === 'HelmRelease' && ref.group !== 'helm.toolkit.fluxcd.io'
1559
- }
1560
-
1561
- function describeGitOpsOwnerSource(owner: GitOpsOwnerRef | null, resource: any): string | null {
1562
- if (!owner || !resource) return null
1563
- const labels = resource.metadata?.labels ?? {}
1564
- const annotations = resource.metadata?.annotations ?? {}
1565
-
1566
- if (owner.tool === 'fluxcd') {
1567
- const nameKey =
1568
- owner.kind === 'helmreleases'
1569
- ? 'helm.toolkit.fluxcd.io/name'
1570
- : 'kustomize.toolkit.fluxcd.io/name'
1571
- const nsKey =
1572
- owner.kind === 'helmreleases'
1573
- ? 'helm.toolkit.fluxcd.io/namespace'
1574
- : 'kustomize.toolkit.fluxcd.io/namespace'
1575
- if (labels[nameKey] || labels[nsKey]) {
1576
- return `${nameKey}=${labels[nameKey] ?? ''}, ${nsKey}=${labels[nsKey] ?? ''}`
1577
- }
1578
- }
1579
-
1580
- const trackingID = annotations['argocd.argoproj.io/tracking-id']
1581
- if (trackingID) return `argocd.argoproj.io/tracking-id=${trackingID}`
1582
- const argoInstance = labels['argocd.argoproj.io/instance']
1583
- if (argoInstance) return `argocd.argoproj.io/instance=${argoInstance}`
1584
- return null
1585
- }
1586
-
1587
- function describeHelmOwnerSource(owner: HelmOwnerRef | null, resource: any): string | null {
1588
- if (!owner || !resource) return null
1589
- const annotations = resource.metadata?.annotations ?? {}
1590
- const releaseName = annotations['meta.helm.sh/release-name']
1591
- const releaseNamespace = annotations['meta.helm.sh/release-namespace']
1592
- if (releaseName || releaseNamespace) {
1593
- return `meta.helm.sh/release-name=${releaseName ?? ''}, meta.helm.sh/release-namespace=${releaseNamespace ?? ''}`
1594
- }
1595
- return null
1596
- }
1597
-
1598
- function gitOpsOwnerGroup(owner: GitOpsOwnerRef): string {
1599
- if (owner.tool === 'argocd') return 'argoproj.io'
1600
- if (owner.kind === 'kustomizations') return 'kustomize.toolkit.fluxcd.io'
1601
- return 'helm.toolkit.fluxcd.io'
1602
- }
1603
-
1604
- function deriveGitOpsOwnerStatus(owner: GitOpsOwnerRef | null, resource: any): GitOpsStatus | null {
1605
- if (!owner || !resource || !hasGitOpsStatusPayload(owner, resource)) return null
1606
- return getGitOpsResourceStatus(owner.kind, resource)
1607
- }
1608
-
1609
- function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
1610
- if (owner.kind === 'applications') {
1611
- const status = resource.status ?? {}
1612
- return Boolean(status.sync?.status || status.health?.status || status.operationState?.phase)
1613
- }
1614
- if (resource.spec?.suspend === true) return true
1615
- return Array.isArray(resource.status?.conditions) && resource.status.conditions.length > 0
1616
- }
1617
-
1618
1485
  // ============================================================================
1619
1486
  // LOGS TAB — platform-specific (uses data-fetching hooks)
1620
1487
  // ============================================================================
@@ -1672,6 +1539,10 @@ function LogsTabContent({
1672
1539
  )
1673
1540
  }
1674
1541
 
1542
+ if (kind === 'Cluster' && isApiGroup(resource?.apiVersion, 'postgresql.cnpg.io')) {
1543
+ return <CNPGClusterLogs namespace={namespace} name={name} />
1544
+ }
1545
+
1675
1546
  // Workload kinds with stable pod selectors use the aggregated workload logs viewer
1676
1547
  if (WORKLOAD_LOG_KINDS.has(kind) && (kind !== 'Job' || isCoreBatchJob(apiKind, group))) {
1677
1548
  return (
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ /**
5
+ * A note from the host about how far back the timeline goes. The host owns
6
+ * the wording and decides when there is one. Standalone Radar serves
7
+ * everything it holds and never sets it.
8
+ */
9
+ const TimelineRetentionNoteContext = createContext<ReactNode>(undefined)
10
+
11
+ export const TimelineRetentionNoteProvider = TimelineRetentionNoteContext.Provider
12
+
13
+ export function useTimelineRetentionNote(): ReactNode {
14
+ return useContext(TimelineRetentionNoteContext)
15
+ }