@skyhook-io/radar-app 1.8.6 → 1.8.7

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 (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. 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,23 +12,31 @@ 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'
26
+ import { kindToPlural, pluralToKind, relatedResourcePath, type NavigateToResource } from '../../utils/navigation'
22
27
  import {
23
28
  useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
24
29
  useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
25
30
  useRestartWorkload, useWorkloadRevisions, useRollbackWorkload,
31
+ useWorkloadPods,
26
32
  useFluxReconcile, useFluxSyncWithSource, useFluxSuspend, useFluxResume,
27
33
  useArgoSync, useArgoRefresh, useArgoSuspend, useArgoResume,
28
34
  useCordonNode, useUncordonNode, useDrainNode,
29
35
  useCascadeDeletePreview,
30
36
  useResourceEvents,
31
37
  useResource,
38
+ useWorkloadRuns,
39
+ useApplications,
32
40
  fetchJSON,
33
41
  } from '../../api/client'
34
42
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
@@ -38,10 +46,14 @@ import { RightsizingStrip } from '../resource/RightsizingStrip'
38
46
  import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
39
47
  import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
40
48
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
49
+ import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
41
50
  import { LogsViewer } from '../logs/LogsViewer'
51
+ import { BatchExecutionFullscreen } from '../execution/BatchExecutionView'
52
+ import { workloadRunTimelineEvents } from '../execution/batch-timeline'
42
53
  import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
43
54
  import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
44
- import { PortForwardButton } from '../portforward/PortForwardButton'
55
+ import { PortForwardButton, PortForwardInlineButton } from '../portforward/PortForwardButton'
56
+ import { CurlButton, CurlPanel, isHttpishPort, defaultScheme, defaultPathForPort } from '../curl/ServiceCurlButton'
45
57
  import { useToast } from '../ui/Toast'
46
58
  import { Tooltip } from '../ui/Tooltip'
47
59
  import { PodRenderer } from '../resources/renderers/PodRenderer'
@@ -59,9 +71,11 @@ import { CreateResourceDialog } from '../shared/CreateResourceDialog'
59
71
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
60
72
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
61
73
  import { useCompareLauncher } from '../compare/useCompareLauncher'
74
+ import { useDiagnoseCustomization } from '../../context/DiagnoseCustomization'
62
75
  import { apiVersionToGroup } from '../../utils/navigation'
63
76
 
64
77
  type TabType = WorkloadTabType
78
+ const BATCH_EXECUTION_KINDS = new Set(['Job', 'CronJob', 'Workflow', 'CronWorkflow', 'WorkflowTemplate', 'ClusterWorkflowTemplate', 'ScaledJob'])
65
79
 
66
80
  // Stable reference — web renderer wrappers inject platform hooks internally
67
81
  const rendererOverrides: RendererOverrides = {
@@ -122,7 +136,7 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
122
136
  }, [navigate])
123
137
 
124
138
  const handleNavigate = useCallback((resource: SelectedResource) => {
125
- navigate(buildWorkloadPath(resource))
139
+ navigate(relatedResourcePath(resource))
126
140
  }, [navigate])
127
141
 
128
142
  // Hooks must run unconditionally — the invalid-URL guard comes after them.
@@ -169,6 +183,7 @@ interface WorkloadViewProps {
169
183
  onCancelExpandIntent?: () => void
170
184
  initialTab?: 'detail' | 'yaml'
171
185
  group?: string
186
+ pushTabHistory?: boolean
172
187
  }
173
188
 
