@skyhook-io/radar-app 1.12.2 → 1.12.3

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 (41) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +34 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +208 -33
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  16. package/src/components/gitops/GitOpsView.tsx +8 -3
  17. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +10 -2
  19. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  20. package/src/components/home/ClusterHealthCard.tsx +60 -1
  21. package/src/components/home/HomeView.tsx +36 -11
  22. package/src/components/home/MCPSetupDialog.tsx +5 -4
  23. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  24. package/src/components/home/RadarVersionLine.tsx +137 -0
  25. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  26. package/src/components/resources/ResourcesView.tsx +31 -8
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  28. package/src/components/settings/SettingsDialog.tsx +21 -4
  29. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  30. package/src/components/ui/ErrorBoundary.tsx +17 -2
  31. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  32. package/src/components/ui/UpdateNotification.tsx +6 -2
  33. package/src/components/workload/WorkloadView.test.ts +60 -0
  34. package/src/components/workload/WorkloadView.tsx +270 -29
  35. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  36. package/src/contexts/CapabilitiesContext.tsx +7 -3
  37. package/src/utils/navigation.test.ts +45 -0
  38. package/src/utils/navigation.ts +5 -5
  39. package/src/utils/topology-selection.ts +3 -2
  40. package/src/utils/version.test.ts +37 -0
  41. package/src/utils/version.ts +56 -0
@@ -23,11 +23,17 @@ import {
23
23
  getGitOpsResourceStatus,
24
24
  isDiagnoseKind,
25
25
  isRolloutKind,
26
+ canSetWorkloadImages,
27
+ isCoreBatchJob,
28
+ type ManagedImageSource,
29
+ type WorkloadImageTarget,
26
30
  } from '@skyhook-io/k8s-ui'
