@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
@@ -1,5 +1,5 @@
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'
@@ -12,36 +12,83 @@ import {
12
12
  type GitOpsOwnerRef,
13
13
  type GitOpsStatus,
14
14
  type HelmOwnerRef,
15
+ type AppRow,
16
+ type ResourceOwnershipContext,
17
+ type ServingResourceDetail,
18
+ type AuditFinding,
15
19
  gitOpsRouteForOwner,
16
20
  gitOpsOwnerFromRelationships,
17
21
  getGitOpsResourceStatus,
18
22
  resolvedEnvFromKey,
19
23
  } from '@skyhook-io/k8s-ui'
24
+ import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
20
25
  import type { SelectedResource, ResourceRef, Relationships, ResolvedEnvFrom } from '../../types'
21
- import { kindToPlural, buildWorkloadPath, type NavigateToResource } from '../../utils/navigation'
22
26
  import {
23
- useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
24
- useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
25
- useRestartWorkload, useWorkloadRevisions, useRollbackWorkload,
26
- useFluxReconcile, useFluxSyncWithSource, useFluxSuspend, useFluxResume,
27
- useArgoSync, useArgoRefresh, useArgoSuspend, useArgoResume,
28
- 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
+ useDeleteResource,
39
+ useTriggerCronJob,
40
+ useSuspendCronJob,
41
+ useResumeCronJob,
42
+ useRestartWorkload,
43
+ useWorkloadRevisions,
44
+ useRollbackWorkload,
45
+ useWorkloadPods,
46
+ useFluxReconcile,
47
+ useFluxSyncWithSource,
48
+ useFluxSuspend,
49
+ useFluxResume,
50
+ useArgoSync,
51
+ useArgoRefresh,
52
+ useArgoSuspend,
53
+ useArgoResume,
54
+ useCordonNode,
55
+ useUncordonNode,
56
+ useDrainNode,
29
57
  useCascadeDeletePreview,
30
58
  useResourceEvents,
31
59
  useResource,
60
+ useWorkloadRuns,
61
+ useApplications,
32
62
  fetchJSON,
33
63
  } from '../../api/client'
34
64
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
35
65
  import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
36
66
  import { RestartEventLane } from '../resource/RestartChart'
37
- import { RightsizingStrip } from '../resource/RightsizingStrip'
67
+ import { RightsizingPanel, RightsizingStrip } from '../resource/RightsizingStrip'
68
+ import { WorkloadCostTab } from '../cost/WorkloadCostTab'
69
+ import { isOpenCostWorkloadKind } from '../cost/kinds'
38
70
  import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
39
71
  import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
40
72
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
73
+ import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
41
74
  import { LogsViewer } from '../logs/LogsViewer'
42
- import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
75
+ import { BatchExecutionFullscreen } from '../execution/BatchExecutionView'
76
+ import { workloadRunTimelineEvents } from '../execution/batch-timeline'
77
+ import {
78
+ useCanUpdateSecrets,
79
+ useCanNodeWrite,
80
+ useNamespacedCapabilities,
81
+ useIsLocalDeployment,
82
+ } from '../../contexts/CapabilitiesContext'
43
83
  import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
44
- import { PortForwardButton } from '../portforward/PortForwardButton'
84
+ import { PortForwardButton, PortForwardInlineButton } from '../portforward/PortForwardButton'
85
+ import {
86
+ CurlButton,
87
+ CurlPanel,
88
+ isHttpishPort,
89
+ defaultScheme,
90
+ defaultPathForPort,
91
+ } from '../curl/ServiceCurlButton'
45
92
  import { useToast } from '../ui/Toast'
46
93
  import { Tooltip } from '../ui/Tooltip'
47
94
  import { PodRenderer } from '../resources/renderers/PodRenderer'
@@ -59,13 +106,27 @@ import { CreateResourceDialog } from '../shared/CreateResourceDialog'
59
106
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
60
107
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
61
108
  import { useCompareLauncher } from '../compare/useCompareLauncher'
109
+ import { useDiagnoseCustomization } from '../../context/DiagnoseCustomization'
62
110
  import { apiVersionToGroup } from '../../utils/navigation'
63
111
 
64
112
  type TabType = WorkloadTabType
113
+ const BATCH_EXECUTION_KINDS = new Set([
114
+ 'Job',
115
+ 'CronJob',
116
+ 'Workflow',
117
+ 'CronWorkflow',
118
+ 'WorkflowTemplate',
119
+ 'ClusterWorkflowTemplate',
120
+ 'ScaledJob',
121
+ ])
65
122
 
66
123
  // Stable reference — web renderer wrappers inject platform hooks internally
67
124
  const rendererOverrides: RendererOverrides = {
68
- PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer, CompositeRenderer,
125
+ PodRenderer,
126
+ NodeRenderer,
127
+ ServiceRenderer,
128
+ WorkloadRenderer,
129
+ CompositeRenderer,
69
130
  ServiceAccountRenderer,
70
131
  RoleRenderer,
71
132
  RoleBindingRenderer,
@@ -97,7 +158,11 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
97
158
  // collapsed three-segment form (/workload/:kind/:name) for older links.
98
159
  const parts = location.pathname.replace(/^\//, '').split('/')
99
160
  const decode = (s: string): string => {
100
- try { return decodeURIComponent(s) } catch { return s }
161
+ try {
162
+ return decodeURIComponent(s)
163
+ } catch {
164
+ return s
165
+ }
101
166
  }
102
167
  const kind = decode(parts[1] ?? '')
103
168
  let namespace: string
@@ -121,9 +186,12 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
121
186
  }
122
187
  }, [navigate])
123
188
 
124
- const handleNavigate = useCallback((resource: SelectedResource) => {
125
- navigate(buildWorkloadPath(resource))
126
- }, [navigate])
189
+ const handleNavigate = useCallback(
190
+ (resource: SelectedResource) => {
191
+ navigate(relatedResourcePath(resource))
192
+ },
193
+ [navigate],
194
+ )
127
195
 
128
196
  // Hooks must run unconditionally — the invalid-URL guard comes after them.
129
197
  // Namespace is empty for cluster-scoped resources, so only kind + name are required.
@@ -169,6 +237,7 @@ interface WorkloadViewProps {
169
237
  onCancelExpandIntent?: () => void
170
238
  initialTab?: 'detail' | 'yaml'
171
239
  group?: string
240
+ pushTabHistory?: boolean
172
241
  }
173
242
 
174
243
  function useActionsBarProps(kind: string, namespace: string, name: string) {
@@ -191,7 +260,11 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
191
260
  const resumeCronJobMutation = useResumeCronJob()
192
261
 
193
262
  const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind.toLowerCase())
194
- const { data: revisionsList, isLoading: revisionsLoading, error: revisionsError } = useWorkloadRevisions(kind.toLowerCase(), namespace, name, isRollbackKind)
263
+ const {
264
+ data: revisionsList,
265
+ isLoading: revisionsLoading,
266
+ error: revisionsError,
267
+ } = useWorkloadRevisions(kind.toLowerCase(), namespace, name, isRollbackKind)
195
268
 
196
269
  const fluxReconcileMutation = useFluxReconcile()
197
270
  const fluxSyncWithSourceMutation = useFluxSyncWithSource()
@@ -203,13 +276,20 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
203
276
  const argoSuspendMutation = useArgoSuspend()
204
277
  const argoResumeMutation = useArgoResume()
205
278
 
206
- const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(kind, namespace, name, true)
279
+ const { data: cascadePreview, isLoading: cascadeLoading } = useCascadeDeletePreview(
280
+ kind,
281
+ namespace,
282
+ name,
283
+ true,
284
+ )
207
285
 
208
286
  const canNodeWrite = useCanNodeWrite()
209
287
  const cordonMutation = useCordonNode()
210
288
  const uncordonMutation = useUncordonNode()
211
289
  const drainMutation = useDrainNode()
212
290
 
291
+ const renderDiagnose = useDiagnoseCustomization()
292
+
213
293
  return {
214
294
  canExec,
215
295
  canViewLogs,
@@ -218,49 +298,80 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
218
298
  onOpenLogs: openLogs,
219
299
  onOpenWorkloadLogs: openWorkloadLogs,
220
300
  onOpenNodeTerminal: openNodeTerminal,
221
- onCopyCommand: (text: string, message: string, event: React.MouseEvent) => showCopied(text, message, event),
222
- renderPortForward: ({ type, namespace: ns, name: n, className }: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => (
223
- <PortForwardButton type={type} namespace={ns} name={n} className={className} />
224
- ),
225
- onDelete: (params: Parameters<typeof deleteMutation.mutate>[0], callbacks?: { onSuccess?: () => void }) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
301
+ onCopyCommand: (text: string, message: string, event: React.MouseEvent) =>
302
+ showCopied(text, message, event),
303
+ renderPortForward: ({
304
+ type,
305
+ namespace: ns,
306
+ name: n,
307
+ className,
308
+ }: {
309
+ type: 'pod' | 'service'
310
+ namespace: string
311
+ name: string
312
+ className?: string
313
+ }) => <PortForwardButton type={type} namespace={ns} name={n} className={className} />,
314
+ renderDiagnose,
315
+ onDelete: (
316
+ params: Parameters<typeof deleteMutation.mutate>[0],
317
+ callbacks?: { onSuccess?: () => void },
318
+ ) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
226
319
  isDeleting: deleteMutation.isPending,
227
320
  cascadeDependents: cascadePreview?.dependents,
228
321
  cascadeLoading,
229
- onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) => restartWorkloadMutation.mutate(params),
322
+ onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
323
+ restartWorkloadMutation.mutate(params),
230
324
  isRestarting: restartWorkloadMutation.isPending,
231
325
  revisions: revisionsList,
232
326
  revisionsLoading,
233
327
  revisionsError: revisionsError ?? null,
234
- onRollback: (params: Parameters<typeof rollbackMutation.mutate>[0], callbacks?: { onSuccess?: () => void }) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
328
+ onRollback: (
329
+ params: Parameters<typeof rollbackMutation.mutate>[0],
330
+ callbacks?: { onSuccess?: () => void },
331
+ ) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
235
332
  isRollingBack: rollbackMutation.isPending,
236
- onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) => triggerCronJobMutation.mutate(params),
333
+ onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) =>
334
+ triggerCronJobMutation.mutate(params),
237
335
  isTriggeringCronJob: triggerCronJobMutation.isPending,
