@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,46 +1,141 @@
1
1
  import { useMemo, useEffect, useCallback, useState } from 'react'
2
- import { useQueries } from '@tanstack/react-query'
2
+ import { useQueries, useQueryClient } from '@tanstack/react-query'
3
3
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
4
4
  import { clsx } from 'clsx'
5
5
  import { Terminal } from 'lucide-react'
6
6
  import {
7
7
  WorkloadView as BaseWorkloadView,
8
+ EditableYamlView,
9
+ FetchResult,
10
+ type WorkloadTabType,
8
11
  type RendererOverrides,
12
+ type GitOpsOwnerRef,
13
+ type GitOpsStatus,
14
+ type HelmOwnerRef,
15
+ type AppRow,
16
+ type ResourceOwnershipContext,
17
+ type ServingResourceDetail,
18
+ type AuditFinding,
19
+ gitOpsRouteForOwner,
20
+ gitOpsOwnerFromRelationships,
21
+ getGitOpsResourceStatus,
22
+ resolvedEnvFromKey,
9
23
  } from '@skyhook-io/k8s-ui'
10
- import type { SelectedResource, ResourceRef, ResolvedEnvFrom } from '../../types'
11
- import type { NavigateToResource } from '../../utils/navigation'
24
+ import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
25
+ import type { SelectedResource, ResourceRef, Relationships, ResolvedEnvFrom } from '../../types'
12
26
  import {
13
- useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
14
- useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
15
- useRestartWorkload, useWorkloadRevisions, useRollbackWorkload,
16
- useFluxReconcile, useFluxSyncWithSource, useFluxSuspend, useFluxResume,
17
- useArgoSync, useArgoRefresh, useArgoSuspend, useArgoResume,
18
- useCordonNode, useUncordonNode, useDrainNode,
27
+ kindToPlural,
28
+ pluralToKind,
29
+ relatedResourcePath,
30
+ type NavigateToResource,
31
+ } from '../../utils/navigation'
32
+ import {
33
+ useChanges,
34
+ useResourceWithRelationships,
35
+ usePodLogs,
36
+ useTopology,
37
+ useUpdateResource,
38
+ usePreviewResources,
39
+ useDeleteResource,
40
+ useTriggerCronJob,
41
+ useSuspendCronJob,
42
+ useResumeCronJob,
43
+ useRestartWorkload,
44
+ useWorkloadRevisions,
45
+ useRollbackWorkload,
46
+ useWorkloadPods,
47
+ useFluxReconcile,
48
+ useFluxSyncWithSource,
49
+ useFluxSuspend,
50
+ useFluxResume,
51
+ useArgoSync,
52
+ useArgoRefresh,
53
+ useArgoSuspend,
54
+ useArgoResume,
55
+ useCordonNode,
56
+ useUncordonNode,
57
+ useDrainNode,
19
58
  useCascadeDeletePreview,
20
59
  useResourceEvents,
60
+ useResource,
61
+ useWorkloadRuns,
62
+ useApplications,
21
63
  fetchJSON,
64
+ fetchYamlSchemas,
22
65
  } from '../../api/client'
23
66
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
24
- import { useResourceAudit } from '../../api/client'
25
- import { AuditAlerts } from '@skyhook-io/k8s-ui'
67
+ import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
68
+ import { RestartEventLane } from '../resource/RestartChart'
69
+ import { RightsizingPanel, RightsizingStrip } from '../resource/RightsizingStrip'
70
+ import { WorkloadCostTab } from '../cost/WorkloadCostTab'
71
+ import { isOpenCostWorkloadKind } from '../cost/kinds'
72
+ import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
73
+ import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
26
74
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
75
+ import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
27
76
  import { LogsViewer } from '../logs/LogsViewer'
28
- import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
77
+ import { BatchExecutionFullscreen } from '../execution/BatchExecutionView'
78
+ import { workloadRunTimelineEvents } from '../execution/batch-timeline'
79
+ import {
80
+ useCanUpdateSecrets,
81
+ useCanNodeWrite,
82
+ useNamespacedCapabilities,
83
+ useIsLocalDeployment,
84
+ useCapabilitiesContext,
85
+ } from '../../contexts/CapabilitiesContext'
29
86
  import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
30
- import { PortForwardButton } from '../portforward/PortForwardButton'
87
+ import { PortForwardButton, PortForwardInlineButton } from '../portforward/PortForwardButton'
88
+ import {
89
+ CurlButton,
90
+ CurlPanel,
91
+ isHttpishPort,
92
+ defaultScheme,
93
+ defaultPathForPort,
94
+ } from '../curl/ServiceCurlButton'
31
95
  import { useToast } from '../ui/Toast'
96
+ import { Tooltip } from '../ui/Tooltip'
32
97
  import { PodRenderer } from '../resources/renderers/PodRenderer'
33
98
  import { NodeRenderer } from '../resources/renderers/NodeRenderer'
34
99
  import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
35
100
  import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
101
+ import { CompositeRenderer } from '../resources/CompositeRenderer'
102
+ import { ServiceAccountRenderer } from '../resources/renderers/ServiceAccountRenderer'
103
+ import { RoleRenderer } from '../resources/renderers/RoleRenderer'
104
+ import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
105
+ import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
106
+ import { HPARenderer } from '../resources/renderers/HPARenderer'
107
+ import { PVCRenderer } from '../resources/renderers/PVCRenderer'
36
108
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
37
109
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
110
+ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
111
+ import { useCompareLauncher } from '../compare/useCompareLauncher'
112
+ import { useDiagnoseCustomization } from '../../context/DiagnoseCustomization'
113
+ import { apiVersionToGroup } from '../../utils/navigation'
38
114
 
39
- type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
115
+ type TabType = WorkloadTabType
116
+ const BATCH_EXECUTION_KINDS = new Set([
117
+ 'Job',
118
+ 'CronJob',
119
+ 'Workflow',
120
+ 'CronWorkflow',
121
+ 'WorkflowTemplate',
122
+ 'ClusterWorkflowTemplate',
123
+ 'ScaledJob',
124
+ ])
40
125
 
41
126
  // Stable reference — web renderer wrappers inject platform hooks internally
42
127
  const rendererOverrides: RendererOverrides = {
43
- PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer,
128
+ PodRenderer,
129
+ NodeRenderer,
130
+ ServiceRenderer,
131
+ WorkloadRenderer,
132
+ CompositeRenderer,
133
+ ServiceAccountRenderer,
134
+ RoleRenderer,
135
+ RoleBindingRenderer,
136
+ NamespaceRenderer,
137
+ HPARenderer,
138
+ PVCRenderer,
44
139
  }
45
140
 
46
141
  // ============================================================================
