@skyhook-io/radar-app 1.16.3 → 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 (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
@@ -19,26 +19,32 @@ import {
19
19
  type WorkloadExtraTab,
20
20
  type RendererOverrides,
21
21
  type GitOpsOwnerRef,
22
- type GitOpsStatus,
23
22
  type HelmOwnerRef,
24
23
  type AppRow,
25
24
  type ResourceOwnershipContext,
26
25
  type ServingResourceDetail,
27
26
  type AuditFinding,
27
+ dedupeResourceRefs,
28
28
  gitOpsRouteForOwner,
29
29
  gitOpsOwnerFromRelationships,
30
- getGitOpsResourceStatus,
31
30
  isDiagnoseKind,
32
31
  isRolloutKind,
33
32
  canSetWorkloadImages,
34
33
  isCoreBatchJob,
35
- type ManagedImageSource,
34
+ type SetImageOwnership,
35
+ SET_IMAGE_WRITES,
36
+ setImageWrites,
37
+ setImageSelectionKey,
38
+ gitOpsWriteEvidencePaths,
39
+ type SetImageSelection,
36
40
  type WorkloadImageTarget,
37
41
  } from '@skyhook-io/k8s-ui'
38
42
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
39
43
  import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
40
44
  import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
41
45
  import { useHistoryPaging } from './historyPaging'
46
+ import { findInheritedGitOpsLookupRef, useResolvedGitOpsOwner } from '../../hooks/useResolvedGitOpsOwner'
47
+ import { useGitOpsWriteGuard } from '../../hooks/useGitOpsWriteGuard'
42
48
  import {
43
49
  kindToPlural,
44
50
  kindToPluralWithGroup,
@@ -97,6 +103,7 @@ import { RightsizingPanel } from '../resource/RightsizingStrip'
97
103
  import { WorkloadCostTab } from '../cost/WorkloadCostTab'
98
104
  import { isOpenCostWorkloadKind } from '../cost/kinds'
99
105
  import { isRadarFeatureUnsupported } from '../../api/radarFeatures'
106
+ import { useTrafficAvailable } from '../../api/traffic'
100
107
  import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
101
108
  import { AuditAlerts, getRadarUpgradeRequirement, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
102
109
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
@@ -305,6 +312,10 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
305
312
  )
306
313
  }
307
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
+
308
319
  // ============================================================================
309
320
  // WORKLOAD VIEW WRAPPER — injects data fetching hooks
310
321
  // ============================================================================
@@ -340,7 +351,6 @@ interface ImageTargetOwnershipContext {
340
351
  }
341
352
  target: WorkloadImageTarget
342
353
  response: ResourceWithRelationships<Record<string, unknown>>
343
- inheritedResponse?: ResourceWithRelationships<Record<string, unknown>>
344
354
  }
345
355
 