238
- onSuspendCronJob: (params: Parameters<typeof suspendCronJobMutation.mutate>[0]) => suspendCronJobMutation.mutate(params),
336
+ onSuspendCronJob: (params: Parameters<typeof suspendCronJobMutation.mutate>[0]) =>
337
+ suspendCronJobMutation.mutate(params),
239
338
  isSuspendingCronJob: suspendCronJobMutation.isPending,
240
- onResumeCronJob: (params: Parameters<typeof resumeCronJobMutation.mutate>[0]) => resumeCronJobMutation.mutate(params),
339
+ onResumeCronJob: (params: Parameters<typeof resumeCronJobMutation.mutate>[0]) =>
340
+ resumeCronJobMutation.mutate(params),
241
341
  isResumingCronJob: resumeCronJobMutation.isPending,
242
- onFluxReconcile: (params: Parameters<typeof fluxReconcileMutation.mutate>[0]) => fluxReconcileMutation.mutate(params),
342
+ onFluxReconcile: (params: Parameters<typeof fluxReconcileMutation.mutate>[0]) =>
343
+ fluxReconcileMutation.mutate(params),
243
344
  isFluxReconciling: fluxReconcileMutation.isPending,
244
- onFluxSyncWithSource: (params: Parameters<typeof fluxSyncWithSourceMutation.mutate>[0]) => fluxSyncWithSourceMutation.mutate(params),
345
+ onFluxSyncWithSource: (params: Parameters<typeof fluxSyncWithSourceMutation.mutate>[0]) =>
346
+ fluxSyncWithSourceMutation.mutate(params),
245
347
  isFluxSyncing: fluxSyncWithSourceMutation.isPending,
246
- onFluxSuspend: (params: Parameters<typeof fluxSuspendMutation.mutate>[0]) => fluxSuspendMutation.mutate(params),
348
+ onFluxSuspend: (params: Parameters<typeof fluxSuspendMutation.mutate>[0]) =>
349
+ fluxSuspendMutation.mutate(params),
247
350
  isFluxSuspending: fluxSuspendMutation.isPending,
248
- onFluxResume: (params: Parameters<typeof fluxResumeMutation.mutate>[0]) => fluxResumeMutation.mutate(params),
351
+ onFluxResume: (params: Parameters<typeof fluxResumeMutation.mutate>[0]) =>
352
+ fluxResumeMutation.mutate(params),
249
353
  isFluxResuming: fluxResumeMutation.isPending,
250
- onArgoSync: (params: Parameters<typeof argoSyncMutation.mutate>[0]) => argoSyncMutation.mutate(params),
354
+ onArgoSync: (params: Parameters<typeof argoSyncMutation.mutate>[0]) =>
355
+ argoSyncMutation.mutate(params),
251
356
  isArgoSyncing: argoSyncMutation.isPending,
252
- onArgoRefresh: (params: Parameters<typeof argoRefreshMutation.mutate>[0]) => argoRefreshMutation.mutate(params),
357
+ onArgoRefresh: (params: Parameters<typeof argoRefreshMutation.mutate>[0]) =>
358
+ argoRefreshMutation.mutate(params),
253
359
  isArgoRefreshing: argoRefreshMutation.isPending,
254
- onArgoSuspend: (params: Parameters<typeof argoSuspendMutation.mutate>[0]) => argoSuspendMutation.mutate(params),
360
+ onArgoSuspend: (params: Parameters<typeof argoSuspendMutation.mutate>[0]) =>
361
+ argoSuspendMutation.mutate(params),
255
362
  isArgoSuspending: argoSuspendMutation.isPending,
256
- onArgoResume: (params: Parameters<typeof argoResumeMutation.mutate>[0]) => argoResumeMutation.mutate(params),
363
+ onArgoResume: (params: Parameters<typeof argoResumeMutation.mutate>[0]) =>
364
+ argoResumeMutation.mutate(params),
257
365
  isArgoResuming: argoResumeMutation.isPending,
258
366
  canNodeWrite,
259
- onCordonNode: (params: Parameters<typeof cordonMutation.mutate>[0]) => cordonMutation.mutate(params),
367
+ onCordonNode: (params: Parameters<typeof cordonMutation.mutate>[0]) =>
368
+ cordonMutation.mutate(params),
260
369
  isCordoningNode: cordonMutation.isPending,
261
- onUncordonNode: (params: Parameters<typeof uncordonMutation.mutate>[0]) => uncordonMutation.mutate(params),
370
+ onUncordonNode: (params: Parameters<typeof uncordonMutation.mutate>[0]) =>
371
+ uncordonMutation.mutate(params),
262
372
  isUncordoningNode: uncordonMutation.isPending,
263
- onDrainNode: (params: Parameters<typeof drainMutation.mutate>[0]) => drainMutation.mutate(params),
373
+ onDrainNode: (params: Parameters<typeof drainMutation.mutate>[0]) =>
374
+ drainMutation.mutate(params),
264
375
  isDrainingNode: drainMutation.isPending,
265
376
  }
266
377
  }
@@ -270,36 +381,101 @@ export function WorkloadView({
270
381
  namespace,
271
382
  name,
272
383
  expanded = true,
384
+ pushTabHistory = false,
273
385
  ...rest
274
386
  }: WorkloadViewProps) {
275
387
  const [searchParams, setSearchParams] = useSearchParams()
388
+ const apiKind = kindToPlural(kindProp)
389
+ const queryClient = useQueryClient()
276
390
 
277
391
  // Tab state from URL query param — migrate legacy tab names
278
392
  const rawTab = searchParams.get('tab')
279
- const migratedTab: TabType = rawTab === 'info' ? 'overview'
280
- : rawTab === 'events' ? 'timeline'
281
- : (rawTab as TabType) || 'overview'
282
-
283
- const handleTabChange = useCallback((tab: TabType) => {
284
- const params = new URLSearchParams(searchParams)
285
- if (tab === 'overview') {
286
- params.delete('tab')
287
- } else {
288
- params.set('tab', tab)
289
- }
290
- setSearchParams(params, { replace: true })
291
- }, [searchParams, setSearchParams])
393
+ const migratedTab: TabType =
394
+ rawTab === 'info'
395
+ ? 'overview'
396
+ : rawTab === 'events'
397
+ ? 'timeline'
398
+ : (rawTab as TabType) || 'overview'
399
+
400
+ const handleTabChange = useCallback(
401
+ (tab: TabType, opts?: { replace?: boolean }) => {
402
+ const params = new URLSearchParams(searchParams)
403
+ if (tab === 'overview') {
404
+ params.delete('tab')
405
+ } else {
406
+ params.set('tab', tab)
407
+ }
408
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
409
+ },
410
+ [pushTabHistory, searchParams, setSearchParams],
411
+ )
412
+
413
+ const selectedRunKey = searchParams.get('run') ?? ''
414
+ const handleSelectedRunChange = useCallback(
415
+ (runKey: string) => {
416
+ const params = new URLSearchParams(searchParams)
417
+ if (runKey) params.set('run', runKey)
418
+ else params.delete('run')
419
+ setSearchParams(params, { replace: true })
420
+ },
421
+ [searchParams, setSearchParams],
422
+ )
423
+
424
+ const batchKind = pluralToKind(apiKind)
425
+ const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind)
426
+ const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
427
+ refetchActive: true,
428
+ clusterScoped: batchKind === 'ClusterWorkflowTemplate',
429
+ })
430
+ const relatedTimelineEvents = useMemo(
431
+ () => workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
432
+ [batchRunsQuery.data?.runs],
433
+ )
292
434
 
293
435
  // Fetch resource with relationships
294
- const { data: resourceResponse, isLoading: resourceLoading, error: resourceError, refetch: refetchResource } = useResourceWithRelationships<any>(kindProp, namespace, name, rest.group)
436
+ const {
437
+ data: resourceResponse,
438
+ isLoading: resourceLoading,
439
+ error: resourceError,
440
+ refetch: refetchResource,
441
+ } = useResourceWithRelationships<any>(apiKind, namespace, name, rest.group)
295
442
  const resource = resourceResponse?.resource
296
443
  const relationships = resourceResponse?.relationships
