@skyhook-io/radar-app 1.14.4 → 1.14.6

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 (98) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AIMarkdown.tsx +8 -3
  18. package/src/components/diagnose/AgentControls.tsx +26 -3
  19. package/src/components/diagnose/AssessmentCard.tsx +249 -237
  20. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +47 -0
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +15 -2
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +1 -1
  24. package/src/components/diagnose/InvestigationView.tsx +4 -1
  25. package/src/components/diagnose/agentCatalog.ts +6 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +56 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +60 -0
  28. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +7 -3
  29. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +1 -1
  30. package/src/components/diagnose/investigationEvidence/observations.ts +8 -5
  31. package/src/components/diagnose/investigationEvidence/types.ts +1 -1
  32. package/src/components/diagnose/launch.test.ts +23 -0
  33. package/src/components/diagnose/launch.ts +4 -0
  34. package/src/components/diagnose/parts.test.tsx +159 -2
  35. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  36. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  37. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  38. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  40. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  41. package/src/components/execution/BatchExecutionView.tsx +324 -65
  42. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  43. package/src/components/execution/JobSetAdmission.tsx +17 -0
  44. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  45. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  46. package/src/components/execution/batch-run-actions.test.ts +1 -0
  47. package/src/components/execution/batch-timeline.test.ts +3 -0
  48. package/src/components/execution/execution-definition.test.ts +15 -1
  49. package/src/components/execution/execution-definition.ts +4 -0
  50. package/src/components/execution/member-collection.render.test.tsx +142 -0
  51. package/src/components/gitops/GitOpsView.tsx +33 -2
  52. package/src/components/gitops/destination-toast.test.ts +33 -0
  53. package/src/components/gitops/destination-toast.ts +46 -0
  54. package/src/components/gitops/useDestinationCluster.ts +30 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  56. package/src/components/home/MCPSetupDialog.tsx +10 -0
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  60. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  61. package/src/components/resource/PVCUsageBar.tsx +62 -16
  62. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  63. package/src/components/resources/ResourcesView.tsx +3 -3
  64. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  65. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  66. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  67. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  68. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  69. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  70. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  71. package/src/components/rightsizing/RightsizingScanView.tsx +207 -83
  72. package/src/components/rightsizing/copy.test.ts +7 -0
  73. package/src/components/rightsizing/empty-state.test.tsx +67 -0
  74. package/src/components/rightsizing/model.test.ts +120 -2
  75. package/src/components/rightsizing/model.ts +36 -13
  76. package/src/components/rightsizing/notices.test.tsx +176 -0
  77. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  78. package/src/components/settings/SettingsDialog.tsx +157 -42
  79. package/src/components/settings/settings-state.test.ts +18 -0
  80. package/src/components/settings/settings-state.ts +18 -0
  81. package/src/components/timeline/TimelineView.tsx +9 -3
  82. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  83. package/src/components/ui/Markdown.tsx +33 -6
  84. package/src/components/ui/command-items.ts +0 -3
  85. package/src/components/useContextSwitchFlow.tsx +147 -0
  86. package/src/components/workload/WorkloadView.test.ts +17 -1
  87. package/src/components/workload/WorkloadView.tsx +36 -18
  88. package/src/context/ContextSwitchContext.tsx +18 -2
  89. package/src/context/NavCustomization.tsx +4 -58
  90. package/src/context/ThemeContext.tsx +3 -11
  91. package/src/hooks/useClusterLoadState.ts +2 -2
  92. package/src/hooks/useFavorites.ts +3 -5
  93. package/src/utils/auditBadges.ts +1 -1
  94. package/src/utils/navigation.test.ts +18 -1
  95. package/src/utils/navigation.ts +12 -5
  96. package/src/utils/topology-selection.test.ts +56 -0
  97. package/src/utils/topology-selection.ts +9 -9
  98. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -146,9 +146,6 @@ const VIEW_ENTRIES: {
146
146
  { view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
147
147
  ];
148
148
 
149
- // The static command-palette items (Views, Resource Kinds, Contexts,
150
- // Namespaces, Actions) — shared by the centered modal (embedded) and the
151
- // standalone omnibar so the two never drift.
152
149
  export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
153
150
  const { data: namespacesData } = useNamespaces();
154
151
  const { data: contexts } = useContexts();
@@ -0,0 +1,147 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { AlertTriangle } from 'lucide-react'
3
+ import { pluralize } from '@skyhook-io/k8s-ui'
4
+ import { useSwitchContext, fetchSessionCounts, type SessionCounts } from '../api/client'
5
+ import { useContextSwitch } from '../context/ContextSwitchContext'
6
+ import { useToast } from './ui/Toast'
7
+ import { useDock } from './dock'
8
+ import type { ContextInfo, SelectedResource } from '../types'
9
+ import { parseContextForSwitcher } from '../utils/context-name'
10
+
11
+ function shouldSuppressSwitchErrorToast(error: unknown): boolean {
12
+ const message = error instanceof Error ? error.message : ''
13
+ return message.includes('cluster connection failed:')
14
+ }
15
+
16
+ interface PendingSwitch {
17
+ context: ContextInfo
18
+ openAfter?: SelectedResource
19
+ }
20
+
21
+ // The one way to switch contexts from the UI: the switching overlay, a
22
+ // confirmation when port-forwards or terminals would be killed, and an error
23
+ // toast when the switch fails. `openAfter` is opened once the new context is
24
+ // live. Callers render `confirmDialog`.
25
+ export function useContextSwitchFlow() {
26
+ const switchContext = useSwitchContext()
27
+ const { startSwitch, endSwitch, setOpenAfterSwitch } = useContextSwitch()
28
+ const { showError } = useToast()
29
+ const { tabs } = useDock()
30
+ const [pending, setPending] = useState<PendingSwitch | null>(null)
31
+ const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
32
+
33
+ const performSwitch = async ({ context, openAfter }: PendingSwitch) => {
34
+ const parsed = parseContextForSwitcher(context)
35
+ startSwitch({
36
+ raw: parsed.raw,
37
+ provider: parsed.provider,
38
+ account: parsed.account,
39
+ region: parsed.region,
40
+ clusterName: parsed.clusterName,
41
+ })
42
+ setOpenAfterSwitch(openAfter ? { context: context.name, resource: openAfter } : null)
43
+ try {
44
+ await switchContext.mutateAsync({ name: context.name })
45
+ } catch (error) {
46
+ console.error('Failed to switch context:', error)
47
+ setOpenAfterSwitch(null)
48
+ endSwitch()
49
+ // Backend may not transition to StateDisconnected on client-side errors
50
+ // (network, timeout) — without this toast the user gets no feedback.
51
+ if (!shouldSuppressSwitchErrorToast(error)) {
52
+ const message = error instanceof Error ? error.message : 'Unknown error'
53
+ showError('Failed to switch context', message)
54
+ }
55
+ }
56
+ }
57
+
58
+ const requestSwitch = async (context: ContextInfo, openAfter?: SelectedResource) => {
59
+ if (context.isCurrent || switchContext.isPending) return
60
+ // Active sessions (port forwards from API + terminal tabs from dock) get
61
+ // a confirmation prompt — switching contexts kills both.
62
+ try {
63
+ const counts = await fetchSessionCounts()
64
+ const terminalTabs = tabs.filter(t => t.type === 'terminal').length
65
+ const total = counts.portForwards + terminalTabs
66
+ if (total > 0) {
67
+ setSessionCounts({ ...counts, execSessions: terminalTabs, total })
68
+ setPending({ context, openAfter })
69
+ return
70
+ }
71
+ } catch (error) {
72
+ // Session-counts is best-effort; failing it shouldn't block the user.
73
+ // But warn — if there ARE active sessions we couldn't see, the switch
74
+ // will silently kill them.
75
+ console.error('Failed to check sessions:', error)
76
+ showError(
77
+ 'Could not check active sessions',
78
+ 'Switching anyway. Any open port-forwards or terminals will be terminated.',
79
+ )
80
+ }
81
+ performSwitch({ context, openAfter })
82
+ }
83
+
84
+ const confirm = () => {
85
+ if (pending) performSwitch(pending)
86
+ setPending(null)
87
+ }
88
+
89
+ const cancel = () => {
90
+ setPending(null)
91
+ setSessionCounts(null)
92
+ }
93
+
94
+ const confirmDialog: ReactNode = pending && sessionCounts ? (
95
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
96
+ <div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
97
+ <div className="px-4 py-3 border-b border-theme-border flex items-center gap-2">
98
+ <AlertTriangle className="w-5 h-5 text-amber-400" />
99
+ <span className="font-medium text-theme-text-primary">Active Sessions</span>
100
+ </div>
101
+ <div className="px-4 py-4">
102
+ <p className="text-sm text-theme-text-secondary mb-3">
103
+ Switching contexts will terminate active sessions:
104
+ </p>
105
+ <ul className="text-sm text-theme-text-primary space-y-1 mb-4">
106
+ {sessionCounts.portForwards > 0 && (
107
+ <li className="flex items-center gap-2">
108
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
109
+ {pluralize(sessionCounts.portForwards, 'port forward')}
110
+ </li>
111
+ )}
112
+ {sessionCounts.execSessions > 0 && (
113
+ <li className="flex items-center gap-2">
114
+ <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
115
+ {pluralize(sessionCounts.execSessions, 'terminal session')}
116
+ </li>
117
+ )}
118
+ </ul>
119
+ <p className="text-xs text-theme-text-tertiary">
120
+ Switch to: <span className="text-theme-text-secondary">{parseContextForSwitcher(pending.context).clusterName}</span>
121
+ </p>
122
+ </div>
123
+ <div className="px-4 py-3 border-t border-theme-border flex justify-end gap-2">
124
+ <button
125
+ onClick={cancel}
126
+ className="px-3 py-1.5 text-sm rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary transition-colors"
127
+ >
128
+ Cancel
129
+ </button>
130
+ <button
131
+ onClick={confirm}
132
+ className="px-3 py-1.5 text-sm rounded-md bg-amber-500 hover:bg-amber-600 text-white transition-colors"
133
+ >
134
+ Switch Anyway
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ ) : null
140
+
141
+ return {
142
+ requestSwitch,
143
+ isPending: switchContext.isPending,
144
+ error: switchContext.isError ? switchContext.error : null,
145
+ confirmDialog,
146
+ }
147
+ }
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { gitOpsOwnerFromRelationships } from '@skyhook-io/k8s-ui'
4
4
  import type { Relationships } from '../../types'
5
- import { findInheritedGitOpsLookupRef } from './WorkloadView'
5
+ import { findInheritedGitOpsLookupRef, supportsBatchExecution } from './WorkloadView'
6
6
 
7
7
  describe('findInheritedGitOpsLookupRef', () => {
8
8
  it('follows a referenced ReplicaSet to its parent workload for inherited ownership', () => {
@@ -58,3 +58,19 @@ describe('findInheritedGitOpsLookupRef', () => {
58
58
  ).toBeNull()
59
59
  })
60
60
  })
61
+
62
+ describe('supportsBatchExecution', () => {
63
+ it('enables only the supported JobSet API identity', () => {
64
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1alpha2')).toBe(true)
65
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io')).toBe(false)
66
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'example.io', 'example.io/v1alpha2')).toBe(false)
67
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'example.io', 'jobset.x-k8s.io/v1alpha2')).toBe(false)
68
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1beta1')).toBe(false)
69
+ })
70
+
71
+ it('preserves the core Job collision guard and existing scheduled kinds', () => {
72
+ expect(supportsBatchExecution('Job', 'jobs', 'batch')).toBe(true)
73
+ expect(supportsBatchExecution('Job', 'jobs', 'example.io')).toBe(false)
74
+ expect(supportsBatchExecution('CronJob', 'cronjobs', 'batch')).toBe(true)
75
+ })
76
+ })
@@ -1,3 +1,6 @@
1
+ import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
2
+ import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
3
+ import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
1
4
  import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