27
31
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
28
- import type { SelectedResource, ResourceRef, Relationships } from '../../types'
32
+ import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
29
33
  import {
30
34
  kindToPlural,
35
+ kindToPluralWithGroup,
36
+ apiVersionToGroup,
31
37
  pluralToKind,
32
38
  relatedResourcePath,
33
39
  buildWorkloadPath,
@@ -45,6 +51,9 @@ import {
45
51
  useSuspendCronJob,
46
52
  useResumeCronJob,
47
53
  useRestartWorkload,
54
+ fetchWorkloadImages,
55
+ fetchResourceWithRelationships,
56
+ useSetWorkloadImages,
48
57
  useWorkloadRevisions,
49
58
  useRollbackWorkload,
50
59
  useRolloutAction,
@@ -131,7 +140,6 @@ import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
131
140
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
132
141
  import { useCompareLauncher } from '../compare/useCompareLauncher'
133
142
  import { useDiagnoseCustomization } from '../../context/DiagnoseCustomization'
134
- import { apiVersionToGroup } from '../../utils/navigation'
135
143
 
136
144
  type TabType = WorkloadTabType
137
145
  const BATCH_EXECUTION_KINDS = new Set([
@@ -277,6 +285,17 @@ interface WorkloadViewProps {
277
285
  pushTabHistory?: boolean
278
286
  }
279
287
 
288
+ interface ImageTargetOwnershipContext {
289
+ root: {
290
+ resource: string
291
+ namespace: string
292
+ name: string
293
+ }
294
+ target: WorkloadImageTarget
295
+ response: ResourceWithRelationships<Record<string, unknown>>
296
+ inheritedResponse?: ResourceWithRelationships<Record<string, unknown>>
297
+ }
298
+
280
299
  function useActionsBarProps(
281
300
  kind: string,
282
301
  namespace: string,
@@ -285,11 +304,13 @@ function useActionsBarProps(
285
304
  cascadeEnabled: boolean,
286
305
  ) {
287
306
  const { showCopied } = useToast()
307
+ const { features } = useCapabilitiesContext()
288
308
  const openTerminal = useOpenTerminal()
289
309
  const openLogs = useOpenLogs()
290
310
  const openWorkloadLogs = useOpenWorkloadLogs()
291
311
  const openNodeTerminal = useOpenNodeTerminal()
292
- const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
312
+ const { canExec, canViewLogs, canPortForward, workloadWrites, workloadWritesPending } = useNamespacedCapabilities(namespace)
313
+ const queryClient = useQueryClient()
293
314
  // Live forward when local+RBAC; otherwise (in-cluster/Cloud) still surface the
294
315
  // copy-paste kubectl command. The button picks live vs. copy by deployment mode.
295
316
  const isLocal = useIsLocalDeployment()
@@ -297,20 +318,37 @@ function useActionsBarProps(
297
318
 
298
319
  const deleteMutation = useDeleteResource()
299
320
  const restartWorkloadMutation = useRestartWorkload()
321
+ const setWorkloadImagesMutation = useSetWorkloadImages()
300
322
  const rollbackMutation = useRollbackWorkload()
301
- const rolloutActionMutation = useRolloutAction()
323
+ // Only the revision dialog drives this one, and it explains a failed promote in place
324
+ // with the retry — the generic action toast would be a second, vaguer voice.
325
+ const rolloutDialogPromote = useRolloutAction({ reportErrors: false })
302
326
  const triggerCronJobMutation = useTriggerCronJob()
303
327
  const suspendCronJobMutation = useSuspendCronJob()
304
328
  const resumeCronJobMutation = useResumeCronJob()
305
329
 
306
- const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind.toLowerCase())
307
- const isRollout = isRolloutKind(kind)
308
- const { data: rolloutCapabilities } = useRolloutCapabilities(namespace, name, isRollout)
330
+ const isRolloutShape = isRolloutKind(kind)
331
+ const isRollout = isRolloutShape && group === 'argoproj.io'
332
+ const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind.toLowerCase()) || isRollout
333
+ const { data: rolloutCapabilities, isPending: rolloutCapabilitiesPending } = useRolloutCapabilities(namespace, name, isRollout)
309
334
  // Restart and Rollback are the generic workload buttons, so a Rollout has to
310
335
  // withhold the callback the way promote-full does. Permissive until the probe
311
336
  // answers — withholding while it loads would flicker the shared buttons.
312
337
  const rolloutAllows = (verb: 'restart' | 'rollback') =>
313
338
  !isRollout || !rolloutCapabilities || rolloutCapabilities[verb]
339
+ const imageUpdatesSupported = features?.workloadImages === true
340
+ const canSetImages = isRolloutShape
341
+ ? imageUpdatesSupported && isRollout && !rolloutCapabilitiesPending && rolloutCapabilities?.setImage === true
342
+ : !workloadWritesPending && canSetWorkloadImages({ name: kind, group }, workloadWrites, imageUpdatesSupported)
343
+ const loadImages = useCallback(
344
+ (params: { kind: string; namespace: string; name: string }) =>
345
+ queryClient.fetchQuery({
346
+ queryKey: ['workload-images', params.kind, params.namespace, params.name],
347
+ queryFn: () => fetchWorkloadImages(params.kind, params.namespace, params.name),
348
+ staleTime: 0,
349
+ }),
350
+ [queryClient],
351
+ )
314
352
  const {
315
353
  data: revisionsList,
316
354
  isLoading: revisionsLoading,
@@ -381,6 +419,12 @@ function useActionsBarProps(
381
419
  restartWorkloadMutation.mutate(params)
382
420
  : undefined,
383
421
  isRestarting: restartWorkloadMutation.isPending,
422
+ onLoadImages: canSetImages ? loadImages : undefined,
423
+ onSetImages: canSetImages
424
+ ? (params: Parameters<typeof setWorkloadImagesMutation.mutateAsync>[0]) =>
425
+ setWorkloadImagesMutation.mutateAsync(params)
426
+ : undefined,
427
+ isSettingImages: setWorkloadImagesMutation.isPending,
384
428
  revisions: revisionsList,
385
429
  revisionsLoading,
386
430
  revisionsError: revisionsError ?? null,
@@ -396,7 +440,7 @@ function useActionsBarProps(
396
440
  // mutateAsync, not mutate: the revision dialog awaits this before closing.
397
441
  onRolloutPromoteFull: rolloutCapabilities?.promoteFull
398
442
  ? (params: { namespace: string; name: string }) =>
399
- rolloutActionMutation.mutateAsync({ action: 'promote-full', ...params })
443
+ rolloutDialogPromote.mutateAsync({ action: 'promote-full', ...params })
400
444
  : undefined,
401
445
  onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) =>
402
446
  triggerCronJobMutation.mutate(params),
@@ -453,8 +497,11 @@ export function WorkloadView({
453
497
  ...rest
454
498
  }: WorkloadViewProps) {
455
499
  const [searchParams, setSearchParams] = useSearchParams()
456
- const apiKind = kindToPlural(kindProp)
500
+ const apiKind = kindToPluralWithGroup(kindProp, rest.group ?? '')
457
501
  const queryClient = useQueryClient()
502
+ const [imageTargetOwnership, setImageTargetOwnership] =
503
+ useState<ImageTargetOwnershipContext | null>(null)
504
+ const imageOwnershipRequestRef = useRef(0)
458
505
 
459
506
  // Tab state from URL query param — migrate legacy tab names
460
507
  const rawTab = searchParams.get('tab')
@@ -492,7 +539,8 @@ export function WorkloadView({
492
539
  )
493
540
 
494
541
  const batchKind = pluralToKind(apiKind)
495
- const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind)
542
+ const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind) &&
543
+ (batchKind !== 'Job' || isCoreBatchJob(apiKind, rest.group))
496
544
  const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
497
545
  refetchActive: true,
498
546
  clusterScoped: batchKind === 'ClusterWorkflowTemplate',
@@ -535,6 +583,9 @@ export function WorkloadView({
535
583
  queryClient.refetchQueries({
536
584
  queryKey: ['workload-runs', apiKind, namespace, name],
537
585
  }),
586
+ queryClient.refetchQueries({
587
+ queryKey: ['workload-pods', apiKind, namespace, name],
588
+ }),
538
589
  ])
539
590
  }, [apiKind, name, namespace, queryClient, refetchResource])
540
591
  const podWorkloadOwner = useMemo(
@@ -551,6 +602,16 @@ export function WorkloadView({
551
602
  )
552
603
  const certificateInfo = resourceResponse?.certificateInfo
553
604
  const hpaDiagnosis = resourceResponse?.hpaDiagnosis
605
+ const activeImageTargetOwnership = useMemo(
606
+ () =>
607
+ imageTargetOwnership &&
608
+ imageTargetOwnership.root.resource.toLowerCase() === apiKind.toLowerCase() &&
609
+ imageTargetOwnership.root.namespace === namespace &&
610
+ imageTargetOwnership.root.name === name
611
+ ? imageTargetOwnership
612
+ : null,
613
+ [apiKind, imageTargetOwnership, name, namespace],
614
+ )
554
615
  const relationshipGitopsOwner = useMemo(
555
616
  () => gitOpsOwnerFromRelationships(relationships),
556
617
  [relationships],
@@ -566,7 +627,7 @@ export function WorkloadView({
566
627
  [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
567
628
  )
568
629
  const inheritedGitOpsResponse = useResourceWithRelationships<any>(
569
- inheritedGitOpsLookupRef ? kindToPlural(inheritedGitOpsLookupRef.kind) : '',
630
+ inheritedGitOpsLookupRef ? kindToPluralWithGroup(inheritedGitOpsLookupRef.kind, inheritedGitOpsLookupRef.group ?? '') : '',
570
631
  inheritedGitOpsLookupRef?.namespace ?? '',
571
632
  inheritedGitOpsLookupRef?.name ?? '',
572
633
  inheritedGitOpsLookupRef?.group,
@@ -600,7 +661,30 @@ export function WorkloadView({
600
661
  const helmSourceResource = relationshipHelmOwner
601
662
  ? resource
602
663
  : inheritedGitOpsResponse.data?.resource
603
- const shouldResolveArgoOwner = rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace
664
+ const targetRelationshipGitopsOwner = useMemo(
665
+ () => gitOpsOwnerFromRelationships(activeImageTargetOwnership?.response.relationships),
666
+ [activeImageTargetOwnership?.response.relationships],
667
+ )
668
+ const targetInheritedGitopsOwner = useMemo(
669
+ () =>
670
+ gitOpsOwnerFromRelationships(
671
+ activeImageTargetOwnership?.inheritedResponse?.relationships,
672
+ ),
673
+ [activeImageTargetOwnership?.inheritedResponse?.relationships],
674
+ )
675
+ const targetRawGitopsOwner = targetRelationshipGitopsOwner ?? targetInheritedGitopsOwner
676
+ const targetRelationshipHelmOwner = nativeHelmOwnerFromRelationships(
677
+ activeImageTargetOwnership?.response.relationships,
678
+ activeImageTargetOwnership?.target.namespace ?? namespace,
679
+ )
680
+ const targetInheritedHelmOwner = nativeHelmOwnerFromRelationships(
681
+ activeImageTargetOwnership?.inheritedResponse?.relationships,
682
+ activeImageTargetOwnership?.target.namespace ?? namespace,
683
+ )
684
+ const targetHelmOwner = targetRelationshipHelmOwner ?? targetInheritedHelmOwner
685
+ const shouldResolveArgoOwner =
686
+ (rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace) ||
687
+ (targetRawGitopsOwner?.tool === 'argocd' && !targetRawGitopsOwner.namespace)
604
688
  const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', {
605
689
  enabled: shouldResolveArgoOwner,
606
690
  })
@@ -608,6 +692,10 @@ export function WorkloadView({
608
692
  () => resolveGitOpsOwner(rawGitopsOwner, argoApplications),
609
693
  [rawGitopsOwner, argoApplications],
610
694
  )
695
+ const targetGitopsOwner = useMemo(
696
+ () => resolveGitOpsOwner(targetRawGitopsOwner, argoApplications),
697
+ [argoApplications, targetRawGitopsOwner],
698
+ )
611
699
  const gitopsOwnerGroup = gitopsOwner ? gitOpsOwnerGroup(gitopsOwner) : ''
612
700
  const shouldFetchGitOpsOwner = Boolean(gitopsOwner?.namespace)
613
701
  const gitopsOwnerQuery = useResource<any>(
@@ -616,6 +704,17 @@ export function WorkloadView({
616
704
  gitopsOwner?.name ?? '',
617
705
  gitopsOwnerGroup,
618
706
  )
707
+ const targetGitopsOwnerGroup = targetGitopsOwner ? gitOpsOwnerGroup(targetGitopsOwner) : ''
708
+ const shouldFetchTargetGitOpsOwner = Boolean(
709
+ activeImageTargetOwnership && targetGitopsOwner?.namespace,
710
+ )
711
+ const targetGitopsOwnerQuery = useResource<Record<string, unknown>>(
712
+ shouldFetchTargetGitOpsOwner ? targetGitopsOwner!.kind : '',
713
+ targetGitopsOwner?.namespace ?? '',
714
+ targetGitopsOwner?.name ?? '',
715
+ targetGitopsOwnerGroup,
716
+ { enabled: shouldFetchTargetGitOpsOwner },
717
+ )
619
718
  const gitOpsOwnerStatus = useMemo(
620
719
  () => deriveGitOpsOwnerStatus(gitopsOwner, gitopsOwnerQuery.data),
621
720
  [gitopsOwner, gitopsOwnerQuery.data],
@@ -768,11 +867,6 @@ export function WorkloadView({
768
867
  // pass one.
769
868
  group: effectiveGroup,
770
869
  })
771
- const actionsBarProps = useMemo(
772
- () => ({ ...baseActionsBarProps, onCompareTo, onCompareAcrossClusters }),
773
- [baseActionsBarProps, onCompareTo, onCompareAcrossClusters],
774
- )
775
-
776
870
  const handleUpdateResource = useCallback(
777
871
  async (params: Parameters<typeof updateResource.mutateAsync>[0]) => {
778
872
  await updateResource.mutateAsync(params)
@@ -823,6 +917,136 @@ export function WorkloadView({
823
917
  },
824
918
  [navigateRouter, searchParams],
825
919
  )
920
+ const loadImagesWithTargetOwnership = useCallback(
921
+ async (params: { kind: string; namespace: string; name: string }) => {
922
+ const request = ++imageOwnershipRequestRef.current
923
+ const inventory = await baseActionsBarProps.onLoadImages!(params)
924
+ const targetDiffers =
925
+ inventory.target.resource.toLowerCase() !== params.kind.toLowerCase() ||
926
+ inventory.target.namespace !== params.namespace ||
927
+ inventory.target.name !== params.name
928
+ if (!targetDiffers) {
929
+ if (request === imageOwnershipRequestRef.current) {
930
+ setImageTargetOwnership(null)
931
+ }
932
+ return inventory
933
+ }
934
+
935
+ const fetchRelationships = (
936
+ resource: string,
937
+ targetNamespace: string,
938
+ targetName: string,
939
+ group?: string,
940
+ ) =>
941
+ queryClient.fetchQuery({
942
+ queryKey: ['resource', resource, targetNamespace, targetName, group],
943
+ queryFn: () =>
944
+ fetchResourceWithRelationships<Record<string, unknown>>(
945
+ resource,
946
+ targetNamespace,
947
+ targetName,
948
+ group,
949
+ ),
950
+ staleTime: 0,
951
+ })
952
+ const response = await fetchRelationships(
953
+ inventory.target.resource,
954
+ inventory.target.namespace,
955
+ inventory.target.name,
956
+ inventory.target.group,
957
+ )
958
+ const inheritedRef = findInheritedGitOpsLookupRef(
959
+ response.relationships,
960
+ gitOpsOwnerFromRelationships(response.relationships),
961
+ {
962
+ kind: inventory.target.kind,
963
+ namespace: inventory.target.namespace,
964
+ name: inventory.target.name,
965
+ group: inventory.target.group,
966
+ },
967
+ )
968
+ const inheritedResponse = inheritedRef
969
+ ? await fetchRelationships(
970
+ kindToPluralWithGroup(inheritedRef.kind, inheritedRef.group ?? ''),
971
+ inheritedRef.namespace,
972
+ inheritedRef.name,
973
+ inheritedRef.group,
974
+ )
975
+ : undefined
976
+ if (request === imageOwnershipRequestRef.current) {
977
+ setImageTargetOwnership({
978
+ root: {
979
+ resource: params.kind,
980
+ namespace: params.namespace,
981
+ name: params.name,
982
+ },
983
+ target: inventory.target,
984
+ response,
985
+ inheritedResponse,
986
+ })
987
+ }
988
+ return inventory
989
+ },
990
+ [baseActionsBarProps.onLoadImages, queryClient, setImageTargetOwnership],
991
+ )
992
+ const imageGitopsOwner = activeImageTargetOwnership ? targetGitopsOwner : gitopsOwner
993
+ const imageHelmOwner = activeImageTargetOwnership ? targetHelmOwner : helmOwner
994
+ const imageGitopsOwnerData = activeImageTargetOwnership
995
+ ? targetGitopsOwnerQuery.data
996
+ : gitopsOwnerQuery.data
997
+ const managedImageSources = useMemo<ManagedImageSource[] | undefined>(() => {
998
+ if (!activeImageTargetOwnership && inheritedGitOpsLookupRef && (inheritedGitOpsResponse.isPending || inheritedGitOpsResponse.isError)) {
999
+ return undefined
1000
+ }
1001
+ const sources: ManagedImageSource[] = []
1002
+ if (imageGitopsOwner) {
1003
+ sources.push({
1004
+ type: 'GitOps',
1005
+ label: imageGitopsOwner.namespace
1006
+ ? `${imageGitopsOwner.namespace}/${imageGitopsOwner.name}`
1007
+ : imageGitopsOwner.name,
1008
+ onOpen: imageGitopsOwnerData
1009
+ ? () => handleOpenGitOpsResource(imageGitopsOwner)
1010
+ : undefined,
1011
+ })
1012
+ }
1013
+ if (imageHelmOwner) {
1014
+ sources.push({
1015
+ type: 'Helm',
1016
+ label: `${imageHelmOwner.namespace}/${imageHelmOwner.name}`,
1017
+ onOpen: () => handleOpenHelmRelease(imageHelmOwner),
1018
+ })
1019
+ }
1020
+ return sources
1021
+ }, [
1022
+ handleOpenGitOpsResource,
1023
+ handleOpenHelmRelease,
1024
+ activeImageTargetOwnership,
1025
+ imageGitopsOwner,
1026
+ imageGitopsOwnerData,
1027
+ imageHelmOwner,
1028
+ inheritedGitOpsLookupRef,
1029
+ inheritedGitOpsResponse.isError,
1030
+ inheritedGitOpsResponse.isPending,
1031
+ ])
1032
+ const actionsBarProps = useMemo(
1033
+ () => ({
1034
+ ...baseActionsBarProps,
1035
+ onLoadImages: baseActionsBarProps.onLoadImages
1036
+ ? loadImagesWithTargetOwnership
1037
+ : undefined,
1038
+ onCompareTo,
1039
+ onCompareAcrossClusters,
1040
+ managedImageSources,
1041
+ }),
1042
+ [
1043
+ baseActionsBarProps,
1044
+ loadImagesWithTargetOwnership,
1045
+ managedImageSources,
1046
+ onCompareTo,
1047
+ onCompareAcrossClusters,
1048
+ ],
1049
+ )
826
1050
  const handleOpenApplication = useCallback(
827
1051
  (appKey: string) => {
828
1052
  const params = new URLSearchParams()
@@ -854,7 +1078,7 @@ export function WorkloadView({
854
1078
  [],
855
1079
  )
856
1080
 
857
- const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
1081
+ const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(apiKind)
858
1082
  const workloadPodsQuery = useWorkloadPods(supportsWorkloadPods ? apiKind : '', namespace, name)
859
1083
  const workloadAwaitsCapacity =
860
1084
  karpenter?.state === 'available' &&
@@ -862,7 +1086,7 @@ export function WorkloadView({
862
1086
  const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
863
1087
  const servingQueries = useQueries({
864
1088
  queries: servingRefs.map((ref) => {
865
- const pluralKind = kindToPlural(ref.kind)
1089
+ const pluralKind = kindToPluralWithGroup(ref.kind, ref.group ?? '')
866
1090
  const ns = ref.namespace || '_'
867
1091
  const params = new URLSearchParams()
868
1092
  if (ref.group) params.set('group', ref.group)
@@ -953,12 +1177,15 @@ export function WorkloadView({
953
1177
  renderLogsTab={(props) => (
954
1178
  <LogsTabContent
955
1179
  {...props}
1180
+ group={effectiveGroup}
956
1181
  selectedRunKey={selectedRunKey}
957
1182
  onSelectRun={handleSelectedRunChange}
958
1183
  />
959
1184
  )}
960
1185
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
961
- BATCH_EXECUTION_KINDS.has(k) && res ? (
1186
+ BATCH_EXECUTION_KINDS.has(k) &&
1187
+ (k !== 'Job' || isCoreBatchJob(apiKind, effectiveGroup)) &&
1188
+ res ? (
962
1189
  <BatchExecutionFullscreen
963
1190
  kind={k}
964
1191
  apiKind={apiKind}
@@ -1054,7 +1281,7 @@ export function WorkloadView({
1054
1281
  rest.onNavigateToResource
1055
1282
  ? (ref) =>
1056
1283
  rest.onNavigateToResource?.({
1057
- kind: kindToPlural(ref.kind),
1284
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
1058
1285
  namespace: ref.namespace ?? '',
1059
1286
  name: ref.name,
1060
1287
  group: ref.group ?? '',
@@ -1082,11 +1309,12 @@ export function WorkloadView({
1082
1309
  initialYaml={duplicateYaml}
1083
1310
  title="Duplicate Resource"
1084
1311
  onCreated={(result) => {
1312
+ const group = apiVersionToGroup(result.apiVersion)
1085
1313
  rest.onNavigateToResource?.({
1086
- kind: kindToPlural(result.kind),
1314
+ kind: kindToPluralWithGroup(result.kind, group),
1087
1315
  namespace: result.namespace,
1088
1316
  name: result.name,
1089
- group: '',
1317
+ group,
1090
1318
  })
1091
1319
  }}
1092
1320
  />
@@ -1126,7 +1354,7 @@ function resolveGitOpsOwner(
1126
1354
  return namespace ? { ...owner, namespace } : owner
1127
1355
  }
1128
1356
 
1129
- function findInheritedGitOpsLookupRef(
1357
+ export function findInheritedGitOpsLookupRef(
1130
1358
  relationships: Relationships | undefined,
1131
1359
  directOwner: GitOpsOwnerRef | null,
1132
1360
  current: ResourceRef,
@@ -1237,7 +1465,8 @@ function nativeHelmOwnerFromRelationships(
1237
1465
 
1238
1466
  function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
1239
1467
  return (
1240
- kindToPlural(ref.kind) === kindToPlural(current.kind) &&
1468
+ kindToPluralWithGroup(ref.kind, ref.group ?? '') ===
1469
+ kindToPluralWithGroup(current.kind, current.group ?? '') &&
1241
1470
  ref.namespace === current.namespace &&
1242
1471
  ref.name === current.name &&
1243
1472
  (ref.group ?? '') === (current.group ?? '')
@@ -1321,6 +1550,7 @@ const SCHEDULED_LOG_KINDS = new Set([
1321
1550
  function LogsTabContent({
1322
1551
  kind,
1323
1552
  apiKind,
1553
+ group,
1324
1554
  namespace,
1325
1555
  name,
1326
1556
  resource,
@@ -1334,6 +1564,7 @@ function LogsTabContent({
1334
1564
  }: {
1335
1565
  kind: string
1336
1566
  apiKind: string
1567
+ group?: string
1337
1568
  namespace: string
1338
1569
  name: string
1339
1570
  resource: any
@@ -1360,7 +1591,7 @@ function LogsTabContent({
1360
1591
  }
1361
1592
 
1362
1593
  // Workload kinds with stable pod selectors use the aggregated workload logs viewer
1363
- if (WORKLOAD_LOG_KINDS.has(kind)) {
1594
+ if (WORKLOAD_LOG_KINDS.has(kind) && (kind !== 'Job' || isCoreBatchJob(apiKind, group))) {
1364
1595
  return (
1365
1596
  <div className="h-full">
1366
1597
  <WorkloadLogsViewer
@@ -1986,7 +2217,17 @@ function DiagnoseTabContent({
1986
2217
  runNonce={runNonce}
1987
2218
  testedAt={testedAt}
1988
2219
  clusterChangedSinceTest={clusterChanged}
1989
- onNavigateToResource={onNavigate ? (ref) => onNavigate({ kind: kindToPlural(ref.kind), namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' }) : undefined}
2220
+ onNavigateToResource={
2221
+ onNavigate
2222
+ ? (ref) =>
2223
+ onNavigate({
2224
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
2225
+ namespace: ref.namespace ?? '',
2226
+ name: ref.name,
2227
+ group: ref.group ?? '',
2228
+ })
2229
+ : undefined
2230
+ }
1990
2231
  />
1991
2232
  <InClusterConsentDialog
1992
2233
  open={pendingRunPath !== null}
@@ -2248,7 +2489,7 @@ function RelatedResourceYaml({
2248
2489
  target: { kind: string; namespace: string; name: string; group?: string }
2249
2490
  }) {
2250
2491
  const { data, isLoading, error } = useResource<any>(
2251
- kindToPlural(target.kind),
2492
+ kindToPluralWithGroup(target.kind, target.group ?? ''),
2252
2493
  target.namespace,
2253
2494
  target.name,
2254
2495
  target.group,
@@ -2264,7 +2505,7 @@ function RelatedResourceYaml({
2264
2505
  return (
2265
2506
  <EditableYamlView
2266
2507
  resource={{
2267
- kind: kindToPlural(target.kind),
2508
+ kind: kindToPluralWithGroup(target.kind, target.group ?? ''),
2268
2509
  namespace: target.namespace,
2269
2510
  name: target.name,
2270
2511
  group: target.group,
@@ -0,0 +1,29 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ vi.mock('../api/client', () => ({
5
+ useCapabilities: () => ({ data: undefined, error: null }),
6
+ useNamespaceCapabilities: vi.fn(),
7
+ }))
8
+
9
+ import { CapabilitiesProvider, useCapabilitiesContext } from './CapabilitiesContext'
10
+
11
+ function LocalTerminalCapability() {
12
+ return <span>{String(useCapabilitiesContext().localTerminal)}</span>
13
+ }
14
+
15
+ describe('CapabilitiesContext local terminal defaults', () => {
16
+ it('fails closed when a standalone surface has no provider', () => {
17
+ expect(renderToStaticMarkup(<LocalTerminalCapability />)).toBe('<span>false</span>')
18
+ })
19
+
20
+ it('preserves the local loading default inside CapabilitiesProvider', () => {
21
+ const markup = renderToStaticMarkup(
22
+ <CapabilitiesProvider>
23
+ <LocalTerminalCapability />
24
+ </CapabilitiesProvider>,
25
+ )
26
+
27
+ expect(markup).toBe('<span>true</span>')
28
+ })
29
+ })
@@ -46,7 +46,10 @@ const restrictedCapabilities: Capabilities = {
46
46
  deployment: { mode: 'local' },
47
47
  }
48
48
 
49
- const CapabilitiesContext = createContext<Capabilities>(defaultCapabilities)
49
+ const CapabilitiesContext = createContext<Capabilities>({
50
+ ...defaultCapabilities,
51
+ localTerminal: false,
52
+ })
50
53
 
51
54
  export function CapabilitiesProvider({ children }: { children: ReactNode }) {
52
55
  const { data: capabilities, error } = useCapabilities()
@@ -145,7 +148,7 @@ export function useHasLimitedAccess(): boolean {
145
148
  // capability check; callers use global capability values until it resolves.
146
149
  export function useNamespacedCapabilities(namespace: string | undefined) {
147
150
  const globalCaps = useContext(CapabilitiesContext)
148
- const { data: nsCaps, error } = useNamespaceCapabilities(namespace, globalCaps)
151
+ const { data: nsCaps, error, isPending } = useNamespaceCapabilities(namespace, globalCaps)
149
152
 
150
153
  if (error) {
151
154
  console.warn(`Failed to fetch namespace capabilities for ${namespace}, using global:`, error)
@@ -156,5 +159,6 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
156
159
  canViewLogs: nsCaps?.logs ?? globalCaps.logs,
157
160
  canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
158
161
  workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
159
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, nsCaps])
162
+ workloadWritesPending: Boolean(namespace && isPending),
163
+ }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, isPending, namespace, nsCaps])
160
164
  }
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ initNavigationMap,
4
+ resetNavigationMap,
5
+ } from '@skyhook-io/k8s-ui/utils/navigation'
6
+ import { relatedResourcePath, resourcePath } from './navigation'
7
+
8
+ afterEach(resetNavigationMap)
9
+
10
+ describe('resource navigation paths', () => {
11
+ it('routes the native PodGroup by its scheduling API group', () => {
12
+ initNavigationMap([
13
+ {
14
+ group: 'scheduling.k8s.io',
15
+ version: 'v1beta1',
16
+ kind: 'PodGroup',
17
+ name: 'podgroups',
18
+ namespaced: true,
19
+ isCrd: false,
20
+ verbs: ['get', 'list'],
21
+ },
22
+ ])
23
+
24
+ expect(
25
+ resourcePath({
26
+ kind: 'PodGroup',
27
+ group: 'scheduling.k8s.io',
28
+ namespace: 'default',
29
+ name: 'batch',
30
+ }),
31
+ ).toBe(
32
+ '/resources/podgroups?resource=default%2Fbatch&apiGroup=scheduling.k8s.io',
33
+ )
34
+ })
35
+
36
+ it('preserves Radar virtual PodGroup navigation without an API group', () => {
37
+ expect(
38
+ relatedResourcePath({
39
+ kind: 'PodGroup',
40
+ namespace: 'default',
41
+ name: 'batch',
42
+ }),
43
+ ).toBe('/workload/pods/default/batch')
44
+ })
45
+ })
@@ -1,5 +1,5 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
- import { apiVersionToGroup, kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
2
+ import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
3
3
  import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
4
4
  import type { SearchHit } from '../api/client'
5
5
 
@@ -14,7 +14,7 @@ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
14
14
  }
15
15
 
16
16
  // Re-export shared navigation utilities from @skyhook-io/k8s-ui.
17
- export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
17
+ export { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
18
18
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
19
19
 
20
20
  const NETWORK_POLICY_TOPOLOGY_KINDS = new Set([
@@ -48,7 +48,7 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
48
48
 
49
49
  const group = networkPolicyGroup(node)
50
50
  const target = {
51
- kind: kindToPlural(node.kind),
51
+ kind: kindToPluralWithGroup(node.kind, group ?? ''),
52
52
  ...(group ? { group } : {}),
53
53
  }
54
54
  targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
@@ -96,13 +96,13 @@ export function resourcePath(resource: SelectedResource): string {
96
96
  }
97
97
  if (resource.group) params.set('apiGroup', resource.group)
98
98
  const query = params.toString()
99
- return `/resources/${kindToPlural(resource.kind)}${query ? `?${query}` : ''}`
99
+ return `/resources/${kindToPluralWithGroup(resource.kind, resource.group ?? '')}${query ? `?${query}` : ''}`
100
100
  }
101
101
 
102
102
  const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
103
103
 
104
104
  export function relatedResourcePath(resource: SelectedResource): string {
105
- const apiKind = kindToPlural(resource.kind).toLowerCase()
105
+ const apiKind = kindToPluralWithGroup(resource.kind, resource.group ?? '').toLowerCase()
106
106
  if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
107
107
  return buildWorkloadPath({ ...resource, kind: apiKind })
108
108
  }