174
189
  function useActionsBarProps(kind: string, namespace: string, name: string) {
@@ -210,6 +225,8 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
210
225
  const uncordonMutation = useUncordonNode()
211
226
  const drainMutation = useDrainNode()
212
227
 
228
+ const renderDiagnose = useDiagnoseCustomization()
229
+
213
230
  return {
214
231
  canExec,
215
232
  canViewLogs,
@@ -222,6 +239,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
222
239
  renderPortForward: ({ type, namespace: ns, name: n, className }: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => (
223
240
  <PortForwardButton type={type} namespace={ns} name={n} className={className} />
224
241
  ),
242
+ renderDiagnose,
225
243
  onDelete: (params: Parameters<typeof deleteMutation.mutate>[0], callbacks?: { onSuccess?: () => void }) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
226
244
  isDeleting: deleteMutation.isPending,
227
245
  cascadeDependents: cascadePreview?.dependents,
@@ -270,9 +288,12 @@ export function WorkloadView({
270
288
  namespace,
271
289
  name,
272
290
  expanded = true,
291
+ pushTabHistory = false,
273
292
  ...rest
274
293
  }: WorkloadViewProps) {
275
294
  const [searchParams, setSearchParams] = useSearchParams()
295
+ const apiKind = kindToPlural(kindProp)
296
+ const queryClient = useQueryClient()
276
297
 
277
298
  // Tab state from URL query param — migrate legacy tab names
278
299
  const rawTab = searchParams.get('tab')
@@ -280,26 +301,63 @@ export function WorkloadView({
280
301
  : rawTab === 'events' ? 'timeline'
281
302
  : (rawTab as TabType) || 'overview'
282
303
 
283
- const handleTabChange = useCallback((tab: TabType) => {
304
+ const handleTabChange = useCallback((tab: TabType, opts?: { replace?: boolean }) => {
284
305
  const params = new URLSearchParams(searchParams)
285
306
  if (tab === 'overview') {
286
307
  params.delete('tab')
287
308
  } else {
288
309
  params.set('tab', tab)
289
310
  }
311
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
312
+ }, [pushTabHistory, searchParams, setSearchParams])
313
+
314
+ const selectedRunKey = searchParams.get('run') ?? ''
315
+ const handleSelectedRunChange = useCallback((runKey: string) => {
316
+ const params = new URLSearchParams(searchParams)
317
+ if (runKey) params.set('run', runKey)
318
+ else params.delete('run')
290
319
  setSearchParams(params, { replace: true })
291
320
  }, [searchParams, setSearchParams])
292
321
 
322
+ const batchKind = pluralToKind(apiKind)
323
+ const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind)
324
+ const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
325
+ refetchActive: true,
326
+ clusterScoped: batchKind === 'ClusterWorkflowTemplate',
327
+ })
328
+ const relatedTimelineEvents = useMemo(
329
+ () => workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
330
+ [batchRunsQuery.data?.runs],
331
+ )
332
+
293
333
  // Fetch resource with relationships
294
- const { data: resourceResponse, isLoading: resourceLoading, error: resourceError, refetch: refetchResource } = useResourceWithRelationships<any>(kindProp, namespace, name, rest.group)
334
+ const { data: resourceResponse, isLoading: resourceLoading, error: resourceError, refetch: refetchResource } = useResourceWithRelationships<any>(apiKind, namespace, name, rest.group)
295
335
  const resource = resourceResponse?.resource
296
336
  const relationships = resourceResponse?.relationships
337
+ const refetchResourceAndRuns = useCallback(async () => {
338
+ await Promise.all([
339
+ refetchResource(),
340
+ queryClient.refetchQueries({ queryKey: ['workload-runs', apiKind, namespace, name] }),
341
+ ])
342
+ }, [apiKind, name, namespace, queryClient, refetchResource])
343
+ const podWorkloadOwner = useMemo(
344
+ () => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
345
+ [apiKind, namespace, relationships, resource],
346
+ )
347
+ const podOwnerAppsQuery = useApplications(
348
+ podWorkloadOwner?.namespace ? [podWorkloadOwner.namespace] : [],
349
+ { enabled: Boolean(podWorkloadOwner?.namespace) },
350
+ )
351
+ const ownershipContext = useMemo(
352
+ () => buildPodOwnershipContext(podWorkloadOwner, podOwnerAppsQuery.data?.applications),
353
+ [podWorkloadOwner, podOwnerAppsQuery.data?.applications],
354
+ )
297
355
  const certificateInfo = resourceResponse?.certificateInfo
298
356
  const hpaDiagnosis = resourceResponse?.hpaDiagnosis
299
357
  const relationshipGitopsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
300
358
  const inheritedGitOpsLookupRef = useMemo(
301
- () => findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, { kind: kindProp, namespace, name, group: rest.group }),
302
- [relationships, relationshipGitopsOwner, kindProp, namespace, name, rest.group],
359
+ () => findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, { kind: apiKind, namespace, name, group: rest.group }),
360
+ [relationships, relationshipGitopsOwner, apiKind, namespace, name, rest.group],
303
361
  )
304
362
  const inheritedGitOpsResponse = useResourceWithRelationships<any>(
305
363
  inheritedGitOpsLookupRef ? kindToPlural(inheritedGitOpsLookupRef.kind) : '',
@@ -353,7 +411,7 @@ export function WorkloadView({
353
411
  )
354
412
 
355
413
  // For pods: extract envFrom ConfigMap/Secret names and resolve their keys
356
- const isPod = kindProp.toLowerCase() === 'pods'
414
+ const isPod = apiKind === 'pods'
357
415
  const { envFromConfigMapNames, envFromSecretNames } = useMemo(() => {
358
416
  if (!isPod || !resource) return { envFromConfigMapNames: [] as string[], envFromSecretNames: [] as string[] }
359
417
  const cmNames = new Set<string>()
@@ -418,7 +476,7 @@ export function WorkloadView({
418
476
  isLoading: resourceFocusedEventsLoading,
419
477
  k8sError: resourceFocusedK8sError,
420
478
  updatesError: resourceFocusedUpdatesError,
421
- } = useResourceEvents(kindProp, namespace, name)
479
+ } = useResourceEvents(apiKind, namespace, name)
422
480
 
423
481
  // Fetch all events for this resource's namespace (only when expanded)
424
482
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
@@ -432,8 +490,60 @@ export function WorkloadView({
432
490
 
433
491
  // RBAC
434
492
  const canUpdateSecrets = useCanUpdateSecrets()
493
+ const { canPortForward } = useNamespacedCapabilities(namespace)
494
+ const isLocalDeployment = useIsLocalDeployment()
495
+ const showServingPortForward = canPortForward || !isLocalDeployment
496
+ const showServingCurl = !isLocalDeployment
497
+ const [servingCurl, setServingCurl] = useState<{ namespace: string; serviceName: string; port: number; closing: boolean } | null>(null)
498
+ const closeServingCurl = useCallback(() => {
499
+ setServingCurl((p) => (p ? { ...p, closing: true } : null))
500
+ window.setTimeout(() => setServingCurl((p) => (p?.closing ? null : p)), 220)
501
+ }, [])
502
+ const renderServicePortAction = useCallback((props: ServicePortRenderProps) => {
503
+ const active = servingCurl?.namespace === props.namespace &&
504
+ servingCurl?.serviceName === props.serviceName &&
505
+ servingCurl?.port === props.port &&
506
+ !servingCurl.closing
507
+ return (
508
+ <>
509
+ {showServingCurl && isHttpishPort(props.port, props.name, props.appProtocol, props.protocol) && (
510
+ <CurlButton
511
+ active={active}
512
+ onClick={() => {
513
+ if (active) closeServingCurl()
514
+ else setServingCurl({ namespace: props.namespace, serviceName: props.serviceName, port: props.port, closing: false })
515
+ }}
516
+ />
517
+ )}
518
+ {showServingPortForward && (
519
+ <PortForwardInlineButton
520
+ namespace={props.namespace}
521
+ serviceName={props.serviceName}
522
+ port={props.port}
523
+ protocol={props.protocol}
524
+ />
525
+ )}
526
+ </>
527
+ )
528
+ }, [closeServingCurl, servingCurl, showServingCurl, showServingPortForward])
529
+ const renderServicePortPanel = useCallback((props: ServicePortRenderProps) => {
530
+ const active = servingCurl?.namespace === props.namespace &&
531
+ servingCurl?.serviceName === props.serviceName &&
532
+ servingCurl?.port === props.port
533
+ return active ? (
534
+ <CurlPanel
535
+ namespace={props.namespace}
536
+ serviceName={props.serviceName}
537
+ port={props.port}
538
+ initialScheme={defaultScheme(props.port, props.name, props.appProtocol)}
539
+ initialPath={defaultPathForPort(props.port, props.name, props.appProtocol)}
540
+ open={!servingCurl.closing}
541
+ onClose={closeServingCurl}
542
+ />
543
+ ) : null
544
+ }, [closeServingCurl, servingCurl])
435
545
  const updateResource = useUpdateResource()
436
- const baseActionsBarProps = useActionsBarProps(kindProp, namespace, name)
546
+ const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
437
547
  const desktopDownload = useDesktopDownload()
438
548
 
439
549
  const resourceGroup = useMemo(
@@ -443,14 +553,16 @@ export function WorkloadView({
443
553
  // Live Operational Issues for this resource. Fetched here (not inside the lead
444
554
  // render-prop) so the count also gates `hasOperationalIssues` — which tells the
445
555
  // 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),
556
+ // Keyed on the stable API kind+group (same inputs as the resource fetch above),
447
557
  // NOT the manifest-derived ones: deriving kind/group from the loaded resource
448
558
  // would flip the query key when the manifest arrives, drop liveIssues, and flash
449
559
  // 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)
560
+ // so using the normalized API kind resolves direct links and app navigation alike.
561
+ const { data: liveIssues } = useResourceIssues(apiKind, rest.group, namespace, name)
562
+ const { data: auditFindings } = useResourceAudit(apiKind, namespace, name)
563
+ const hasOperationalIssues = Boolean(liveIssues?.length)
452
564
  const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
453
- kind: kindProp,
565
+ kind: apiKind,
454
566
  namespace,
455
567
  name,
456
568
  // Prefer the URL-supplied group so Compare works even before the resource
@@ -491,6 +603,19 @@ export function WorkloadView({
491
603
  },
492
604
  [navigateRouter, searchParams],
493
605
  )
606
+ const handleOpenApplication = useCallback(
607
+ (appKey: string) => {
608
+ const params = new URLSearchParams()
609
+ const namespaces = new Set((searchParams.get('namespaces') ?? '').split(',').map((ns) => ns.trim()).filter(Boolean))
610
+ if (ownershipContext?.application?.key === appKey && ownershipContext.workload.namespace) {
611
+ namespaces.add(ownershipContext.workload.namespace)
612
+ }
613
+ if (namespaces.size > 0) params.set('namespaces', Array.from(namespaces).join(','))
614
+ params.set('app', appKey)
615
+ navigateRouter({ pathname: '/applications', search: params.toString() })
616
+ },
617
+ [navigateRouter, ownershipContext, searchParams],
618
+ )
494
619
 
495
620
  // Duplicate dialog
496
621
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
@@ -501,10 +626,46 @@ export function WorkloadView({
501
626
  setDuplicateDialogOpen(true)
502
627
  }, [])
503
628
 
629
+ const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
630
+ const workloadPodsQuery = useWorkloadPods(
631
+ supportsWorkloadPods ? apiKind : '',
632
+ namespace,
633
+ name,
634
+ )
635
+ const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
636
+ const servingQueries = useQueries({
637
+ queries: servingRefs.map((ref) => {
638
+ const pluralKind = kindToPlural(ref.kind)
639
+ const ns = ref.namespace || '_'
640
+ const params = new URLSearchParams()
641
+ if (ref.group) params.set('group', ref.group)
642
+ const queryString = params.toString()
643
+ return {
644
+ queryKey: ['resource', pluralKind, ref.namespace, ref.name, ref.group],
645
+ queryFn: () => fetchJSON<any>(`/resources/${pluralKind}/${ns}/${ref.name}${queryString ? `?${queryString}` : ''}`),
646
+ enabled: expanded && Boolean(ref.kind && ref.name),
647
+ staleTime: 30000,
648
+ }
649
+ }),
650
+ })
651
+ const servingResources = useMemo<ServingResourceDetail[]>(
652
+ () => servingRefs.map((ref, index) => {
653
+ const query = servingQueries[index]
654
+ const data = query?.data?.resource ?? query?.data
655
+ return {
656
+ ref,
657
+ resource: data,
658
+ loading: query?.isLoading ?? false,
659
+ error: (query?.error as Error | null) ?? null,
660
+ }
661
+ }),
662
+ [servingRefs, servingQueries],
663
+ )
664
+
504
665
  return (
505
666
  <>
506
667
  <BaseWorkloadView
507
- kind={kindProp}
668
+ kind={apiKind}
508
669
  namespace={namespace}
509
670
  name={name}
510
671
  expanded={expanded}
@@ -512,14 +673,23 @@ export function WorkloadView({
512
673
  // Data
513
674
  resource={resource}
514
675
  relationships={relationships}
676
+ ownershipContext={ownershipContext}
677
+ onOpenApplication={handleOpenApplication}
515
678
  certificateInfo={certificateInfo}
516
679
  hpaDiagnosis={hpaDiagnosis}
680
+ workloadPods={supportsWorkloadPods ? workloadPodsQuery.data?.pods : undefined}
681
+ workloadPodsLoading={supportsWorkloadPods ? workloadPodsQuery.isLoading : false}
682
+ workloadPodsError={supportsWorkloadPods ? (workloadPodsQuery.error as Error | null) : null}
683
+ servingResources={servingResources}
684
+ renderServicePortAction={renderServicePortAction}
685
+ renderServicePortPanel={renderServicePortPanel}
517
686
  isLoading={resourceLoading}
518
687
  resourceError={resourceError}
519
- refetch={refetchResource}
688
+ refetch={refetchResourceAndRuns}
520
689
  // Timeline
521
690
  allEvents={allEvents}
522
- eventsLoading={eventsLoading}
691
+ relatedTimelineEvents={relatedTimelineEvents}
692
+ eventsLoading={eventsLoading || (batchExecution && batchRunsQuery.isLoading)}
523
693
  topology={topology}
524
694
  resourceFocusedK8sEvents={resourceFocusedK8sEvents}
525
695
  resourceFocusedUpdates={resourceFocusedUpdates}
@@ -536,7 +706,24 @@ export function WorkloadView({
536
706
  activeTab={migratedTab}
537
707
  onTabChange={handleTabChange}
538
708
  // Render props
539
- renderLogsTab={(props) => <LogsTabContent {...props} />}
709
+ renderLogsTab={(props) => <LogsTabContent {...props} selectedRunKey={selectedRunKey} onSelectRun={handleSelectedRunChange} />}
710
+ renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
711
+ BATCH_EXECUTION_KINDS.has(k) && res ? (
712
+ <BatchExecutionFullscreen
713
+ kind={k}
714
+ apiKind={apiKind}
715
+ namespace={ns}
716
+ name={n}
717
+ resource={res}
718
+ selectedRunKey={selectedRunKey}
719
+ canViewLogs={baseActionsBarProps.canViewLogs}
720
+ onSelectRun={handleSelectedRunChange}
721
+ onSwitchToLogs={() => handleTabChange('logs')}
722
+ onSwitchToTimeline={() => handleTabChange('timeline')}
723
+ onNavigateToResource={rest.onNavigateToResource}
724
+ />
725
+ ) : null
726
+ }
540
727
  renderRelatedYaml={(ref) => <RelatedResourceYaml key={`${ref.kind}/${ref.namespace}/${ref.name}`} target={ref} />}
541
728
  renderMetricsTab={({ kind, namespace: ns, name: n }) => (
542
729
  <MetricsTabContent kind={kind} namespace={ns} name={n} resource={resource} expanded={expanded} />
@@ -551,8 +738,8 @@ export function WorkloadView({
551
738
  resolvedEnvFrom={resolvedEnvFrom}
552
739
  renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
553
740
  <>
554
- <AuditSection kind={k} namespace={ns} name={n} />
555
741
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
742
+ <AuditOverviewSection findings={auditFindings ?? []} onViewAll={() => navigateRouter('/checks')} />
556
743
  </>
557
744
  )}
558
745
  renderOverviewLead={() => (
@@ -571,7 +758,7 @@ export function WorkloadView({
571
758
  }
572
759
  />
573
760
  )}
574
- hasOperationalIssues={!!liveIssues?.length}
761
+ hasOperationalIssues={hasOperationalIssues}
575
762
  onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
576
763
  resolvedGitOpsOwner={gitopsOwner}
577
764
  gitOpsOwnerVerified={gitOpsOwnerVerified}
@@ -597,6 +784,26 @@ export function WorkloadView({
597
784
  )
598
785
  }
599
786
 
787
+ function collectServingRefs(relationships: Relationships | undefined): ResourceRef[] {
788
+ if (!relationships) return []
789
+ return dedupeRefs([
790
+ ...(relationships.services ?? []),
791
+ ...(relationships.ingresses ?? []),
792
+ ...(relationships.gateways ?? []),
793
+ ...(relationships.routes ?? []),
794
+ ])
795
+ }
796
+
797
+ function dedupeRefs(refs: ResourceRef[]): ResourceRef[] {
798
+ const seen = new Set<string>()
799
+ return refs.filter((ref) => {
800
+ const key = `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
801
+ if (seen.has(key)) return false
802
+ seen.add(key)
803
+ return true
804
+ })
805
+ }
806
+
600
807
  function resolveGitOpsOwner(owner: GitOpsOwnerRef | null, argoApplications: any[] | undefined): GitOpsOwnerRef | null {
601
808
  if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
602
809
  const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
@@ -624,6 +831,60 @@ function findInheritedGitOpsLookupRef(
624
831
  return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
625
832
  }
626
833
 
834
+ const POD_OWNERSHIP_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'rollouts'])
835
+
836
+ function podWorkloadOwnerFromRelationships(kind: string, namespace: string, relationships: Relationships | undefined, resource: any): ResourceRef | null {
837
+ if (kindToPlural(kind).toLowerCase() !== 'pods') return null
838
+
839
+ if (relationships?.deployment) return relationships.deployment
840
+
841
+ const managedWorkload = relationships?.managedBy?.find((ref) => isPodOwnershipWorkloadRef(ref))
842
+ if (managedWorkload) return managedWorkload
843
+
844
+ if (relationships?.owner && isPodOwnershipWorkloadRef(relationships.owner)) return relationships.owner
845
+
846
+ return podControllerOwnerFromMetadata(namespace, resource)
847
+ }
848
+
849
+ function isPodOwnershipWorkloadRef(ref: ResourceRef): boolean {
850
+ return POD_OWNERSHIP_WORKLOAD_KINDS.has(kindToPlural(ref.kind).toLowerCase())
851
+ }
852
+
853
+ function podControllerOwnerFromMetadata(namespace: string, resource: any): ResourceRef | null {
854
+ const ownerRefs = resource?.metadata?.ownerReferences
855
+ if (!Array.isArray(ownerRefs)) return null
856
+ const owner = ownerRefs.find((ref) => ref?.controller === true) ?? null
857
+ if (!owner?.kind || !owner?.name) return null
858
+ if (!isPodOwnershipWorkloadRef({ kind: owner.kind, namespace, name: owner.name })) return null
859
+ return {
860
+ kind: owner.kind,
861
+ namespace,
862
+ name: owner.name,
863
+ group: apiVersionToGroup(owner.apiVersion),
864
+ }
865
+ }
866
+
867
+ function buildPodOwnershipContext(
868
+ workload: ResourceRef | null,
869
+ apps: AppRow[] | undefined,
870
+ ): ResourceOwnershipContext | undefined {
871
+ if (!workload) return undefined
872
+ const matches = (apps ?? []).filter((app) =>
873
+ (app.workloads ?? []).some((candidate) => sameWorkload(candidate, workload)),
874
+ )
875
+ const app = matches.length === 1 ? matches[0] : null
876
+ return {
877
+ workload,
878
+ application: app ? { key: app.key, name: app.name } : undefined,
879
+ }
880
+ }
881
+
882
+ function sameWorkload(candidate: { kind: string; namespace: string; name: string }, workload: ResourceRef): boolean {
883
+ return kindToPlural(candidate.kind).toLowerCase() === kindToPlural(workload.kind).toLowerCase()
884
+ && candidate.namespace === workload.namespace
885
+ && candidate.name === workload.name
886
+ }
887
+
627
888
  function nativeHelmOwnerFromRelationships(relationships: Relationships | undefined, fallbackNamespace: string): HelmOwnerRef | null {
628
889
  const ref = relationships?.managedBy?.[0]
629
890
  if (!ref || !isNativeHelmManager(ref)) return null
@@ -699,7 +960,8 @@ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
699
960
  // LOGS TAB — platform-specific (uses data-fetching hooks)
700
961
  // ============================================================================
701
962
 
702
- const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
963
+ const WORKLOAD_LOG_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'Workflow'])
964
+ const SCHEDULED_LOG_KINDS = new Set(['CronJob', 'CronWorkflow', 'WorkflowTemplate', 'ClusterWorkflowTemplate', 'ScaledJob'])
703
965
 
704
966
  function LogsTabContent({
705
967
  kind,
@@ -712,6 +974,8 @@ function LogsTabContent({
712
974
  onSelectPod,
713
975
  initialContainer,
714
976
  onConsumeInitialContainer,
977
+ selectedRunKey,
978
+ onSelectRun,
715
979
  }: {
716
980
  kind: string
717
981
  apiKind: string
@@ -723,12 +987,22 @@ function LogsTabContent({
723
987
  onSelectPod: (name: string | null) => void
724
988
  initialContainer: string | null
725
989
  onConsumeInitialContainer: () => void
990
+ selectedRunKey: string
991
+ onSelectRun: (runKey: string) => void
726
992
  }) {
727
- // Workload kinds (Deployment, StatefulSet, DaemonSet) use the aggregated workload logs viewer
993
+ if (SCHEDULED_LOG_KINDS.has(kind)) {
994
+ return (
995
+ <div className="h-full">
996
+ <ScheduledWorkloadLogsViewer kind={apiKind} namespace={namespace} name={name} selectedRunKey={selectedRunKey} onSelectRun={onSelectRun} />
997
+ </div>
998
+ )
999
+ }
1000
+
1001
+ // Workload kinds with stable pod selectors use the aggregated workload logs viewer
728
1002
  if (WORKLOAD_LOG_KINDS.has(kind)) {
729
1003
  return (
730
1004
  <div className="h-full">
731
- <WorkloadLogsViewer kind={apiKind} namespace={namespace} name={name} />
1005
+ <WorkloadLogsViewer kind={apiKind} namespace={namespace} name={name} autoStream={shouldAutoStreamWorkloadLogs(kind, resource)} />
732
1006
  </div>
733
1007
  )
734
1008
  }
@@ -750,6 +1024,17 @@ function LogsTabContent({
750
1024
  )
751
1025
  }
752
1026
 
1027
+ function shouldAutoStreamWorkloadLogs(kind: string, resource: any): boolean {
1028
+ if (kind === 'Job') {
1029
+ return (resource?.status?.active ?? 0) > 0
1030
+ }
1031
+ if (kind === 'Workflow') {
1032
+ const phase = resource?.status?.phase
1033
+ return phase === 'Running' || phase === 'Pending'
1034
+ }
1035
+ return true
1036
+ }
1037
+
753
1038
  function PodLogsTab({ namespace, name, resource, initialContainer, onConsumeInitialContainer }: {
754
1039
  namespace: string
755
1040
  name: string
@@ -860,11 +1145,9 @@ function MultiPodLogsTab({ pods, namespace, selectedPod, onSelectPod, initialCon
860
1145
  )
861
1146
  }
862
1147
 
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')} />
1148
+ function AuditOverviewSection({ findings, onViewAll }: { findings: AuditFinding[]; onViewAll: () => void }) {
1149
+ if (findings.length === 0) return null
1150
+ return <AuditAlerts findings={findings} onViewAll={onViewAll} />
868
1151
  }
869
1152
 
870
1153
  // FluxSourceConsumersSection lists the reconcilers (Kustomization, HelmRelease)
@@ -917,15 +1200,16 @@ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind:
917
1200
 
918
1201
  if (consumers.length === 0) {
919
1202
  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>
1203
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1204
+ <h3 className="mb-2 text-sm font-semibold text-theme-text-primary">Consumed by</h3>
1205
+ <p className="text-xs text-theme-text-tertiary">No Kustomization or HelmRelease references this source.</p>
1206
+ </section>
923
1207
  )
924
1208
  }
925
1209
 
926
1210
  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">
1211
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1212
+ <h3 className="mb-3 text-sm font-semibold text-theme-text-primary">
929
1213
  Consumed by ({consumers.length})
930
1214
  </h3>
931
1215
  <div className="flex flex-wrap gap-1.5">
@@ -944,7 +1228,7 @@ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind:
944
1228
  </Tooltip>
945
1229
  ))}
946
1230
  </div>
947
- </div>
1231
+ </section>
948
1232
  )
949
1233
  }
950
1234