2
5
  import { useQueries, useQueryClient } from '@tanstack/react-query'
3
6
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
@@ -29,6 +32,7 @@ import {
29
32
  type WorkloadImageTarget,
30
33
  } from '@skyhook-io/k8s-ui'
31
34
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
35
+ import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
32
36
  import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
33
37
  import {
34
38
  kindToPlural,
@@ -121,6 +125,7 @@ import { ServiceAccountRenderer } from '../resources/renderers/ServiceAccountRen
121
125
  import { RoleRenderer } from '../resources/renderers/RoleRenderer'
122
126
  import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
123
127
  import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
128
+ import { CAPIClusterRenderer } from '../resources/renderers/CAPIClusterRenderer'
124
129
  import { HPARenderer } from '../resources/renderers/HPARenderer'
125
130
  import { PVCRenderer } from '../resources/renderers/PVCRenderer'
126
131
  import { RolloutRenderer } from '../resources/renderers/RolloutRenderer'
@@ -152,10 +157,24 @@ const BATCH_EXECUTION_KINDS = new Set([
152
157
  'WorkflowTemplate',
153
158
  'ClusterWorkflowTemplate',
154
159
  'ScaledJob',
160
+ 'JobSet',
155
161
  ])
156
162
 
163
+ export function supportsBatchExecution(kind: string, apiKind: string, group?: string, apiVersion?: string): boolean {
164
+ if (!BATCH_EXECUTION_KINDS.has(kind)) return false
165
+ if (kind === 'Job') return isCoreBatchJob(apiKind, group)
166
+ if (kind === 'JobSet') {
167
+ return group === 'jobset.x-k8s.io' && isJobSetV1Alpha2({ kind, apiVersion })
168
+ }
169
+ return true
170
+ }
171
+
157
172
  // Stable reference — web renderer wrappers inject platform hooks internally
158
173
  const rendererOverrides: RendererOverrides = {
174
+ RayServiceRenderer,
175
+ RayClusterRenderer,
176
+ KueueWorkloadRenderer,
177
+ CAPIClusterRenderer,
159
178
  PodRenderer,
160
179
  KarpenterNodePoolRenderer,
161
180
  NodeRenderer,
@@ -548,18 +567,6 @@ export function WorkloadView({
548
567
  [searchParams, setSearchParams],
549
568
  )
550
569
 
551
- const batchKind = pluralToKind(apiKind)
552
- const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind) &&
553
- (batchKind !== 'Job' || isCoreBatchJob(apiKind, rest.group))
554
- const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
555
- refetchActive: true,
556
- clusterScoped: batchKind === 'ClusterWorkflowTemplate',
557
- })
558
- const relatedTimelineEvents = useMemo(
559
- () => workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
560
- [batchRunsQuery.data?.runs],
561
- )
562
-
563
570
  // Fetch resource with relationships