@@ -54,21 +149,37 @@ interface WorkloadViewRouteProps {
54
149
  export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRouteProps) {
55
150
  const location = useLocation()
56
151
  const navigate = useNavigate()
152
+ const [searchParams] = useSearchParams()
57
153
 
58
- // Parse /workload/:kind/:ns/:name from pathname
154
+ // Parse /workload/:kind/:ns/:name from pathname. Segments are URL-encoded by
155
+ // buildWorkloadPath; names can also contain literal slashes (e.g. some CRD names),
156
+ // which survive encoding as %2F and reassemble correctly here.
157
+ //
158
+ // Cluster-scoped resources (Node, PersistentVolume, Namespace, …) have no
159
+ // namespace: buildWorkloadPath encodes the namespace segment as '_'. Decode
160
+ // that back to '' here, and tolerate a legacy empty segment ('//') and the
161
+ // collapsed three-segment form (/workload/:kind/:name) for older links.
59
162
  const parts = location.pathname.replace(/^\//, '').split('/')
60
- // parts[0] = 'workload', parts[1] = kind, parts[2] = ns, parts[3+] = name (may contain slashes)
61
- const kind = parts[1] || ''
62
- const namespace = parts[2] || ''
63
- const name = parts.slice(3).join('/') || ''
64
-
65
- if (!kind || !namespace || !name) {
66
- return (
67
- <div className="flex items-center justify-center h-full text-theme-text-tertiary">
68
- Invalid workload URL
69
- </div>
70
- )
163
+ const decode = (s: string): string => {
164
+ try {
165
+ return decodeURIComponent(s)
166
+ } catch {
167
+ return s
168
+ }
169
+ }
170
+ const kind = decode(parts[1] ?? '')
171
+ let namespace: string
172
+ let name: string
173
+ if (parts.length <= 3) {
174
+ // /workload/:kind/:name — cluster-scoped link with no namespace segment.
175
+ namespace = ''
176
+ name = decode(parts[2] ?? '')
177
+ } else {
178
+ const nsSegment = parts[2] ?? ''
179
+ namespace = nsSegment === '_' || nsSegment === '' ? '' : decode(nsSegment)
180
+ name = parts.slice(3).map(decode).join('/')
71
181
  }
182
+ const group = searchParams.get('apiGroup') || ''
72
183
 
73
184
  const handleBack = useCallback(() => {
74
185
  if (window.history.length > 1) {
@@ -78,16 +189,29 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
78
189
  }
79
190
  }, [navigate])
80
191
 
81
- const handleNavigate = useCallback((resource: SelectedResource) => {
82
- // Navigate to another workload view
83
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
84
- }, [navigate])
192
+ const handleNavigate = useCallback(
193
+ (resource: SelectedResource) => {
194
+ navigate(relatedResourcePath(resource))
195
+ },
196
+ [navigate],
197
+ )
198
+
199
+ // Hooks must run unconditionally — the invalid-URL guard comes after them.
200
+ // Namespace is empty for cluster-scoped resources, so only kind + name are required.
201
+ if (!kind || !name) {
202
+ return (
203
+ <div className="flex items-center justify-center h-full text-theme-text-tertiary">
204
+ Invalid workload URL
205
+ </div>
206
+ )
207
+ }
85
208
 
86
209
  return (
87
210
  <WorkloadView
88
211
  kind={kind}
89
212
  namespace={namespace}
90
213
  name={name}
214
+ group={group}
91
215
  onBack={handleBack}
92
216
  onNavigateToResource={onNavigateToResource || handleNavigate}
93
217
  />
@@ -103,13 +227,20 @@ interface WorkloadViewProps {
103
227
  namespace: string
104
228
  name: string
105
229
  onBack: () => void
230
+ hideBackButton?: boolean
231
+ compactHeader?: boolean
106
232
  onNavigateToResource?: NavigateToResource
107
233
  onCollapseToDrawer?: () => void
108
234
  expanded?: boolean
235
+ /** false on the outgoing layer during an expand/collapse crossfade (default true) */
236
+ active?: boolean
109
237
  onClose?: () => void
110
- onExpand?: () => void
238
+ onExpand?: (opts?: { yaml?: boolean }) => void
239
+ onExpandIntent?: () => void
240
+ onCancelExpandIntent?: () => void
111
241
  initialTab?: 'detail' | 'yaml'
112
242
  group?: string
243
+ pushTabHistory?: boolean
113
244
  }
114
245
 
115
246
  function useActionsBarProps(kind: string, namespace: string, name: string) {
@@ -119,6 +250,10 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
119
250
  const openWorkloadLogs = useOpenWorkloadLogs()
120
251
  const openNodeTerminal = useOpenNodeTerminal()
121
252
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
253
+ // Live forward when local+RBAC; otherwise (in-cluster/Cloud) still surface the
254
+ // copy-paste kubectl command. The button picks live vs. copy by deployment mode.
255
+ const isLocal = useIsLocalDeployment()
256
+ const showPortForward = canPortForward || !isLocal
122
257
 
123
258
  const deleteMutation = useDeleteResource()
124
259
  const restartWorkloadMutation = useRestartWorkload()
@@ -128,7 +263,11 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
128
263
  const resumeCronJobMutation = useResumeCronJob()
129
264
 
130
265
  const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind.toLowerCase())
131
- const { data: revisionsList, isLoading: revisionsLoading, error: revisionsError } = useWorkloadRevisions(kind.toLowerCase(), namespace, name, isRollbackKind)
266
+ const {
267
+ data: revisionsList,
268
+ isLoading: revisionsLoading,
269
+ error: revisionsError,
270
+ } = useWorkloadRevisions(kind.toLowerCase(), namespace, name, isRollbackKind)
132
271
 
133
272
  const fluxReconcileMutation = useFluxReconcile()
134
273
  const fluxSyncWithSourceMutation = useFluxSyncWithSource()
@@ -140,64 +279,102 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
140
279
  const argoSuspendMutation = useArgoSuspend()
141
280
  const argoResumeMutation = useArgoResume()
142
281
 
143
- const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(kind, namespace, name, true)
282
+ const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(
283
+ kind,
284
+ namespace,
285
+ name,
286
+ true,
287
+ )
144
288
 
145
289
  const canNodeWrite = useCanNodeWrite()
146
290
  const cordonMutation = useCordonNode()
147
291
  const uncordonMutation = useUncordonNode()
148
292
  const drainMutation = useDrainNode()
149
293
 
294
+ const { renderAction: renderDiagnose } = useDiagnoseCustomization()
295
+
150
296
  return {
151
297
  canExec,
152
298
  canViewLogs,
153
- canPortForward,
299
+ canPortForward: showPortForward,
154
300
  onOpenTerminal: openTerminal,
155
301
  onOpenLogs: openLogs,
156
302
  onOpenWorkloadLogs: openWorkloadLogs,
157
303
  onOpenNodeTerminal: openNodeTerminal,
158
- onCopyCommand: (text: string, message: string, event: React.MouseEvent) => showCopied(text, message, event),
159
- renderPortForward: ({ type, namespace: ns, name: n, className }: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => (
160
- <PortForwardButton type={type} namespace={ns} name={n} className={className} />
161
- ),
162
- onDelete: (params: any, callbacks?: any) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
304
+ onCopyCommand: (text: string, message: string, event: React.MouseEvent) =>
305
+ showCopied(text, message, event),
306
+ renderPortForward: ({
307
+ type,
308
+ namespace: ns,
309
+ name: n,
310
+ className,
311
+ }: {
312
+ type: 'pod' | 'service'
313
+ namespace: string
314
+ name: string
315
+ className?: string
316
+ }) => <PortForwardButton type={type} namespace={ns} name={n} className={className} />,
317
+ renderDiagnose,
318
+ onDelete: (
319
+ params: Parameters<typeof deleteMutation.mutate>[0],
320
+ callbacks?: { onSuccess?: () => void },
321
+ ) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
163
322
  isDeleting: deleteMutation.isPending,
164
323
  cascadeDependents: cascadePreview?.dependents,
165
324
  cascadeLoading,
166
- onRestart: (params: any) => restartWorkloadMutation.mutate(params),
325
+ onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
326
+ restartWorkloadMutation.mutate(params),
167
327
  isRestarting: restartWorkloadMutation.isPending,
168
328
  revisions: revisionsList,
169
329
  revisionsLoading,
170
330
  revisionsError: revisionsError ?? null,
171
- onRollback: (params: any, callbacks?: any) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
331
+ onRollback: (
332
+ params: Parameters<typeof rollbackMutation.mutate>[0],
333
+ callbacks?: { onSuccess?: () => void },
334
+ ) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
172
335
  isRollingBack: rollbackMutation.isPending,
173
- onTriggerCronJob: (params: any) => triggerCronJobMutation.mutate(params),
336
+ onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) =>
337
+ triggerCronJobMutation.mutate(params),
174
338
  isTriggeringCronJob: triggerCronJobMutation.isPending,
175
- onSuspendCronJob: (params: any) => suspendCronJobMutation.mutate(params),
339
+ onSuspendCronJob: (params: Parameters<typeof suspendCronJobMutation.mutate>[0]) =>
340
+ suspendCronJobMutation.mutate(params),
176
341
  isSuspendingCronJob: suspendCronJobMutation.isPending,
177
- onResumeCronJob: (params: any) => resumeCronJobMutation.mutate(params),
342
+ onResumeCronJob: (params: Parameters<typeof resumeCronJobMutation.mutate>[0]) =>
343
+ resumeCronJobMutation.mutate(params),
178
344
  isResumingCronJob: resumeCronJobMutation.isPending,
179
- onFluxReconcile: (params: any) => fluxReconcileMutation.mutate(params),
345
+ onFluxReconcile: (params: Parameters<typeof fluxReconcileMutation.mutate>[0]) =>
346
+ fluxReconcileMutation.mutate(params),
180
347
  isFluxReconciling: fluxReconcileMutation.isPending,
181
- onFluxSyncWithSource: (params: any) => fluxSyncWithSourceMutation.mutate(params),
348
+ onFluxSyncWithSource: (params: Parameters<typeof fluxSyncWithSourceMutation.mutate>[0]) =>
349
+ fluxSyncWithSourceMutation.mutate(params),
182
350
  isFluxSyncing: fluxSyncWithSourceMutation.isPending,
183
- onFluxSuspend: (params: any) => fluxSuspendMutation.mutate(params),
351
+ onFluxSuspend: (params: Parameters<typeof fluxSuspendMutation.mutate>[0]) =>
352
+ fluxSuspendMutation.mutate(params),
184
353
  isFluxSuspending: fluxSuspendMutation.isPending,
185
- onFluxResume: (params: any) => fluxResumeMutation.mutate(params),
354
+ onFluxResume: (params: Parameters<typeof fluxResumeMutation.mutate>[0]) =>
355
+ fluxResumeMutation.mutate(params),
186
356
  isFluxResuming: fluxResumeMutation.isPending,
187
- onArgoSync: (params: any) => argoSyncMutation.mutate(params),
357
+ onArgoSync: (params: Parameters<typeof argoSyncMutation.mutate>[0]) =>
358
+ argoSyncMutation.mutate(params),
188
359
  isArgoSyncing: argoSyncMutation.isPending,
189
- onArgoRefresh: (params: any) => argoRefreshMutation.mutate(params),
360
+ onArgoRefresh: (params: Parameters<typeof argoRefreshMutation.mutate>[0]) =>
361
+ argoRefreshMutation.mutate(params),
190
362
  isArgoRefreshing: argoRefreshMutation.isPending,
191
- onArgoSuspend: (params: any) => argoSuspendMutation.mutate(params),
363
+ onArgoSuspend: (params: Parameters<typeof argoSuspendMutation.mutate>[0]) =>
364
+ argoSuspendMutation.mutate(params),
192
365
  isArgoSuspending: argoSuspendMutation.isPending,
193
- onArgoResume: (params: any) => argoResumeMutation.mutate(params),
366
+ onArgoResume: (params: Parameters<typeof argoResumeMutation.mutate>[0]) =>
367
+ argoResumeMutation.mutate(params),
194
368
  isArgoResuming: argoResumeMutation.isPending,
195
369
  canNodeWrite,
196
- onCordonNode: (params: any) => cordonMutation.mutate(params),
370
+ onCordonNode: (params: Parameters<typeof cordonMutation.mutate>[0]) =>
371
+ cordonMutation.mutate(params),
197
372
  isCordoningNode: cordonMutation.isPending,
198
- onUncordonNode: (params: any) => uncordonMutation.mutate(params),
373
+ onUncordonNode: (params: Parameters<typeof uncordonMutation.mutate>[0]) =>
374
+ uncordonMutation.mutate(params),
199
375
  isUncordoningNode: uncordonMutation.isPending,
200
- onDrainNode: (params: any) => drainMutation.mutate(params),
376
+ onDrainNode: (params: Parameters<typeof drainMutation.mutate>[0]) =>
377
+ drainMutation.mutate(params),
201
378
  isDrainingNode: drainMutation.isPending,
202
379
  }
203
380
  }
@@ -207,46 +384,194 @@ export function WorkloadView({
207
384
  namespace,
208
385
  name,
209
386
  expanded = true,
387
+ pushTabHistory = false,
210
388
  ...rest
211
389
  }: WorkloadViewProps) {
212
390
  const [searchParams, setSearchParams] = useSearchParams()
391
+ const apiKind = kindToPlural(kindProp)
392
+ const queryClient = useQueryClient()
213
393
 
214
394
  // Tab state from URL query param — migrate legacy tab names
215
395
  const rawTab = searchParams.get('tab')
216
- const migratedTab: TabType = rawTab === 'info' ? 'overview'
217
- : rawTab === 'events' ? 'timeline'
218
- : (rawTab as TabType) || 'overview'
219
-
220
- const handleTabChange = useCallback((tab: TabType) => {
221
- const params = new URLSearchParams(searchParams)
222
- if (tab === 'overview') {
223
- params.delete('tab')
224
- } else {
225
- params.set('tab', tab)
226
- }
227
- setSearchParams(params, { replace: true })
228
- }, [searchParams, setSearchParams])
396
+ const migratedTab: TabType =
397
+ rawTab === 'info'
398
+ ? 'overview'
399
+ : rawTab === 'events'
400
+ ? 'timeline'
401
+ : (rawTab as TabType) || 'overview'
402
+
403
+ const handleTabChange = useCallback(
404
+ (tab: TabType, opts?: { replace?: boolean }) => {
405
+ const params = new URLSearchParams(searchParams)
406
+ if (tab === 'overview') {
407
+ params.delete('tab')
408
+ } else {
409
+ params.set('tab', tab)
410
+ }
411
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
412
+ },
413
+ [pushTabHistory, searchParams, setSearchParams],
414
+ )
415
+
416
+ const selectedRunKey = searchParams.get('run') ?? ''
417
+ const handleSelectedRunChange = useCallback(
418
+ (runKey: string) => {
419
+ const params = new URLSearchParams(searchParams)
420
+ if (runKey) params.set('run', runKey)
421
+ else params.delete('run')
422
+ setSearchParams(params, { replace: true })
423
+ },
424
+ [searchParams, setSearchParams],
425
+ )
426
+
427
+ const batchKind = pluralToKind(apiKind)
428
+ const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind)
429
+ const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
430
+ refetchActive: true,
431
+ clusterScoped: batchKind === 'ClusterWorkflowTemplate',
432
+ })
433
+ const relatedTimelineEvents = useMemo(
434
+ () => workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
435
+ [batchRunsQuery.data?.runs],
436
+ )
229
437
 
230
438
  // Fetch resource with relationships
231
- const { data: resourceResponse, isLoading: resourceLoading, refetch: refetchResource } = useResourceWithRelationships<any>(kindProp, namespace, name, rest.group)
439
+ const {
440
+ data: resourceResponse,
441
+ isLoading: resourceLoading,
442
+ error: resourceError,
443
+ refetch: refetchResource,
444
+ } = useResourceWithRelationships<any>(apiKind, namespace, name, rest.group)
232
445
  const resource = resourceResponse?.resource
233
446
  const relationships = resourceResponse?.relationships
447
+ const refetchResourceAndRuns = useCallback(async () => {
448
+ await Promise.all([
449
+ refetchResource(),
450
+ queryClient.refetchQueries({
451
+ queryKey: ['workload-runs', apiKind, namespace, name],
452
+ }),
453
+ ])
454
+ }, [apiKind, name, namespace, queryClient, refetchResource])
455
+ const podWorkloadOwner = useMemo(
456
+ () => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
457
+ [apiKind, namespace, relationships, resource],
458
+ )
459
+ const podOwnerAppsQuery = useApplications(
460
+ podWorkloadOwner?.namespace ? [podWorkloadOwner.namespace] : [],
461
+ { enabled: Boolean(podWorkloadOwner?.namespace) },
462
+ )
463
+ const ownershipContext = useMemo(
464
+ () => buildPodOwnershipContext(podWorkloadOwner, podOwnerAppsQuery.data?.applications),
465
+ [podWorkloadOwner, podOwnerAppsQuery.data?.applications],
466
+ )
234
467
  const certificateInfo = resourceResponse?.certificateInfo
468
+ const hpaDiagnosis = resourceResponse?.hpaDiagnosis
469
+ const relationshipGitopsOwner = useMemo(
470
+ () => gitOpsOwnerFromRelationships(relationships),
471
+ [relationships],
472
+ )
473
+ const inheritedGitOpsLookupRef = useMemo(
474
+ () =>
475
+ findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, {
476
+ kind: apiKind,
477
+ namespace,
478
+ name,
479
+ group: rest.group,
480
+ }),
481
+ [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
482
+ )
483
+ const inheritedGitOpsResponse = useResourceWithRelationships<any>(
484
+ inheritedGitOpsLookupRef ? kindToPlural(inheritedGitOpsLookupRef.kind) : '',
485
+ inheritedGitOpsLookupRef?.namespace ?? '',
486
+ inheritedGitOpsLookupRef?.name ?? '',
487
+ inheritedGitOpsLookupRef?.group,
488
+ )
489
+ const inheritedGitopsOwner = useMemo(
490
+ () => gitOpsOwnerFromRelationships(inheritedGitOpsResponse.data?.relationships),
491
+ [inheritedGitOpsResponse.data?.relationships],
492
+ )
493
+ const relationshipHelmOwner = useMemo(
494
+ () =>
495
+ nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace),
496
+ [relationships, resource?.metadata?.namespace, namespace],
497
+ )
498
+ const inheritedHelmOwner = useMemo(
499
+ () =>
500
+ nativeHelmOwnerFromRelationships(
501
+ inheritedGitOpsResponse.data?.relationships,
502
+ inheritedGitOpsResponse.data?.resource?.metadata?.namespace ?? namespace,
503
+ ),
504
+ [
505
+ inheritedGitOpsResponse.data?.relationships,
506
+ inheritedGitOpsResponse.data?.resource?.metadata?.namespace,
507
+ namespace,
508
+ ],
509
+ )
510
+ const rawGitopsOwner = relationshipGitopsOwner ?? inheritedGitopsOwner
511
+ const gitOpsSourceResource = relationshipGitopsOwner
512
+ ? resource
513
+ : inheritedGitOpsResponse.data?.resource
514
+ const helmOwner = relationshipHelmOwner ?? inheritedHelmOwner
515
+ const helmSourceResource = relationshipHelmOwner
516
+ ? resource
517
+ : inheritedGitOpsResponse.data?.resource
518
+ const shouldResolveArgoOwner = rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace
519
+ const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', {
520
+ enabled: shouldResolveArgoOwner,
521
+ })
522
+ const gitopsOwner = useMemo(
523
+ () => resolveGitOpsOwner(rawGitopsOwner, argoApplications),
524
+ [rawGitopsOwner, argoApplications],
525
+ )
526
+ const gitopsOwnerGroup = gitopsOwner ? gitOpsOwnerGroup(gitopsOwner) : ''
527
+ const shouldFetchGitOpsOwner = Boolean(gitopsOwner?.namespace)
528
+ const gitopsOwnerQuery = useResource<any>(
529
+ shouldFetchGitOpsOwner ? gitopsOwner!.kind : '',
530
+ gitopsOwner?.namespace ?? '',
531
+ gitopsOwner?.name ?? '',
532
+ gitopsOwnerGroup,
533
+ )
534
+ const gitOpsOwnerStatus = useMemo(
535
+ () => deriveGitOpsOwnerStatus(gitopsOwner, gitopsOwnerQuery.data),
536
+ [gitopsOwner, gitopsOwnerQuery.data],
537
+ )
538
+ const gitOpsOwnerVerified = Boolean(gitopsOwner?.namespace && gitopsOwnerQuery.data)
539
+ const gitOpsOwnerPending = Boolean(
540
+ gitopsOwner?.namespace && gitopsOwnerQuery.isLoading && !gitopsOwnerQuery.data,
541
+ )
542
+ const gitOpsOwnerSource = useMemo(
543
+ () => describeGitOpsOwnerSource(rawGitopsOwner, gitOpsSourceResource),
544
+ [rawGitopsOwner, gitOpsSourceResource],
545
+ )
546
+ const helmOwnerSource = useMemo(
547
+ () => describeHelmOwnerSource(helmOwner, helmSourceResource),
548
+ [helmOwner, helmSourceResource],
549
+ )
235
550
 