444
+ const refetchResourceAndRuns = useCallback(async () => {
445
+ await Promise.all([
446
+ refetchResource(),
447
+ queryClient.refetchQueries({
448
+ queryKey: ['workload-runs', apiKind, namespace, name],
449
+ }),
450
+ ])
451
+ }, [apiKind, name, namespace, queryClient, refetchResource])
452
+ const podWorkloadOwner = useMemo(
453
+ () => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
454
+ [apiKind, namespace, relationships, resource],
455
+ )
456
+ const podOwnerAppsQuery = useApplications(
457
+ podWorkloadOwner?.namespace ? [podWorkloadOwner.namespace] : [],
458
+ { enabled: Boolean(podWorkloadOwner?.namespace) },
459
+ )
460
+ const ownershipContext = useMemo(
461
+ () => buildPodOwnershipContext(podWorkloadOwner, podOwnerAppsQuery.data?.applications),
462
+ [podWorkloadOwner, podOwnerAppsQuery.data?.applications],
463
+ )
297
464
  const certificateInfo = resourceResponse?.certificateInfo
298
465
  const hpaDiagnosis = resourceResponse?.hpaDiagnosis
299
- const relationshipGitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
466
+ const relationshipGitopsOwner = useMemo(
467
+ () => gitOpsOwnerFromRelationships(relationships),
468
+ [relationships],
469
+ )
300
470
  const inheritedGitOpsLookupRef = useMemo(
301
- () => findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, { kind: kindProp, namespace, name, group: rest.group }),
302
- [relationships, relationshipGitopsOwner, kindProp, namespace, name, rest.group],
471
+ () =>
472
+ findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, {
473
+ kind: apiKind,
474
+ namespace,
475
+ name,
476
+ group: rest.group,
477
+ }),
478
+ [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
303
479
  )
304
480
  const inheritedGitOpsResponse = useResourceWithRelationships<any>(
305
481
  inheritedGitOpsLookupRef ? kindToPlural(inheritedGitOpsLookupRef.kind) : '',
@@ -312,19 +488,34 @@ export function WorkloadView({
312
488
  [inheritedGitOpsResponse.data?.relationships],
313
489
  )
314
490
  const relationshipHelmOwner = useMemo(
315
- () => nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace),
491
+ () =>
492
+ nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace),
316
493
  [relationships, resource?.metadata?.namespace, namespace],
317
494
  )
318
495
  const inheritedHelmOwner = useMemo(
319
- () => nativeHelmOwnerFromRelationships(inheritedGitOpsResponse.data?.relationships, inheritedGitOpsResponse.data?.resource?.metadata?.namespace ?? namespace),
320
- [inheritedGitOpsResponse.data?.relationships, inheritedGitOpsResponse.data?.resource?.metadata?.namespace, namespace],
496
+ () =>
497
+ nativeHelmOwnerFromRelationships(
498
+ inheritedGitOpsResponse.data?.relationships,
499
+ inheritedGitOpsResponse.data?.resource?.metadata?.namespace ?? namespace,
500
+ ),
501
+ [
502
+ inheritedGitOpsResponse.data?.relationships,
503
+ inheritedGitOpsResponse.data?.resource?.metadata?.namespace,
504
+ namespace,
505
+ ],
321
506
  )
322
507
  const rawGitopsOwner = relationshipGitopsOwner ?? inheritedGitopsOwner
323
- const gitOpsSourceResource = relationshipGitopsOwner ? resource : inheritedGitOpsResponse.data?.resource
508
+ const gitOpsSourceResource = relationshipGitopsOwner
509
+ ? resource
510
+ : inheritedGitOpsResponse.data?.resource
324
511
  const helmOwner = relationshipHelmOwner ?? inheritedHelmOwner
325
- const helmSourceResource = relationshipHelmOwner ? resource : inheritedGitOpsResponse.data?.resource
512
+ const helmSourceResource = relationshipHelmOwner
513
+ ? resource
514
+ : inheritedGitOpsResponse.data?.resource
326
515
  const shouldResolveArgoOwner = rawGitopsOwner?.tool === 'argocd' && !rawGitopsOwner.namespace