564
571
  const {
565
572
  data: resourceResponse,
@@ -579,6 +586,16 @@ export function WorkloadView({
579
586
  // of itself. Fall back to the group derived from the fetched resource, then
580
587
  // to undefined (which the gate treats as "group unknown → allow").
581
588
  const effectiveGroup = rest.group || resourceGroup || undefined
589
+ const batchKind = pluralToKind(apiKind)
590
+ const batchExecution = supportsBatchExecution(batchKind, apiKind, effectiveGroup, resource?.apiVersion)
591
+ const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
592
+ refetchActive: true,
593
+ clusterScoped: batchKind === 'ClusterWorkflowTemplate',
594
+ })
595
+ const relatedTimelineEvents = useMemo(
596
+ () => batchKind === 'JobSet' ? [] : workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
597
+ [batchKind, batchRunsQuery.data?.runs],
598
+ )
582
599
  // Reachability for a workload IS the reachability of the Services in front of
583
600
  // it - a Deployment has no address of its own. Empty for a workload nothing
584
601
  // selects, which correctly leaves the tab hidden: there is no path to trace.
@@ -595,6 +612,7 @@ export function WorkloadView({
595
612
  }),
596
613
  queryClient.refetchQueries({
597
614
  queryKey: ['workload-pods', apiKind, namespace, name],
615
+ ...(apiKind === 'rayclusters' ? { type: 'active' as const } : {}),
598
616
  }),
599
617
  ])