236
551
  // For pods: extract envFrom ConfigMap/Secret names and resolve their keys
237
- const isPod = kindProp.toLowerCase() === 'pods'
552
+ const isPod = apiKind === 'pods'
238
553
  const { envFromConfigMapNames, envFromSecretNames } = useMemo(() => {
239
- if (!isPod || !resource) return { envFromConfigMapNames: [] as string[], envFromSecretNames: [] as string[] }
554
+ if (!isPod || !resource)
555
+ return {
556
+ envFromConfigMapNames: [] as string[],
557
+ envFromSecretNames: [] as string[],
558
+ }
240
559
  const cmNames = new Set<string>()
241
560
  const secretNames = new Set<string>()
242
- const containers = [...(resource.spec?.containers || []), ...(resource.spec?.initContainers || [])]
561
+ const containers = [
562
+ ...(resource.spec?.containers || []),
563
+ ...(resource.spec?.initContainers || []),
564
+ ]
243
565
  for (const c of containers) {
244
- for (const ef of (c.envFrom || [])) {
566
+ for (const ef of c.envFrom || []) {
245
567
  if (ef.configMapRef?.name) cmNames.add(ef.configMapRef.name)
246
568
  if (ef.secretRef?.name) secretNames.add(ef.secretRef.name)
247
569
  }
248
570
  }
249
- return { envFromConfigMapNames: Array.from(cmNames), envFromSecretNames: Array.from(secretNames) }
571
+ return {
572
+ envFromConfigMapNames: Array.from(cmNames),
573
+ envFromSecretNames: Array.from(secretNames),
574
+ }
250
575
  }, [isPod, resource])
251
576
 
252
577
  const configMapQueries = useQueries({
@@ -268,28 +593,44 @@ export function WorkloadView({
268
593
  })
269
594
 
270
595
  const resolvedEnvFrom = useMemo(() => {
271
- if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0)) return undefined
596
+ if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0))
597
+ return undefined
272
598
  const result: ResolvedEnvFrom = {}
273
599
  envFromConfigMapNames.forEach((n, i) => {
274
600
  // Single-resource endpoint returns { resource, relationships } wrapper
275
601
  const cm = configMapQueries[i]?.data?.resource ?? configMapQueries[i]?.data
276
- if (cm) result[n] = { keys: Object.keys(cm.data || {}), values: cm.data || {}, isSecret: false }
602
+ if (cm)
603
+ result[resolvedEnvFromKey('configmap', n)] = {
604
+ keys: Object.keys(cm.data || {}),
605
+ values: cm.data || {},
606
+ isSecret: false,
607
+ }
277
608
  })
278
609
  envFromSecretNames.forEach((n, i) => {
279
610
  const secret = secretQueries[i]?.data?.resource ?? secretQueries[i]?.data
280
611
  if (secret) {
281
612
  const decodedValues: Record<string, string> = {}
282
613
  for (const [k, v] of Object.entries(secret.data || {})) {
283
- try { decodedValues[k] = atob(v as string) } catch { decodedValues[k] = v as string }
614
+ try {
615
+ decodedValues[k] = atob(v as string)
616
+ } catch {
617
+ decodedValues[k] = v as string
618
+ }
619
+ }
620
+ result[resolvedEnvFromKey('secret', n)] = {
621
+ keys: Object.keys(decodedValues),
622
+ values: decodedValues,
623
+ isSecret: true,
284
624
  }
285
- result[n] = { keys: Object.keys(decodedValues), values: decodedValues, isSecret: true }
286
625
  }
287
626
  })
288
627
  return Object.keys(result).length > 0 ? result : undefined
289
628
  }, [isPod, envFromConfigMapNames, envFromSecretNames, configMapQueries, secretQueries])