346
356
  function useActionsBarProps(
@@ -560,6 +570,8 @@ export function WorkloadView({
560
570
  const [imageTargetOwnership, setImageTargetOwnership] =
561
571
  useState<ImageTargetOwnershipContext | null>(null)
562
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)
563
575
 
564
576
  // Tab state from URL query param — migrate legacy tab names
565
577
  const rawTab = searchParams.get('tab')
@@ -674,125 +686,33 @@ export function WorkloadView({
674
686
  : null,
675
687
  [apiKind, imageTargetOwnership, name, namespace],
676
688
  )
677
- const relationshipGitopsOwner = useMemo(
678
- () => gitOpsOwnerFromRelationships(relationships),
679
- [relationships],
680
- )
681
- const inheritedGitOpsLookupRef = useMemo(
682
- () =>
683
- findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, {
684
- kind: apiKind,
685
- namespace,
686
- name,
687
- group: rest.group,
688
- }),
689
- [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
690
- )
691
- const inheritedGitOpsResponse = useResourceWithRelationships<any>(
692
- inheritedGitOpsLookupRef ? kindToPluralWithGroup(inheritedGitOpsLookupRef.kind, inheritedGitOpsLookupRef.group ?? '') : '',
693
- inheritedGitOpsLookupRef?.namespace ?? '',
694
- inheritedGitOpsLookupRef?.name ?? '',
695
- inheritedGitOpsLookupRef?.group,
696
- )
697
- const inheritedGitopsOwner = useMemo(
698
- () => gitOpsOwnerFromRelationships(inheritedGitOpsResponse.data?.relationships),
699
- [inheritedGitOpsResponse.data?.relationships],
700
- )
701
- const relationshipHelmOwner = useMemo(
702
- () =>
703
- nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace),
704
- [relationships, resource?.metadata?.namespace, namespace],
705
- )
706
- const inheritedHelmOwner = useMemo(
707
- () =>
708
- nativeHelmOwnerFromRelationships(
709
- inheritedGitOpsResponse.data?.relationships,
710
- inheritedGitOpsResponse.data?.resource?.metadata?.namespace ?? namespace,
711
- ),
712
- [
713
- inheritedGitOpsResponse.data?.relationships,
714
- inheritedGitOpsResponse.data?.resource?.metadata?.namespace,
715
- namespace,
716
- ],
717
- )
718
- const rawGitopsOwner = relationshipGitopsOwner ?? inheritedGitopsOwner
719
- const gitOpsSourceResource = relationshipGitopsOwner
720
- ? resource
721
- : inheritedGitOpsResponse.data?.resource
722
- const helmOwner = relationshipHelmOwner ?? inheritedHelmOwner
723
- const helmSourceResource = relationshipHelmOwner
724
- ? resource
725
- : inheritedGitOpsResponse.data?.resource
726
- const targetRelationshipGitopsOwner = useMemo(
727
- () => gitOpsOwnerFromRelationships(activeImageTargetOwnership?.response.relationships),
728
- [activeImageTargetOwnership?.response.relationships],
729
- )
730
- const targetInheritedGitopsOwner = useMemo(
731
- () =>
732
- gitOpsOwnerFromRelationships(
733
- activeImageTargetOwnership?.inheritedResponse?.relationships,
734
- ),
735
- [activeImageTargetOwnership?.inheritedResponse?.relationships],
736
- )
737
- const targetRawGitopsOwner = targetRelationshipGitopsOwner ?? targetInheritedGitopsOwner
738
- const targetRelationshipHelmOwner = nativeHelmOwnerFromRelationships(
739
- activeImageTargetOwnership?.response.relationships,
740
- activeImageTargetOwnership?.target.namespace ?? namespace,
741
- )
742
- const targetInheritedHelmOwner = nativeHelmOwnerFromRelationships(
743
- activeImageTargetOwnership?.inheritedResponse?.relationships,
744
- activeImageTargetOwnership?.target.namespace ?? namespace,
745
- )
746
- const targetHelmOwner = targetRelationshipHelmOwner ?? targetInheritedHelmOwner
747
- const shouldResolveArgoOwner =
748
- (rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace) ||
749
- (targetRawGitopsOwner?.tool === 'argocd' && !targetRawGitopsOwner.namespace)
750
- const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', {
751
- 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),
752
715
  })
753
- const gitopsOwner = useMemo(
754
- () => resolveGitOpsOwner(rawGitopsOwner, argoApplications),
755
- [rawGitopsOwner, argoApplications],
756
- )
757
- const targetGitopsOwner = useMemo(
758
- () => resolveGitOpsOwner(targetRawGitopsOwner, argoApplications),
759
- [argoApplications, targetRawGitopsOwner],
760
- )
761
- const gitopsOwnerGroup = gitopsOwner ? gitOpsOwnerGroup(gitopsOwner) : ''
762
- const shouldFetchGitOpsOwner = Boolean(gitopsOwner?.namespace)
763
- const gitopsOwnerQuery = useResource<any>(
764
- shouldFetchGitOpsOwner ? gitopsOwner!.kind : '',
765
- gitopsOwner?.namespace ?? '',
766
- gitopsOwner?.name ?? '',
767
- gitopsOwnerGroup,
768
- )
769
- const targetGitopsOwnerGroup = targetGitopsOwner ? gitOpsOwnerGroup(targetGitopsOwner) : ''
770
- const shouldFetchTargetGitOpsOwner = Boolean(
771
- activeImageTargetOwnership && targetGitopsOwner?.namespace,
772
- )
773
- const targetGitopsOwnerQuery = useResource<Record<string, unknown>>(
774
- shouldFetchTargetGitOpsOwner ? targetGitopsOwner!.kind : '',
775
- targetGitopsOwner?.namespace ?? '',
776
- targetGitopsOwner?.name ?? '',
777
- targetGitopsOwnerGroup,
778
- { enabled: shouldFetchTargetGitOpsOwner },
779
- )
780
- const gitOpsOwnerStatus = useMemo(
781
- () => deriveGitOpsOwnerStatus(gitopsOwner, gitopsOwnerQuery.data),
782
- [gitopsOwner, gitopsOwnerQuery.data],
783
- )
784
- const gitOpsOwnerVerified = Boolean(gitopsOwner?.namespace && gitopsOwnerQuery.data)
785
- const gitOpsOwnerPending = Boolean(
786
- gitopsOwner?.namespace && gitopsOwnerQuery.isLoading && !gitopsOwnerQuery.data,
787
- )
788
- const gitOpsOwnerSource = useMemo(
789
- () => describeGitOpsOwnerSource(rawGitopsOwner, gitOpsSourceResource),
790
- [rawGitopsOwner, gitOpsSourceResource],
791
- )
792
- const helmOwnerSource = useMemo(
793
- () => describeHelmOwnerSource(helmOwner, helmSourceResource),
794
- [helmOwner, helmSourceResource],
795
- )
796
716
 