600
618
  }, [apiKind, name, namespace, queryClient, refetchResource])
@@ -761,7 +779,7 @@ export function WorkloadView({
761
779
  isLoading: resourceFocusedEventsLoading,
762
780
  k8sError: resourceFocusedK8sError,
763
781
  updatesError: resourceFocusedUpdatesError,
764
- } = useResourceEvents(apiKind, namespace, name)
782
+ } = useResourceEvents(apiKind, namespace, name, effectiveGroup)
765
783
 
766
784
  // Fetch all events for this resource's namespace (only when expanded)
767
785
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
@@ -868,7 +886,7 @@ export function WorkloadView({
868
886
  // the renderer banners. The backend canonicalizes a plural kind via discovery,
869
887
  // so using the normalized API kind resolves direct links and app navigation alike.
870
888
  const { data: liveIssues, isPending: issuesPending } = useResourceIssues(apiKind, rest.group, namespace, name)
871
- const { data: auditFindings } = useResourceAudit(apiKind, namespace, name)
889
+ const { data: auditFindings } = useResourceAudit(apiKind, namespace, name, effectiveGroup)
872
890
  const hasOperationalIssues = Boolean(liveIssues?.length)
873
891
  const {
874
892
  onCompareTo,
@@ -1169,7 +1187,7 @@ export function WorkloadView({
1169
1187
  // Timeline
1170
1188
  allEvents={allEvents}
1171
1189
  relatedTimelineEvents={relatedTimelineEvents}
1172
- eventsLoading={eventsLoading || (batchExecution && batchRunsQuery.isLoading)}
1190
+ eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
1173
1191
  topology={topology}
1174
1192
  resourceFocusedK8sEvents={resourceFocusedK8sEvents}
1175
1193
  resourceFocusedUpdates={resourceFocusedUpdates}
@@ -1199,8 +1217,7 @@ export function WorkloadView({
1199
1217
  />
1200
1218
  )}
1201
1219
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
1202
- BATCH_EXECUTION_KINDS.has(k) &&
1203
- (k !== 'Job' || isCoreBatchJob(apiKind, effectiveGroup)) &&
1220
+ supportsBatchExecution(k, apiKind, effectiveGroup, res?.apiVersion) &&
1204
1221
  res ? (
1205
1222
  <BatchExecutionFullscreen
1206
1223
  kind={k}
@@ -1561,6 +1578,7 @@ const SCHEDULED_LOG_KINDS = new Set([
1561
1578
  'WorkflowTemplate',
1562
1579
  'ClusterWorkflowTemplate',
1563
1580
  'ScaledJob',
1581
+ 'JobSet',
1564
1582
  ])
1565
1583
 
1566
1584
  function LogsTabContent({
@@ -1592,7 +1610,7 @@ function LogsTabContent({
1592
1610
  selectedRunKey: string
1593
1611
  onSelectRun: (runKey: string) => void
1594
1612
  }) {
1595
- if (SCHEDULED_LOG_KINDS.has(kind)) {
1613
+ if (SCHEDULED_LOG_KINDS.has(kind) && supportsBatchExecution(kind, apiKind, group, resource?.apiVersion)) {
1596
1614
  return (
1597
1615
  <div className="h-full">
1598
1616
  <ScheduledWorkloadLogsViewer
@@ -1,4 +1,5 @@
1
- import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
1
+ import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
2
+ import type { SelectedResource } from '../types'
2
3
 
3
4
  export interface ParsedContextInfo {
4
5
  raw: string
@@ -15,6 +16,11 @@ interface ContextSwitchState {
15
16
  startSwitch: (context: ParsedContextInfo) => void
16
17
  updateProgress: (message: string) => void
17
18
  endSwitch: () => void
19
+ // A resource to open once a switch to `context` is live. The app's context
20
+ // reset takes it, so the open can't race the reset that clears the old
21
+ // cluster's drawer and URL. A change to any other context discards it.
22
+ setOpenAfterSwitch: (target: { context: string; resource: SelectedResource } | null) => void
23
+ takeOpenAfterSwitch: (context: string) => SelectedResource | null
18
24
  }
19
25
 
20
26
  const ContextSwitchContext = createContext<ContextSwitchState | null>(null)
@@ -40,8 +46,18 @@ export function ContextSwitchProvider({ children }: { children: ReactNode }) {
40
46
  setProgressMessage(null)
41
47
  }, [])
42
48
 
49
+ const openAfterSwitch = useRef<{ context: string; resource: SelectedResource } | null>(null)
50
+ const setOpenAfterSwitch = useCallback((target: { context: string; resource: SelectedResource } | null) => {
51
+ openAfterSwitch.current = target
52
+ }, [])
53
+ const takeOpenAfterSwitch = useCallback((context: string) => {
54
+ const target = openAfterSwitch.current
55
+ openAfterSwitch.current = null
56
+ return target && target.context === context ? target.resource : null
57
+ }, [])
58
+
43
59
  return (
44
- <ContextSwitchContext.Provider value={{ isSwitching, targetContext, progressMessage, startSwitch, updateProgress, endSwitch }}>
60
+ <ContextSwitchContext.Provider value={{ isSwitching, targetContext, progressMessage, startSwitch, updateProgress, endSwitch, setOpenAfterSwitch, takeOpenAfterSwitch }}>
45
61
  {children}
46
62
  </ContextSwitchContext.Provider>
47
63
  )
@@ -1,15 +1,3 @@
1
- // Slot-based customization of Radar's top nav.
2
- //
3
- // Lets library consumers (e.g. Radar Hub) swap out the brand area, the
4
- // context picker, and append items on the right of the action bar —
5
- // without forking App.tsx or building a parallel nav.
6
- //
7
- // The `embedded` flag hides chrome that only makes sense for Radar's
8
- // standalone OSS binary: GitHub star link, update-from-GitHub notifier,
9
- // Radar's own OIDC/proxy-mode UserMenu. Consumers typically provide
10
- // their own auth UI via `rightExtras`.
11
- //
12
- // Default (no provider): Radar renders its standalone nav unchanged.
13
1
  import { createContext, useContext } from 'react';
14
2
  import type { ReactNode } from 'react';
15
3
 
@@ -21,11 +9,9 @@ import type { ReactNode } from 'react';
21
9
  */
22
10
  export type FleetTakeoverTarget = 'issues' | 'gitops' | 'checks' | 'certs';
23
11
 
24
- interface NavCustomizationBase {
25
- /** Replaces Radar's Skyhook/radar logo + wordmark. */
26
- brandSlot?: ReactNode;
27
- /** Replaces the ContextSwitcher (kubeconfig-context picker). */
28
- contextSlot?: ReactNode;
12
+ export interface NavCustomization {
13
+ /** Radar Hub owns all chrome; Radar renders only the active view. */
14
+ embedded?: boolean;
29
15
  /**
30
16
  * When set, a "Compare across clusters" option is added to the Compare
31
17
  * button in resource action bars. The host returns the URL that should
@@ -50,8 +36,7 @@ interface NavCustomizationBase {
50
36
  * canonical surfaces rather than a second, diverging per-cluster copy:
51
37
  * - 'issues' → the Active Issues panel + cluster-health issues count
52
38
  * - 'gitops' → the GitOps controllers card
53
- * - 'checks' → the Cluster Audit card (and any route to /audit; legacy
54
- * `clusterChecksHref` folded in here)
39
+ * - 'checks' → the Cluster Audit card (and any route to /audit)
55
40
  * - 'certs' → the Certificate Health card
56
41
  *
57
42
  * View-shaped targets (issues / gitops / checks) are honored for every entry:
@@ -64,13 +49,6 @@ interface NavCustomizationBase {
64
49
  * falls back to `window.location` (a hard reload).
65
50
  */
66
51
  fleetTakeoverHref?: (target: FleetTakeoverTarget) => string | undefined;
67
- /**
68
- * @deprecated Superseded by `fleetTakeoverHref('checks')`. Kept so consumers
69
- * still on the pre-1.7 hook keep working (App.tsx folds it into the 'checks'
70
- * target) — this makes adding `fleetTakeoverHref` an additive, non-breaking
71
- * change. Remove in a major release once all consumers have migrated.
72
- */
73
- clusterChecksHref?: () => string;
74
52
  /**
75
53
  * Optional smooth navigator for host-owned URLs. When the host takes a
76
54
  * destination over (`fleetTakeoverHref`, `crossClusterCompareHref`), Radar
@@ -82,40 +60,8 @@ interface NavCustomizationBase {
82
60
  * OSS / other hosts are unaffected.
83
61
  */
84
62
  onHostNavigate?: (url: string) => void;
85
- /**
86
- * Chrome level for embedded hosts. Default ('full', or omitted) renders
87
- * Radar's top bar + the view-switcher. 'none' suppresses BOTH — the host
88
- * drives view navigation and cluster/namespace scope from its OWN chrome, and
89
- * Radar renders just the active view's content full-bleed. Radar Hub uses this
90
- * to surface per-cluster views (Topology / Resources / Traffic / Cost) that
91
- * don't aggregate to the fleet as native cloud destinations under one chrome,
92
- * gated by a cluster picker — instead of a second, redundant in-cluster nav.
93
- * Only meaningful with `embedded: true`.
94
- */
95
- chrome?: 'full' | 'none';
96
63
  }
97
64
 
98
- /**
99
- * Slot-based customization of Radar's top nav.
100
- *
101
- * Standalone-mode consumers pass `embedded: false` (or omit it) and may
102
- * optionally append items via `rightExtras`. Embedded-mode consumers must
103
- * supply `rightExtras` — Radar's OSS chrome (GitHub star, update notifier,
104
- * built-in UserMenu) is hidden, so the host app owns the right side of the
105
- * nav and must render its own user/auth UI there.
106
- */
107
- export type NavCustomization =
108
- | (NavCustomizationBase & {
109
- embedded?: false;
110
- /** Appended to the right of the action bar (before the UserMenu). */
111
- rightExtras?: ReactNode;
112
- })
113
- | (NavCustomizationBase & {
114
- embedded: true;
115
- /** Required in embedded mode: Radar's own UserMenu is hidden. */
116
- rightExtras: ReactNode;
117
- });
118
-
119
65
  const NavCustomizationContext = createContext<NavCustomization>({});
120
66
 
121
67
  export function NavCustomizationProvider({
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
2
- import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import { loadPreferences, persistPreferences } from '../api/preferences'
3
3
 
4
4
  type Theme = 'dark' | 'light'
5
5
 
@@ -34,14 +34,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
34
34
  const setTheme = (newTheme: Theme) => {
35
35
  setThemeState(newTheme)
36
36
  localStorage.setItem(THEME_STORAGE_KEY, newTheme)
37
- fetch(apiUrl('/settings'), {
38
- method: 'PUT',
39
- credentials: getCredentialsMode(),
40
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
41
- body: JSON.stringify({ theme: newTheme }),
42
- }).then((res) => {
43
- if (!res.ok) console.warn('[settings] Failed to persist theme:', res.status)
44
- }).catch((err) => console.warn('[settings] Failed to persist theme:', err))
37
+ persistPreferences({ theme: newTheme }).catch((err) => console.warn('[settings] Failed to persist theme:', err))
45
38
  }
46
39
 
47
40
  const toggleTheme = () => {
@@ -56,8 +49,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
56
49
 
57
50
  // Sync theme from server (persisted settings survive port changes in desktop app)
58
51
  useEffect(() => {
59
- fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
60
- .then((res) => res.ok ? res.json() : null)
52
+ loadPreferences()
61
53
  .then((data) => {
62
54
  if (data?.theme && (data.theme === 'dark' || data.theme === 'light') && data.theme !== theme) {
63
55
  setThemeState(data.theme)
@@ -21,8 +21,8 @@ interface UseClusterLoadStateResult {
21
21
  }
22
22
 
23
23
  // Tracks cluster-data warmup (deferred/partial dashboard load) once the main
24
- // connection is usable. Standalone / embedded-with-chrome render it in Radar's
25
- // topbar; chromeless hosts (Radar Hub) receive it via onClusterLoadStateChange;
24
+ // connection is usable. Standalone renders it in Radar's topbar; Radar Hub
25
+ // receives it via onClusterLoadStateChange;
26
26
  // a chromeless host without a callback gets a fallback row on Home.
27
27
  export function useClusterLoadState({
28
28
  namespaces,
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect } from 'react'
2
- import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
+ import { loadPreferences, persistPreferences } from '../api/preferences'
3
3
 
4
4
  export interface PinnedKind {
5
5
  name: string // plural name for API calls, e.g. "pods", "deployments"
@@ -25,8 +25,7 @@ function savePinned(pinned: PinnedKind[]) {
25
25
  } catch {
26
26
  // ignore storage errors
27
27
  }
28
- fetch(apiUrl('/settings'), { method: 'PUT', credentials: getCredentialsMode(), headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ pinnedKinds: pinned }) })
29
- .then((res) => { if (!res.ok) console.warn('[settings] Failed to persist pinned kinds:', res.status) })
28
+ persistPreferences({ pinnedKinds: pinned })
30
29
  .catch((err) => console.warn('[settings] Failed to persist pinned kinds:', err))
31
30
  }
32
31
 
@@ -39,8 +38,7 @@ export function usePinnedKinds() {
39
38
 
40
39
  // Sync from server (persisted settings survive port changes in desktop app)
41
40
  useEffect(() => {
42
- fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
43
- .then((res) => res.ok ? res.json() : null)
41
+ loadPreferences()
44
42
  .then((data) => {
45
43
  if (data?.pinnedKinds?.length && loadPinned().length === 0) {
46
44
  setPinned(data.pinnedKinds)
@@ -30,7 +30,7 @@ export function isBadgeWorthy(
30
30
  /**
31
31
  * buildAuditSeverityMap keys badge-worthy findings by the same resource key the
32
32
  * backend stamps onto topology nodes (`node.data.auditKey`): `group|Kind|ns|name`,
33
- * group following the audit convention (built-ins → their group, CRDs → "").
33
+ * using the resource’s actual API group.
34
34
  */
35
35
  export function buildAuditSeverityMap(
36
36
  findings: AuditFinding[] | undefined,
@@ -1,9 +1,10 @@
1
+ import type { Topology } from '@skyhook-io/k8s-ui/types/core'
1
2
  import { afterEach, describe, expect, it } from 'vitest'
2
3
  import {
3
4
  initNavigationMap,
4
5
  resetNavigationMap,
5
6
  } from '@skyhook-io/k8s-ui/utils/navigation'
6
- import { relatedResourcePath, resourcePath } from './navigation'
7
+ import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath } from './navigation'
7
8
 
8
9
  afterEach(resetNavigationMap)
9
10
 
@@ -43,3 +44,19 @@ describe('resource navigation paths', () => {
43
44
  ).toBe('/workload/pods/default/batch')
44
45
  })
45
46
  })
47
+
48
+
49
+ describe('JobSet investigation navigation', () => {
50
+ it('preserves selected member and tab on the exact JobSet route', () => {
51
+ expect(relatedResourcePath({ kind: 'jobsets', namespace: 'training', name: 'wide', group: 'jobset.x-k8s.io', run: 'jobs/training/worker-240', tab: 'logs' })).toBe('/workload/jobsets/training/wide?apiGroup=jobset.x-k8s.io&run=jobs%2Ftraining%2Fworker-240&tab=logs')
52
+ })
53
+ it('leaves colliding JobSet kinds in their group-aware drawer', () => {
54
+ expect(relatedResourcePath({ kind: 'jobsets', namespace: 'training', name: 'wide', group: 'other.example' })).toBe('/resources/jobsets?resource=training%2Fwide&apiGroup=other.example')
55
+ })
56
+ })
57
+
58
+
59
+ it('uses the Kubernetes kind for a Calico aggregate target', () => {
60
+ expect(getNetworkPolicyResourceTarget({nodes: [{id: 'policy', kind: 'CalicoNetworkPolicy', name: 'allow', status: 'healthy', data: {apiVersion: 'crd.projectcalico.org/v1', resourceKind: 'NetworkPolicy'}}], edges: []} as unknown as Topology))
61
+ .toEqual({kind: 'networkpolicies', group: 'crd.projectcalico.org'})
62
+ })
@@ -1,5 +1,6 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
2
  import { apiVersionToGroup, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
3
+ import { topologyNodeResourceKind } from '@skyhook-io/k8s-ui/utils/topology-neighborhood'
3
4
  import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
4
5
  import type { SearchHit } from '../api/client'
5
6
 
@@ -48,7 +49,7 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
48
49
 
49
50
  const group = networkPolicyGroup(node)
50
51
  const target = {
51
- kind: kindToPluralWithGroup(node.kind, group ?? ''),
52
+ kind: kindToPluralWithGroup(topologyNodeResourceKind(node), group ?? ''),
52
53
  ...(group ? { group } : {}),
53
54
  }
54
55
  targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
@@ -67,12 +68,18 @@ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kin
67
68
  * positional and WorkloadViewRoute can parse it back. '_' is safe — it's not a
68
69
  * valid DNS-1123 namespace label, so it can never collide with a real one.
69
70
  */
70
- export function buildWorkloadPath(resource: SelectedResource): string {
71
+ export type ResourceNavigationTarget = SelectedResource & { run?: string; tab?: string }
72
+
73
+ export function buildWorkloadPath(resource: ResourceNavigationTarget): string {
71
74
  const kind = encodeURIComponent(resource.kind)
72
75
  const namespace = encodeURIComponent(resource.namespace || '_')
73
76
  const name = encodeURIComponent(resource.name)
74
77
  const base = `/workload/${kind}/${namespace}/${name}`
75
- return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
78
+ const params = new URLSearchParams()
79
+ if (resource.group) params.set('apiGroup', resource.group)
80
+ if (resource.run) params.set('run', resource.run)
81
+ if (resource.tab) params.set('tab', resource.tab)
82
+ return params.size ? `${base}?${params}` : base
76
83
  }
77
84
 
78
85
  /**
@@ -101,9 +108,9 @@ export function resourcePath(resource: SelectedResource): string {
101
108
 
102
109
  const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
103
110
 
104
- export function relatedResourcePath(resource: SelectedResource): string {
111
+ export function relatedResourcePath(resource: ResourceNavigationTarget): string {
105
112
  const apiKind = kindToPluralWithGroup(resource.kind, resource.group ?? '').toLowerCase()
106
- if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
113
+ if (FULLSCREEN_RESOURCE_KINDS.has(apiKind) || (apiKind === 'jobsets' && resource.group === 'jobset.x-k8s.io')) {
107
114
  return buildWorkloadPath({ ...resource, kind: apiKind })
108
115
  }
109
116
  return resourcePath(resource)