290
629
 
291
630
  // Fetch topology for hierarchy building (only when expanded)
292
- const { data: topology } = useTopology([namespace], 'resources', { enabled: expanded })
631
+ const { data: topology } = useTopology([namespace], 'resources', {
632
+ enabled: expanded,
633
+ })
293
634
 
294
635
  // Always fetched so Recent Events populates on drawer open; allEvents below is
295
636
  // gated on expanded because it's namespace-wide and expensive.
@@ -299,7 +640,7 @@ export function WorkloadView({
299
640
  isLoading: resourceFocusedEventsLoading,
300
641
  k8sError: resourceFocusedK8sError,
301
642
  updatesError: resourceFocusedUpdatesError,
302
- } = useResourceEvents(kindProp, namespace, name)
643
+ } = useResourceEvents(apiKind, namespace, name)
303
644
 
304
645
  // Fetch all events for this resource's namespace (only when expanded)
305
646
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
@@ -313,88 +654,602 @@ export function WorkloadView({
313
654
 
314
655
  // RBAC
315
656
  const canUpdateSecrets = useCanUpdateSecrets()
657
+ const { features } = useCapabilitiesContext()
658
+ const { canPortForward } = useNamespacedCapabilities(namespace)
659
+ const isLocalDeployment = useIsLocalDeployment()
660
+ const showServingPortForward = canPortForward || !isLocalDeployment
661
+ const showServingCurl = !isLocalDeployment
662
+ const [servingCurl, setServingCurl] = useState<{
663
+ namespace: string
664
+ serviceName: string
665
+ port: number
666
+ closing: boolean
667
+ } | null>(null)
668
+ const closeServingCurl = useCallback(() => {
669
+ setServingCurl((p) => (p ? { ...p, closing: true } : null))
670
+ window.setTimeout(() => setServingCurl((p) => (p?.closing ? null : p)), 220)
671
+ }, [])
672
+ const renderServicePortAction = useCallback(
673
+ (props: ServicePortRenderProps) => {
674
+ const active =
675
+ servingCurl?.namespace === props.namespace &&
676
+ servingCurl?.serviceName === props.serviceName &&
677
+ servingCurl?.port === props.port &&
678
+ !servingCurl.closing
679
+ return (
680
+ <>
681
+ {showServingCurl &&
682
+ isHttpishPort(props.port, props.name, props.appProtocol, props.protocol) && (
683
+ <CurlButton
684
+ active={active}
685
+ onClick={() => {
686
+ if (active) closeServingCurl()
687
+ else
688
+ setServingCurl({
689
+ namespace: props.namespace,
690
+ serviceName: props.serviceName,
691
+ port: props.port,
692
+ closing: false,
693
+ })
694
+ }}
695
+ />
696
+ )}
697
+ {showServingPortForward && (
698
+ <PortForwardInlineButton
699
+ namespace={props.namespace}
700
+ serviceName={props.serviceName}
701
+ port={props.port}
702
+ protocol={props.protocol}
703
+ />
704
+ )}
705
+ </>
706
+ )
707
+ },
708
+ [closeServingCurl, servingCurl, showServingCurl, showServingPortForward],
709
+ )
710
+ const renderServicePortPanel = useCallback(
711
+ (props: ServicePortRenderProps) => {
712
+ const active =
713
+ servingCurl?.namespace === props.namespace &&
714
+ servingCurl?.serviceName === props.serviceName &&
715
+ servingCurl?.port === props.port
716
+ return active ? (
717
+ <CurlPanel
718
+ namespace={props.namespace}
719
+ serviceName={props.serviceName}
720
+ port={props.port}
721
+ initialScheme={defaultScheme(props.port, props.name, props.appProtocol)}
722
+ initialPath={defaultPathForPort(props.port, props.name, props.appProtocol)}
723
+ open={!servingCurl.closing}
724
+ onClose={closeServingCurl}
725
+ />
726
+ ) : null
727
+ },
728
+ [closeServingCurl, servingCurl],
729
+ )
316
730
  const updateResource = useUpdateResource()
317
- const actionsBarProps = useActionsBarProps(kindProp, namespace, name)
731
+ const previewResources = usePreviewResources()
732
+ const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
733
+ const desktopDownload = useDesktopDownload()
318
734
 
319
- const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
320
- await updateResource.mutateAsync(params)
321
- }, [updateResource])
735
+ const resourceGroup = useMemo(
736
+ () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
737
+ [resource?.apiVersion],
738
+ )
739
+ // Live Operational Issues for this resource. Fetched here (not inside the lead
740
+ // render-prop) so the count also gates `hasOperationalIssues` — which tells the
741
+ // renderers to suppress their own status-derived problems and avoid duplicates.
742
+ // Keyed on the stable API kind+group (same inputs as the resource fetch above),
743
+ // NOT the manifest-derived ones: deriving kind/group from the loaded resource
744
+ // would flip the query key when the manifest arrives, drop liveIssues, and flash
745
+ // the renderer banners. The backend canonicalizes a plural kind via discovery,
746
+ // so using the normalized API kind resolves direct links and app navigation alike.
747
+ const { data: liveIssues, isPending: issuesPending } = useResourceIssues(apiKind, rest.group, namespace, name)
748
+ const { data: auditFindings } = useResourceAudit(apiKind, namespace, name)
749
+ const hasOperationalIssues = Boolean(liveIssues?.length)
750
+ const {
751
+ onCompareTo,
752
+ onCompareAcrossClusters,
753
+ picker: comparePicker,
754
+ } = useCompareLauncher({
755
+ kind: apiKind,
756
+ namespace,
757
+ name,
758
+ // Prefer the URL-supplied group so Compare works even before the resource
759
+ // fetch completes; fall back to the derived group for callers that don't
760
+ // pass one.
761
+ group: rest.group || resourceGroup || undefined,
762
+ })
763
+ const actionsBarProps = useMemo(
764
+ () => ({ ...baseActionsBarProps, onCompareTo, onCompareAcrossClusters }),
765
+ [baseActionsBarProps, onCompareTo, onCompareAcrossClusters],
766
+ )
767
+
768
+ const handleUpdateResource = useCallback(
769
+ async (params: Parameters<typeof updateResource.mutateAsync>[0]) => {
770
+ await updateResource.mutateAsync(params)
771
+ },
772
+ [updateResource],
773
+ )
774
+ const handlePreviewResource = useCallback(
775
+ async (params: Parameters<typeof previewResources.mutateAsync>[0]) =>
776
+ previewResources.mutateAsync(params),
777
+ [previewResources],
778
+ )
779
+
780
+ const navigateRouter = useNavigate()
781
+ const handleOpenGitOpsResource = useCallback(
782
+ (ref: GitOpsOwnerRef) => {
783
+ const params = new URLSearchParams()
784
+ const namespaces = searchParams.get('namespaces')
785
+ if (namespaces) params.set('namespaces', namespaces)
786
+ navigateRouter({
787
+ pathname: gitOpsRouteForOwner(ref),
788
+ search: params.toString(),
789
+ })
790
+ },
791
+ [navigateRouter, searchParams],
792
+ )
793
+ const handleNavigateGitOpsPath = useCallback(
794
+ (path: string) => navigateRouter(path),
795
+ [navigateRouter],
796
+ )
797
+ const handleOpenHelmRelease = useCallback(
798
+ (ref: HelmOwnerRef) => {
799
+ const params = new URLSearchParams()
800
+ const namespaces = searchParams.get('namespaces')
801
+ if (namespaces) params.set('namespaces', namespaces)
802
+ params.set('release', `${ref.namespace}/${ref.name}`)
803
+ navigateRouter({ pathname: '/helm', search: params.toString() })
804
+ },
805
+ [navigateRouter, searchParams],
806
+ )
807
+ const handleOpenApplication = useCallback(
808
+ (appKey: string) => {
809
+ const params = new URLSearchParams()
810
+ const namespaces = new Set(
811
+ (searchParams.get('namespaces') ?? '')
812
+ .split(',')
813
+ .map((ns) => ns.trim())
814
+ .filter(Boolean),
815
+ )
816
+ if (ownershipContext?.application?.key === appKey && ownershipContext.workload.namespace) {
817
+ namespaces.add(ownershipContext.workload.namespace)
818
+ }
819
+ if (namespaces.size > 0) params.set('namespaces', Array.from(namespaces).join(','))
820
+ params.set('app', appKey)
821
+ navigateRouter({ pathname: '/applications', search: params.toString() })
822
+ },
823
+ [navigateRouter, ownershipContext, searchParams],
824
+ )
322
825
 