797
717
  // Fetch topology for hierarchy building (only when expanded). Polled like
798
718
  // useTrace's "drawer feeling live" pattern — without this, a resource
@@ -989,6 +909,23 @@ export function WorkloadView({
989
909
  (path: string) => navigateRouter(path),
990
910
  [navigateRouter],
991
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
992
929
  // Drawer TraceSummary CTA → open the full resource view ON the Reachability tab.
993
930
  // The generic onExpand navigates to the workload path but drops the query, so we
994
931
  // navigate directly to that path WITH ?tab=reachability - the deeplink the
@@ -1013,6 +950,10 @@ export function WorkloadView({
1013
950
  const loadImagesWithTargetOwnership = useCallback(
1014
951
  async (params: { kind: string; namespace: string; name: string }) => {
1015
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'] })
1016
957
  const inventory = await baseActionsBarProps.onLoadImages!(params)
1017
958
  const targetDiffers =
1018
959
  inventory.target.resource.toLowerCase() !== params.kind.toLowerCase() ||
@@ -1058,14 +999,17 @@ export function WorkloadView({
1058
999
  group: inventory.target.group,
1059
1000
  },
1060
1001
  )
1061
- const inheritedResponse = inheritedRef
1062
- ? await fetchRelationships(
1063
- kindToPluralWithGroup(inheritedRef.kind, inheritedRef.group ?? ''),
1064
- inheritedRef.namespace,
1065
- inheritedRef.name,
1066
- inheritedRef.group,
1067
- )
1068
- : 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
+ }
1069
1013
  if (request === imageOwnershipRequestRef.current) {
1070
1014
  setImageTargetOwnership({
1071
1015
  root: {
@@ -1075,52 +1019,74 @@ export function WorkloadView({
1075
1019
  },
1076
1020
  target: inventory.target,
1077
1021
  response,
1078
- inheritedResponse,
1079
1022
  })
1080
1023
  }
1081
1024
  return inventory
1082
1025
  },
1083
1026
  [baseActionsBarProps.onLoadImages, queryClient, setImageTargetOwnership],
1084
1027
  )
1085
- const imageGitopsOwner = activeImageTargetOwnership ? targetGitopsOwner : gitopsOwner
1086
- const imageHelmOwner = activeImageTargetOwnership ? targetHelmOwner : helmOwner
1087
- const imageGitopsOwnerData = activeImageTargetOwnership
1088
- ? targetGitopsOwnerQuery.data
1089
- : gitopsOwnerQuery.data
1090
- const managedImageSources = useMemo<ManagedImageSource[] | undefined>(() => {
1091
- if (!activeImageTargetOwnership && inheritedGitOpsLookupRef && (inheritedGitOpsResponse.isPending || inheritedGitOpsResponse.isError)) {
1092
- return undefined
1093
- }
1094
- const sources: ManagedImageSource[] = []
1095
- if (imageGitopsOwner) {
1096
- sources.push({
1097
- type: 'GitOps',
1098
- label: imageGitopsOwner.namespace
1099
- ? `${imageGitopsOwner.namespace}/${imageGitopsOwner.name}`
1100
- : imageGitopsOwner.name,
1101
- onOpen: imageGitopsOwnerData
1102
- ? () => 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)
1103
1080
  : undefined,
1104
- })
1105
- }
1106
- if (imageHelmOwner) {
1107
- sources.push({
1108
- type: 'Helm',
1109
- label: `${imageHelmOwner.namespace}/${imageHelmOwner.name}`,
1110
- onOpen: () => handleOpenHelmRelease(imageHelmOwner),
1111
- })
1112
1081
  }
1113
- return sources
1114
1082
  }, [
1115
1083
  handleOpenGitOpsResource,
1116
1084
  handleOpenHelmRelease,
1117
- activeImageTargetOwnership,
1118
- imageGitopsOwner,
1119
- imageGitopsOwnerData,
1085
+ imageGuard,
1086
+ imageOwner,
1120
1087
  imageHelmOwner,
1121
- inheritedGitOpsLookupRef,
1122
- inheritedGitOpsResponse.isError,
1123
- inheritedGitOpsResponse.isPending,
1088
+ imageOwnerVerified,
1089
+ imageSelection,
1124
1090
  ])
1125
1091
  const actionsBarProps = useMemo(
1126
1092
  () => ({
@@ -1130,12 +1096,12 @@ export function WorkloadView({
1130
1096
  : undefined,
1131
1097
  onCompareTo,
1132
1098
  onCompareAcrossClusters,
1133
- managedImageSources,
1099
+ imageOwnership,
1134
1100
  }),
1135
1101
  [
1136
1102
  baseActionsBarProps,
1137
1103
  loadImagesWithTargetOwnership,
1138
- managedImageSources,
1104
+ imageOwnership,
1139
1105
  onCompareTo,
1140
1106
  onCompareAcrossClusters,
1141
1107
  ],
@@ -1395,7 +1361,7 @@ export function WorkloadView({
1395
1361
  )}
1396
1362
  hasOperationalIssues={hasOperationalIssues}
1397
1363
  operationalIssuesPending={issuesPending}
1398
- onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
1364
+ onOpenGitOpsResource={gitopsOwnerObject ? handleOpenGitOpsResource : undefined}
1399
1365
  resolvedGitOpsOwner={gitopsOwner}
1400
1366
  gitOpsOwnerVerified={gitOpsOwnerVerified}
1401
1367
  gitOpsOwnerPending={gitOpsOwnerPending}
@@ -1405,6 +1371,7 @@ export function WorkloadView({
1405
1371
  helmOwnerSource={helmOwnerSource}
1406
1372
  onOpenHelmRelease={handleOpenHelmRelease}
1407
1373
  onNavigateGitOpsPath={handleNavigateGitOpsPath}
1374
+ onOpenLiveTraffic={offerLiveTraffic ? openLiveTraffic : undefined}
1408
1375
  />
1409
1376
  <CreateResourceDialog
1410
1377
  open={duplicateDialogOpen}
@@ -1428,7 +1395,7 @@ export function WorkloadView({
1428
1395
 
1429
1396
  function collectServingRefs(relationships: Relationships | undefined): ResourceRef[] {
1430
1397
  if (!relationships) return []
1431
- return dedupeRefs([
1398
+ return dedupeResourceRefs([
1432
1399
  ...(relationships.services ?? []),
1433
1400
  ...(relationships.ingresses ?? []),
1434
1401
  ...(relationships.gateways ?? []),
@@ -1436,45 +1403,6 @@ function collectServingRefs(relationships: Relationships | undefined): ResourceR
1436
1403
  ])
1437
1404
  }
1438
1405
 
1439
- function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
1440
- const seen = new Set<string>()
1441
- return refs.filter((ref) => {
1442
- const key = `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
1443
- if (seen.has(key)) return false
1444
- seen.add(key)
1445
- return true
1446
- })
1447
- }
1448
-
1449
- function resolveGitOpsOwner(
1450
- owner: GitOpsOwnerRef | null,
1451
- argoApplications: any[] | undefined,
1452
- ): GitOpsOwnerRef | null {
1453
- if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
1454
- const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
1455
- if (matches.length !== 1) return owner
1456
- const namespace = matches[0]?.metadata?.namespace
1457
- return namespace ? { ...owner, namespace } : owner
1458
- }
1459
-
1460
- export function findInheritedGitOpsLookupRef(
1461
- relationships: Relationships | undefined,
1462
- directOwner: GitOpsOwnerRef | null,
1463
- current: ResourceRef,
1464
- ): ResourceRef | null {
1465
- if (directOwner) return null
1466
- const inheritedManagerRefs = (relationships?.managedBy ?? []).filter(
1467
- (ref) => !gitOpsOwnerFromRelationships({ managedBy: [ref] }) && !isNativeHelmManager(ref),
1468
- )
1469
- const candidates = [
1470
- relationships?.deployment,
1471
- ...inheritedManagerRefs,
1472
- relationships?.owner,
1473
- ].filter(Boolean) as ResourceRef[]
1474
-
1475
- return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
1476
- }
1477
-
1478
1406
  const POD_OWNERSHIP_WORKLOAD_KINDS = new Set([
1479
1407
  'deployments',
1480
1408
  'statefulsets',
@@ -1554,89 +1482,6 @@ function sameWorkload(
1554
1482
  )
1555
1483
  }
1556
1484
 
1557
- function nativeHelmOwnerFromRelationships(
1558
- relationships: Relationships | undefined,
1559
- fallbackNamespace: string,
1560
- ): HelmOwnerRef | null {
1561
- const ref = relationships?.managedBy?.[0]
1562
- if (!ref || !isNativeHelmManager(ref)) return null
1563
- return {
1564
- namespace: ref.namespace || fallbackNamespace,
1565
- name: ref.name,
1566
- }
1567
- }
1568
-
1569
- function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
1570
- return (
1571
- kindToPluralWithGroup(ref.kind, ref.group ?? '') ===
1572
- kindToPluralWithGroup(current.kind, current.group ?? '') &&
1573
- ref.namespace === current.namespace &&
1574
- ref.name === current.name &&
1575
- (ref.group ?? '') === (current.group ?? '')
1576
- )
1577
- }
1578
-
1579
- function isNativeHelmManager(ref: ResourceRef): boolean {
1580
- return ref.kind === 'HelmRelease' && ref.group !== 'helm.toolkit.fluxcd.io'
1581
- }
1582
-
1583
- function describeGitOpsOwnerSource(owner: GitOpsOwnerRef | null, resource: any): string | null {
1584
- if (!owner || !resource) return null
1585
- const labels = resource.metadata?.labels ?? {}
1586
- const annotations = resource.metadata?.annotations ?? {}
1587
-
1588
- if (owner.tool === 'fluxcd') {
1589
- const nameKey =
1590
- owner.kind === 'helmreleases'
1591
- ? 'helm.toolkit.fluxcd.io/name'
1592
- : 'kustomize.toolkit.fluxcd.io/name'
1593
- const nsKey =
1594
- owner.kind === 'helmreleases'
1595
- ? 'helm.toolkit.fluxcd.io/namespace'
1596
- : 'kustomize.toolkit.fluxcd.io/namespace'
1597
- if (labels[nameKey] || labels[nsKey]) {
1598
- return `${nameKey}=${labels[nameKey] ?? ''}, ${nsKey}=${labels[nsKey] ?? ''}`
1599
- }
1600
- }
1601
-
1602
- const trackingID = annotations['argocd.argoproj.io/tracking-id']
1603
- if (trackingID) return `argocd.argoproj.io/tracking-id=${trackingID}`
1604
- const argoInstance = labels['argocd.argoproj.io/instance']
1605
- if (argoInstance) return `argocd.argoproj.io/instance=${argoInstance}`
1606
- return null
1607
- }
1608
-
1609
- function describeHelmOwnerSource(owner: HelmOwnerRef | null, resource: any): string | null {
1610
- if (!owner || !resource) return null
1611
- const annotations = resource.metadata?.annotations ?? {}
1612
- const releaseName = annotations['meta.helm.sh/release-name']
1613
- const releaseNamespace = annotations['meta.helm.sh/release-namespace']
1614
- if (releaseName || releaseNamespace) {
1615
- return `meta.helm.sh/release-name=${releaseName ?? ''}, meta.helm.sh/release-namespace=${releaseNamespace ?? ''}`
1616
- }
1617
- return null
1618
- }
1619
-
1620
- function gitOpsOwnerGroup(owner: GitOpsOwnerRef): string {
1621
- if (owner.tool === 'argocd') return 'argoproj.io'
1622
- if (owner.kind === 'kustomizations') return 'kustomize.toolkit.fluxcd.io'
1623
- return 'helm.toolkit.fluxcd.io'
1624
- }
1625
-
1626
- function deriveGitOpsOwnerStatus(owner: GitOpsOwnerRef | null, resource: any): GitOpsStatus | null {
1627
- if (!owner || !resource || !hasGitOpsStatusPayload(owner, resource)) return null
1628
- return getGitOpsResourceStatus(owner.kind, resource)
1629
- }
1630
-
1631
- function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
1632
- if (owner.kind === 'applications') {
1633
- const status = resource.status ?? {}
1634
- return Boolean(status.sync?.status || status.health?.status || status.operationState?.phase)
1635
- }
1636
- if (resource.spec?.suspend === true) return true
1637
- return Array.isArray(resource.status?.conditions) && resource.status.conditions.length > 0
1638
- }
1639
-
1640
1485
  // ============================================================================
1641
1486
  // LOGS TAB — platform-specific (uses data-fetching hooks)
1642
1487
  // ============================================================================
@@ -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
+ }