327
- const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', { enabled: shouldResolveArgoOwner })
516
+ const { data: argoApplications } = useResources<any>('applications', undefined, 'argoproj.io', {
517
+ enabled: shouldResolveArgoOwner,
518
+ })
328
519
  const gitopsOwner = useMemo(
329
520
  () => resolveGitOpsOwner(rawGitopsOwner, argoApplications),
330
521
  [rawGitopsOwner, argoApplications],
@@ -342,7 +533,9 @@ export function WorkloadView({
342
533
  [gitopsOwner, gitopsOwnerQuery.data],
343
534
  )
344
535
  const gitOpsOwnerVerified = Boolean(gitopsOwner?.namespace && gitopsOwnerQuery.data)
345
- const gitOpsOwnerPending = Boolean(gitopsOwner?.namespace && gitopsOwnerQuery.isLoading && !gitopsOwnerQuery.data)
536
+ const gitOpsOwnerPending = Boolean(
537
+ gitopsOwner?.namespace && gitopsOwnerQuery.isLoading && !gitopsOwnerQuery.data,
538
+ )
346
539
  const gitOpsOwnerSource = useMemo(
347
540
  () => describeGitOpsOwnerSource(rawGitopsOwner, gitOpsSourceResource),
348
541
  [rawGitopsOwner, gitOpsSourceResource],
@@ -353,19 +546,29 @@ export function WorkloadView({
353
546
  )
354
547
 
355
548
  // For pods: extract envFrom ConfigMap/Secret names and resolve their keys
356
- const isPod = kindProp.toLowerCase() === 'pods'
549
+ const isPod = apiKind === 'pods'
357
550
  const { envFromConfigMapNames, envFromSecretNames } = useMemo(() => {
358
- if (!isPod || !resource) return { envFromConfigMapNames: [] as string[], envFromSecretNames: [] as string[] }
551
+ if (!isPod || !resource)
552
+ return {
553
+ envFromConfigMapNames: [] as string[],
554
+ envFromSecretNames: [] as string[],
555
+ }
359
556
  const cmNames = new Set<string>()
360
557
  const secretNames = new Set<string>()
361
- const containers = [...(resource.spec?.containers || []), ...(resource.spec?.initContainers || [])]
558
+ const containers = [
559
+ ...(resource.spec?.containers || []),
560
+ ...(resource.spec?.initContainers || []),
561
+ ]
362
562
  for (const c of containers) {
363
- for (const ef of (c.envFrom || [])) {
563
+ for (const ef of c.envFrom || []) {
364
564
  if (ef.configMapRef?.name) cmNames.add(ef.configMapRef.name)
365
565
  if (ef.secretRef?.name) secretNames.add(ef.secretRef.name)
366
566
  }
367
567
  }
368
- return { envFromConfigMapNames: Array.from(cmNames), envFromSecretNames: Array.from(secretNames) }
568
+ return {
569
+ envFromConfigMapNames: Array.from(cmNames),
570
+ envFromSecretNames: Array.from(secretNames),
571
+ }
369
572
  }, [isPod, resource])
370
573
 
371
574
  const configMapQueries = useQueries({
@@ -387,28 +590,44 @@ export function WorkloadView({
387
590
  })
388
591
 
389
592
  const resolvedEnvFrom = useMemo(() => {
390
- if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0)) return undefined
593
+ if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0))
594
+ return undefined
391
595
  const result: ResolvedEnvFrom = {}
392
596
  envFromConfigMapNames.forEach((n, i) => {
393
597
  // Single-resource endpoint returns { resource, relationships } wrapper
394
598
  const cm = configMapQueries[i]?.data?.resource ?? configMapQueries[i]?.data
395
- if (cm) result[resolvedEnvFromKey('configmap', n)] = { keys: Object.keys(cm.data || {}), values: cm.data || {}, isSecret: false }
599
+ if (cm)
600
+ result[resolvedEnvFromKey('configmap', n)] = {
601
+ keys: Object.keys(cm.data || {}),
602
+ values: cm.data || {},
603
+ isSecret: false,
604
+ }
396
605
  })
397
606
  envFromSecretNames.forEach((n, i) => {
398
607
  const secret = secretQueries[i]?.data?.resource ?? secretQueries[i]?.data
399
608
  if (secret) {
400
609
  const decodedValues: Record<string, string> = {}
401
610
  for (const [k, v] of Object.entries(secret.data || {})) {
402
- try { decodedValues[k] = atob(v as string) } catch { decodedValues[k] = v as string }
611
+ try {
612
+ decodedValues[k] = atob(v as string)
613
+ } catch {
614
+ decodedValues[k] = v as string
615
+ }
616
+ }
617
+ result[resolvedEnvFromKey('secret', n)] = {
618
+ keys: Object.keys(decodedValues),
619
+ values: decodedValues,
620
+ isSecret: true,
403
621
  }
404
- result[resolvedEnvFromKey('secret', n)] = { keys: Object.keys(decodedValues), values: decodedValues, isSecret: true }
405
622
  }
406
623
  })
407
624
  return Object.keys(result).length > 0 ? result : undefined
408
625
  }, [isPod, envFromConfigMapNames, envFromSecretNames, configMapQueries, secretQueries])
409
626
 
410
627
  // Fetch topology for hierarchy building (only when expanded)
411
- const { data: topology } = useTopology([namespace], 'resources', { enabled: expanded })
628
+ const { data: topology } = useTopology([namespace], 'resources', {
629
+ enabled: expanded,
630
+ })
412
631
 
413
632
  // Always fetched so Recent Events populates on drawer open; allEvents below is
414
633
  // gated on expanded because it's namespace-wide and expensive.
@@ -418,7 +637,7 @@ export function WorkloadView({
418
637
  isLoading: resourceFocusedEventsLoading,
419
638
  k8sError: resourceFocusedK8sError,
420
639
  updatesError: resourceFocusedUpdatesError,
421
- } = useResourceEvents(kindProp, namespace, name)
640
+ } = useResourceEvents(apiKind, namespace, name)
422
641
 
423
642
  // Fetch all events for this resource's namespace (only when expanded)
424
643
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
@@ -432,8 +651,80 @@ export function WorkloadView({
432
651
 
433
652
  // RBAC
434
653
  const canUpdateSecrets = useCanUpdateSecrets()
654
+ const { canPortForward } = useNamespacedCapabilities(namespace)
655
+ const isLocalDeployment = useIsLocalDeployment()
656
+ const showServingPortForward = canPortForward || !isLocalDeployment
657
+ const showServingCurl = !isLocalDeployment
658
+ const [servingCurl, setServingCurl] = useState<{
659
+ namespace: string
660
+ serviceName: string
661
+ port: number
662
+ closing: boolean
663
+ } | null>(null)
664
+ const closeServingCurl = useCallback(() => {
665
+ setServingCurl((p) => (p ? { ...p, closing: true } : null))
666
+ window.setTimeout(() => setServingCurl((p) => (p?.closing ? null : p)), 220)
667
+ }, [])
668
+ const renderServicePortAction = useCallback(
669
+ (props: ServicePortRenderProps) => {
670
+ const active =
671
+ servingCurl?.namespace === props.namespace &&
672
+ servingCurl?.serviceName === props.serviceName &&
673
+ servingCurl?.port === props.port &&
674
+ !servingCurl.closing
675
+ return (
676
+ <>
677
+ {showServingCurl &&
678
+ isHttpishPort(props.port, props.name, props.appProtocol, props.protocol) && (
679
+ <CurlButton
680
+ active={active}
681
+ onClick={() => {
682
+ if (active) closeServingCurl()
683
+ else
684
+ setServingCurl({
685
+ namespace: props.namespace,
686
+ serviceName: props.serviceName,
687
+ port: props.port,
688
+ closing: false,
689
+ })
690
+ }}
691
+ />
692
+ )}
693
+ {showServingPortForward && (
694
+ <PortForwardInlineButton
695
+ namespace={props.namespace}
696
+ serviceName={props.serviceName}
697
+ port={props.port}
698
+ protocol={props.protocol}
699
+ />
700
+ )}
701
+ </>
702
+ )
703
+ },
704
+ [closeServingCurl, servingCurl, showServingCurl, showServingPortForward],
705
+ )
706
+ const renderServicePortPanel = useCallback(
707
+ (props: ServicePortRenderProps) => {
708
+ const active =
709
+ servingCurl?.namespace === props.namespace &&
710
+ servingCurl?.serviceName === props.serviceName &&
711
+ servingCurl?.port === props.port
712
+ return active ? (
713
+ <CurlPanel
714
+ namespace={props.namespace}
715
+ serviceName={props.serviceName}
716
+ port={props.port}
717
+ initialScheme={defaultScheme(props.port, props.name, props.appProtocol)}
718
+ initialPath={defaultPathForPort(props.port, props.name, props.appProtocol)}
719
+ open={!servingCurl.closing}
720
+ onClose={closeServingCurl}
721
+ />
722
+ ) : null
723
+ },
724
+ [closeServingCurl, servingCurl],
725
+ )
435
726
  const updateResource = useUpdateResource()
436
- const baseActionsBarProps = useActionsBarProps(kindProp, namespace, name)
727
+ const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
437
728
  const desktopDownload = useDesktopDownload()
438
729
 
439
730
  const resourceGroup = useMemo(
@@ -443,14 +734,20 @@ export function WorkloadView({
443
734
  // Live Operational Issues for this resource. Fetched here (not inside the lead
444
735
  // render-prop) so the count also gates `hasOperationalIssues` — which tells the
445
736
  // renderers to suppress their own status-derived problems and avoid duplicates.
446
- // Keyed on the STABLE prop kind+group (same inputs as the resource fetch above),
737
+ // Keyed on the stable API kind+group (same inputs as the resource fetch above),
447
738
  // NOT the manifest-derived ones: deriving kind/group from the loaded resource
448
739
  // would flip the query key when the manifest arrives, drop liveIssues, and flash
449
740
  // the renderer banners. The backend canonicalizes a plural kind via discovery,
450
- // so passing the route's plural kindProp resolves correctly.
451
- const { data: liveIssues } = useResourceIssues(kindProp, rest.group, namespace, name)
452
- const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
453
- kind: kindProp,
741
+ // so using the normalized API kind resolves direct links and app navigation alike.
742
+ const { data: liveIssues, isPending: issuesPending } = useResourceIssues(apiKind, rest.group, namespace, name)
743
+ const { data: auditFindings } = useResourceAudit(apiKind, namespace, name)
744
+ const hasOperationalIssues = Boolean(liveIssues?.length)
745
+ const {
746
+ onCompareTo,
747
+ onCompareAcrossClusters,
748
+ picker: comparePicker,
749
+ } = useCompareLauncher({
750
+ kind: apiKind,
454
751
  namespace,
455
752
  name,
456
753
  // Prefer the URL-supplied group so Compare works even before the resource
@@ -463,9 +760,12 @@ export function WorkloadView({
463
760
  [baseActionsBarProps, onCompareTo, onCompareAcrossClusters],
464
761
  )
465
762
 
466
- const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
467
- await updateResource.mutateAsync(params)
468
- }, [updateResource])
763
+ const handleUpdateResource = useCallback(
764
+ async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
765
+ await updateResource.mutateAsync(params)
766
+ },
767
+ [updateResource],
768
+ )
469
769
 
470
770
  const navigateRouter = useNavigate()
471
771
  const handleOpenGitOpsResource = useCallback(
@@ -473,7 +773,10 @@ export function WorkloadView({
473
773
  const params = new URLSearchParams()
474
774
  const namespaces = searchParams.get('namespaces')
475
775
  if (namespaces) params.set('namespaces', namespaces)
476
- navigateRouter({ pathname: gitOpsRouteForOwner(ref), search: params.toString() })
776
+ navigateRouter({
777
+ pathname: gitOpsRouteForOwner(ref),
778
+ search: params.toString(),
779
+ })
477
780
  },
478
781
  [navigateRouter, searchParams],
479
782
  )
@@ -491,113 +794,250 @@ export function WorkloadView({
491
794
  },
492
795
  [navigateRouter, searchParams],
493
796
  )
797
+ const handleOpenApplication = useCallback(
798
+ (appKey: string) => {
799
+ const params = new URLSearchParams()
800
+ const namespaces = new Set(
801
+ (searchParams.get('namespaces') ?? '')
802
+ .split(',')
803
+ .map((ns) => ns.trim())
804
+ .filter(Boolean),
805
+ )
806
+ if (ownershipContext?.application?.key === appKey && ownershipContext.workload.namespace) {
807
+ namespaces.add(ownershipContext.workload.namespace)
808
+ }
809
+ if (namespaces.size > 0) params.set('namespaces', Array.from(namespaces).join(','))
810
+ params.set('app', appKey)
811
+ navigateRouter({ pathname: '/applications', search: params.toString() })
812
+ },
813
+ [navigateRouter, ownershipContext, searchParams],
814
+ )
494
815
 
495
816
  // Duplicate dialog
496
817
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
497
818
  const [duplicateYaml, setDuplicateYaml] = useState('')
498
819
 
499
- const handleDuplicate = useCallback((params: { kind: string; namespace: string; name: string; yaml: string }) => {
500
- setDuplicateYaml(cleanYamlForDuplicate(params.yaml))
501
- setDuplicateDialogOpen(true)
502
- }, [])
820
+ const handleDuplicate = useCallback(
821
+ (params: { kind: string; namespace: string; name: string; yaml: string }) => {
822
+ setDuplicateYaml(cleanYamlForDuplicate(params.yaml))
823
+ setDuplicateDialogOpen(true)
824
+ },
825
+ [],
826
+ )
827
+
828
+ const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
829
+ const workloadPodsQuery = useWorkloadPods(supportsWorkloadPods ? apiKind : '', namespace, name)
830
+ const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
831
+ const servingQueries = useQueries({
832
+ queries: servingRefs.map((ref) => {
833
+ const pluralKind = kindToPlural(ref.kind)
834
+ const ns = ref.namespace || '_'
835
+ const params = new URLSearchParams()
836
+ if (ref.group) params.set('group', ref.group)
837
+ const queryString = params.toString()
838
+ return {
839
+ queryKey: ['resource', pluralKind, ref.namespace, ref.name, ref.group],
840
+ queryFn: () =>
841
+ fetchJSON<any>(
842
+ `/resources/${pluralKind}/${ns}/${ref.name}${queryString ? `?${queryString}` : ''}`,
843
+ ),
844
+ enabled: expanded && Boolean(ref.kind && ref.name),
845
+ staleTime: 30000,
846
+ }
847
+ }),
848
+ })
849
+ const servingResources = useMemo<ServingResourceDetail[]>(
850
+ () =>
851
+ servingRefs.map((ref, index) => {
852
+ const query = servingQueries[index]
853
+ const data = query?.data?.resource ?? query?.data
854
+ return {
855
+ ref,
856
+ resource: data,
857
+ loading: query?.isLoading ?? false,
858
+ error: (query?.error as Error | null) ?? null,
859
+ }
860
+ }),
861
+ [servingRefs, servingQueries],
862
+ )
503
863
 
504
864
  return (
505
865
  <>
506
- <BaseWorkloadView
507
- kind={kindProp}
508
- namespace={namespace}
509
- name={name}
510
- expanded={expanded}
511
- {...rest}
512
- // Data
513
- resource={resource}
514
- relationships={relationships}
515
- certificateInfo={certificateInfo}
516
- hpaDiagnosis={hpaDiagnosis}
517
- isLoading={resourceLoading}
518
- resourceError={resourceError}
519
- refetch={refetchResource}
520
- // Timeline
521
- allEvents={allEvents}
522
- eventsLoading={eventsLoading}
523
- topology={topology}
524
- resourceFocusedK8sEvents={resourceFocusedK8sEvents}
525
- resourceFocusedUpdates={resourceFocusedUpdates}
526
- resourceFocusedEventsLoading={resourceFocusedEventsLoading}
527
- resourceFocusedK8sError={resourceFocusedK8sError}
528
- resourceFocusedUpdatesError={resourceFocusedUpdatesError}
529
- // Capabilities
530
- canUpdateSecrets={canUpdateSecrets}
531
- // Mutations
532
- onUpdateResource={handleUpdateResource}
533
- isUpdatingResource={updateResource.isPending}
534
- updateResourceError={updateResource.error?.message ?? null}
535
- // Tab state (URL-synced)
536
- activeTab={migratedTab}
537
- onTabChange={handleTabChange}
538
- // Render props
539
- renderLogsTab={(props) => <LogsTabContent {...props} />}
540
- renderRelatedYaml={(ref) => <RelatedResourceYaml key={`${ref.kind}/${ref.namespace}/${ref.name}`} target={ref} />}
541
- renderMetricsTab={({ kind, namespace: ns, name: n }) => (
542
- <MetricsTabContent kind={kind} namespace={ns} name={n} resource={resource} expanded={expanded} />
543
- )}
544
- isMetricsAvailable={(kind, res) =>
545
- isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
546
- }
547
- onDuplicate={handleDuplicate}
548
- onDownload={desktopDownload}
549
- actionsBarProps={actionsBarProps}
550
- rendererOverrides={rendererOverrides}
551
- resolvedEnvFrom={resolvedEnvFrom}
552
- renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
553
- <>
554
- <AuditSection kind={k} namespace={ns} name={n} />
555
- <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
556
- </>
557
- )}
558
- renderOverviewLead={() => (
559
- <ResourceIssuesSection
560
- issues={liveIssues}
561
- onResourceClick={
562
- rest.onNavigateToResource
563
- ? (ref) =>
564
- rest.onNavigateToResource?.({
565
- kind: kindToPlural(ref.kind),
566
- namespace: ref.namespace ?? '',
567
- name: ref.name,
568
- group: ref.group ?? '',
569
- })
570
- : undefined
571
- }
572
- />
573
- )}
574
- hasOperationalIssues={!!liveIssues?.length}
575
- onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
576
- resolvedGitOpsOwner={gitopsOwner}
577
- gitOpsOwnerVerified={gitOpsOwnerVerified}
578
- gitOpsOwnerPending={gitOpsOwnerPending}
579
- gitOpsOwnerSource={gitOpsOwnerSource}
580
- gitOpsOwnerStatus={gitOpsOwnerStatus}
581
- helmOwner={helmOwner}
582
- helmOwnerSource={helmOwnerSource}
583
- onOpenHelmRelease={handleOpenHelmRelease}
584
- onNavigateGitOpsPath={handleNavigateGitOpsPath}
585
- />
586
- <CreateResourceDialog
587
- open={duplicateDialogOpen}
588
- onClose={() => setDuplicateDialogOpen(false)}
589
- initialYaml={duplicateYaml}
590
- title="Duplicate Resource"
591
- onCreated={(result) => {
592
- rest.onNavigateToResource?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
593
- }}
594
- />
595
- {comparePicker}
866
+ <BaseWorkloadView
867
+ kind={apiKind}
868
+ namespace={namespace}
869
+ name={name}
870
+ expanded={expanded}
871
+ {...rest}
872
+ // Data
873
+ resource={resource}
874
+ relationships={relationships}
875
+ ownershipContext={ownershipContext}
876
+ onOpenApplication={handleOpenApplication}
877
+ certificateInfo={certificateInfo}
878
+ hpaDiagnosis={hpaDiagnosis}
879
+ workloadPods={supportsWorkloadPods ? workloadPodsQuery.data?.pods : undefined}
880
+ workloadPodsLoading={supportsWorkloadPods ? workloadPodsQuery.isLoading : false}
881
+ workloadPodsError={supportsWorkloadPods ? (workloadPodsQuery.error as Error | null) : null}
882
+ servingResources={servingResources}
883
+ renderServicePortAction={renderServicePortAction}
884
+ renderServicePortPanel={renderServicePortPanel}
885
+ isLoading={resourceLoading}
886
+ resourceError={resourceError}
887
+ refetch={refetchResourceAndRuns}
888
+ // Timeline
889
+ allEvents={allEvents}
890
+ relatedTimelineEvents={relatedTimelineEvents}
891
+ eventsLoading={eventsLoading || (batchExecution && batchRunsQuery.isLoading)}
892
+ topology={topology}
893
+ resourceFocusedK8sEvents={resourceFocusedK8sEvents}
894
+ resourceFocusedUpdates={resourceFocusedUpdates}
895
+ resourceFocusedEventsLoading={resourceFocusedEventsLoading}
896
+ resourceFocusedK8sError={resourceFocusedK8sError}
897
+ resourceFocusedUpdatesError={resourceFocusedUpdatesError}
898
+ // Capabilities
899
+ canUpdateSecrets={canUpdateSecrets}
900
+ // Mutations
901
+ onUpdateResource={handleUpdateResource}
902
+ isUpdatingResource={updateResource.isPending}
903
+ updateResourceError={updateResource.error?.message ?? null}
904
+ // Tab state (URL-synced)
905
+ activeTab={migratedTab}
906
+ onTabChange={handleTabChange}
907
+ // Render props
908
+ renderLogsTab={(props) => (
909
+ <LogsTabContent
910
+ {...props}
911
+ selectedRunKey={selectedRunKey}
912
+ onSelectRun={handleSelectedRunChange}
913
+ />
914
+ )}
915
+ renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
916
+ BATCH_EXECUTION_KINDS.has(k) && res ? (
917
+ <BatchExecutionFullscreen
918
+ kind={k}
919
+ apiKind={apiKind}
920
+ namespace={ns}
921
+ name={n}
922
+ resource={res}
923
+ selectedRunKey={selectedRunKey}
924
+ canViewLogs={baseActionsBarProps.canViewLogs}
925
+ onSelectRun={handleSelectedRunChange}
926
+ onSwitchToLogs={() => handleTabChange('logs')}
927
+ onSwitchToTimeline={() => handleTabChange('timeline')}
928
+ onNavigateToResource={rest.onNavigateToResource}
929
+ />
930
+ ) : null
931
+ }
932
+ renderRelatedYaml={(ref) => (
933
+ <RelatedResourceYaml key={`${ref.kind}/${ref.namespace}/${ref.name}`} target={ref} />
934
+ )}
935
+ renderMetricsTab={({ kind, namespace: ns, name: n }) => (
936
+ <MetricsTabContent
937
+ kind={kind}
938
+ namespace={ns}
939
+ name={n}
940
+ resource={resource}
941
+ expanded={expanded}
942
+ />
943
+ )}
944
+ renderCostTab={({ kind, namespace: ns, name: n }) => (
945
+ <div className="space-y-4">
946
+ <RightsizingPanel kind={kind} namespace={ns} name={n} />
947
+ <WorkloadCostTab kind={kind} namespace={ns} name={n} />
948
+ </div>
949
+ )}
950
+ isMetricsAvailable={(kind, res) =>
951
+ isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
952
+ }
953
+ isCostAvailable={(kind) => isOpenCostWorkloadKind(kind)}
954
+ onDuplicate={handleDuplicate}
955
+ onDownload={desktopDownload}
956
+ actionsBarProps={actionsBarProps}
957
+ rendererOverrides={rendererOverrides}
958
+ resolvedEnvFrom={resolvedEnvFrom}
959
+ renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
960
+ <>
961
+ <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
962
+ <AuditOverviewSection
963
+ findings={auditFindings ?? []}
964
+ onViewAll={() => navigateRouter('/checks')}
965
+ />
966
+ </>
967
+ )}
968
+ renderOverviewLead={() => (
969
+ <ResourceIssuesSection
970
+ issues={liveIssues}
971
+ subjectResource={{ kind: apiKind, namespace, name, group: rest.group }}
972
+ onResourceClick={
973
+ rest.onNavigateToResource
974
+ ? (ref) =>
975
+ rest.onNavigateToResource?.({
976
+ kind: kindToPlural(ref.kind),
977
+ namespace: ref.namespace ?? '',
978
+ name: ref.name,
979
+ group: ref.group ?? '',
980
+ })
981
+ : undefined
982
+ }
983
+ />
984
+ )}
985
+ hasOperationalIssues={hasOperationalIssues}
986
+ operationalIssuesPending={issuesPending}
987
+ onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
988
+ resolvedGitOpsOwner={gitopsOwner}
989
+ gitOpsOwnerVerified={gitOpsOwnerVerified}
990
+ gitOpsOwnerPending={gitOpsOwnerPending}
991
+ gitOpsOwnerSource={gitOpsOwnerSource}
992
+ gitOpsOwnerStatus={gitOpsOwnerStatus}
993
+ helmOwner={helmOwner}
994
+ helmOwnerSource={helmOwnerSource}
995
+ onOpenHelmRelease={handleOpenHelmRelease}
996
+ onNavigateGitOpsPath={handleNavigateGitOpsPath}
997
+ />
998
+ <CreateResourceDialog
999
+ open={duplicateDialogOpen}
1000
+ onClose={() => setDuplicateDialogOpen(false)}
1001
+ initialYaml={duplicateYaml}
1002
+ title="Duplicate Resource"
1003
+ onCreated={(result) => {
1004
+ rest.onNavigateToResource?.({
1005
+ kind: kindToPlural(result.kind),
1006
+ namespace: result.namespace,
1007
+ name: result.name,
1008
+ group: '',
1009
+ })
1010
+ }}
1011
+ />
1012
+ {comparePicker}
596
1013
  </>
597
1014
  )
598
1015
  }
599
1016
 
600
- function resolveGitOpsOwner(owner: GitOpsOwnerRef | null, argoApplications: any[] | undefined): GitOpsOwnerRef | null {
1017
+ function collectServingRefs(relationships: Relationships | undefined): ResourceRef[] {
1018
+ if (!relationships) return []
1019
+ return dedupeRefs([
1020
+ ...(relationships.services ?? []),
1021
+ ...(relationships.ingresses ?? []),
1022
+ ...(relationships.gateways ?? []),
1023
+ ...(relationships.routes ?? []),
1024
+ ])
1025
+ }
1026
+
1027
+ function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
1028
+ const seen = new Set<string>()
1029
+ return refs.filter((ref) => {
1030
+ const key = `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
1031
+ if (seen.has(key)) return false
1032
+ seen.add(key)
1033
+ return true
1034
+ })
1035
+ }
1036
+
1037
+ function resolveGitOpsOwner(
1038
+ owner: GitOpsOwnerRef | null,
1039
+ argoApplications: any[] | undefined,
1040
+ ): GitOpsOwnerRef | null {
601
1041
  if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
602
1042
  const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
603
1043
  if (matches.length !== 1) return owner
@@ -611,9 +1051,8 @@ function findInheritedGitOpsLookupRef(
611
1051
  current: ResourceRef,
612
1052
  ): ResourceRef | null {
613
1053
  if (directOwner) return null
614
- const inheritedManagerRefs = (relationships?.managedBy ?? []).filter((ref) =>
615
- !gitOpsOwnerFromRelationships({ managedBy: [ref] })
616
- && !isNativeHelmManager(ref)
1054
+ const inheritedManagerRefs = (relationships?.managedBy ?? []).filter(
1055
+ (ref) => !gitOpsOwnerFromRelationships({ managedBy: [ref] }) && !isNativeHelmManager(ref),
617
1056
  )
618
1057
  const candidates = [
619
1058
  relationships?.deployment,
@@ -624,7 +1063,89 @@ function findInheritedGitOpsLookupRef(
624
1063
  return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
625
1064
  }
626
1065
 
627
- function nativeHelmOwnerFromRelationships(relationships: Relationships | undefined, fallbackNamespace: string): HelmOwnerRef | null {
1066
+ const POD_OWNERSHIP_WORKLOAD_KINDS = new Set([
1067
+ 'deployments',
1068
+ 'statefulsets',
1069
+ 'daemonsets',
1070
+ 'jobs',
1071
+ 'cronjobs',
1072
+ 'rollouts',
1073
+ ])
1074
+
1075
+ function podWorkloadOwnerFromRelationships(
1076
+ kind: string,
1077
+ namespace: string,
1078
+ relationships: Relationships | undefined,
1079
+ resource: any,
1080
+ ): ResourceRef | null {
1081
+ if (kindToPlural(kind).toLowerCase() !== 'pods') return null
1082
+
1083
+ if (relationships?.deployment) return relationships.deployment
1084
+
1085
+ const managedWorkload = relationships?.managedBy?.find((ref) => isPodOwnershipWorkloadRef(ref))
1086
+ if (managedWorkload) return managedWorkload
1087
+
1088
+ if (relationships?.owner && isPodOwnershipWorkloadRef(relationships.owner))
1089
+ return relationships.owner
1090
+
1091
+ return podControllerOwnerFromMetadata(namespace, resource)
1092
+ }
1093
+
1094
+ function isPodOwnershipWorkloadRef(ref: ResourceRef): boolean {
1095
+ return POD_OWNERSHIP_WORKLOAD_KINDS.has(kindToPlural(ref.kind).toLowerCase())
1096
+ }
1097
+
1098
+ function podControllerOwnerFromMetadata(namespace: string, resource: any): ResourceRef | null {
1099
+ const ownerRefs = resource?.metadata?.ownerReferences
1100
+ if (!Array.isArray(ownerRefs)) return null
1101
+ const owner = ownerRefs.find((ref) => ref?.controller === true) ?? null
1102
+ if (!owner?.kind || !owner?.name) return null
1103
+ if (
1104
+ !isPodOwnershipWorkloadRef({
1105
+ kind: owner.kind,
1106
+ namespace,
1107
+ name: owner.name,
1108
+ })
1109
+ )
1110
+ return null
1111
+ return {
1112
+ kind: owner.kind,
1113
+ namespace,
1114
+ name: owner.name,
1115
+ group: apiVersionToGroup(owner.apiVersion),
1116
+ }
1117
+ }
1118
+
1119
+ function buildPodOwnershipContext(
1120
+ workload: ResourceRef | null,
1121
+ apps: AppRow[] | undefined,
1122
+ ): ResourceOwnershipContext | undefined {
1123
+ if (!workload) return undefined
1124
+ const matches = (apps ?? []).filter((app) =>
1125
+ (app.workloads ?? []).some((candidate) => sameWorkload(candidate, workload)),
1126
+ )
1127
+ const app = matches.length === 1 ? matches[0] : null
1128
+ return {
1129
+ workload,
1130
+ application: app ? { key: app.key, name: app.name } : undefined,
1131
+ }
1132
+ }
1133
+
1134
+ function sameWorkload(
1135
+ candidate: { kind: string; namespace: string; name: string },
1136
+ workload: ResourceRef,
1137
+ ): boolean {
1138
+ return (
1139
+ kindToPlural(candidate.kind).toLowerCase() === kindToPlural(workload.kind).toLowerCase() &&
1140
+ candidate.namespace === workload.namespace &&
1141
+ candidate.name === workload.name
1142
+ )
1143
+ }
1144
+
1145
+ function nativeHelmOwnerFromRelationships(
1146
+ relationships: Relationships | undefined,
1147
+ fallbackNamespace: string,
1148
+ ): HelmOwnerRef | null {
628
1149
  const ref = relationships?.managedBy?.[0]
629
1150
  if (!ref || !isNativeHelmManager(ref)) return null
630
1151
  return {
@@ -634,10 +1155,12 @@ function nativeHelmOwnerFromRelationships(relationships: Relationships | undefin
634
1155
  }
635
1156
 
636
1157
  function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
637
- return kindToPlural(ref.kind) === kindToPlural(current.kind)
638
- && ref.namespace === current.namespace
639
- && ref.name === current.name
640
- && (ref.group ?? '') === (current.group ?? '')
1158
+ return (
1159
+ kindToPlural(ref.kind) === kindToPlural(current.kind) &&
1160
+ ref.namespace === current.namespace &&
1161
+ ref.name === current.name &&
1162
+ (ref.group ?? '') === (current.group ?? '')
1163
+ )
641
1164
  }
642
1165
 
643
1166
  function isNativeHelmManager(ref: ResourceRef): boolean {
@@ -650,8 +1173,14 @@ function describeGitOpsOwnerSource(owner: GitOpsOwnerRef | null, resource: any):
650
1173
  const annotations = resource.metadata?.annotations ?? {}
651
1174
 
652
1175
  if (owner.tool === 'fluxcd') {
653
- const nameKey = owner.kind === 'helmreleases' ? 'helm.toolkit.fluxcd.io/name' : 'kustomize.toolkit.fluxcd.io/name'
654
- const nsKey = owner.kind === 'helmreleases' ? 'helm.toolkit.fluxcd.io/namespace' : 'kustomize.toolkit.fluxcd.io/namespace'
1176
+ const nameKey =
1177
+ owner.kind === 'helmreleases'
1178
+ ? 'helm.toolkit.fluxcd.io/name'
1179
+ : 'kustomize.toolkit.fluxcd.io/name'
1180
+ const nsKey =
1181
+ owner.kind === 'helmreleases'
1182
+ ? 'helm.toolkit.fluxcd.io/namespace'
1183
+ : 'kustomize.toolkit.fluxcd.io/namespace'
655
1184
  if (labels[nameKey] || labels[nsKey]) {
656
1185
  return `${nameKey}=${labels[nameKey] ?? ''}, ${nsKey}=${labels[nsKey] ?? ''}`
657
1186
  }
@@ -699,7 +1228,14 @@ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
699
1228
  // LOGS TAB — platform-specific (uses data-fetching hooks)
700
1229
  // ============================================================================
701
1230
 
702
- const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
1231
+ const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'Workflow'])
1232
+ const SCHEDULED_LOG_KINDS = new Set([
1233
+ 'CronJob',
1234
+ 'CronWorkflow',
1235
+ 'WorkflowTemplate',
1236
+ 'ClusterWorkflowTemplate',
1237
+ 'ScaledJob',
1238
+ ])
703
1239
 
704
1240
  function LogsTabContent({
705
1241
  kind,
@@ -712,6 +1248,8 @@ function LogsTabContent({
712
1248
  onSelectPod,
713
1249
  initialContainer,
714
1250
  onConsumeInitialContainer,
1251
+ selectedRunKey,
1252
+ onSelectRun,
715
1253
  }: {
716
1254
  kind: string
717
1255
  apiKind: string
@@ -723,19 +1261,48 @@ function LogsTabContent({
723
1261
  onSelectPod: (name: string | null) => void
724
1262
  initialContainer: string | null
725
1263
  onConsumeInitialContainer: () => void
1264
+ selectedRunKey: string
1265
+ onSelectRun: (runKey: string) => void
726
1266
  }) {
727
- // Workload kinds (Deployment, StatefulSet, DaemonSet) use the aggregated workload logs viewer
1267
+ if (SCHEDULED_LOG_KINDS.has(kind)) {
1268
+ return (
1269
+ <div className="h-full">
1270
+ <ScheduledWorkloadLogsViewer
1271
+ kind={apiKind}
1272
+ namespace={namespace}
1273
+ name={name}
1274
+ selectedRunKey={selectedRunKey}
1275
+ onSelectRun={onSelectRun}
1276
+ />
1277
+ </div>
1278
+ )
1279
+ }
1280
+
1281
+ // Workload kinds with stable pod selectors use the aggregated workload logs viewer
728
1282
  if (WORKLOAD_LOG_KINDS.has(kind)) {
729
1283
  return (
730
1284
  <div className="h-full">
731
- <WorkloadLogsViewer kind={apiKind} namespace={namespace} name={name} />
1285
+ <WorkloadLogsViewer
1286
+ kind={apiKind}
1287
+ namespace={namespace}
1288
+ name={name}
1289
+ autoStream={shouldAutoStreamWorkloadLogs(kind, resource)}
1290
+ />
732
1291
  </div>
733
1292
  )
734
1293
  }
735
1294
 
736
1295
  // Individual Pod — use LogsViewer with container list from resource data
737
1296
  if (kind === 'Pod') {
738
- return <PodLogsTab namespace={namespace} name={name} resource={resource} initialContainer={initialContainer} onConsumeInitialContainer={onConsumeInitialContainer} />
1297
+ return (
1298
+ <PodLogsTab
1299
+ namespace={namespace}
1300
+ name={name}
1301
+ resource={resource}
1302
+ initialContainer={initialContainer}
1303
+ onConsumeInitialContainer={onConsumeInitialContainer}
1304
+ />
1305
+ )
739
1306
  }
740
1307
 
741
1308
  // Other kinds with associated pods (Jobs, CronJobs, ReplicaSets, etc.) — pod selector + LogsViewer
@@ -750,7 +1317,24 @@ function LogsTabContent({
750
1317
  )
751
1318
  }
752
1319
 
753
- function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInitialContainer }: {
1320
+ function shouldAutoStreamWorkloadLogs(kind: string, resource: any): boolean {
1321
+ if (kind === 'Job') {
1322
+ return (resource?.status?.active ?? 0) > 0
1323
+ }
1324
+ if (kind === 'Workflow') {
1325
+ const phase = resource?.status?.phase
1326
+ return phase === 'Running' || phase === 'Pending'
1327
+ }
1328
+ return true
1329
+ }
1330
+
1331
+ function PodLogsTab({
1332
+ namespace,
1333
+ name,
1334
+ resource,
1335
+ initialContainer,
1336
+ onConsumeInitialContainer,
1337
+ }: {
754
1338
  namespace: string
755
1339
  name: string
756
1340
  resource: any
@@ -789,7 +1373,13 @@ function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInit
789
1373
  )
790
1374
  }
791
1375
 
792
- function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialContainer }: {
1376
+ function MultiPodLogsTab({
1377
+ pods,
1378
+ namespace,
1379
+ selectedPod,
1380
+ onSelectPod,
1381
+ initialContainer,
1382
+ }: {
793
1383
  pods: ResourceRef[]
794
1384
  namespace: string
795
1385
  selectedPod: string | null
@@ -802,10 +1392,12 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
802
1392
  }
803
1393
  }, [pods, selectedPod, onSelectPod])
804
1394
 
805
- const podNamespace = pods.find(p => p.name === selectedPod)?.namespace || namespace
1395
+ const podNamespace = pods.find((p) => p.name === selectedPod)?.namespace || namespace
806
1396
 
807
1397
  // Fetch container list for the selected pod
808
- const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', { tailLines: 1 })
1398
+ const { data: logsData } = usePodLogs(podNamespace, selectedPod || '', {
1399
+ tailLines: 1,
1400
+ })
809
1401
  const containers = logsData?.containers || []
810
1402
 
811
1403
  // A terminated pod (common for Job/CronJob children) has nothing to follow —
@@ -828,7 +1420,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
828
1420
  <div className="h-full flex flex-col">
829
1421
  {pods.length > 1 && (
830
1422
  <div className="shrink-0 border-b border-theme-border bg-theme-surface/50 px-4 py-2 flex gap-2 overflow-x-auto">
831
- {pods.map(pod => (
1423
+ {pods.map((pod) => (
832
1424
  <button
833
1425
  key={pod.name}
834
1426
  onClick={() => onSelectPod(pod.name)}
@@ -836,7 +1428,7 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
836
1428
  'px-3 py-1.5 text-sm rounded-lg whitespace-nowrap transition-colors',
837
1429
  selectedPod === pod.name
838
1430
  ? 'bg-blue-500 text-theme-text-primary'
839
- : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
1431
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover',
840
1432
  )}
841
1433
  >
842
1434
  {pod.name.length > 40 ? '...' + pod.name.slice(-37) : pod.name}
@@ -860,11 +1452,15 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
860
1452
  )
861
1453
  }
862
1454
 
863
- function AuditSection({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
864
- const navigate = useNavigate()
865
- const { data: findings } = useResourceAudit(kind, namespace, name)
866
- if (!findings || findings.length === 0) return null
867
- return <AuditAlerts findings={findings} onViewAll={() => navigate('/checks')} />
1455
+ function AuditOverviewSection({
1456
+ findings,
1457
+ onViewAll,
1458
+ }: {
1459
+ findings: AuditFinding[]
1460
+ onViewAll: () => void
1461
+ }) {
1462
+ if (findings.length === 0) return null
1463
+ return <AuditAlerts findings={findings} onViewAll={onViewAll} />
868
1464
  }
869
1465
 
870
1466
  // FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
@@ -884,7 +1480,15 @@ function AuditSection({ kind, namespace, name }: { kind: string; namespace: stri
884
1480
  // …), since the hook has no `enabled` flag and can't be conditionally called
885
1481
  // (Rules of Hooks). The hooks only need to run when the focused resource is
886
1482
  // actually a Flux source CR.
887
- function FluxSourceConsumersSection({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
1483
+ function FluxSourceConsumersSection({
1484
+ kind,
1485
+ namespace,
1486
+ name,
1487
+ }: {
1488
+ kind: string
1489
+ namespace: string
1490
+ name: string
1491
+ }) {
888
1492
  // The inner WorkloadView de-pluralizes the URL's plural form, which gives
889
1493
  // "Gitrepository" (single-uppercase) rather than the wire-correct
890
1494
  // "GitRepository" — so we match lowercase. spec.sourceRef.kind on consumers
@@ -894,38 +1498,72 @@ function FluxSourceConsumersSection({ kind, namespace, name }: { kind: string; n
894
1498
  return <FluxSourceConsumersInner sourceKind={sourceKind} namespace={namespace} name={name} />
895
1499
  }
896
1500
 
897
- function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind: string; namespace: string; name: string }) {
1501
+ function FluxSourceConsumersInner({
1502
+ sourceKind,
1503
+ namespace,
1504
+ name,
1505
+ }: {
1506
+ sourceKind: string
1507
+ namespace: string
1508
+ name: string
1509
+ }) {
898
1510
  const navigate = useNavigate()
899
- const { data: kustomizations } = useResources<any>('kustomizations', undefined, 'kustomize.toolkit.fluxcd.io')
900
- const { data: helmReleases } = useResources<any>('helmreleases', undefined, 'helm.toolkit.fluxcd.io')
1511
+ const { data: kustomizations } = useResources<any>(
1512
+ 'kustomizations',
1513
+ undefined,
1514
+ 'kustomize.toolkit.fluxcd.io',
1515
+ )
1516
+ const { data: helmReleases } = useResources<any>(
1517
+ 'helmreleases',
1518
+ undefined,
1519
+ 'helm.toolkit.fluxcd.io',
1520
+ )
901
1521
 
902
- const consumers: Array<{ kind: 'Kustomization' | 'HelmRelease'; namespace: string; name: string; plural: string }> = []
1522
+ const consumers: Array<{
1523
+ kind: 'Kustomization' | 'HelmRelease'
1524
+ namespace: string
1525
+ name: string
1526
+ plural: string
1527
+ }> = []
903
1528
  for (const k of kustomizations ?? []) {
904
1529
  const ref = k?.spec?.sourceRef ?? {}
905
1530
  const refNs = ref.namespace || k?.metadata?.namespace
906
1531
  if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
907
- consumers.push({ kind: 'Kustomization', namespace: k.metadata.namespace, name: k.metadata.name, plural: 'kustomizations' })
1532
+ consumers.push({
1533
+ kind: 'Kustomization',
1534
+ namespace: k.metadata.namespace,
1535
+ name: k.metadata.name,
1536
+ plural: 'kustomizations',
1537
+ })
908
1538
  }
909
1539
  }
910
1540
  for (const h of helmReleases ?? []) {
911
1541
  const ref = h?.spec?.chart?.spec?.sourceRef ?? {}
912
1542
  const refNs = ref.namespace || h?.metadata?.namespace
913
1543
  if (ref.kind === sourceKind && ref.name === name && refNs === namespace) {
914
- consumers.push({ kind: 'HelmRelease', namespace: h.metadata.namespace, name: h.metadata.name, plural: 'helmreleases' })
1544
+ consumers.push({
1545
+ kind: 'HelmRelease',
1546
+ namespace: h.metadata.namespace,
1547
+ name: h.metadata.name,
1548
+ plural: 'helmreleases',
1549
+ })
915
1550
  }
916
1551
  }
917
1552
 
918
1553
  if (consumers.length === 0) {
919
1554
  return (
920
- <div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3 text-xs text-theme-text-tertiary">
921
- Consumed by no Kustomization or HelmRelease references this source.
922
- </div>
1555
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1556
+ <h3 className="mb-2 text-sm font-semibold text-theme-text-primary">Consumed by</h3>
1557
+ <p className="text-xs text-theme-text-tertiary">
1558
+ No Kustomization or HelmRelease references this source.
1559
+ </p>
1560
+ </section>
923
1561
  )
924
1562
  }
925
1563
 
926
1564
  return (
927
- <div className="rounded-lg border border-theme-border bg-theme-elevated/40 p-3">
928
- <h3 className="mb-2 text-xs font-medium text-theme-text-secondary">
1565
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1566
+ <h3 className="mb-3 text-sm font-semibold text-theme-text-primary">
929
1567
  Consumed by ({consumers.length})
930
1568
  </h3>
931
1569
  <div className="flex flex-wrap gap-1.5">
@@ -934,24 +1572,38 @@ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind:
934
1572
  key={`${c.kind}/${c.namespace}/${c.name}`}
935
1573
  content={`${c.kind} ${c.namespace}/${c.name}`}
936
1574
  >
937
- <button
938
- onClick={() => navigate(`/gitops/detail/${c.plural}/${encodeURIComponent(c.namespace)}/${encodeURIComponent(c.name)}`)}
939
- 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"
940
- >
941
- <span className="text-theme-text-tertiary">{c.kind === 'HelmRelease' ? 'HR' : 'K'}</span>
942
- <span>{c.namespace}/{c.name}</span>
943
- </button>
1575
+ <button
1576
+ onClick={() =>
1577
+ navigate(
1578
+ `/gitops/detail/${c.plural}/${encodeURIComponent(c.namespace)}/${encodeURIComponent(c.name)}`,
1579
+ )
1580
+ }
1581
+ 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"
1582
+ >
1583
+ <span className="text-theme-text-tertiary">
1584
+ {c.kind === 'HelmRelease' ? 'HR' : 'K'}
1585
+ </span>
1586
+ <span>
1587
+ {c.namespace}/{c.name}
1588
+ </span>
1589
+ </button>
944
1590
  </Tooltip>
945
1591
  ))}
946
1592
  </div>
947
- </div>
1593
+ </section>
948
1594
  )
949
1595
  }
950
1596
 
951
1597
  // Drawer mode: single chart + category tabs (compact for ~500px width).
952
1598
  // Full-screen mode: multi-chart grid so CPU + Memory + Network can be
953
1599
  // compared side-by-side without tab switching.
954
- function MetricsTabContent({ kind, namespace, name, resource, expanded }: {
1600
+ function MetricsTabContent({
1601
+ kind,
1602
+ namespace,
1603
+ name,
1604
+ resource,
1605
+ expanded,
1606
+ }: {
955
1607
  kind: string
956
1608
  namespace: string
957
1609
  name: string
@@ -969,12 +1621,7 @@ function MetricsTabContent({ kind, namespace, name, resource, expanded }: {
969
1621
  </div>
970
1622
  )}
971
1623
  <div className="flex-1 min-h-0">
972
- <PrometheusChartsGrid
973
- kind={kind}
974
- namespace={namespace}
975
- name={name}
976
- resource={resource}
977
- />
1624
+ <PrometheusChartsGrid kind={kind} namespace={namespace} name={name} resource={resource} />
978
1625
  </div>
979
1626
  </div>
980
1627
  )
@@ -982,17 +1629,15 @@ function MetricsTabContent({ kind, namespace, name, resource, expanded }: {
982
1629
 
983
1630
  // Drawer fallback: single chart with tabs + restart lane below. The chart's
984
1631
  // time-range selector is mirrored to the restart lane so they stay aligned.
985
- return (
986
- <DrawerMetricsContent
987
- kind={kind}
988
- namespace={namespace}
989
- name={name}
990
- resource={resource}
991
- />
992
- )
1632
+ return <DrawerMetricsContent kind={kind} namespace={namespace} name={name} resource={resource} />
993
1633
  }
994
1634
 
995
- function DrawerMetricsContent({ kind, namespace, name, resource }: {
1635
+ function DrawerMetricsContent({
1636
+ kind,
1637
+ namespace,
1638
+ name,
1639
+ resource,
1640
+ }: {
996
1641
  kind: string
997
1642
  namespace: string
998
1643
  name: string
@@ -1004,7 +1649,14 @@ function DrawerMetricsContent({ kind, namespace, name, resource }: {
1004
1649
  return (
1005
1650
  <div className="flex flex-col h-full">
1006
1651
  <div className="flex-1 min-h-0">
1007
- <PrometheusCharts kind={kind} namespace={namespace} name={name} showEmptyState resource={resource} onTimeRangeChange={setChartRange} />
1652
+ <PrometheusCharts
1653
+ kind={kind}
1654
+ namespace={namespace}
1655
+ name={name}
1656
+ showEmptyState
1657
+ resource={resource}
1658
+ onTimeRangeChange={setChartRange}
1659
+ />
1008
1660
  </div>
1009
1661
  {showRestartLane && (
1010
1662
  <div className="px-4 pb-4">
@@ -1030,18 +1682,33 @@ const FLUX_SOURCE_KIND_BY_LOWER = new Map<string, string>([
1030
1682
  // Read-only manifest view for an object in the workload's neighborhood (the
1031
1683
  // YAML tab's object rail). Read-only by design — editing an arbitrary related
1032
1684
  // object belongs on that resource's own page.
1033
- function RelatedResourceYaml({ target }: { target: { kind: string; namespace: string; name: string; group?: string } }) {
1034
- const { data, isLoading, error } = useResource<any>(kindToPlural(target.kind), target.namespace, target.name, target.group)
1685
+ function RelatedResourceYaml({
1686
+ target,
1687
+ }: {
1688
+ target: { kind: string; namespace: string; name: string; group?: string }
1689
+ }) {
1690
+ const { data, isLoading, error } = useResource<any>(
1691
+ kindToPlural(target.kind),
1692
+ target.namespace,
1693
+ target.name,
1694
+ target.group,
1695
+ )
1035
1696
  const [copied, setCopied] = useState(false)
1036
1697
  const handleCopy = useCallback((text: string) => {
1037
1698
  navigator.clipboard.writeText(text)
1038
1699
  setCopied(true)
1039
1700
  setTimeout(() => setCopied(false), 1500)
1040
1701
  }, [])
1041
- if (!data) return <FetchResult loading={isLoading} error={error as Error | null} className="h-32" />
1702
+ if (!data)
1703
+ return <FetchResult loading={isLoading} error={error as Error | null} className="h-32" />
1042
1704
  return (
1043
1705
  <EditableYamlView
1044
- resource={{ kind: kindToPlural(target.kind), namespace: target.namespace, name: target.name, group: target.group }}
1706
+ resource={{
1707
+ kind: kindToPlural(target.kind),
1708
+ namespace: target.namespace,
1709
+ name: target.name,
1710
+ group: target.group,
1711
+ }}
1045
1712
  data={data}
1046
1713
  onCopy={handleCopy}
1047
1714
  copied={copied}