323
826
  // Duplicate dialog
324
827
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
325
828
  const [duplicateYaml, setDuplicateYaml] = useState('')
326
829
 
327
- const handleDuplicate = useCallback((params: { kind: string; namespace: string; name: string; yaml: string }) => {
328
- setDuplicateYaml(cleanYamlForDuplicate(params.yaml))
329
- setDuplicateDialogOpen(true)
330
- }, [])
830
+ const handleDuplicate = useCallback(
831
+ (params: { kind: string; namespace: string; name: string; yaml: string }) => {
832
+ setDuplicateYaml(cleanYamlForDuplicate(params.yaml))
833
+ setDuplicateDialogOpen(true)
834
+ },
835
+ [],
836
+ )
837
+
838
+ const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
839
+ const workloadPodsQuery = useWorkloadPods(supportsWorkloadPods ? apiKind : '', namespace, name)
840
+ const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
841
+ const servingQueries = useQueries({
842
+ queries: servingRefs.map((ref) => {
843
+ const pluralKind = kindToPlural(ref.kind)
844
+ const ns = ref.namespace || '_'
845
+ const params = new URLSearchParams()
846
+ if (ref.group) params.set('group', ref.group)
847
+ const queryString = params.toString()
848
+ return {
849
+ queryKey: ['resource', pluralKind, ref.namespace, ref.name, ref.group],
850
+ queryFn: () =>
851
+ fetchJSON<any>(
852
+ `/resources/${pluralKind}/${ns}/${ref.name}${queryString ? `?${queryString}` : ''}`,
853
+ ),
854
+ enabled: expanded && Boolean(ref.kind && ref.name),
855
+ staleTime: 30000,
856
+ }
857
+ }),
858
+ })
859
+ const servingResources = useMemo<ServingResourceDetail[]>(
860
+ () =>
861
+ servingRefs.map((ref, index) => {
862
+ const query = servingQueries[index]
863
+ const data = query?.data?.resource ?? query?.data
864
+ return {
865
+ ref,
866
+ resource: data,
867
+ loading: query?.isLoading ?? false,
868
+ error: (query?.error as Error | null) ?? null,
869
+ }
870
+ }),
871
+ [servingRefs, servingQueries],
872
+ )
331
873
 
332
874
  return (
333
875
  <>
334
- <BaseWorkloadView
335
- kind={kindProp}
336
- namespace={namespace}
337
- name={name}
338
- expanded={expanded}
339
- {...rest}
340
- // Data
341
- resource={resource}
342
- relationships={relationships}
343
- certificateInfo={certificateInfo}
344
- isLoading={resourceLoading}
345
- refetch={refetchResource}
346
- // Timeline
347
- allEvents={allEvents}
348
- eventsLoading={eventsLoading}
349
- topology={topology}
350
- resourceFocusedK8sEvents={resourceFocusedK8sEvents}
351
- resourceFocusedUpdates={resourceFocusedUpdates}
352
- resourceFocusedEventsLoading={resourceFocusedEventsLoading}
353
- resourceFocusedK8sError={resourceFocusedK8sError}
354
- resourceFocusedUpdatesError={resourceFocusedUpdatesError}
355
- // Capabilities
356
- canUpdateSecrets={canUpdateSecrets}
357
- // Mutations
358
- onUpdateResource={handleUpdateResource}
359
- isUpdatingResource={updateResource.isPending}
360
- updateResourceError={updateResource.error?.message ?? null}
361
- // Tab state (URL-synced)
362
- activeTab={migratedTab}
363
- onTabChange={handleTabChange}
364
- // Render props
365
- renderLogsTab={(props) => <LogsTabContent {...props} />}
366
- renderMetricsTab={({ kind, namespace: ns, name: n }) => (
367
- <PrometheusCharts kind={kind} namespace={ns} name={n} showEmptyState />
368
- )}
369
- isMetricsAvailable={(kind, res) =>
370
- isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
371
- }
372
- onDuplicate={handleDuplicate}
373
- actionsBarProps={actionsBarProps}
374
- rendererOverrides={rendererOverrides}
375
- resolvedEnvFrom={resolvedEnvFrom}
376
- renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
377
- <AuditSection kind={k} namespace={ns} name={n} />
378
- )}
379
- />
380
- <CreateResourceDialog
381
- open={duplicateDialogOpen}
382
- onClose={() => setDuplicateDialogOpen(false)}
383
- initialYaml={duplicateYaml}
384
- title="Duplicate Resource"
385
- onCreated={(result) => {
386
- rest.onNavigateToResource?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
387
- }}
388
- />
876
+ <BaseWorkloadView
877
+ kind={apiKind}
878
+ namespace={namespace}
879
+ name={name}
880
+ expanded={expanded}
881
+ {...rest}
882
+ // Data
883
+ resource={resource}
884
+ relationships={relationships}
885
+ ownershipContext={ownershipContext}
886
+ onOpenApplication={handleOpenApplication}
887
+ certificateInfo={certificateInfo}
888
+ hpaDiagnosis={hpaDiagnosis}
889
+ workloadPods={supportsWorkloadPods ? workloadPodsQuery.data?.pods : undefined}
890
+ workloadPodsLoading={supportsWorkloadPods ? workloadPodsQuery.isLoading : false}
891
+ workloadPodsError={supportsWorkloadPods ? (workloadPodsQuery.error as Error | null) : null}
892
+ servingResources={servingResources}
893
+ renderServicePortAction={renderServicePortAction}
894
+ renderServicePortPanel={renderServicePortPanel}
895
+ isLoading={resourceLoading}
896
+ resourceError={resourceError}
897
+ refetch={refetchResourceAndRuns}
898
+ // Timeline
899
+ allEvents={allEvents}
900
+ relatedTimelineEvents={relatedTimelineEvents}
901
+ eventsLoading={eventsLoading || (batchExecution && batchRunsQuery.isLoading)}
902
+ topology={topology}
903
+ resourceFocusedK8sEvents={resourceFocusedK8sEvents}
904
+ resourceFocusedUpdates={resourceFocusedUpdates}
905
+ resourceFocusedEventsLoading={resourceFocusedEventsLoading}
906
+ resourceFocusedK8sError={resourceFocusedK8sError}
907
+ resourceFocusedUpdatesError={resourceFocusedUpdatesError}
908
+ // Capabilities
909
+ canUpdateSecrets={canUpdateSecrets}
910
+ // Mutations
911
+ onUpdateResource={handleUpdateResource}
912
+ isUpdatingResource={updateResource.isPending}
913
+ updateResourceError={updateResource.error?.message ?? null}
914
+ onPreviewResource={features?.yamlReview ? handlePreviewResource : undefined}
915
+ isPreviewingResource={previewResources.isPending}
916
+ previewResourceError={previewResources.error?.message ?? null}
917
+ yamlSchemaLoader={features?.yamlSchemas ? fetchYamlSchemas : undefined}
918
+ // Tab state (URL-synced)
919
+ activeTab={migratedTab}
920
+ onTabChange={handleTabChange}
921
+ // Render props
922
+ renderLogsTab={(props) => (
923
+ <LogsTabContent
924
+ {...props}
925
+ selectedRunKey={selectedRunKey}
926
+ onSelectRun={handleSelectedRunChange}
927
+ />
928
+ )}
929
+ renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
930
+ BATCH_EXECUTION_KINDS.has(k) && res ? (
931
+ <BatchExecutionFullscreen
932
+ kind={k}
933
+ apiKind={apiKind}
934
+ namespace={ns}
935
+ name={n}
936
+ resource={res}
937
+ selectedRunKey={selectedRunKey}
938
+ canViewLogs={baseActionsBarProps.canViewLogs}
939
+ onSelectRun={handleSelectedRunChange}
940
+ onSwitchToLogs={() => handleTabChange('logs')}
941
+ onSwitchToTimeline={() => handleTabChange('timeline')}
942
+ onNavigateToResource={rest.onNavigateToResource}
943
+ />
944
+ ) : null
945
+ }
946
+ renderRelatedYaml={(ref) => (
947
+ <RelatedResourceYaml key={`${ref.kind}/${ref.namespace}/${ref.name}`} target={ref} />
948
+ )}
949
+ renderMetricsTab={({ kind, namespace: ns, name: n }) => (
950
+ <MetricsTabContent
951
+ kind={kind}
952
+ namespace={ns}
953
+ name={n}
954
+ resource={resource}
955
+ expanded={expanded}
956
+ />
957
+ )}
958
+ renderCostTab={({ kind, namespace: ns, name: n }) => (
959
+ <div className="space-y-4">
960
+ <RightsizingPanel kind={kind} namespace={ns} name={n} />
961
+ <WorkloadCostTab kind={kind} namespace={ns} name={n} />
962
+ </div>
963
+ )}
964
+ isMetricsAvailable={(kind, res) =>
965
+ isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
966
+ }
967
+ isCostAvailable={(kind) => isOpenCostWorkloadKind(kind)}
968
+ onDuplicate={handleDuplicate}
969
+ onDownload={desktopDownload}
970
+ actionsBarProps={actionsBarProps}
971
+ rendererOverrides={rendererOverrides}
972
+ resolvedEnvFrom={resolvedEnvFrom}
973
+ renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
974
+ <>
975
+ <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
976
+ <AuditOverviewSection
977
+ findings={auditFindings ?? []}
978
+ onViewAll={() => navigateRouter('/checks')}
979
+ />
980
+ </>
981
+ )}
982
+ renderOverviewLead={() => (
983
+ <ResourceIssuesSection
984
+ issues={liveIssues}
985
+ subjectResource={{ kind: apiKind, namespace, name, group: rest.group }}
986
+ onResourceClick={
987
+ rest.onNavigateToResource
988
+ ? (ref) =>
989
+ rest.onNavigateToResource?.({
990
+ kind: kindToPlural(ref.kind),
991
+ namespace: ref.namespace ?? '',
992
+ name: ref.name,
993
+ group: ref.group ?? '',
994
+ })
995
+ : undefined
996
+ }
997
+ />
998
+ )}
999
+ hasOperationalIssues={hasOperationalIssues}
1000
+ operationalIssuesPending={issuesPending}
1001
+ onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
1002
+ resolvedGitOpsOwner={gitopsOwner}
1003
+ gitOpsOwnerVerified={gitOpsOwnerVerified}
1004
+ gitOpsOwnerPending={gitOpsOwnerPending}
1005
+ gitOpsOwnerSource={gitOpsOwnerSource}
1006
+ gitOpsOwnerStatus={gitOpsOwnerStatus}
1007
+ helmOwner={helmOwner}
1008
+ helmOwnerSource={helmOwnerSource}
1009
+ onOpenHelmRelease={handleOpenHelmRelease}
1010
+ onNavigateGitOpsPath={handleNavigateGitOpsPath}
1011
+ />
1012
+ <CreateResourceDialog
1013
+ open={duplicateDialogOpen}
1014
+ onClose={() => setDuplicateDialogOpen(false)}
1015
+ initialYaml={duplicateYaml}
1016
+ title="Duplicate Resource"
1017
+ onCreated={(result) => {
1018
+ rest.onNavigateToResource?.({
1019
+ kind: kindToPlural(result.kind),
1020
+ namespace: result.namespace,
1021
+ name: result.name,
1022
+ group: '',
1023
+ })
1024
+ }}
1025
+ />
1026
+ {comparePicker}
389
1027
  </>
390
1028
  )
391
1029
  }
392
1030
 
1031
+ function collectServingRefs(relationships: Relationships | undefined): ResourceRef[] {
1032
+ if (!relationships) return []
1033
+ return dedupeRefs([
1034
+ ...(relationships.services ?? []),
1035
+ ...(relationships.ingresses ?? []),
1036
+ ...(relationships.gateways ?? []),
1037
+ ...(relationships.routes ?? []),
1038
+ ])
1039
+ }
1040
+
1041
+ function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
1042
+ const seen = new Set<string>()
1043
+ return refs.filter((ref) => {
1044
+ const key = `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
1045
+ if (seen.has(key)) return false
1046
+ seen.add(key)
1047
+ return true
1048
+ })
1049
+ }
1050
+
1051
+ function resolveGitOpsOwner(
1052
+ owner: GitOpsOwnerRef | null,
1053
+ argoApplications: any[] | undefined,
1054
+ ): GitOpsOwnerRef | null {
1055
+ if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
1056
+ const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
1057
+ if (matches.length !== 1) return owner
1058
+ const namespace = matches[0]?.metadata?.namespace
1059
+ return namespace ? { ...owner, namespace } : owner
1060
+ }
1061
+
1062
+ function findInheritedGitOpsLookupRef(
1063
+ relationships: Relationships | undefined,
1064
+ directOwner: GitOpsOwnerRef | null,
1065
+ current: ResourceRef,
1066
+ ): ResourceRef | null {
1067
+ if (directOwner) return null
1068
+ const inheritedManagerRefs = (relationships?.managedBy ?? []).filter(
1069
+ (ref) => !gitOpsOwnerFromRelationships({ managedBy: [ref] }) && !isNativeHelmManager(ref),
1070
+ )
1071
+ const candidates = [
1072
+ relationships?.deployment,
1073
+ ...inheritedManagerRefs,
1074
+ relationships?.owner,
1075
+ ].filter(Boolean) as ResourceRef[]
1076
+
1077
+ return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
1078
+ }
1079
+
1080
+ const POD_OWNERSHIP_WORKLOAD_KINDS = new Set([
1081
+ 'deployments',
1082
+ 'statefulsets',
1083
+ 'daemonsets',
1084
+ 'jobs',
1085
+ 'cronjobs',
1086
+ 'rollouts',
1087
+ ])
1088
+
1089
+ function podWorkloadOwnerFromRelationships(
1090
+ kind: string,
1091
+ namespace: string,
1092
+ relationships: Relationships | undefined,
1093
+ resource: any,
1094
+ ): ResourceRef | null {
1095
+ if (kindToPlural(kind).toLowerCase() !== 'pods') return null
1096
+
1097
+ if (relationships?.deployment) return relationships.deployment
1098
+
1099
+ const managedWorkload = relationships?.managedBy?.find((ref) => isPodOwnershipWorkloadRef(ref))
1100
+ if (managedWorkload) return managedWorkload
1101
+
1102
+ if (relationships?.owner && isPodOwnershipWorkloadRef(relationships.owner))
1103
+ return relationships.owner
1104
+
1105
+ return podControllerOwnerFromMetadata(namespace, resource)
1106
+ }
1107
+
1108
+ function isPodOwnershipWorkloadRef(ref: ResourceRef): boolean {
1109
+ return POD_OWNERSHIP_WORKLOAD_KINDS.has(kindToPlural(ref.kind).toLowerCase())
1110
+ }
1111
+
1112
+ function podControllerOwnerFromMetadata(namespace: string, resource: any): ResourceRef | null {
1113
+ const ownerRefs = resource?.metadata?.ownerReferences
1114
+ if (!Array.isArray(ownerRefs)) return null
1115
+ const owner = ownerRefs.find((ref) => ref?.controller === true) ?? null
1116
+ if (!owner?.kind || !owner?.name) return null
1117
+ if (
1118
+ !isPodOwnershipWorkloadRef({
1119
+ kind: owner.kind,
1120
+ namespace,
1121
+ name: owner.name,
1122
+ })
1123
+ )
1124
+ return null
1125
+ return {
1126
+ kind: owner.kind,
1127
+ namespace,
1128
+ name: owner.name,
1129
+ group: apiVersionToGroup(owner.apiVersion),
1130
+ }
1131
+ }
1132
+
1133
+ function buildPodOwnershipContext(
1134
+ workload: ResourceRef | null,
1135
+ apps: AppRow[] | undefined,
1136
+ ): ResourceOwnershipContext | undefined {
1137
+ if (!workload) return undefined
1138
+ const matches = (apps ?? []).filter((app) =>
1139
+ (app.workloads ?? []).some((candidate) => sameWorkload(candidate, workload)),
1140
+ )
1141
+ const app = matches.length === 1 ? matches[0] : null
1142
+ return {
1143
+ workload,
1144
+ application: app ? { key: app.key, name: app.name } : undefined,
1145
+ }
1146
+ }
1147
+
1148
+ function sameWorkload(
1149
+ candidate: { kind: string; namespace: string; name: string },
1150
+ workload: ResourceRef,
1151
+ ): boolean {
1152
+ return (
1153
+ kindToPlural(candidate.kind).toLowerCase() === kindToPlural(workload.kind).toLowerCase() &&
1154
+ candidate.namespace === workload.namespace &&
1155
+ candidate.name === workload.name
1156
+ )
1157
+ }
1158
+
1159
+ function nativeHelmOwnerFromRelationships(
1160
+ relationships: Relationships | undefined,
1161
+ fallbackNamespace: string,
1162
+ ): HelmOwnerRef | null {
1163
+ const ref = relationships?.managedBy?.[0]
1164
+ if (!ref || !isNativeHelmManager(ref)) return null
1165
+ return {
1166
+ namespace: ref.namespace || fallbackNamespace,
1167
+ name: ref.name,
1168
+ }
1169
+ }
1170
+
1171
+ function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
1172
+ return (
1173
+ kindToPlural(ref.kind) === kindToPlural(current.kind) &&
1174
+ ref.namespace === current.namespace &&
1175
+ ref.name === current.name &&
1176
+ (ref.group ?? '') === (current.group ?? '')
1177
+ )
1178
+ }
1179
+
1180
+ function isNativeHelmManager(ref: ResourceRef): boolean {
1181
+ return ref.kind === 'HelmRelease' && ref.group !== 'helm.toolkit.fluxcd.io'
1182
+ }
1183
+
1184
+ function describeGitOpsOwnerSource(owner: GitOpsOwnerRef | null, resource: any): string | null {
1185
+ if (!owner || !resource) return null
1186
+ const labels = resource.metadata?.labels ?? {}
1187
+ const annotations = resource.metadata?.annotations ?? {}
1188
+
1189
+ if (owner.tool === 'fluxcd') {
1190
+ const nameKey =
1191
+ owner.kind === 'helmreleases'
1192
+ ? 'helm.toolkit.fluxcd.io/name'
1193
+ : 'kustomize.toolkit.fluxcd.io/name'
1194
+ const nsKey =
1195
+ owner.kind === 'helmreleases'
1196
+ ? 'helm.toolkit.fluxcd.io/namespace'
1197
+ : 'kustomize.toolkit.fluxcd.io/namespace'
1198
+ if (labels[nameKey] || labels[nsKey]) {
1199
+ return `${nameKey}=${labels[nameKey] ?? ''}, ${nsKey}=${labels[nsKey] ?? ''}`
1200
+ }
1201
+ }
1202
+
1203
+ const trackingID = annotations['argocd.argoproj.io/tracking-id']
1204
+ if (trackingID) return `argocd.argoproj.io/tracking-id=${trackingID}`
1205
+ const argoInstance = labels['argocd.argoproj.io/instance']
1206
+ if (argoInstance) return `argocd.argoproj.io/instance=${argoInstance}`
1207
+ return null
1208
+ }
1209
+
1210
+ function describeHelmOwnerSource(owner: HelmOwnerRef | null, resource: any): string | null {
1211
+ if (!owner || !resource) return null
1212
+ const annotations = resource.metadata?.annotations ?? {}
1213
+ const releaseName = annotations['meta.helm.sh/release-name']
1214
+ const releaseNamespace = annotations['meta.helm.sh/release-namespace']
1215
+ if (releaseName || releaseNamespace) {
1216
+ return `meta.helm.sh/release-name=${releaseName ?? ''}, meta.helm.sh/release-namespace=${releaseNamespace ?? ''}`
1217
+ }
1218
+ return null
1219
+ }
1220
+
1221
+ function gitOpsOwnerGroup(owner: GitOpsOwnerRef): string {
1222
+ if (owner.tool === 'argocd') return 'argoproj.io'
1223
+ if (owner.kind === 'kustomizations') return 'kustomize.toolkit.fluxcd.io'
1224
+ return 'helm.toolkit.fluxcd.io'
1225
+ }
1226
+
1227
+ function deriveGitOpsOwnerStatus(owner: GitOpsOwnerRef | null, resource: any): GitOpsStatus | null {
1228
+ if (!owner || !resource || !hasGitOpsStatusPayload(owner, resource)) return null
1229
+ return getGitOpsResourceStatus(owner.kind, resource)
1230
+ }
1231
+
1232
+ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
1233
+ if (owner.kind === 'applications') {
1234
+ const status = resource.status ?? {}
1235
+ return Boolean(status.sync?.status || status.health?.status || status.operationState?.phase)
1236
+ }
1237
+ if (resource.spec?.suspend === true) return true
1238
+ return Array.isArray(resource.status?.conditions) && resource.status.conditions.length > 0
1239
+ }
1240
+
393
1241
  // ============================================================================
394
1242
  // LOGS TAB — platform-specific (uses data-fetching hooks)
395
1243
  // ============================================================================
396
1244
 
397
- const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
1245
+ const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'Workflow'])
1246
+ const SCHEDULED_LOG_KINDS = new Set([
1247
+ 'CronJob',
1248
+ 'CronWorkflow',
1249
+ 'WorkflowTemplate',
1250
+ 'ClusterWorkflowTemplate',
1251
+ 'ScaledJob',
1252
+ ])
398
1253
 
399
1254
  function LogsTabContent({
400
1255
  kind,
@@ -407,6 +1262,8 @@ function LogsTabContent({
407
1262
  onSelectPod,
408
1263
  initialContainer,
409
1264
  onConsumeInitialContainer,
1265
+ selectedRunKey,
1266
+ onSelectRun,
410
1267
  }: {
411
1268
  kind: string
412
1269
  apiKind: string
@@ -418,19 +1275,48 @@ function LogsTabContent({
418
1275
  onSelectPod: (name: string | null) => void
419
1276
  initialContainer: string | null
420
1277
  onConsumeInitialContainer: () => void
1278
+ selectedRunKey: string
1279
+ onSelectRun: (runKey: string) => void
421
1280
  }) {
422
- // Workload kinds (Deployment, StatefulSet, DaemonSet) use the aggregated workload logs viewer
1281
+ if (SCHEDULED_LOG_KINDS.has(kind)) {
1282
+ return (
1283
+ <div className="h-full">
1284
+ <ScheduledWorkloadLogsViewer
1285
+ kind={apiKind}
1286
+ namespace={namespace}
1287
+ name={name}
1288
+ selectedRunKey={selectedRunKey}
1289
+ onSelectRun={onSelectRun}
1290
+ />
1291
+ </div>
1292
+ )
1293
+ }
1294
+
1295
+ // Workload kinds with stable pod selectors use the aggregated workload logs viewer
423
1296
  if (WORKLOAD_LOG_KINDS.has(kind)) {
424
1297
  return (
425
1298
  <div className="h-full">
426
- <WorkloadLogsViewer kind={apiKind} namespace={namespace} name={name} />
1299
+ <WorkloadLogsViewer
1300
+ kind={apiKind}
1301
+ namespace={namespace}
1302
+ name={name}
1303
+ autoStream={shouldAutoStreamWorkloadLogs(kind, resource)}
1304
+ />
427
1305
  </div>
428
1306
  )
429
1307
  }
430
1308
 
431
1309
  // Individual Pod — use LogsViewer with container list from resource data
432
1310
  if (kind === 'Pod') {
433
- return <PodLogsTab namespace={namespace} name={name} resource={resource} initialContainer={initialContainer} onConsumeInitialContainer={onConsumeInitialContainer} />
1311
+ return (
1312
+ <PodLogsTab
1313
+ namespace={namespace}
1314
+ name={name}
1315
+ resource={resource}
1316
+ initialContainer={initialContainer}
1317
+ onConsumeInitialContainer={onConsumeInitialContainer}
1318
+ />
1319
+ )
434
1320
  }
435
1321
 
436
1322
  // Other kinds with associated pods (Jobs, CronJobs, ReplicaSets, etc.) — pod selector + LogsViewer
@@ -445,7 +1331,24 @@ function LogsTabContent({
445
1331
  )
446
1332
  }
447
1333
 
448
- function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInitialContainer }: {
1334
+ function shouldAutoStreamWorkloadLogs(kind: string, resource: any): boolean {
1335
+ if (kind === 'Job') {
1336
+ return (resource?.status?.active ?? 0) > 0
1337
+ }
1338
+ if (kind === 'Workflow') {
1339
+ const phase = resource?.status?.phase
1340
+ return phase === 'Running' || phase === 'Pending'
1341
+ }
1342
+ return true
1343
+ }
1344
+
1345
+ function PodLogsTab({
1346
+ namespace,
1347
+ name,
1348
+ resource,
1349
+ initialContainer,
1350
+ onConsumeInitialContainer,
1351
+ }: {
449
1352
  namespace: string
450
1353
  name: string
451
1354
  resource: any
@@ -459,6 +1362,12 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
459
1362
  return names
460
1363
  }, [resource])
461
1364
 
1365
+ // A terminated pod has nothing to follow — only stream live ones. Wait for
1366
+ // the phase to be known so a completed pod isn't briefly streamed while the
1367
+ // resource is still loading.
1368
+ const phase = resource?.status?.phase
1369
+ const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
1370
+
462
1371
  useEffect(() => {
463
1372
  if (initialContainer && containers.includes(initialContainer)) {
464
1373
  onConsumeInitialContainer?.()
@@ -472,12 +1381,19 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
472
1381
  podName={name}
473
1382
  containers={containers}
474
1383
  initialContainer={initialContainer || undefined}
1384
+ autoStream={autoStream}
475
1385
  />
476
1386
  </div>
477
1387
  )
478
1388
  }
479
1389
 
480
- function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialContainer }: {
1390
+ function MultiPodLogsTab({
1391
+ pods,
1392
+ namespace,
1393
+ selectedPod,
1394
+ onSelectPod,
1395
+ initialContainer,
1396
+ }: {
481
1397
  pods: ResourceRef[]
482
1398
  namespace: string
483
1399
  selectedPod: string | null
@@ -490,12 +1406,21 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
490
1406
  }
491
1407
  }, [pods, selectedPod, onSelectPod])
492
1408
 
493
- const podNamespace = pods.find(p => p.name === selectedPod)?.namespace || namespace
1409
+ const podNamespace = pods.find((p) => p.name === selectedPod)?.namespace || namespace
494
1410
 
495
1411
  // Fetch container list for the selected pod
496
- const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', { tailLines: 1 })
1412
+ const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', {
1413
+ tailLines: 1,
1414
+ })
497
1415
  const containers = logsData?.containers || []
498
1416
 
1417
+ // A terminated pod (common for Job/CronJob children) has nothing to follow —
1418
+ // only stream live ones. Wait for the pod to load before deciding so we don't
1419
+ // briefly auto-stream a completed pod while its phase is still unknown.
1420
+ const { data: selectedPodResource } = useResource<any>('Pod', podNamespace, selectedPod || '')
1421
+ const phase = selectedPodResource?.status?.phase
1422
+ const autoStream = !!phase && phase !== 'Succeeded' && phase !== 'Failed'
1423
+
499
1424
  if (pods.length === 0) {
500
1425
  return (
501
1426
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
@@ -509,7 +1434,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
509
1434
  <div className="h-full flex flex-col">
510
1435
  {pods.length > 1 && (
511
1436
  <div className="shrink-0 border-b border-theme-border bg-theme-surface/50 px-4 py-2 flex gap-2 overflow-x-auto">
512
- {pods.map(pod => (
1437
+ {pods.map((pod) => (
513
1438
  <button
514
1439
  key={pod.name}
515
1440
  onClick={() => onSelectPod(pod.name)}
@@ -517,7 +1442,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
517
1442
  'px-3 py-1.5 text-sm rounded-lg whitespace-nowrap transition-colors',
518
1443
  selectedPod === pod.name
519
1444
  ? 'bg-blue-500 text-theme-text-primary'
520
- : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
1445
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover',
521
1446
  )}
522
1447
  >
523
1448
  {pod.name.length > 40 ? '...' + pod.name.slice(-37) : pod.name}
@@ -533,6 +1458,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
533
1458
  podName={selectedPod}
534
1459
  containers={containers}
535
1460
  initialContainer={initialContainer || undefined}
1461
+ autoStream={autoStream}
536
1462
  />
537
1463
  </div>
538
1464
  )}
@@ -540,9 +1466,267 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
540
1466
  )
541
1467
  }
542
1468
 
543
- function AuditSection({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
1469
+ function AuditOverviewSection({
1470
+ findings,
1471
+ onViewAll,
1472
+ }: {
1473
+ findings: AuditFinding[]
1474
+ onViewAll: () => void
1475
+ }) {
1476
+ if (findings.length === 0) return null
1477
+ return <AuditAlerts findings={findings} onViewAll={onViewAll} />
1478
+ }
1479
+
1480
+ // FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
1481
+ // that reference this Flux source CR — the inverse of `spec.sourceRef`. Renders
1482
+ // only when the focused resource is a Flux source kind; otherwise null. Sources
1483
+ // can have many consumers (one repo feeding multiple apps), so this answers
1484
+ // "if I edit this source, what gets affected on the next reconcile?".
1485
+ //
1486
+ // Filtering happens client-side off the namespaced reconciler lists — these
1487
+ // are typically small (tens, not thousands) and the dynamic informer cache
1488
+ // makes the request cheap. If a cluster ever has thousands of HelmReleases,
1489
+ // a dedicated /api/gitops/consumers endpoint would be the right move; today
1490
+ // it'd be premature.
1491
+ // Outer component is cheap — it does only the kind check and decides whether
1492
+ // to mount the data-fetching child. Without this split, useResources would
1493
+ // fire two API calls on EVERY workload drawer open (Pod, Deployment, Service,
1494
+ // …), since the hook has no `enabled` flag and can't be conditionally called
1495
+ // (Rules of Hooks). The hooks only need to run when the focused resource is
1496
+ // actually a Flux source CR.
1497
+ function FluxSourceConsumersSection({
1498
+ kind,
1499
+ namespace,
1500
+ name,
1501
+ }: {
1502
+ kind: string
1503
+ namespace: string
1504
+ name: string
1505
+ }) {
1506
+ // The inner WorkloadView de-pluralizes the URL's plural form, which gives
1507
+ // "Gitrepository" (single-uppercase) rather than the wire-correct
1508
+ // "GitRepository" — so we match lowercase. spec.sourceRef.kind on consumers
1509
+ // is always wire-correct, so we look that up separately.
1510
+ const sourceKind = FLUX_SOURCE_KIND_BY_LOWER.get(kind.toLowerCase()) ?? null
1511
+ if (!sourceKind) return null
1512
+ return <FluxSourceConsumersInner sourceKind={sourceKind} namespace={namespace} name={name} />
1513
+ }
1514
+
1515
+ function FluxSourceConsumersInner({
1516
+ sourceKind,
1517
+ namespace,
1518
+ name,
1519
+ }: {
1520
+ sourceKind: string
1521
+ namespace: string
1522
+ name: string
1523
+ }) {
544
1524
  const navigate = useNavigate()
545
- const { data: findings } = useResourceAudit(kind, namespace, name)
546
- if (!findings || findings.length === 0) return null
547
- return <AuditAlerts findings={findings} onViewAll={() => navigate('/audit')} />
1525
+ const { data: kustomizations } = useResources<any>(
1526
+ 'kustomizations',
1527
+ undefined,
1528
+ 'kustomize.toolkit.fluxcd.io',
1529
+ )
1530
+ const { data: helmReleases } = useResources<any>(
1531
+ 'helmreleases',
1532
+ undefined,
1533
+ 'helm.toolkit.fluxcd.io',
1534
+ )
1535
+
1536
+ const consumers: Array<{
1537
+ kind: 'Kustomization' | 'HelmRelease'
1538
+ namespace: string
1539
+ name: string
1540
+ plural: string
1541
+ }> = []
1542
+ for (const k of kustomizations ?? []) {
1543
+ const ref = k?.spec?.sourceRef ?? {}
1544
+ const refNs = ref.namespace || k?.metadata?.namespace
1545
+ if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
1546
+ consumers.push({
1547
+ kind: 'Kustomization',
1548
+ namespace: k.metadata.namespace,
1549
+ name: k.metadata.name,
1550
+ plural: 'kustomizations',
1551
+ })
1552
+ }
1553
+ }
1554
+ for (const h of helmReleases ?? []) {
1555
+ const ref = h?.spec?.chart?.spec?.sourceRef ?? {}
1556
+ const refNs = ref.namespace || h?.metadata?.namespace
1557
+ if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
1558
+ consumers.push({
1559
+ kind: 'HelmRelease',
1560
+ namespace: h.metadata.namespace,
1561
+ name: h.metadata.name,
1562
+ plural: 'helmreleases',
1563
+ })
1564
+ }
1565
+ }
1566
+
1567
+ if (consumers.length === 0) {
1568
+ return (
1569
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1570
+ <h3 className="mb-2 text-sm font-semibold text-theme-text-primary">Consumed by</h3>
1571
+ <p className="text-xs text-theme-text-tertiary">
1572
+ No Kustomization or HelmRelease references this source.
1573
+ </p>
1574
+ </section>
1575
+ )
1576
+ }
1577
+
1578
+ return (
1579
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1580
+ <h3 className="mb-3 text-sm font-semibold text-theme-text-primary">
1581
+ Consumed by ({consumers.length})
1582
+ </h3>
1583
+ <div className="flex flex-wrap gap-1.5">
1584
+ {consumers.map((c) => (
1585
+ <Tooltip
1586
+ key={`${c.kind}/${c.namespace}/${c.name}`}
1587
+ content={`${c.kind} ${c.namespace}/${c.name}`}
1588
+ >
1589
+ <button
1590
+ onClick={() =>
1591
+ navigate(
1592
+ `/gitops/detail/${c.plural}/${encodeURIComponent(c.namespace)}/${encodeURIComponent(c.name)}`,
1593
+ )
1594
+ }
1595
+ className="inline-flex items-center gap-1.5 rounded border border-theme-border bg-theme-surface px-1.5 py-0.5 text-[11px] text-theme-text-secondary hover:border-skyhook-500/60 hover:text-skyhook-500 transition-colors"
1596
+ >
1597
+ <span className="text-theme-text-tertiary">
1598
+ {c.kind === 'HelmRelease' ? 'HR' : 'K'}
1599
+ </span>
1600
+ <span>
1601
+ {c.namespace}/{c.name}
1602
+ </span>
1603
+ </button>
1604
+ </Tooltip>
1605
+ ))}
1606
+ </div>
1607
+ </section>
1608
+ )
1609
+ }
1610
+
1611
+ // Drawer mode: single chart + category tabs (compact for ~500px width).
1612
+ // Full-screen mode: multi-chart grid so CPU + Memory + Network can be
1613
+ // compared side-by-side without tab switching.
1614
+ function MetricsTabContent({
1615
+ kind,
1616
+ namespace,
1617
+ name,
1618
+ resource,
1619
+ expanded,
1620
+ }: {
1621
+ kind: string
1622
+ namespace: string
1623
+ name: string
1624
+ resource: any
1625
+ expanded: boolean
1626
+ }) {
1627
+ const showRightsizing = expanded && ['Deployment', 'StatefulSet', 'DaemonSet'].includes(kind)
1628
+
1629
+ if (expanded) {
1630
+ return (
1631
+ <div className="flex flex-col h-full">
1632
+ {showRightsizing && (
1633
+ <div className="px-4 pt-4">
1634
+ <RightsizingStrip kind={kind} namespace={namespace} name={name} />
1635
+ </div>
1636
+ )}
1637
+ <div className="flex-1 min-h-0">
1638
+ <PrometheusChartsGrid kind={kind} namespace={namespace} name={name} resource={resource} />
1639
+ </div>
1640
+ </div>
1641
+ )
1642
+ }
1643
+
1644
+ // Drawer fallback: single chart with tabs + restart lane below. The chart's
1645
+ // time-range selector is mirrored to the restart lane so they stay aligned.
1646
+ return <DrawerMetricsContent kind={kind} namespace={namespace} name={name} resource={resource} />
1647
+ }
1648
+
1649
+ function DrawerMetricsContent({
1650
+ kind,
1651
+ namespace,
1652
+ name,
1653
+ resource,
1654
+ }: {
1655
+ kind: string
1656
+ namespace: string
1657
+ name: string
1658
+ resource: any
1659
+ }) {
1660
+ const [chartRange, setChartRange] = useState<import('../../api/client').PrometheusTimeRange>('1h')
1661
+ const showRestartLane = isPrometheusSupported(kind) && kind !== 'Node'
1662
+
1663
+ return (
1664
+ <div className="flex flex-col h-full">
1665
+ <div className="flex-1 min-h-0">
1666
+ <PrometheusCharts
1667
+ kind={kind}
1668
+ namespace={namespace}
1669
+ name={name}
1670
+ showEmptyState
1671
+ resource={resource}
1672
+ onTimeRangeChange={setChartRange}
1673
+ />
1674
+ </div>
1675
+ {showRestartLane && (
1676
+ <div className="px-4 pb-4">
1677
+ <RestartEventLane kind={kind} namespace={namespace} name={name} range={chartRange} />
1678
+ </div>
1679
+ )}
1680
+ </div>
1681
+ )
1682
+ }
1683
+
1684
+ // FLUX_SOURCE_KIND_BY_LOWER maps lowercase kind (what the inner WorkloadView
1685
+ // produces via its plural-to-singular fallback) to the wire-correct
1686
+ // PascalCase form that consumers carry in spec.sourceRef.kind. HelmChart is
1687
+ // intentionally absent — it's an auto-generated internal CR, not something
1688
+ // users create or point reconcilers at directly.
1689
+ const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
1690
+ ['gitrepository', 'GitRepository'],
1691
+ ['helmrepository', 'HelmRepository'],
1692
+ ['ocirepository', 'OCIRepository'],
1693
+ ['bucket', 'Bucket'],
1694
+ ])
1695
+
1696
+ // Read-only manifest view for an object in the workload's neighborhood (the
1697
+ // YAML tab's object rail). Read-only by design — editing an arbitrary related
1698
+ // object belongs on that resource's own page.
1699
+ function RelatedResourceYaml({
1700
+ target,
1701
+ }: {
1702
+ target: { kind: string; namespace: string; name: string; group?: string }
1703
+ }) {
1704
+ const { data, isLoading, error } = useResource<any>(
1705
+ kindToPlural(target.kind),
1706
+ target.namespace,
1707
+ target.name,
1708
+ target.group,
1709
+ )
1710
+ const [copied, setCopied] = useState(false)
1711
+ const handleCopy = useCallback((text: string) => {
1712
+ navigator.clipboard.writeText(text)
1713
+ setCopied(true)
1714
+ setTimeout(() => setCopied(false), 1500)
1715
+ }, [])
1716
+ if (!data)
1717
+ return <FetchResult loading={isLoading} error={error as Error | null} className="h-32" />
1718
+ return (
1719
+ <EditableYamlView
1720
+ resource={{
1721
+ kind: kindToPlural(target.kind),
1722
+ namespace: target.namespace,
1723
+ name: target.name,
1724
+ group: target.group,
1725
+ }}
1726
+ data={data}
1727
+ onCopy={handleCopy}
1728
+ copied={copied}
1729
+ readOnly
1730
+ />
1731
+ )
548
1732
  }