@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
1
+ import { useState, useMemo, useEffect, useRef, useCallback, type ComponentType, type ReactNode } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
4
  import { startViewTransitionSafe } from '../../utils/view-transition'
@@ -11,18 +11,26 @@ import {
11
11
  ArrowRight,
12
12
  RefreshCw,
13
13
  Activity,
14
+ AlertTriangle,
14
15
  Terminal,
15
16
  Layers,
16
17
  FileText,
17
18
  Copy,
18
19
  Check,
20
+ CheckCircle2,
21
+ Clock3,
22
+ Boxes,
23
+ ExternalLink,
24
+ GitBranch,
19
25
  Minimize2,
20
26
  Maximize2,
27
+ Scale,
28
+ Server,
21
29
  X,
22
30
  BarChart3,
23
31
  Network,
24
32
  } from 'lucide-react'
25
- import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis } from '../../types'
33
+ import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
26
34
  import type { GitOpsStatus } from '../../types/gitops'
27
35
  import type { NavigateToResource } from '../../utils/navigation'
28
36
  import { refToSelectedResource, pluralToKind, kindToPlural, apiVersionToGroup } from '../../utils/navigation'
@@ -30,34 +38,50 @@ import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
30
38
  import { TopologyGraph } from '../topology/TopologyGraph'
31
39
  import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
32
40
  import { gitOpsRouteForResource } from '../../utils/gitops-route'
33
- import { isChangeEvent, isHistoricalEvent } from '../../types'
34
- import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
35
41
  import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
36
- import {
37
- ZOOM_LEVELS,
38
- type ZoomLevel,
39
- formatAxisTime,
40
- EventMarker,
41
- EventDotLegend,
42
- HealthSpanLegend,
43
- HealthSpan,
44
- ZoomControls,
45
- buildHealthSpans,
46
- timeToX,
47
- calculateTimeRange,
48
- } from '../timeline/shared'
42
+ import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
43
+ import { TimelineList } from '../timeline/TimelineList'
49
44
  import { ResourceActionsBar } from '../shared/ResourceActionsBar'
50
45
  import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
51
- import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
46
+ import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
52
47
  import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
53
48
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
54
49
  import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
55
- import { getKindColorOutline, displayKindName, OperationalIssuesShownContext } from '../ui/drawer-components'
50
+ import { getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
51
+ import { Badge, type BadgeSeverity } from '../ui/Badge'
52
+ import { Tooltip } from '../ui/Tooltip'
56
53
  import { midTruncate } from '../../utils/format'
54
+ import { hpaStateLabel } from '../resources/resource-utils-hpa'
55
+ import {
56
+ cronToHuman,
57
+ formatAge,
58
+ formatDuration,
59
+ getGatewayAddresses,
60
+ getGatewayListeners,
61
+ getIngressAddress,
62
+ getIngressHosts,
63
+ getServiceExternalIP,
64
+ } from '../resources/resource-utils'
65
+ import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer'
57
66
 
58
67
  export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
59
68
  type TabType = WorkloadTabType
60
69
 
70
+ export interface ResourceOwnershipContext {
71
+ application?: {
72
+ key: string
73
+ name: string
74
+ }
75
+ workload: ResourceRef
76
+ }
77
+
78
+ export interface ServingResourceDetail {
79
+ ref: ResourceRef
80
+ resource?: any
81
+ loading?: boolean
82
+ error?: Error | null
83
+ }
84
+
61
85
  // ============================================================================
62
86
  // MAIN WORKLOAD VIEW — presentation only, data injected via props
63
87
  // ============================================================================
@@ -105,6 +129,10 @@ interface WorkloadViewProps {
105
129
  scopeControls?: ReactNode
106
130
  /** Hide WorkloadView's own breadcrumb/identity header when a host page owns that chrome. */
107
131
  compactHeader?: boolean
132
+ /** High-confidence containment context for resources whose controller ownership is reliable, currently Pods. */
133
+ ownershipContext?: ResourceOwnershipContext
134
+ /** Open the application wrapper for ownership breadcrumbs. */
135
+ onOpenApplication?: (appKey: string) => void
108
136
 
109
137
  // ── Data (injected by wrapper) ──────────────────────────────────────────
110
138
  /** The resource data object */
@@ -117,6 +145,17 @@ interface WorkloadViewProps {
117
145
  hpaDiagnosis?: HPADiagnosis
118
146
  /** Compact diagnosis for autoscalers controlling this workload */
119
147
  scalerDiagnostics?: ScalerDiagnosis[]
148
+ /** Pod readiness data from the host's workload-pods endpoint. Optional so
149
+ * shared consumers can still render from relationships when they do not wire
150
+ * Radar-specific data hooks. */
151
+ workloadPods?: WorkloadPodInfo[]
152
+ workloadPodsLoading?: boolean
153
+ workloadPodsError?: Error | null
154
+ /** Full objects for service/route refs related to this workload. Optional;
155
+ * overview falls back to relationship refs when hosts do not fetch them. */
156
+ servingResources?: ServingResourceDetail[]
157
+ renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
158
+ renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
120
159
  /** Whether the resource is loading */
121
160
  isLoading?: boolean
122
161
  /** Fetch error for the resource (preserves status + message so the
@@ -128,6 +167,8 @@ interface WorkloadViewProps {
128
167
  // ── Timeline data ────────────────────────────────────────────────────────
129
168
  /** All timeline events for this resource's namespace */
130
169
  allEvents?: TimelineEvent[]
170
+ /** Persisted lifecycle events reconstructed for resources related to this workload. */
171
+ relatedTimelineEvents?: TimelineEvent[]
131
172
  /** Whether timeline events are loading */
132
173
  eventsLoading?: boolean
133
174
  /** Topology data for hierarchy building + the Topology tab's neighborhood. */
@@ -156,7 +197,7 @@ interface WorkloadViewProps {
156
197
  /** Controlled active tab. If not provided, managed internally. */
157
198
  activeTab?: TabType
158
199
  /** Called when tab changes (for URL sync etc.) */
159
- onTabChange?: (tab: TabType) => void
200
+ onTabChange?: (tab: TabType, opts?: { replace?: boolean }) => void
160
201
 
161
202
  // ── GitOps navigation ─────────────────────────────────────────────────────
162
203
  /**
@@ -206,6 +247,14 @@ interface WorkloadViewProps {
206
247
  initialContainer: string | null
207
248
  onConsumeInitialContainer: () => void
208
249
  }) => ReactNode
250
+ /** Render a full replacement for the expanded Overview tab. */
251
+ renderExpandedOverview?: (props: {
252
+ kind: string
253
+ apiKind: string
254
+ namespace: string
255
+ name: string
256
+ resource: any
257
+ }) => ReactNode
209
258
  /** Render the metrics tab content */
210
259
  renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
211
260
  /** Render a read-only YAML view for a related object from the workload's
@@ -218,6 +267,8 @@ interface WorkloadViewProps {
218
267
  isMetricsAvailable?: (kind: string, resource: any) => boolean
219
268
  /** Render extra content at the bottom of the overview tab (e.g. audit findings) */
220
269
  renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
270
+ /** Render lightweight overview intro content before the default renderer. */
271
+ renderOverviewIntro?: (props: { kind: string; namespace: string; name: string }) => ReactNode
221
272
  /** Render content at the TOP of the overview tab, above the renderer (e.g. live
222
273
  * Operational Issues). Optional + additive — consumers that don't pass it are
223
274
  * unaffected. Only rendered when `hasOperationalIssues` is true: the lead
@@ -266,17 +317,26 @@ export function WorkloadView({
266
317
  hideBackButton,
267
318
  scopeControls,
268
319
  compactHeader,
320
+ ownershipContext,
321
+ onOpenApplication,
269
322
  // Data
270
323
  resource,
271
324
  relationships,
272
325
  certificateInfo,
273
326
  hpaDiagnosis,
274
327
  scalerDiagnostics,
328
+ workloadPods,
329
+ workloadPodsLoading = false,
330
+ workloadPodsError = null,
331
+ servingResources,
332
+ renderServicePortAction,
333
+ renderServicePortPanel,
275
334
  isLoading: resourceLoading = false,
276
335
  resourceError,
277
336
  refetch: refetchProp,
278
337
  // Timeline
279
338
  allEvents,
339
+ relatedTimelineEvents = [],
280
340
  eventsLoading = false,
281
341
  topology,
282
342
  resourceFocusedK8sEvents,
@@ -296,6 +356,7 @@ export function WorkloadView({
296
356
  onTabChange,
297
357
  // Render props
298
358
  renderLogsTab,
359
+ renderExpandedOverview,
299
360
  renderRelatedYaml,
300
361
  renderMetricsTab,
301
362
  isMetricsAvailable,
@@ -303,6 +364,7 @@ export function WorkloadView({
303
364
  onDuplicate,
304
365
  onDownload,
305
366
  renderOverviewExtra,
367
+ renderOverviewIntro,
306
368
  renderOverviewLead,
307
369
  hasOperationalIssues,
308
370
  // Actions bar
@@ -349,7 +411,6 @@ export function WorkloadView({
349
411
  }, [])
350
412
 
351
413
  const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
352
- const [zoom, setZoom] = useState<ZoomLevel>(1)
353
414
  const [selectedPod, setSelectedPod] = useState<string | null>(null)
354
415
  const [initialContainer, setInitialContainer] = useState<string | null>(null)
355
416
  const [copied, setCopied] = useState<string | null>(null)
@@ -386,7 +447,6 @@ export function WorkloadView({
386
447
  // A deep-linked ?tab=topology that turns out unavailable falls back to
387
448
  // overview instead of rendering an empty body under a hidden tab.
388
449
  const topologyTabHidden = !!topology && (!neighborhood || neighborhood.nodes.length === 0)
389
- const effectiveTab: TabType = activeTab === 'topology' && topologyTabHidden ? 'overview' : activeTab
390
450
 
391
451
  // YAML tab object rail — the same neighborhood, as a manifest list: the
392
452
  // workload first, then routing → config → policy/scaling → ownership.
@@ -433,8 +493,10 @@ export function WorkloadView({
433
493
 
434
494
  // Flatten events from hierarchy
435
495
  const resourceEvents = useMemo(() => {
436
- return getAllEventsFromHierarchy(resourceLanes)
437
- }, [resourceLanes])
496
+ const events = [...getAllEventsFromHierarchy(resourceLanes), ...relatedTimelineEvents]
497
+ const unique = new Map(events.map((event) => [event.id, event]))
498
+ return Array.from(unique.values()).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.id.localeCompare(b.id))
499
+ }, [resourceLanes, relatedTimelineEvents])
438
500
  const overviewEvents = resourceEvents.length > 0 ? resourceEvents : (resourceFocusedK8sEvents ?? [])
439
501
  const overviewEventsLoading = resourceEvents.length > 0 ? eventsLoading : resourceFocusedEventsLoading
440
502
  const overviewEventsError = resourceEvents.length > 0 ? undefined : resourceFocusedK8sError
@@ -462,6 +524,7 @@ export function WorkloadView({
462
524
  const combined = [
463
525
  ...pods,
464
526
  ...childPods.map(p => ({ kind: 'Pod' as const, namespace: p.namespace, name: p.name })),
527
+ ...(workloadPods ?? []).map(p => ({ kind: 'Pod' as const, namespace, name: p.name })),
465
528
  ]
466
529
  const seen = new Set<string>()
467
530
  return combined.filter(p => {
@@ -470,7 +533,7 @@ export function WorkloadView({
470
533
  seen.add(key)
471
534
  return true
472
535
  })
473
- }, [pods, childPods])
536
+ }, [pods, childPods, workloadPods, namespace])
474
537
 
475
538
  // Metadata
476
539
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
@@ -562,8 +625,30 @@ export function WorkloadView({
562
625
  ], [active, expanded, onBack, onClose, switchView]))
563
626
 
564
627
  const status = getResourceStatus(apiKind, resource)
628
+ const showOwnershipHeading = kind === 'Pod' && Boolean(ownershipContext)
629
+ const headerImage = metadata.find(m => m.label === 'Image')?.value
630
+
631
+ // The AI/Diagnose action lives in the header chrome (next to expand/refresh/close),
632
+ // set apart from the imperative ops in the action bar — it's an invitation to
633
+ // understand the resource, not another verb to run on it. Adaptive by health:
634
+ // prominent "Diagnose" on a problem, a quiet icon when fine. Rendered via the host
635
+ // slot (DiagnoseCustomization); standalone Radar injects it, Hub overrides it.
636
+ const renderDiagnose = actionsBarProps?.renderDiagnose as
637
+ | ((ctx: { kind: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
638
+ | undefined
639
+ const diagnoseAction = renderDiagnose?.({
640
+ kind: apiKind,
641
+ namespace,
642
+ name,
643
+ health: diagnoseHealthHint(apiKind, resource),
644
+ })
565
645
 
566
646
  const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
647
+ const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || LOGS_TAB_WITHOUT_PODS_KINDS.has(kindToPlural(kind).toLowerCase()))
648
+ const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab)
649
+ const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
650
+ const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading)
651
+ const requestedTab: TabType = activeTab
567
652
  const tabs: DetailShellTab<TabType>[] = [
568
653
  { id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
569
654
  { id: 'topology', label: 'Topology', icon: <Network className="w-4 h-4" />, hidden: topologyTabHidden },
@@ -573,10 +658,25 @@ export function WorkloadView({
573
658
  icon: <Activity className="w-4 h-4" />,
574
659
  badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
575
660
  },
576
- { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !(allPods.length > 0 && renderLogsTab) },
577
- { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !(showMetricsTab && renderMetricsTab) },
661
+ { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
662
+ { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
578
663
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
579
664
  ]
665
+ const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
666
+ const effectiveTab: TabType = requestedTabAvailable ? requestedTab : 'overview'
667
+ const shouldCommitFallback =
668
+ requestedTab !== 'overview' &&
669
+ !requestedTabAvailable &&
670
+ (
671
+ (requestedTab === 'topology' && topologyTabHidden) ||
672
+ (requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) ||
673
+ (requestedTab === 'logs' && logsFallbackReady)
674
+ )
675
+ useEffect(() => {
676
+ if (shouldCommitFallback && activeTab !== 'overview') onTabChange?.('overview', { replace: true })
677
+ }, [activeTab, onTabChange, shouldCommitFallback])
678
+ const expandedOverview = expanded ? renderExpandedOverview?.({ kind, apiKind, namespace, name, resource }) : null
679
+ const overviewIntro = renderOverviewIntro?.({ kind, namespace, name })
580
680
 
581
681
  // ── Collapsed (drawer) mode ──────────────────────────────────────────────
582
682
  if (!expanded) {
@@ -596,39 +696,46 @@ export function WorkloadView({
596
696
  </span>
597
697
  )}
598
698
  </div>
599
- <div className="flex items-center gap-1">
699
+ <div className="flex items-center gap-1.5">
700
+ {diagnoseAction}
600
701
  {onExpand && (
702
+ <Tooltip content="Open full view" delay={150} position="bottom">
703
+ <button
704
+ onClick={() => onExpand({ yaml: showYaml })}
705
+ // Pre-mount the fullscreen view on hover/press so the click starts
706
+ // the morph instantly (its heavy mount is already paid for).
707
+ onPointerEnter={onExpandIntent}
708
+ onPointerDown={onExpandIntent}
709
+ onPointerLeave={onCancelExpandIntent}
710
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
711
+ aria-label="Open full view"
712
+ >
713
+ <Maximize2 className="w-4 h-4" />
714
+ </button>
715
+ </Tooltip>
716
+ )}
717
+ <Tooltip content="Refresh" delay={150} position="bottom">
601
718
  <button
602
- onClick={() => onExpand({ yaml: showYaml })}
603
- // Pre-mount the fullscreen view on hover/press so the click starts
604
- // the morph instantly (its heavy mount is already paid for).
605
- onPointerEnter={onExpandIntent}
606
- onPointerDown={onExpandIntent}
607
- onPointerLeave={onCancelExpandIntent}
608
- className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
609
- title="Open full view"
719
+ onClick={() => refetch()}
720
+ disabled={isRefreshAnimating}
721
+ className={clsx(
722
+ 'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
723
+ refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
724
+ )}
725
+ aria-label="Refresh"
610
726
  >
611
- <Maximize2 className="w-4 h-4" />
727
+ {refreshPhase === 'success'
728
+ ? <Check className="w-4 h-4 stroke-[2.5]" />
729
+ : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
730
+ }
612
731
  </button>
613
- )}
614
- <button
615
- onClick={() => refetch()}
616
- disabled={isRefreshAnimating}
617
- className={clsx(
618
- 'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
619
- refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
620
- )}
621
- title="Refresh"
622
- >
623
- {refreshPhase === 'success'
624
- ? <Check className="w-4 h-4 stroke-[2.5]" />
625
- : <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
626
- }
627
- </button>
732
+ </Tooltip>
628
733
  {onClose && (
629
- <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" title="Close (Esc)">
630
- <X className="w-4 h-4" />
631
- </button>
734
+ <Tooltip content="Close (Esc)" delay={150} position="bottom">
735
+ <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" aria-label="Close">
736
+ <X className="w-4 h-4" />
737
+ </button>
738
+ </Tooltip>
632
739
  )}
633
740
  </div>
634
741
  </div>
@@ -637,13 +744,15 @@ export function WorkloadView({
637
744
  <div className="px-4 pb-3">
638
745
  <div className="flex items-center gap-2">
639
746
  <h2 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h2>
640
- <button
641
- onClick={() => copyToClipboard(name, 'name')}
642
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
643
- title="Copy name"
644
- >
645
- {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
646
- </button>
747
+ <Tooltip content="Copy name" delay={150}>
748
+ <button
749
+ onClick={() => copyToClipboard(name, 'name')}
750
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
751
+ aria-label="Copy name"
752
+ >
753
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
754
+ </button>
755
+ </Tooltip>
647
756
  </div>
648
757
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
649
758
  {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && (
@@ -711,12 +820,8 @@ export function WorkloadView({
711
820
  updates={resourceFocusedUpdates}
712
821
  eventsError={resourceFocusedK8sError}
713
822
  updatesError={resourceFocusedUpdatesError}
823
+ mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
714
824
  />
715
- {renderOverviewExtra && (
716
- <div className="px-4 pb-4">
717
- {renderOverviewExtra({ kind, namespace, name })}
718
- </div>
719
- )}
720
825
  </OperationalIssuesShownContext.Provider>
721
826
  )}
722
827
  </div>
@@ -731,27 +836,42 @@ export function WorkloadView({
731
836
  breadcrumb={breadcrumb}
732
837
  nav={
733
838
  breadcrumb || hideBackButton ? undefined : (
734
- <button
735
- onClick={onBack}
736
- className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
737
- title="Go back (Esc)"
738
- >
739
- <ArrowLeft className="w-5 h-5" />
740
- </button>
839
+ <Tooltip content="Go back (Esc)" delay={150} position="bottom">
840
+ <button
841
+ onClick={onBack}
842
+ className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
843
+ aria-label="Go back"
844
+ >
845
+ <ArrowLeft className="w-5 h-5" />
846
+ </button>
847
+ </Tooltip>
741
848
  )
742
849
  }
743
850
  identity={
744
851
  <>
745
- <div className="flex items-center gap-3 mb-1">
746
- <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
747
- <button
748
- onClick={() => copyToClipboard(name, 'name')}
749
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
750
- title="Copy name"
751
- >
752
- {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
753
- </button>
754
- </div>
852
+ {showOwnershipHeading && ownershipContext ? (
853
+ <OwnershipHeading
854
+ podName={name}
855
+ context={ownershipContext}
856
+ copied={copied === 'name'}
857
+ onCopy={() => copyToClipboard(name, 'name')}
858
+ onNavigateToResource={onNavigateToResource}
859
+ onOpenApplication={onOpenApplication}
860
+ />
861
+ ) : (
862
+ <div className="flex items-center gap-3 mb-1">
863
+ <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
864
+ <Tooltip content="Copy name" delay={150}>
865
+ <button
866
+ onClick={() => copyToClipboard(name, 'name')}
867
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
868
+ aria-label="Copy name"
869
+ >
870
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
871
+ </button>
872
+ </Tooltip>
873
+ </div>
874
+ )}
755
875
  <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
756
876
  <span className={clsx('badge', getKindColorOutline(apiKind))}>
757
877
  {displayKindName(apiKind, resource?.kind)}
@@ -764,8 +884,12 @@ export function WorkloadView({
764
884
  {namespace && namespace !== '_' && (
765
885
  <span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
766
886
  )}
767
- {metadata.find(m => m.label === 'Image') && (
768
- <span className="truncate max-w-md font-mono text-xs">{metadata.find(m => m.label === 'Image')?.value}</span>
887
+ {headerImage && (
888
+ <Tooltip content={headerImage} delay={300} wrapperClassName="min-w-0 max-w-md">
889
+ <span className="truncate font-mono text-xs">
890
+ {midTruncate(headerImage, 72)}
891
+ </span>
892
+ </Tooltip>
769
893
  )}
770
894
  {gitopsOwner && (
771
895
  <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} variant="block" />
@@ -776,7 +900,7 @@ export function WorkloadView({
776
900
  {gitOpsResourcePath && onNavigateGitOpsPath && (
777
901
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
778
902
  )}
779
- {relationships?.owner && (
903
+ {relationships?.owner && !showOwnershipHeading && (
780
904
  <span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
781
905
  )}
782
906
  </div>
@@ -784,28 +908,33 @@ export function WorkloadView({
784
908
  }
785
909
  headerActions={
786
910
  <>
787
- <button
788
- onClick={() => refetch()}
789
- disabled={isRefreshAnimating}
790
- className={clsx(
791
- 'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
792
- refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
793
- )}
794
- title="Refresh"
795
- >
796
- {refreshPhase === 'success'
797
- ? <Check className="w-5 h-5 stroke-[2.5]" />
798
- : <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
799
- }
800
- </button>
801
- {onCollapseToDrawer && (
911
+ {diagnoseAction}
912
+ <Tooltip content="Refresh" delay={150} position="bottom">
802
913
  <button
803
- onClick={onCollapseToDrawer}
804
- className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
805
- title="Collapse to drawer"
914
+ onClick={() => refetch()}
915
+ disabled={isRefreshAnimating}
916
+ className={clsx(
917
+ 'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
918
+ refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
919
+ )}
920
+ aria-label="Refresh"
806
921
  >
807
- <Minimize2 className="w-5 h-5" />
922
+ {refreshPhase === 'success'
923
+ ? <Check className="w-5 h-5 stroke-[2.5]" />
924
+ : <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
925
+ }
808
926
  </button>
927
+ </Tooltip>
928
+ {onCollapseToDrawer && (
929
+ <Tooltip content="Collapse to drawer" delay={150} position="bottom">
930
+ <button
931
+ onClick={onCollapseToDrawer}
932
+ className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
933
+ aria-label="Collapse to drawer"
934
+ >
935
+ <Minimize2 className="w-5 h-5" />
936
+ </button>
937
+ </Tooltip>
809
938
  )}
810
939
  </>
811
940
  }
@@ -817,13 +946,29 @@ export function WorkloadView({
817
946
  overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
818
947
  compactHeader={compactHeader}
819
948
  >
820
- {effectiveTab === 'overview' && (
949
+ {effectiveTab === 'overview' && expandedOverview ? (
950
+ <div className="h-full min-h-0">
951
+ {hasOperationalIssues && renderOverviewLead && (
952
+ <div className="px-4 pt-4">
953
+ {renderOverviewLead({ kind, namespace, name })}
954
+ </div>
955
+ )}
956
+ {expandedOverview}
957
+ </div>
958
+ ) : effectiveTab === 'overview' && (
821
959
  <InfoTab
822
960
  resource={resource}
823
961
  selectedResource={selectedResource}
824
962
  relationships={relationships}
963
+ certificateInfo={certificateInfo}
825
964
  hpaDiagnosis={hpaDiagnosis}
826
965
  scalerDiagnostics={scalerDiagnostics}
966
+ workloadPods={workloadPods}
967
+ workloadPodsLoading={workloadPodsLoading}
968
+ workloadPodsError={workloadPodsError}
969
+ servingResources={servingResources}
970
+ renderServicePortAction={renderServicePortAction}
971
+ renderServicePortPanel={renderServicePortPanel}
827
972
  isLoading={resourceLoading}
828
973
  error={resourceError}
829
974
  onNavigate={onNavigateToResource}
@@ -833,6 +978,8 @@ export function WorkloadView({
833
978
  isSavingSecret={isUpdatingResource}
834
979
  onOpenLogs={handleOpenLogs}
835
980
  onSwitchToTimeline={() => handleSetTab('timeline')}
981
+ onSwitchToLogs={logsTabVisible ? () => handleSetTab('logs') : undefined}
982
+ onSwitchToTopology={!topologyTabHidden ? () => handleSetTab('topology') : undefined}
836
983
  rendererOverrides={rendererOverrides}
837
984
  resolvedEnvFrom={resolvedEnvFrom}
838
985
  events={overviewEvents}
@@ -841,6 +988,7 @@ export function WorkloadView({
841
988
  eventsError={overviewEventsError}
842
989
  updatesError={resourceFocusedUpdatesError}
843
990
  extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
991
+ introContent={overviewIntro}
844
992
  leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
845
993
  />
846
994
  )}
@@ -864,14 +1012,11 @@ export function WorkloadView({
864
1012
  {effectiveTab === 'timeline' && (
865
1013
  <EventsTab
866
1014
  events={resourceEvents}
867
- resourceLanes={resourceLanes}
868
1015
  isLoading={eventsLoading}
869
- zoom={zoom}
870
- onZoomChange={setZoom}
871
- resourceKind={kind}
872
- resourceName={name}
873
1016
  selectedEventId={selectedEventId}
874
1017
  onSelectEvent={setSelectedEventId}
1018
+ topology={topology}
1019
+ onResourceClick={onNavigateToResource}
875
1020
  />
876
1021
  )}
877
1022
  {effectiveTab === 'logs' && renderLogsTab && (
@@ -988,15 +1133,90 @@ function extractMetadata(kind: string, resource: any): { label: string; value: s
988
1133
 
989
1134
  function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
990
1135
  return (
991
- <button
992
- type="button"
993
- onClick={onClick}
994
- title="Open this resource in the GitOps tab (tree + insights + ops)"
995
- className="inline-flex items-center gap-1 rounded border border-skyhook-500/40 bg-skyhook-500/10 px-1.5 py-0.5 text-[11px] font-medium text-skyhook-500 hover:bg-skyhook-500/20 transition-colors"
996
- >
997
- Open in GitOps
998
- <ArrowRight className="h-3 w-3 shrink-0" />
999
- </button>
1136
+ <Tooltip content="Open this resource in the GitOps tab (tree + insights + ops)" delay={150}>
1137
+ <button
1138
+ type="button"
1139
+ onClick={onClick}
1140
+ className="inline-flex items-center gap-1 rounded border border-skyhook-500/40 bg-skyhook-500/10 px-1.5 py-0.5 text-[11px] font-medium text-skyhook-500 hover:bg-skyhook-500/20 transition-colors"
1141
+ >
1142
+ Open in GitOps
1143
+ <ArrowRight className="h-3 w-3 shrink-0" />
1144
+ </button>
1145
+ </Tooltip>
1146
+ )
1147
+ }
1148
+
1149
+ function OwnershipHeading({
1150
+ podName,
1151
+ context,
1152
+ copied,
1153
+ onCopy,
1154
+ onNavigateToResource,
1155
+ onOpenApplication,
1156
+ }: {
1157
+ podName: string
1158
+ context: ResourceOwnershipContext
1159
+ copied: boolean
1160
+ onCopy: () => void
1161
+ onNavigateToResource?: NavigateToResource
1162
+ onOpenApplication?: (appKey: string) => void
1163
+ }) {
1164
+ const app = context.application
1165
+ const workload = context.workload
1166
+ const workloadLabel = `${displayKindName(kindToPlural(workload.kind), workload.kind)}/${workload.name}`
1167
+ return (
1168
+ <div className="mb-1 flex min-w-0 items-center gap-2">
1169
+ <div className="flex min-w-0 items-center gap-1.5 text-lg font-semibold text-theme-text-primary">
1170
+ {app ? (
1171
+ onOpenApplication ? (
1172
+ <Tooltip content={`Open application ${app.name}`} delay={150} wrapperClassName="min-w-0">
1173
+ <button
1174
+ type="button"
1175
+ onClick={() => onOpenApplication(app.key)}
1176
+ className="min-w-0 truncate rounded px-0.5 text-left hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent"
1177
+ >
1178
+ {midTruncate(app.name, 32)}
1179
+ </button>
1180
+ </Tooltip>
1181
+ ) : (
1182
+ <Tooltip content={app.name} delay={300} wrapperClassName="min-w-0">
1183
+ <span className="truncate">{midTruncate(app.name, 32)}</span>
1184
+ </Tooltip>
1185
+ )
1186
+ ) : null}
1187
+ {app && <span className="shrink-0 text-theme-text-tertiary">/</span>}
1188
+ {onNavigateToResource ? (
1189
+ <Tooltip content={`Open workload ${workloadLabel}`} delay={150} wrapperClassName="min-w-0">
1190
+ <button
1191
+ type="button"
1192
+ onClick={() => onNavigateToResource(refToSelectedResource(workload))}
1193
+ className="min-w-0 truncate rounded px-0.5 text-left hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent"
1194
+ >
1195
+ {midTruncate(workload.name, 32)}
1196
+ </button>
1197
+ </Tooltip>
1198
+ ) : (
1199
+ <Tooltip content={workloadLabel} delay={300} wrapperClassName="min-w-0">
1200
+ <span className="truncate">{midTruncate(workload.name, 32)}</span>
1201
+ </Tooltip>
1202
+ )}
1203
+ <span className="shrink-0 text-theme-text-tertiary">/</span>
1204
+ <Tooltip content={podName} delay={300} wrapperClassName="min-w-0">
1205
+ <h1 className="truncate text-lg font-semibold text-theme-text-primary">
1206
+ {podName}
1207
+ </h1>
1208
+ </Tooltip>
1209
+ </div>
1210
+ <Tooltip content="Copy pod name" delay={150}>
1211
+ <button
1212
+ onClick={onCopy}
1213
+ className="shrink-0 rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
1214
+ aria-label="Copy pod name"
1215
+ >
1216
+ {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
1217
+ </button>
1218
+ </Tooltip>
1219
+ </div>
1000
1220
  )
1001
1221
  }
1002
1222
 
@@ -1006,162 +1226,98 @@ function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
1006
1226
 
1007
1227
  function EventsTab({
1008
1228
  events,
1009
- resourceLanes,
1010
1229
  isLoading,
1011
- zoom,
1012
- onZoomChange,
1013
- resourceKind,
1014
- resourceName,
1015
1230
  selectedEventId,
1016
1231
  onSelectEvent,
1232
+ topology,
1233
+ onResourceClick,
1017
1234
  }: {
1018
1235
  events: TimelineEvent[]
1019
- resourceLanes: ResourceLane[]
1020
1236
  isLoading: boolean
1021
- zoom: ZoomLevel
1022
- onZoomChange: (zoom: ZoomLevel) => void
1023
- resourceKind: string
1024
- resourceName: string
1025
1237
  selectedEventId: string | null
1026
1238
  onSelectEvent: (id: string | null) => void
1239
+ topology?: Topology
1240
+ onResourceClick?: NavigateToResource
1027
1241
  }) {
1028
- const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map())
1029
- const tableContainerRef = useRef<HTMLDivElement>(null)
1030
- const [hoveredEventId, setHoveredEventId] = useState<string | null>(null)
1031
- const [visibleRowRange, setVisibleRowRange] = useState<{ first: number; last: number } | null>(null)
1032
-
1033
- // Scroll to selected event
1242
+ // A ticking clock so the fitted window's right edge and the swimlane's Now line
1243
+ // track the present, not the mount time — the tab's events refresh (SSE + poll),
1244
+ // so a frozen "now" drifts left of the advancing edge and newer events land "in
1245
+ // the future" to its right.
1246
+ const [now, setNow] = useState(() => Date.now())
1034
1247
  useEffect(() => {
1035
- if (selectedEventId) {
1036
- const eventIndex = events.findIndex(e => e.id === selectedEventId)
1037
- if (eventIndex >= 0) {
1038
- const row = rowRefs.current.get(eventIndex)
1039
- if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
1040
- }
1041
- }
1042
- }, [selectedEventId, events])
1043
-
1044
- // Track visible rows via IntersectionObserver
1045
- useEffect(() => {
1046
- if (!tableContainerRef.current || events.length === 0) return
1047
- const visibleIndices = new Set<number>()
1048
- const observer = new IntersectionObserver(
1049
- (entries) => {
1050
- for (const entry of entries) {
1051
- const idx = parseInt(entry.target.getAttribute('data-row-index') || '-1', 10)
1052
- if (idx >= 0) {
1053
- if (entry.isIntersecting) visibleIndices.add(idx)
1054
- else visibleIndices.delete(idx)
1055
- }
1056
- }
1057
- if (visibleIndices.size > 0) {
1058
- const indices = Array.from(visibleIndices)
1059
- setVisibleRowRange({ first: Math.min(...indices), last: Math.max(...indices) })
1060
- } else {
1061
- setVisibleRowRange(null)
1062
- }
1063
- },
1064
- { root: tableContainerRef.current, threshold: 0.1 }
1065
- )
1066
- const timeoutId = setTimeout(() => {
1067
- rowRefs.current.forEach((row) => observer.observe(row))
1068
- }, 100)
1069
- return () => { clearTimeout(timeoutId); observer.disconnect() }
1070
- }, [events])
1071
-
1072
- // Visible time range from visible rows
1073
- const visibleTimeRangeFromRows = useMemo(() => {
1074
- if (!visibleRowRange || events.length === 0) return null
1075
- const visibleEvents = events.slice(visibleRowRange.first, visibleRowRange.last + 1)
1076
- if (visibleEvents.length === 0) return null
1077
- const timestamps = visibleEvents.map(e => new Date(e.timestamp).getTime())
1078
- const start = Math.min(...timestamps)
1079
- const end = Math.max(...timestamps)
1080
- const timeSpan = end - start
1081
- const padding = Math.max(timeSpan * 0.1, 60000)
1082
- return { start: start - padding, end: end + padding }
1083
- }, [events, visibleRowRange])
1084
-
1085
- const now = Date.now()
1086
- const { start: startTime, windowMs } = calculateTimeRange(zoom, now)
1087
- const zoomIndex = ZOOM_LEVELS.indexOf(zoom)
1088
- const canZoomIn = zoomIndex > 0
1089
- const canZoomOut = zoomIndex < ZOOM_LEVELS.length - 1
1090
- const handleZoomIn = () => { if (canZoomIn) onZoomChange(ZOOM_LEVELS[zoomIndex - 1]) }
1091
- const handleZoomOut = () => { if (canZoomOut) onZoomChange(ZOOM_LEVELS[zoomIndex + 1]) }
1092
- const localTimeToX = (ts: number) => timeToX(ts, startTime, windowMs)
1093
-
1094
- // Build swimlanes
1095
- const swimlanes = useMemo(() => {
1096
- type SwimLane = {
1097
- id: string; label: string
1098
- spans: { start: number; end: number; health: string }[]
1099
- events: TimelineEvent[]
1100
- createdAt?: number; createdBeforeWindow: boolean
1101
- }
1102
-
1103
- if (resourceLanes.length === 0) {
1104
- const mainResourceEvents = events.filter(e => e.kind === resourceKind && e.name === resourceName)
1105
- const healthResult = buildHealthSpans(mainResourceEvents.filter(e => isChangeEvent(e)), startTime, now, mainResourceEvents)
1106
- return [{ id: 'main', label: `${resourceKind}: ${resourceName}`, spans: healthResult.spans, events: mainResourceEvents, createdAt: healthResult.createdAt, createdBeforeWindow: healthResult.createdBeforeWindow }]
1107
- }
1248
+ const id = setInterval(() => setNow(Date.now()), 30_000)
1249
+ return () => clearInterval(id)
1250
+ }, [])
1108
1251
 
1109
- const rootLane = resourceLanes[0]
1110
- const lanes: SwimLane[] = []
1111
-
1112
- const rootHealthResult = buildHealthSpans(rootLane.events.filter(e => isChangeEvent(e)), startTime, now, rootLane.events)
1113
- lanes.push({
1114
- id: rootLane.id,
1115
- label: `${rootLane.kind}: ${rootLane.name.length > 40 ? rootLane.name.slice(0, 20) + '...' + rootLane.name.slice(-17) : rootLane.name}`,
1116
- spans: rootHealthResult.spans, events: rootLane.events,
1117
- createdAt: rootHealthResult.createdAt, createdBeforeWindow: rootHealthResult.createdBeforeWindow,
1252
+ // Drop untimeable events once, at the source, so the swimlane, the list's date
1253
+ // grouping, and selection all agree. The Go/K8s zero time (0001-01-01) parses to
1254
+ // a valid-but-meaningless Date the swimlane can't place, but the list would
1255
+ // otherwise bucket it under a bogus "1/1/1" header (and selecting it strands the
1256
+ // pan). NaN / future timestamps are stripped for the same reason.
1257
+ const cleanEvents = useMemo(() => {
1258
+ const ceiling = Date.now() + 60_000
1259
+ return events.filter((e) => {
1260
+ const t = new Date(e.timestamp).getTime()
1261
+ return Number.isFinite(t) && t > 0 && t <= ceiling
1118
1262
  })
1263
+ }, [events])
1119
1264
 
1120
- const flattenChildren = (lane: ResourceLane): ResourceLane[] => {
1121
- const children = lane.children || []
1122
- return children.flatMap(child => [child, ...flattenChildren(child)])
1123
- }
1124
- const allChildren = flattenChildren(rootLane)
1125
-
1126
- const kindPriority: Record<string, number> = {
1127
- Service: 1, Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
1128
- ReplicaSet: 3, ConfigMap: 4, Secret: 4, Gateway: 5, HTTPRoute: 4,
1129
- GRPCRoute: 4, TCPRoute: 4, TLSRoute: 4, Ingress: 5, Pod: 6,
1265
+ // Fit the swimlane's window to this resource's events. Uncontrolled, the
1266
+ // swimlane anchors to now and shows the last hour — but a resource's events can
1267
+ // be days old (a Deployment created last week), so that window is empty. Default
1268
+ // to the events' span; a user pan/zoom (userWindow) then takes over. Deriving the
1269
+ // default (not latching it in state) means it always tracks the current events —
1270
+ // latching once locked onto the pre-scoping event set and left the window wrong.
1271
+ const eventSpan = useMemo<TimeWindow | null>(() => {
1272
+ if (cleanEvents.length === 0) return null
1273
+ let lo = Infinity, hi = -Infinity
1274
+ for (const e of cleanEvents) {
1275
+ const t = new Date(e.timestamp).getTime()
1276
+ if (t < lo) lo = t
1277
+ if (t > hi) hi = t
1130
1278
  }
1131
- allChildren.sort((a, b) => {
1132
- const aPriority = kindPriority[a.kind] || 10
1133
- const bPriority = kindPriority[b.kind] || 10
1134
- if (aPriority !== bPriority) return aPriority - bPriority
1135
- return b.events.length - a.events.length
1136
- })
1279
+ const pad = Math.max((hi - lo) * 0.05, 60_000)
1280
+ // Never extend into the future: the right edge is the newest event or now,
1281
+ // whichever is earlier, so health bars stop at the Now line.
1282
+ return { fromMs: lo - pad, toMs: Math.min(hi + pad, now) }
1283
+ }, [cleanEvents, now])
1284
+ const [userWindow, setUserWindow] = useState<TimeWindow | null>(null)
1285
+ const viewWindow = userWindow ?? eventSpan
1286
+
1287
+ // Hard bounds for pan/zoom: the window can never leave [oldest event, now], so
1288
+ // scrolling back can't run off into empty/invalid-date territory and zoom-out
1289
+ // can't exceed the resource's actual lifespan.
1290
+ const bounds = useMemo<TimeWindow | null>(
1291
+ () => (eventSpan ? { fromMs: eventSpan.fromMs, toMs: now } : null),
1292
+ [eventSpan, now],
1293
+ )
1137
1294
 
1138
- for (const child of allChildren.slice(0, 6)) {
1139
- const childHealthResult = buildHealthSpans(child.events.filter(e => isChangeEvent(e)), startTime, now, child.events)
1140
- lanes.push({
1141
- id: child.id,
1142
- label: `${child.kind}: ${child.name.length > 40 ? child.name.slice(0, 20) + '...' + child.name.slice(-17) : child.name}`,
1143
- spans: childHealthResult.spans, events: child.events,
1144
- createdAt: childHealthResult.createdAt, createdBeforeWindow: childHealthResult.createdBeforeWindow,
1145
- })
1295
+ // Selecting an event — from the list OR the swimlane — pans the swimlane window
1296
+ // to include it in the SAME update, THEN sets the shared selection. Panning
1297
+ // atomically is what prevents the flicker: the swimlane resolves selectedEventId
1298
+ // against the window on the same render, so an off-window event never briefly
1299
+ // resolves to null and oscillate against a separate pan effect. Refs read the
1300
+ // current window/events without making this callback churn.
1301
+ const selRefs = useRef({ viewWindow, events: cleanEvents, bounds })
1302
+ selRefs.current = { viewWindow, events: cleanEvents, bounds }
1303
+ const handleSelect = useCallback((id: string | null) => {
1304
+ if (id) {
1305
+ const { viewWindow: vw, events: evs, bounds: bd } = selRefs.current
1306
+ const evt = evs.find((e) => e.id === id)
1307
+ const t = evt ? new Date(evt.timestamp).getTime() : NaN
1308
+ if (vw && Number.isFinite(t) && (t < vw.fromMs || t > vw.toMs)) {
1309
+ const width = vw.toMs - vw.fromMs
1310
+ const now = Date.now()
1311
+ let fromMs = t - width / 2
1312
+ let toMs = t + width / 2
1313
+ if (toMs > now) { toMs = now; fromMs = now - width }
1314
+ const lo = bd?.fromMs
1315
+ if (lo != null && fromMs < lo) { fromMs = lo; toMs = lo + width }
1316
+ setUserWindow({ fromMs, toMs })
1317
+ }
1146
1318
  }
1147
-
1148
- return lanes
1149
- }, [resourceLanes, events, resourceKind, resourceName, startTime, now])
1150
-
1151
- // Time axis ticks
1152
- const tickCount = 8
1153
- const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
1154
- const t = startTime + (windowMs * i) / tickCount
1155
- return { time: t, label: formatAxisTime(new Date(t)) }
1156
- })
1157
-
1158
- const formatTimeRangeDisplay = () => {
1159
- const start = new Date(startTime)
1160
- const end = new Date(now)
1161
- return `${start.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} → ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
1162
- }
1163
-
1164
- const nowX = localTimeToX(now)
1319
+ onSelectEvent(id)
1320
+ }, [onSelectEvent])
1165
1321
 
1166
1322
  if (isLoading) {
1167
1323
  return (
@@ -1174,191 +1330,80 @@ function EventsTab({
1174
1330
 
1175
1331
  return (
1176
1332
  <div className="h-full flex flex-col overflow-hidden">
1177
- {/* Timeline toolbar */}
1178
- <div className="shrink-0 px-4 py-2 border-b border-theme-border bg-theme-surface/50 flex items-center justify-between">
1179
- <span className="text-sm font-medium text-theme-text-secondary">Events ({events.length})</span>
1180
- <div className="flex items-center gap-3">
1181
- <ZoomControls zoom={zoom} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} canZoomIn={canZoomIn} canZoomOut={canZoomOut} />
1182
- <span className="text-xs text-theme-text-tertiary">{formatTimeRangeDisplay()}</span>
1183
- </div>
1184
- </div>
1185
-
1186
- {/* Legend */}
1187
- <div className="shrink-0 px-4 py-1.5 border-b border-theme-border bg-theme-surface/30 flex items-center justify-between">
1188
- <HealthSpanLegend />
1189
- <EventDotLegend />
1190
- </div>
1191
-
1192
- {/* Swimlane Timeline */}
1193
- <div className="shrink-0 border-b border-theme-border bg-theme-base relative">
1194
- {/* Scrollable swimlane area — max 4 lanes visible before scrolling */}
1195
- <div className="max-h-[140px] overflow-y-auto relative">
1196
- {nowX >= 0 && nowX <= 100 && (
1197
- <div className="absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-20 pointer-events-none" style={{ left: `calc(280px + (100% - 280px) * ${nowX / 100})` }}>
1198
- <span className="absolute -top-4 left-1/2 -translate-x-1/2 text-xs text-purple-500 font-medium whitespace-nowrap">now</span>
1199
- </div>
1200
- )}
1201
-
1202
- {swimlanes.map((lane) => (
1203
- <div key={lane.id} className="flex border-b border-theme-border/50 last:border-b-0">
1204
- <div className="w-[280px] shrink-0 px-3 py-1 bg-theme-surface/50 border-r border-theme-border text-xs font-medium text-theme-text-secondary truncate flex items-center">
1205
- {lane.label}
1206
- </div>
1207
- <div className="flex-1 relative h-7 bg-theme-base">
1208
- {visibleTimeRangeFromRows && (
1209
- <div className="absolute top-0 bottom-0 bg-blue-500/10 border-x border-blue-500/30 pointer-events-none" style={{
1210
- left: `${Math.max(0, localTimeToX(visibleTimeRangeFromRows.start))}%`,
1211
- width: `${Math.max(2, Math.min(100, localTimeToX(visibleTimeRangeFromRows.end)) - Math.max(0, localTimeToX(visibleTimeRangeFromRows.start)))}%`,
1212
- }} />
1213
- )}
1214
- {lane.spans.map((span, i) => {
1215
- const left = Math.max(0, localTimeToX(span.start))
1216
- const right = Math.min(100, localTimeToX(span.end))
1217
- const width = right - left
1218
- const showCreatedBefore = i === 0 && lane.createdBeforeWindow && lane.createdAt
1219
- return (
1220
- <HealthSpan
1221
- key={i}
1222
- health={span.health}
1223
- left={left}
1224
- width={width}
1225
- title={`${span.health} (${new Date(span.start).toLocaleTimeString()} - ${new Date(span.end).toLocaleTimeString()})`}
1226
- createdBefore={showCreatedBefore ? new Date(lane.createdAt!) : undefined}
1227
- />
1228
- )
1229
- })}
1230
- {lane.events.map((evt, i) => {
1231
- const x = localTimeToX(new Date(evt.timestamp).getTime())
1232
- if (x < 0 || x > 100) return null
1233
- return (
1234
- <EventMarker
1235
- key={`${evt.id}-${i}`}
1236
- event={evt}
1237
- x={x}
1238
- selected={selectedEventId === evt.id}
1239
- onClick={() => onSelectEvent(selectedEventId === evt.id ? null : evt.id)}
1240
- small
1241
- />
1242
- )
1243
- })}
1244
- </div>
1245
- </div>
1246
- ))}
1247
- </div>
1248
-
1249
- {/* Time axis */}
1250
- <div className="flex">
1251
- <div className="w-[280px] shrink-0 bg-theme-surface/50 border-r border-theme-border" />
1252
- <div className="flex-1 relative h-5 bg-theme-elevated/30">
1253
- {ticks.map((tick, i) => {
1254
- const x = localTimeToX(tick.time)
1255
- return (
1256
- <div key={i} className="absolute top-0 flex flex-col items-center" style={{ left: `${x}%`, transform: 'translateX(-50%)' }}>
1257
- <div className="h-1.5 w-px bg-theme-border" />
1258
- <span className="text-[10px] text-theme-text-tertiary">{tick.label}</span>
1259
- </div>
1260
- )
1261
- })}
1262
- </div>
1263
- </div>
1333
+ {/* Swimlane — the shared TimelineSwimlanes widget (kind chips, top axis with
1334
+ ticks + Now line, event clustering), flat + compact for a single subject.
1335
+ Its drawer is suppressed (compact); the list below is the detail surface. */}
1336
+ <div className="shrink-0 border-b border-theme-border">
1337
+ <TimelineSwimlanes
1338
+ events={cleanEvents}
1339
+ grouping="flat"
1340
+ compact
1341
+ topology={topology}
1342
+ onResourceClick={onResourceClick}
1343
+ viewWindow={viewWindow ?? undefined}
1344
+ bounds={bounds ?? undefined}
1345
+ nowMs={now}
1346
+ isLive={userWindow == null}
1347
+ onViewWindowChange={(w) => {
1348
+ const now = Date.now()
1349
+ const MIN = 15 * 60_000
1350
+ let { fromMs, toMs } = w
1351
+ // Floor zoom-in at ~15 min: a tiny empty sub-window strands the user
1352
+ // (the "move the strip above" hint has no strip here).
1353
+ if (toMs - fromMs < MIN) {
1354
+ const c = (fromMs + toMs) / 2
1355
+ fromMs = c - MIN / 2
1356
+ toMs = c + MIN / 2
1357
+ }
1358
+ // Never pan/zoom past now: shift the window back so its right edge is
1359
+ // at most the present (there's nothing in the future to show).
1360
+ if (toMs > now) {
1361
+ const width = toMs - fromMs
1362
+ toMs = now
1363
+ fromMs = now - width
1364
+ }
1365
+ setUserWindow({ fromMs, toMs })
1366
+ }}
1367
+ selectedEventId={selectedEventId}
1368
+ onSelectedEventChange={handleSelect}
1369
+ />
1264
1370
  </div>
1265
1371
 
1266
- {/* Events table */}
1267
- <div ref={tableContainerRef} className="flex-1 overflow-auto">
1268
- <table className="w-full text-sm">
1269
- <thead className="sticky top-0 bg-theme-surface border-b border-theme-border z-10">
1270
- <tr className="text-left text-xs text-theme-text-tertiary">
1271
- <th className="px-4 py-2 font-medium w-32">Event Type</th>
1272
- <th className="px-4 py-2 font-medium">Summary</th>
1273
- <th className="px-4 py-2 font-medium w-40">Time</th>
1274
- <th className="px-4 py-2 font-medium w-32">Resource</th>
1275
- <th className="px-4 py-2 font-medium w-24">Status</th>
1276
- </tr>
1277
- </thead>
1278
- <tbody className="table-divide-subtle">
1279
- {events.length === 0 ? (
1280
- <tr>
1281
- <td colSpan={5} className="px-4 py-8 text-center text-theme-text-tertiary">
1282
- No events in this time range
1283
- </td>
1284
- </tr>
1285
- ) : (
1286
- events.map((evt, evtIdx) => {
1287
- const isSelected = selectedEventId === evt.id
1288
- const isHovered = hoveredEventId === evt.id
1289
- const isWarning = isProblematicEvent(evt)
1290
- return (
1291
- <tr
1292
- key={`${evt.id}-${evtIdx}`}
1293
- ref={(el) => { if (el) rowRefs.current.set(evtIdx, el); else rowRefs.current.delete(evtIdx) }}
1294
- data-row-index={evtIdx}
1295
- onClick={() => onSelectEvent(isSelected ? null : evt.id)}
1296
- onMouseEnter={() => setHoveredEventId(evt.id)}
1297
- onMouseLeave={() => setHoveredEventId(null)}
1298
- className={clsx(
1299
- 'cursor-pointer transition-colors',
1300
- isSelected ? 'bg-blue-500/10' : isHovered ? 'bg-blue-500/5' : 'hover:bg-theme-surface/50'
1301
- )}
1302
- >
1303
- <td className="px-4 py-3">
1304
- <div className="flex items-center gap-2">
1305
- <EventDot event={evt} />
1306
- <span className={clsx('font-medium', isWarning ? 'text-amber-500' : 'text-theme-text-primary')}>
1307
- {isHistoricalEvent(evt) && evt.reason ? evt.reason : isChangeEvent(evt) ? evt.eventType : evt.reason}
1308
- </span>
1309
- </div>
1310
- </td>
1311
- <td className="px-4 py-3 text-theme-text-secondary">
1312
- {evt.message || evt.diff?.summary || '-'}
1313
- </td>
1314
- <td className="px-4 py-3 text-theme-text-tertiary">
1315
- {new Date(evt.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
1316
- </td>
1317
- <td className="px-4 py-3">
1318
- <span className={clsx('badge-sm', getKindBadgeColor(evt.kind))}>
1319
- {evt.kind}
1320
- </span>
1321
- </td>
1322
- <td className="px-4 py-3">
1323
- {isWarning ? (
1324
- <span className="badge status-degraded">Active</span>
1325
- ) : evt.healthState ? (
1326
- <span className={clsx('badge', getHealthBadgeColor(evt.healthState))}>{evt.healthState}</span>
1327
- ) : null}
1328
- </td>
1329
- </tr>
1330
- )
1331
- })
1332
- )}
1333
- </tbody>
1334
- </table>
1372
+ {/* Event list — the shared TimelineList: same event icons/colors as the
1373
+ swimlane, kind badge + resource link, namespace, message, and inline diff,
1374
+ all shown directly. compact drops its toolbar (the swimlane above owns the
1375
+ window). */}
1376
+ <div className="min-h-0 flex-1">
1377
+ <TimelineList
1378
+ compact
1379
+ events={cleanEvents}
1380
+ isLoading={false}
1381
+ onResourceClick={onResourceClick}
1382
+ selectedEventId={selectedEventId}
1383
+ onSelectEvent={handleSelect}
1384
+ />
1335
1385
  </div>
1336
1386
  </div>
1337
1387
  )
1338
1388
  }
1339
1389
 
1340
- function EventDot({ event }: { event: TimelineEvent }) {
1341
- const isWarning = isProblematicEvent(event)
1342
- const isDelete = event.eventType === 'delete'
1343
- const isAdd = event.eventType === 'add'
1344
- return (
1345
- <div className={clsx(
1346
- 'w-3 h-3 rounded-full shrink-0',
1347
- isWarning ? 'bg-amber-500' : isDelete ? 'bg-red-500' : isAdd ? 'bg-green-500' : 'bg-blue-500'
1348
- )} />
1349
- )
1350
- }
1351
-
1352
1390
  // ============================================================================
1353
- // INFO TAB — uses ResourceRendererDispatch for kind-specific rendering
1391
+ // INFO TAB — full-page workload triage overview
1354
1392
  // ============================================================================
1355
1393
 
1356
1394
  function InfoTab({
1357
1395
  resource,
1358
1396
  selectedResource,
1359
1397
  relationships,
1398
+ certificateInfo,
1360
1399
  hpaDiagnosis,
1361
1400
  scalerDiagnostics,
1401
+ workloadPods,
1402
+ workloadPodsLoading,
1403
+ workloadPodsError,
1404
+ servingResources,
1405
+ renderServicePortAction,
1406
+ renderServicePortPanel,
1362
1407
  isLoading,
1363
1408
  error,
1364
1409
  onNavigate,
@@ -1368,6 +1413,8 @@ function InfoTab({
1368
1413
  isSavingSecret,
1369
1414
  onOpenLogs,
1370
1415
  onSwitchToTimeline,
1416
+ onSwitchToLogs,
1417
+ onSwitchToTopology,
1371
1418
  rendererOverrides,
1372
1419
  resolvedEnvFrom,
1373
1420
  events,
@@ -1376,13 +1423,21 @@ function InfoTab({
1376
1423
  eventsError,
1377
1424
  updatesError,
1378
1425
  extraContent,
1426
+ introContent,
1379
1427
  leadContent,
1380
1428
  }: {
1381
1429
  resource: any
1382
1430
  selectedResource: SelectedResource
1383
1431
  relationships?: Relationships
1432
+ certificateInfo?: any
1384
1433
  hpaDiagnosis?: HPADiagnosis
1385
1434
  scalerDiagnostics?: ScalerDiagnosis[]
1435
+ workloadPods?: WorkloadPodInfo[]
1436
+ workloadPodsLoading?: boolean
1437
+ workloadPodsError?: Error | null
1438
+ servingResources?: ServingResourceDetail[]
1439
+ renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
1440
+ renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
1386
1441
  isLoading: boolean
1387
1442
  error?: unknown
1388
1443
  onNavigate?: NavigateToResource
@@ -1392,6 +1447,8 @@ function InfoTab({
1392
1447
  isSavingSecret?: boolean
1393
1448
  onOpenLogs?: (podName: string, containerName: string) => void
1394
1449
  onSwitchToTimeline?: () => void
1450
+ onSwitchToLogs?: () => void
1451
+ onSwitchToTopology?: () => void
1395
1452
  rendererOverrides?: RendererOverrides
1396
1453
  resolvedEnvFrom?: ResolvedEnvFrom
1397
1454
  events?: TimelineEvent[]
@@ -1400,61 +1457,2214 @@ function InfoTab({
1400
1457
  eventsError?: Error | null
1401
1458
  updatesError?: Error | null
1402
1459
  extraContent?: ReactNode
1460
+ introContent?: ReactNode
1403
1461
  leadContent?: ReactNode
1404
1462
  }) {
1405
1463
  if (!resource) {
1406
1464
  return <FetchResult loading={isLoading} error={error} className="h-full" />
1407
1465
  }
1408
1466
 
1467
+ if (!isRuntimeWorkloadOverviewKind(selectedResource.kind)) {
1468
+ return (
1469
+ <div className="h-full overflow-auto">
1470
+ {leadContent && (
1471
+ <div className="px-4 pt-4">
1472
+ {leadContent}
1473
+ </div>
1474
+ )}
1475
+ {introContent && (
1476
+ <div className="px-4 pt-4">
1477
+ {introContent}
1478
+ </div>
1479
+ )}
1480
+ <ResourceRendererDispatch
1481
+ resource={selectedResource}
1482
+ data={resource}
1483
+ relationships={relationships}
1484
+ certificateInfo={certificateInfo}
1485
+ hpaDiagnosis={hpaDiagnosis}
1486
+ scalerDiagnostics={scalerDiagnostics}
1487
+ onCopy={onCopy}
1488
+ copied={copied}
1489
+ onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
1490
+ onSaveSecretValue={onSaveSecretValue}
1491
+ isSavingSecret={isSavingSecret}
1492
+ showCommonSections={true}
1493
+ showMetrics={false}
1494
+ onOpenLogs={onOpenLogs}
1495
+ rendererOverrides={rendererOverrides}
1496
+ resolvedEnvFrom={resolvedEnvFrom}
1497
+ events={events}
1498
+ eventsLoading={eventsLoading}
1499
+ updates={updates}
1500
+ eventsError={eventsError}
1501
+ updatesError={updatesError}
1502
+ eventsHint={onSwitchToTimeline && (
1503
+ <button
1504
+ onClick={onSwitchToTimeline}
1505
+ className="text-xs text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
1506
+ >
1507
+ Showing recent events for this resource. Switch to the <span className="underline">Timeline</span> tab for full history and relationships.
1508
+ </button>
1509
+ )}
1510
+ renderSidebar={(sidebarSections) => (
1511
+ <div className="border-theme-border lg:w-[35%] lg:shrink-0 lg:border-l">
1512
+ <div className="space-y-4 p-4">
1513
+ {sidebarSections}
1514
+ </div>
1515
+ </div>
1516
+ )}
1517
+ mainFooter={extraContent}
1518
+ />
1519
+ </div>
1520
+ )
1521
+ }
1522
+
1409
1523
  return (
1410
- <div className="h-full overflow-auto">
1411
- {leadContent && (
1412
- <div className="px-4 pt-4">
1524
+ <WorkloadOverviewTab
1525
+ resource={resource}
1526
+ selectedResource={selectedResource}
1527
+ relationships={relationships}
1528
+ hpaDiagnosis={hpaDiagnosis}
1529
+ scalerDiagnostics={scalerDiagnostics}
1530
+ workloadPods={workloadPods}
1531
+ workloadPodsLoading={workloadPodsLoading}
1532
+ workloadPodsError={workloadPodsError}
1533
+ servingResources={servingResources}
1534
+ renderServicePortAction={renderServicePortAction}
1535
+ renderServicePortPanel={renderServicePortPanel}
1536
+ onNavigate={onNavigate}
1537
+ onSwitchToTimeline={onSwitchToTimeline}
1538
+ onSwitchToLogs={onSwitchToLogs}
1539
+ onSwitchToTopology={onSwitchToTopology}
1540
+ events={events}
1541
+ eventsLoading={eventsLoading}
1542
+ updates={updates}
1543
+ eventsError={eventsError}
1544
+ updatesError={updatesError}
1545
+ extraContent={extraContent}
1546
+ introContent={introContent}
1547
+ leadContent={leadContent}
1548
+ />
1549
+ )
1550
+ }
1551
+
1552
+ const POD_VISIBLE_LIMIT = 5
1553
+ const EVENT_VISIBLE_LIMIT = 5
1554
+ const RELATIONSHIP_GROUP_LIMIT = 5
1555
+ const RELATIONSHIP_REF_LIMIT = 5
1556
+ const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
1557
+ 'jobs',
1558
+ 'cronjobs',
1559
+ 'workflows',
1560
+ 'cronworkflows',
1561
+ 'workflowtemplates',
1562
+ 'clusterworkflowtemplates',
1563
+ 'scaledjobs',
1564
+ ])
1565
+ const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
1566
+ type RuntimeOverviewShape = 'replicated' | 'job' | 'cronjob'
1567
+
1568
+ function isRuntimeWorkloadOverviewKind(kind: string) {
1569
+ return RUNTIME_WORKLOAD_OVERVIEW_KINDS.has(kindToPlural(kind).toLowerCase())
1570
+ }
1571
+
1572
+ function runtimeOverviewShape(kind: string): RuntimeOverviewShape {
1573
+ const k = kindToPlural(kind).toLowerCase()
1574
+ if (k === 'jobs') return 'job'
1575
+ if (k === 'cronjobs') return 'cronjob'
1576
+ return 'replicated'
1577
+ }
1578
+
1579
+ function WorkloadOverviewTab({
1580
+ resource,
1581
+ selectedResource,
1582
+ relationships,
1583
+ hpaDiagnosis,
1584
+ scalerDiagnostics,
1585
+ workloadPods,
1586
+ workloadPodsLoading,
1587
+ workloadPodsError,
1588
+ servingResources,
1589
+ renderServicePortAction,
1590
+ renderServicePortPanel,
1591
+ onNavigate,
1592
+ onSwitchToTimeline,
1593
+ onSwitchToLogs,
1594
+ onSwitchToTopology,
1595
+ events = [],
1596
+ updates = [],
1597
+ eventsLoading,
1598
+ eventsError,
1599
+ updatesError,
1600
+ extraContent,
1601
+ introContent,
1602
+ leadContent,
1603
+ }: {
1604
+ resource: any
1605
+ selectedResource: SelectedResource
1606
+ relationships?: Relationships
1607
+ hpaDiagnosis?: HPADiagnosis
1608
+ scalerDiagnostics?: ScalerDiagnosis[]
1609
+ workloadPods?: WorkloadPodInfo[]
1610
+ workloadPodsLoading?: boolean
1611
+ workloadPodsError?: Error | null
1612
+ servingResources?: ServingResourceDetail[]
1613
+ renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
1614
+ renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
1615
+ onNavigate?: NavigateToResource
1616
+ onSwitchToTimeline?: () => void
1617
+ onSwitchToLogs?: () => void
1618
+ onSwitchToTopology?: () => void
1619
+ events?: TimelineEvent[]
1620
+ updates?: TimelineEvent[]
1621
+ eventsLoading?: boolean
1622
+ eventsError?: Error | null
1623
+ updatesError?: Error | null
1624
+ extraContent?: ReactNode
1625
+ introContent?: ReactNode
1626
+ leadContent?: ReactNode
1627
+ }) {
1628
+ const apiKind = kindToPlural(selectedResource.kind)
1629
+ const resourceKind = resource?.kind || displayKindName(apiKind, resource?.kind)
1630
+ const readiness = getReadinessSummary(resource, apiKind)
1631
+ const podSummary = getPodSummary(workloadPods, relationships, apiKind)
1632
+ const scaling = getScalingSummary(resource, apiKind, relationships, scalerDiagnostics, hpaDiagnosis)
1633
+ const controlSurface = runtimeControlSurface(apiKind)
1634
+ const overviewShape = runtimeOverviewShape(apiKind)
1635
+ const summaryMetrics = buildSummaryMetrics(resource, apiKind, resourceKind, readiness, podSummary, scaling)
1636
+ const relationshipGroups = buildRelationshipGroups(relationships)
1637
+ const servingRelationshipGroups = buildServingRelationshipGroups(relationships)
1638
+ const dependencyRelationshipGroups = buildDependencyRelationshipGroups(relationships)
1639
+ const showServingPath = servingRelationshipGroups.length > 0 && overviewShape !== 'job' && overviewShape !== 'cronjob'
1640
+ const secondaryRelationshipGroups = showServingPath ? dependencyRelationshipGroups : relationshipGroups
1641
+ const relationshipCardTitle =
1642
+ overviewShape === 'job' || overviewShape === 'cronjob'
1643
+ ? 'Related resources'
1644
+ : showServingPath
1645
+ ? 'Dependencies'
1646
+ : 'Related resources'
1647
+
1648
+ if (overviewShape === 'replicated') {
1649
+ const state = getReplicatedWorkloadState(resource, apiKind)
1650
+ const serviceAccountName = getPodTemplateSpec(resource, apiKind)?.serviceAccountName || 'default'
1651
+ return (
1652
+ <div className="h-full overflow-auto bg-theme-base">
1653
+ <div className="space-y-4 p-4">
1413
1654
  {leadContent}
1655
+ {introContent}
1656
+
1657
+ <WorkloadStatusStrip
1658
+ state={state}
1659
+ readiness={readiness}
1660
+ podSummary={podSummary}
1661
+ servingSummary={showServingPath ? buildServingStripSummary(servingRelationshipGroups, servingResources) : undefined}
1662
+ />
1663
+
1664
+ <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
1665
+ <div className="space-y-4">
1666
+ <div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
1667
+ <div className="space-y-4">
1668
+ {showServingPath && (
1669
+ <ServingConfigurationCard
1670
+ groups={servingRelationshipGroups}
1671
+ details={servingResources}
1672
+ renderPortAction={renderServicePortAction}
1673
+ renderPortPanel={renderServicePortPanel}
1674
+ onNavigate={onNavigate}
1675
+ onSwitchToTopology={onSwitchToTopology}
1676
+ />
1677
+ )}
1678
+ <RuntimeCard
1679
+ resource={resource}
1680
+ apiKind={apiKind}
1681
+ state={state}
1682
+ readiness={readiness}
1683
+ scaling={scaling}
1684
+ namespace={selectedResource.namespace}
1685
+ workloadPods={workloadPods}
1686
+ workloadPodsLoading={workloadPodsLoading}
1687
+ workloadPodsError={workloadPodsError}
1688
+ relationshipPods={relationships?.pods}
1689
+ scalerDiagnostics={scalerDiagnostics}
1690
+ hpaDiagnosis={hpaDiagnosis}
1691
+ relationships={relationships}
1692
+ onNavigate={onNavigate}
1693
+ onSwitchToLogs={onSwitchToLogs}
1694
+ />
1695
+ </div>
1696
+ <div className="space-y-4">
1697
+ <ConfigurationInputsCard
1698
+ serviceAccountName={serviceAccountName}
1699
+ namespace={selectedResource.namespace}
1700
+ relationships={relationships}
1701
+ onNavigate={onNavigate}
1702
+ onSwitchToTopology={onSwitchToTopology}
1703
+ />
1704
+ {extraContent}
1705
+ </div>
1706
+ </div>
1707
+ </div>
1708
+
1709
+ <div className="space-y-4">
1710
+ <ActivityCard
1711
+ events={events}
1712
+ updates={updates}
1713
+ loading={eventsLoading}
1714
+ eventsError={eventsError}
1715
+ updatesError={updatesError}
1716
+ onSwitchToTimeline={onSwitchToTimeline}
1717
+ />
1718
+ </div>
1719
+ </div>
1414
1720
  </div>
1415
- )}
1416
- <ResourceRendererDispatch
1417
- resource={selectedResource}
1418
- data={resource}
1419
- relationships={relationships}
1420
- hpaDiagnosis={hpaDiagnosis}
1421
- scalerDiagnostics={scalerDiagnostics}
1422
- onCopy={onCopy}
1423
- copied={copied}
1424
- onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
1425
- onSaveSecretValue={onSaveSecretValue}
1426
- isSavingSecret={isSavingSecret}
1427
- showCommonSections={true}
1428
- showMetrics={false}
1429
- onOpenLogs={onOpenLogs}
1430
- rendererOverrides={rendererOverrides}
1431
- resolvedEnvFrom={resolvedEnvFrom}
1432
- events={events}
1433
- eventsLoading={eventsLoading}
1434
- updates={updates}
1435
- eventsError={eventsError}
1436
- updatesError={updatesError}
1437
- eventsHint={onSwitchToTimeline && (
1438
- <button
1439
- onClick={onSwitchToTimeline}
1440
- className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
1441
- >
1442
- Showing recent events across this workload. Switch to the <span className="underline">Timeline</span> tab for full history and resource relationships.
1443
- </button>
1721
+ </div>
1722
+ )
1723
+ }
1724
+
1725
+ return (
1726
+ <div className="h-full overflow-auto bg-theme-base">
1727
+ <div className="space-y-4 p-4">
1728
+ {leadContent}
1729
+ {introContent}
1730
+
1731
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-4">
1732
+ {summaryMetrics.map((metric) => (
1733
+ <SummaryMetric key={metric.label} {...metric} />
1734
+ ))}
1735
+ </div>
1736
+
1737
+ {showServingPath && (
1738
+ <ServingConfigurationCard
1739
+ groups={servingRelationshipGroups}
1740
+ details={servingResources}
1741
+ renderPortAction={renderServicePortAction}
1742
+ renderPortPanel={renderServicePortPanel}
1743
+ onNavigate={onNavigate}
1744
+ onSwitchToTopology={onSwitchToTopology}
1745
+ />
1444
1746
  )}
1445
- renderSidebar={(sidebarSections) => (
1446
- <div className="lg:w-[35%] lg:shrink-0 lg:border-l border-theme-border">
1447
- <div className="p-4 space-y-4">
1448
- {sidebarSections}
1747
+
1748
+ <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
1749
+ <div className="space-y-4">
1750
+ <CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
1751
+ <div className="grid items-start gap-4 2xl:grid-cols-2">
1752
+ {overviewShape === 'cronjob' ? (
1753
+ <CronJobRunsCard
1754
+ resource={resource}
1755
+ namespace={selectedResource.namespace}
1756
+ onNavigate={onNavigate}
1757
+ onSwitchToTopology={onSwitchToTopology}
1758
+ />
1759
+ ) : (
1760
+ <PodsAttentionCard
1761
+ namespace={selectedResource.namespace}
1762
+ emptyText={overviewShape === 'job' ? 'No Pods are currently retained for this Job.' : undefined}
1763
+ workloadPods={workloadPods}
1764
+ workloadPodsLoading={workloadPodsLoading}
1765
+ workloadPodsError={workloadPodsError}
1766
+ relationshipPods={relationships?.pods}
1767
+ onNavigate={onNavigate}
1768
+ onSwitchToLogs={onSwitchToLogs}
1769
+ />
1770
+ )}
1771
+ <ScalingCard
1772
+ title={controlSurface.cardTitle}
1773
+ icon={controlSurface.icon}
1774
+ scaling={scaling}
1775
+ scalerDiagnostics={scalerDiagnostics}
1776
+ hpaDiagnosis={hpaDiagnosis}
1777
+ relationships={relationships}
1778
+ onNavigate={onNavigate}
1779
+ />
1449
1780
  </div>
1781
+ {secondaryRelationshipGroups.length > 0 ? (
1782
+ <ServingDependenciesCard
1783
+ title={relationshipCardTitle}
1784
+ relationships={relationships}
1785
+ groups={secondaryRelationshipGroups}
1786
+ onNavigate={onNavigate}
1787
+ onSwitchToTopology={onSwitchToTopology}
1788
+ />
1789
+ ) : null}
1790
+ {extraContent && (
1791
+ <div className="grid items-start gap-4 2xl:grid-cols-2">
1792
+ {extraContent}
1793
+ </div>
1794
+ )}
1795
+ </div>
1796
+
1797
+ <div className="space-y-4">
1798
+ <ActivityCard
1799
+ events={events}
1800
+ updates={updates}
1801
+ loading={eventsLoading}
1802
+ eventsError={eventsError}
1803
+ updatesError={updatesError}
1804
+ onSwitchToTimeline={onSwitchToTimeline}
1805
+ />
1450
1806
  </div>
1451
- )}
1452
- />
1453
- {extraContent && (
1454
- <div className="px-4 pb-4">
1455
- {extraContent}
1456
1807
  </div>
1457
- )}
1808
+
1809
+ </div>
1458
1810
  </div>
1459
1811
  )
1460
1812
  }
1813
+
1814
+ function OverviewCard({
1815
+ title,
1816
+ icon: Icon,
1817
+ action,
1818
+ children,
1819
+ }: {
1820
+ title: string
1821
+ icon: ComponentType<{ className?: string }>
1822
+ action?: ReactNode
1823
+ children: ReactNode
1824
+ }) {
1825
+ return (
1826
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1827
+ <div className="mb-3 flex min-w-0 items-center justify-between gap-3">
1828
+ <div className="flex min-w-0 items-center gap-2">
1829
+ <Icon className="h-4 w-4 shrink-0 text-theme-text-secondary" />
1830
+ <h2 className="truncate text-[11px] font-semibold uppercase tracking-wide text-theme-text-secondary">{title}</h2>
1831
+ </div>
1832
+ {action}
1833
+ </div>
1834
+ {children}
1835
+ </section>
1836
+ )
1837
+ }
1838
+
1839
+ function SummaryMetric({
1840
+ label,
1841
+ value,
1842
+ detail,
1843
+ icon: Icon,
1844
+ mono,
1845
+ }: {
1846
+ label: string
1847
+ value: ReactNode
1848
+ detail?: ReactNode
1849
+ icon: ComponentType<{ className?: string }>
1850
+ mono?: boolean
1851
+ }) {
1852
+ return (
1853
+ <section className="min-w-0 rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
1854
+ <div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-theme-text-secondary">
1855
+ <Icon className="h-4 w-4 shrink-0" />
1856
+ <span>{label}</span>
1857
+ </div>
1858
+ <div className={clsx('truncate text-lg font-semibold text-theme-text-primary', mono && 'font-mono')}>{value}</div>
1859
+ {detail && <div className="mt-1 truncate text-xs text-theme-text-tertiary">{detail}</div>}
1860
+ </section>
1861
+ )
1862
+ }
1863
+
1864
+ interface ReplicatedWorkloadState {
1865
+ label: string
1866
+ detail: string
1867
+ badgeClass: string
1868
+ level: 'healthy' | 'neutral' | 'degraded' | 'unhealthy' | 'unknown'
1869
+ healthy: boolean
1870
+ needsAttention: boolean
1871
+ }
1872
+
1873
+ function WorkloadStatusStrip({
1874
+ state,
1875
+ readiness,
1876
+ podSummary,
1877
+ servingSummary,
1878
+ }: {
1879
+ state: ReplicatedWorkloadState
1880
+ readiness: ReadinessSummary
1881
+ podSummary: ReturnType<typeof getPodSummary>
1882
+ servingSummary?: string
1883
+ }) {
1884
+ const servingMode = Boolean(servingSummary)
1885
+ const stateDetail = servingMode && state.healthy ? 'runtime health' : state.detail
1886
+ const podItem = servingMode && typeof podSummary.total === 'number'
1887
+ ? {
1888
+ label: 'Pods',
1889
+ value: `${podSummary.total}`,
1890
+ detail: podSummary.total === 0
1891
+ ? 'none running'
1892
+ : (podSummary.notReady ?? 0) > 0
1893
+ ? `${podSummary.notReady} need attention`
1894
+ : 'runtime instances',
1895
+ }
1896
+ : { label: 'Pods', value: podSummary.label, detail: podSummary.detail }
1897
+ const items = [
1898
+ { label: 'State', value: <span className={clsx('badge-sm', state.badgeClass)}>{state.label}</span>, detail: stateDetail },
1899
+ { label: 'Ready', value: readiness.readyLabel, detail: readiness.detail },
1900
+ podItem,
1901
+ servingSummary ? { label: 'Exposure', value: servingSummary, detail: 'configured, not probed' } : null,
1902
+ ].filter(Boolean) as Array<{ label: string; value: ReactNode; detail?: ReactNode }>
1903
+
1904
+ return (
1905
+ <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
1906
+ <div className="grid gap-3 sm:grid-cols-2 2xl:grid-cols-4">
1907
+ {items.map((item) => (
1908
+ <div key={item.label} className="min-w-0">
1909
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{item.label}</div>
1910
+ <div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">{item.value}</div>
1911
+ {item.detail ? <div className="mt-0.5 truncate text-xs text-theme-text-tertiary">{item.detail}</div> : null}
1912
+ </div>
1913
+ ))}
1914
+ </div>
1915
+ </section>
1916
+ )
1917
+ }
1918
+
1919
+ function getReplicatedWorkloadState(resource: any, apiKind: string): ReplicatedWorkloadState {
1920
+ const status = resource?.status || {}
1921
+ const spec = resource?.spec || {}
1922
+ const metadata = resource?.metadata || {}
1923
+ const k = apiKind.toLowerCase()
1924
+ const generation = metadata.generation
1925
+ const observedGeneration = status.observedGeneration
1926
+ const controllerBehind =
1927
+ typeof generation === 'number' &&
1928
+ typeof observedGeneration === 'number' &&
1929
+ observedGeneration < generation
1930
+
1931
+ if (k === 'daemonsets') {
1932
+ const desired = status.desiredNumberScheduled ?? 0
1933
+ const ready = status.numberReady ?? 0
1934
+ const updated = status.updatedNumberScheduled ?? 0
1935
+ const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
1936
+ if (desired === 0) return stateSummary('No targets', 'selector matches no nodes', 'status-neutral', 'neutral')
1937
+ if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
1938
+ if (updated < desired) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
1939
+ if (ready === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
1940
+ if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
1941
+ return stateSummary('Unhealthy', 'no scheduled pods are ready', 'status-unhealthy', 'unhealthy')
1942
+ }
1943
+
1944
+ const desired = spec.replicas ?? status.replicas ?? 0
1945
+ const ready = status.readyReplicas ?? 0
1946
+ const updated = status.updatedReplicas ?? 0
1947
+ const available = status.availableReplicas ?? ready
1948
+ const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
1949
+ if (desired === 0) return stateSummary('Scaled to zero', 'no replicas requested', 'status-neutral', 'neutral')
1950
+ if (k === 'deployments' && spec.paused) return stateSummary('Paused', `${ready}/${desired} ready`, 'status-neutral', 'neutral')
1951
+ const progressCondition = Array.isArray(status.conditions)
1952
+ ? status.conditions.find((condition: any) => condition.type === 'Progressing')
1953
+ : undefined
1954
+ if (progressCondition?.status === 'False' || progressCondition?.reason === 'ProgressDeadlineExceeded') {
1955
+ return stateSummary('Stalled', progressCondition.message || `${ready}/${desired} ready`, 'status-unhealthy', 'unhealthy')
1956
+ }
1957
+ if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
1958
+ const statefulSetRolling = k === 'statefulsets' && status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision
1959
+ if (updated < desired || statefulSetRolling) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
1960
+ if (ready === desired && available === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
1961
+ if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
1962
+ return stateSummary('Unhealthy', 'no replicas are ready', 'status-unhealthy', 'unhealthy')
1963
+ }
1964
+
1965
+ function stateSummary(
1966
+ label: string,
1967
+ detail: string,
1968
+ badgeClass: string,
1969
+ level: ReplicatedWorkloadState['level'],
1970
+ ): ReplicatedWorkloadState {
1971
+ return {
1972
+ label,
1973
+ detail,
1974
+ badgeClass,
1975
+ level,
1976
+ healthy: level === 'healthy',
1977
+ needsAttention: level === 'degraded' || level === 'unhealthy',
1978
+ }
1979
+ }
1980
+
1981
+ function buildServingStripSummary(groups: RelationshipGroup[], details?: ServingResourceDetail[]): string {
1982
+ const services = groups.find((group) => group.label === 'Services')?.refs ?? []
1983
+ const entrypoints = groups.find((group) => group.label === 'Entry points')?.refs ?? []
1984
+ const serviceResources = (details ?? [])
1985
+ .filter((detail) => services.some((ref) => resourceRefId(ref) === resourceRefId(detail.ref)))
1986
+ .map((detail) => detail.resource)
1987
+ .filter(Boolean)
1988
+ const serviceTypes = new Set(serviceResources.map((service) => service?.spec?.type || 'ClusterIP'))
1989
+ if (entrypoints.length > 0) return 'External entrypoint'
1990
+ if ([...serviceTypes].some((type) => ['LoadBalancer', 'NodePort', 'ExternalName'].includes(String(type)))) return 'External service'
1991
+ if (services.length > 0) return 'Internal service'
1992
+ return 'None'
1993
+ }
1994
+
1995
+ interface SummaryMetricConfig {
1996
+ label: string
1997
+ value: ReactNode
1998
+ detail?: ReactNode
1999
+ icon: ComponentType<{ className?: string }>
2000
+ mono?: boolean
2001
+ }
2002
+
2003
+ function buildSummaryMetrics(
2004
+ resource: any,
2005
+ apiKind: string,
2006
+ resourceKind: string,
2007
+ readiness: ReadinessSummary,
2008
+ podSummary: ReturnType<typeof getPodSummary>,
2009
+ scaling: ScalingSummary,
2010
+ ): SummaryMetricConfig[] {
2011
+ const spec = resource?.spec || {}
2012
+ const status = resource?.status || {}
2013
+ const k = apiKind.toLowerCase()
2014
+
2015
+ if (k === 'jobs') {
2016
+ const duration = formatJobDuration(resource)
2017
+ return [
2018
+ {
2019
+ label: 'State',
2020
+ value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
2021
+ detail: resourceKind,
2022
+ icon: readiness.hasIssue ? AlertTriangle : Activity,
2023
+ },
2024
+ {
2025
+ label: 'Progress',
2026
+ value: readiness.readyLabel,
2027
+ detail: readiness.detail,
2028
+ icon: CheckCircle2,
2029
+ },
2030
+ {
2031
+ label: 'Duration',
2032
+ value: duration || '-',
2033
+ detail: status.startTime ? (status.completionTime ? 'finished run' : 'current run') : 'not started',
2034
+ icon: Clock3,
2035
+ },
2036
+ {
2037
+ label: 'Pods',
2038
+ value: podSummary.label,
2039
+ detail: podSummary.detail,
2040
+ icon: Boxes,
2041
+ },
2042
+ ]
2043
+ }
2044
+
2045
+ if (k === 'cronjobs') {
2046
+ const active = Array.isArray(status.active) ? status.active.length : 0
2047
+ return [
2048
+ {
2049
+ label: 'State',
2050
+ value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
2051
+ detail: spec.suspend ? 'schedule suspended' : active > 0 ? 'run in progress' : 'waiting for next run',
2052
+ icon: readiness.hasIssue ? AlertTriangle : Clock3,
2053
+ },
2054
+ {
2055
+ label: 'Schedule',
2056
+ value: spec.schedule || '-',
2057
+ detail: spec.schedule ? cronToHuman(spec.schedule) : 'cron unavailable',
2058
+ icon: Clock3,
2059
+ mono: Boolean(spec.schedule),
2060
+ },
2061
+ {
2062
+ label: 'Latest run',
2063
+ value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never',
2064
+ detail: status.lastSuccessfulTime ? `last success ${formatAge(status.lastSuccessfulTime)}` : 'no successful run recorded',
2065
+ icon: Activity,
2066
+ },
2067
+ {
2068
+ label: 'Active jobs',
2069
+ value: active,
2070
+ detail: active > 0 ? 'currently running' : 'none running',
2071
+ icon: Boxes,
2072
+ },
2073
+ ]
2074
+ }
2075
+
2076
+ const control = runtimeControlSurface(apiKind)
2077
+ return [
2078
+ {
2079
+ label: 'State',
2080
+ value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
2081
+ detail: resourceKind,
2082
+ icon: readiness.hasIssue ? AlertTriangle : CheckCircle2,
2083
+ },
2084
+ {
2085
+ label: 'Readiness',
2086
+ value: readiness.readyLabel,
2087
+ detail: readiness.detail,
2088
+ icon: Server,
2089
+ },
2090
+ {
2091
+ label: 'Pods',
2092
+ value: podSummary.label,
2093
+ detail: podSummary.detail,
2094
+ icon: Boxes,
2095
+ },
2096
+ {
2097
+ label: control.metricLabel,
2098
+ value: scaling.label,
2099
+ detail: scaling.detail,
2100
+ icon: control.icon,
2101
+ },
2102
+ ]
2103
+ }
2104
+
2105
+ interface ReadinessSummary {
2106
+ readyLabel: string
2107
+ detail: string
2108
+ healthLabel: string
2109
+ healthBadge: string
2110
+ hasIssue: boolean
2111
+ stats: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }>
2112
+ }
2113
+
2114
+ function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
2115
+ const status = resource?.status || {}
2116
+ const spec = resource?.spec || {}
2117
+ const k = apiKind.toLowerCase()
2118
+
2119
+ if (k === 'daemonsets') {
2120
+ const desired = status.desiredNumberScheduled ?? 0
2121
+ const ready = status.numberReady ?? 0
2122
+ const updated = status.updatedNumberScheduled ?? 0
2123
+ const available = status.numberAvailable ?? ready
2124
+ const unavailable = status.numberUnavailable ?? Math.max(0, desired - available)
2125
+ const noTargets = desired === 0
2126
+ const healthy = !noTargets && ready === desired && unavailable === 0
2127
+ return {
2128
+ readyLabel: `${ready}/${desired}`,
2129
+ detail: `${updated}/${desired} up-to-date`,
2130
+ healthLabel: noTargets ? 'No targets' : healthy ? 'Healthy' : 'Degraded',
2131
+ healthBadge: noTargets ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
2132
+ hasIssue: !healthy && !noTargets,
2133
+ stats: [
2134
+ { label: 'Desired', value: desired },
2135
+ { label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
2136
+ { label: 'Available', value: available },
2137
+ { label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
2138
+ ],
2139
+ }
2140
+ }
2141
+
2142
+ if (k === 'jobs') {
2143
+ const desired = spec.completions ?? 1
2144
+ const succeeded = status.succeeded ?? 0
2145
+ const active = status.active ?? 0
2146
+ const failed = status.failed ?? 0
2147
+ const phase = jobPhase(resource)
2148
+ const complete = phase === 'Complete'
2149
+ return {
2150
+ readyLabel: `${succeeded}/${desired}`,
2151
+ detail: complete ? 'completed successfully' : phase === 'Failed' ? jobConditionMessage(resource) || `${failed} failed` : active > 0 ? `${active} active` : 'waiting for pods',
2152
+ healthLabel: phase,
2153
+ healthBadge: phase === 'Failed' ? 'status-unhealthy' : phase === 'Pending' ? 'status-degraded' : complete ? 'status-healthy' : 'status-neutral',
2154
+ hasIssue: phase === 'Failed',
2155
+ stats: [
2156
+ { label: 'Completions', value: `${succeeded}/${desired}`, tone: complete ? 'good' : 'default' },
2157
+ { label: 'Active', value: active },
2158
+ { label: 'Failed attempts', value: failed, tone: phase === 'Failed' ? 'warn' : 'default' },
2159
+ { label: 'Parallelism', value: spec.parallelism ?? '-' },
2160
+ ],
2161
+ }
2162
+ }
2163
+
2164
+ if (k === 'cronjobs') {
2165
+ const active = Array.isArray(status.active) ? status.active.length : 0
2166
+ const suspended = !!spec.suspend
2167
+ const lastSchedule = status.lastScheduleTime ? Date.parse(status.lastScheduleTime) : 0
2168
+ const lastSuccess = status.lastSuccessfulTime ? Date.parse(status.lastSuccessfulTime) : 0
2169
+ const missingRecentSuccess = !suspended && active === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
2170
+ return {
2171
+ readyLabel: active > 0 ? `${active} active` : 'Idle',
2172
+ detail: spec.schedule ? `schedule ${spec.schedule}` : 'scheduled job',
2173
+ healthLabel: missingRecentSuccess ? 'No recent success' : suspended ? 'Suspended' : active > 0 ? 'Active' : 'Scheduled',
2174
+ healthBadge: missingRecentSuccess ? 'status-degraded' : suspended ? 'status-neutral' : active > 0 ? 'status-neutral' : 'status-healthy',
2175
+ hasIssue: missingRecentSuccess,
2176
+ stats: [
2177
+ { label: 'Schedule', value: spec.schedule || '-' },
2178
+ { label: 'Active jobs', value: active, tone: active > 0 ? 'warn' : 'default' },
2179
+ { label: 'Suspend', value: suspended ? 'Yes' : 'No', tone: suspended ? 'warn' : 'default' },
2180
+ { label: 'Last schedule', value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : '-' },
2181
+ { label: 'Last success', value: status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : '-' },
2182
+ ],
2183
+ }
2184
+ }
2185
+
2186
+ const desired = spec.replicas ?? status.replicas ?? 0
2187
+ const current = status.replicas ?? desired
2188
+ const ready = status.readyReplicas ?? 0
2189
+ const updated = status.updatedReplicas ?? 0
2190
+ const available = status.availableReplicas ?? ready
2191
+ const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
2192
+ const scaledToZero = desired === 0
2193
+ const healthy = !scaledToZero && ready === desired && unavailable === 0
2194
+ return {
2195
+ readyLabel: `${ready}/${desired}`,
2196
+ detail: `${updated}/${desired} updated · ${available} available`,
2197
+ healthLabel: scaledToZero ? 'Scaled to zero' : healthy ? 'Healthy' : 'Degraded',
2198
+ healthBadge: scaledToZero ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
2199
+ hasIssue: !healthy && !scaledToZero,
2200
+ stats: [
2201
+ { label: 'Desired', value: desired },
2202
+ { label: 'Current', value: current },
2203
+ { label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
2204
+ { label: 'Updated', value: updated },
2205
+ { label: 'Available', value: available },
2206
+ { label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
2207
+ ],
2208
+ }
2209
+ }
2210
+
2211
+ function getPodSummary(workloadPods: WorkloadPodInfo[] | undefined, relationships: Relationships | undefined, apiKind?: string) {
2212
+ const k = apiKind?.toLowerCase()
2213
+ if (workloadPods) {
2214
+ const ready = workloadPods.filter((pod) => pod.ready).length
2215
+ const total = workloadPods.length
2216
+ const notReady = total - ready
2217
+ return {
2218
+ label: `${ready}/${total}`,
2219
+ detail: total === 0 ? (k === 'jobs' ? 'no pods retained' : 'no pods found') : notReady === 0 ? 'all ready' : `${notReady} need attention`,
2220
+ ready,
2221
+ total,
2222
+ notReady,
2223
+ }
2224
+ }
2225
+ const count = relationships?.pods?.length ?? 0
2226
+ return {
2227
+ label: count ? String(count) : '-',
2228
+ detail: count ? 'status unavailable' : k === 'jobs' ? 'no pods retained' : 'no pod refs',
2229
+ ready: undefined,
2230
+ total: count || undefined,
2231
+ notReady: undefined,
2232
+ }
2233
+ }
2234
+
2235
+ interface ScalingSummary {
2236
+ label: string
2237
+ detail: string
2238
+ stats: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }>
2239
+ }
2240
+
2241
+ function runtimeControlSurface(apiKind: string): { metricLabel: string; cardTitle: string; icon: ComponentType<{ className?: string }> } {
2242
+ const k = apiKind.toLowerCase()
2243
+ if (k === 'cronjobs') return { metricLabel: 'Schedule', cardTitle: 'Schedule', icon: Clock3 }
2244
+ if (k === 'jobs') return { metricLabel: 'Execution', cardTitle: 'Execution', icon: Activity }
2245
+ return { metricLabel: 'Scaling', cardTitle: 'Scaling', icon: Scale }
2246
+ }
2247
+
2248
+ function getScalingSummary(
2249
+ resource: any,
2250
+ apiKind: string,
2251
+ relationships: Relationships | undefined,
2252
+ scalerDiagnostics: ScalerDiagnosis[] | undefined,
2253
+ hpaDiagnosis: HPADiagnosis | undefined,
2254
+ ): ScalingSummary {
2255
+ const spec = resource?.spec || {}
2256
+ const status = resource?.status || {}
2257
+ const k = apiKind.toLowerCase()
2258
+ const scalers = relationships?.scalers ?? []
2259
+ const primaryDiagnosis = scalerDiagnostics?.find((entry) => entry.diagnosis)?.diagnosis ?? hpaDiagnosis
2260
+
2261
+ if (k === 'daemonsets') {
2262
+ return {
2263
+ label: 'Node scheduled',
2264
+ detail: `${status.desiredNumberScheduled ?? 0} desired nodes`,
2265
+ stats: [
2266
+ { label: 'Controller', value: 'DaemonSet' },
2267
+ { label: 'Desired nodes', value: status.desiredNumberScheduled ?? '-' },
2268
+ { label: 'Misscheduled', value: status.numberMisscheduled ?? 0, tone: (status.numberMisscheduled ?? 0) > 0 ? 'warn' : 'default' },
2269
+ ],
2270
+ }
2271
+ }
2272
+
2273
+ if (k === 'jobs') {
2274
+ const phase = jobPhase(resource)
2275
+ return {
2276
+ label: phase,
2277
+ detail: '',
2278
+ stats: [
2279
+ { label: 'Started', value: status.startTime ? formatAge(status.startTime) : '-' },
2280
+ { label: 'Finished', value: status.completionTime ? formatAge(status.completionTime) : phase === 'Running' ? 'Running' : '-' },
2281
+ { label: 'Parallelism', value: spec.parallelism ?? 1 },
2282
+ { label: 'Completions', value: spec.completions ?? 1 },
2283
+ { label: 'Completion mode', value: spec.completionMode ?? 'NonIndexed' },
2284
+ { label: 'Backoff limit', value: spec.backoffLimit ?? 6 },
2285
+ { label: 'TTL after finish', value: spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None' },
2286
+ ],
2287
+ }
2288
+ }
2289
+
2290
+ if (k === 'cronjobs') {
2291
+ return {
2292
+ label: spec.suspend ? 'Suspended' : 'Scheduled',
2293
+ detail: '',
2294
+ stats: [
2295
+ { label: 'Schedule', value: spec.schedule ?? '-' },
2296
+ { label: 'Human', value: spec.schedule ? cronToHuman(spec.schedule) : '-' },
2297
+ { label: 'Concurrency', value: spec.concurrencyPolicy ?? 'Allow' },
2298
+ { label: 'Starting deadline', value: spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None' },
2299
+ { label: 'Success history', value: spec.successfulJobsHistoryLimit ?? 3 },
2300
+ { label: 'Failed history', value: spec.failedJobsHistoryLimit ?? 1 },
2301
+ { label: 'Suspend', value: spec.suspend ? 'Yes' : 'No', tone: spec.suspend ? 'warn' : 'default' },
2302
+ ],
2303
+ }
2304
+ }
2305
+
2306
+ if (scalers.length > 0 || primaryDiagnosis) {
2307
+ return {
2308
+ label: scalers.length > 0 ? `${scalers.length} controller${scalers.length === 1 ? '' : 's'}` : hpaStateLabel(primaryDiagnosis!.state),
2309
+ detail: primaryDiagnosis ? hpaStateLabel(primaryDiagnosis.state) : 'replicas controlled externally',
2310
+ stats: [
2311
+ { label: 'Desired', value: primaryDiagnosis?.bounds?.desired ?? spec.replicas ?? '-' },
2312
+ { label: 'Current', value: primaryDiagnosis?.bounds?.current ?? status.replicas ?? '-' },
2313
+ { label: 'Min', value: primaryDiagnosis?.bounds?.min ?? '-' },
2314
+ { label: 'Max', value: primaryDiagnosis?.bounds?.max ?? '-' },
2315
+ ],
2316
+ }
2317
+ }
2318
+
2319
+ return {
2320
+ label: spec.replicas !== undefined ? `${spec.replicas} replicas` : 'Manual',
2321
+ detail: 'replicas from workload spec',
2322
+ stats: [
2323
+ { label: 'Desired', value: spec.replicas ?? '-' },
2324
+ { label: 'Current', value: status.replicas ?? '-' },
2325
+ { label: 'Strategy', value: spec.strategy?.type || spec.updateStrategy?.type || '-' },
2326
+ ],
2327
+ }
2328
+ }
2329
+
2330
+ function CurrentStateCard({ resource, apiKind, readiness }: { resource: any; apiKind: string; readiness: ReadinessSummary }) {
2331
+ const metadata = resource?.metadata || {}
2332
+ const templateSpec = getPodTemplateSpec(resource, apiKind)
2333
+ const containers = templateSpec?.containers ?? []
2334
+ const image = containers[0]?.image
2335
+ const k = apiKind.toLowerCase()
2336
+ const title = k === 'jobs' ? 'Execution state' : k === 'cronjobs' ? 'Schedule state' : 'Current state'
2337
+ const generation = metadata.generation
2338
+ const observedGeneration = resource?.status?.observedGeneration
2339
+ const controllerBehind =
2340
+ typeof generation === 'number' &&
2341
+ typeof observedGeneration === 'number' &&
2342
+ observedGeneration < generation
2343
+
2344
+ return (
2345
+ <OverviewCard title={title} icon={k === 'jobs' ? Activity : k === 'cronjobs' ? Clock3 : Server}>
2346
+ <div className="grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
2347
+ {readiness.stats.map((stat) => (
2348
+ <OverviewStat key={String(stat.label)} label={stat.label} value={stat.value} tone={stat.tone} />
2349
+ ))}
2350
+ {controllerBehind && (
2351
+ <>
2352
+ <OverviewStat label="Spec generation" value={generation} tone="warn" />
2353
+ <OverviewStat label="Controller seen" value={observedGeneration} tone="warn" />
2354
+ </>
2355
+ )}
2356
+ <OverviewStat label="Age" value={metadata.creationTimestamp ? formatAge(metadata.creationTimestamp) : '-'} />
2357
+ </div>
2358
+ <div className="mt-4 grid gap-2 border-t border-theme-border pt-3 text-sm sm:grid-cols-2">
2359
+ <InlineFact label="Kind" value={displayKindName(apiKind, resource?.kind)} />
2360
+ {templateSpec && <InlineFact label="Service account" value={templateSpec.serviceAccountName || 'default'} />}
2361
+ {image && <InlineFact label="Image" value={<span className="font-mono text-xs">{image}</span>} />}
2362
+ </div>
2363
+ </OverviewCard>
2364
+ )
2365
+ }
2366
+
2367
+ function getPodTemplateSpec(resource: any, apiKind: string) {
2368
+ const spec = resource?.spec || {}
2369
+ return apiKind.toLowerCase() === 'cronjobs'
2370
+ ? spec.jobTemplate?.spec?.template?.spec
2371
+ : spec.template?.spec
2372
+ }
2373
+
2374
+ function jobPhase(job: any): 'Complete' | 'Failed' | 'Suspended' | 'Running' | 'Pending' {
2375
+ const status = job?.status || {}
2376
+ const spec = job?.spec || {}
2377
+ const conditions = Array.isArray(status.conditions) ? status.conditions : []
2378
+ if (conditions.some((condition: any) => condition.type === 'Failed' && condition.status === 'True')) return 'Failed'
2379
+ if (conditions.some((condition: any) => condition.type === 'Complete' && condition.status === 'True')) return 'Complete'
2380
+ if (spec.suspend) return 'Suspended'
2381
+ if ((status.active ?? 0) > 0) return 'Running'
2382
+ return 'Pending'
2383
+ }
2384
+
2385
+ function jobConditionMessage(job: any): string {
2386
+ const conditions = Array.isArray(job?.status?.conditions) ? job.status.conditions : []
2387
+ const condition = conditions.find((entry: any) => (entry.type === 'Failed' || entry.type === 'Complete') && entry.status === 'True')
2388
+ return condition?.message || condition?.reason || ''
2389
+ }
2390
+
2391
+ function formatJobDuration(job: any): string {
2392
+ const startTime = job?.status?.startTime
2393
+ if (!startTime) return ''
2394
+ const start = Date.parse(startTime)
2395
+ const endRaw = job?.status?.completionTime
2396
+ const end = endRaw ? Date.parse(endRaw) : Date.now()
2397
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return ''
2398
+ return formatDuration(end - start, true)
2399
+ }
2400
+
2401
+ function OverviewStat({ label, value, tone = 'default' }: { label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }) {
2402
+ return (
2403
+ <div className="min-w-0">
2404
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</div>
2405
+ <div className={clsx(
2406
+ 'mt-1 truncate text-sm font-semibold',
2407
+ tone === 'good' ? 'text-emerald-500' : tone === 'warn' ? 'text-amber-500' : 'text-theme-text-primary',
2408
+ )}>
2409
+ {value}
2410
+ </div>
2411
+ </div>
2412
+ )
2413
+ }
2414
+
2415
+ function InlineFact({ label, value }: { label: string; value: ReactNode }) {
2416
+ return (
2417
+ <div className="min-w-0">
2418
+ <span className="text-theme-text-tertiary">{label}</span>
2419
+ <span className="mx-1 text-theme-text-tertiary">·</span>
2420
+ <span className="break-all text-theme-text-primary">{value}</span>
2421
+ </div>
2422
+ )
2423
+ }
2424
+
2425
+ function RuntimeCard({
2426
+ resource,
2427
+ apiKind,
2428
+ state,
2429
+ readiness,
2430
+ scaling,
2431
+ namespace,
2432
+ workloadPods,
2433
+ workloadPodsLoading,
2434
+ workloadPodsError,
2435
+ relationshipPods,
2436
+ scalerDiagnostics,
2437
+ hpaDiagnosis,
2438
+ relationships,
2439
+ onNavigate,
2440
+ onSwitchToLogs,
2441
+ }: {
2442
+ resource: any
2443
+ apiKind: string
2444
+ state: ReplicatedWorkloadState
2445
+ readiness: ReadinessSummary
2446
+ scaling: ScalingSummary
2447
+ namespace: string
2448
+ workloadPods?: WorkloadPodInfo[]
2449
+ workloadPodsLoading?: boolean
2450
+ workloadPodsError?: Error | null
2451
+ relationshipPods?: ResourceRef[]
2452
+ scalerDiagnostics?: ScalerDiagnosis[]
2453
+ hpaDiagnosis?: HPADiagnosis
2454
+ relationships?: Relationships
2455
+ onNavigate?: NavigateToResource
2456
+ onSwitchToLogs?: () => void
2457
+ }) {
2458
+ const progress = rolloutProgress(resource, apiKind, state)
2459
+ const headline = rolloutHeadline(resource, apiKind, readiness, scaling)
2460
+ const facts = rolloutFacts(resource, apiKind, scaling, Boolean(progress))
2461
+ const scalerRefs = relationships?.scalers ?? []
2462
+ const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
2463
+
2464
+ return (
2465
+ <OverviewCard
2466
+ title="Runtime"
2467
+ icon={Boxes}
2468
+ action={onSwitchToLogs && (
2469
+ <button type="button" onClick={onSwitchToLogs} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
2470
+ <Terminal className="h-3.5 w-3.5" />
2471
+ Logs
2472
+ </button>
2473
+ )}
2474
+ >
2475
+ <div className="space-y-4">
2476
+ <div className="space-y-3">
2477
+ <div className="flex min-w-0 items-center gap-2">
2478
+ <span className={clsx('h-2 w-2 shrink-0 rounded-full', rolloutStateDotClass(state.level))} />
2479
+ <div className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{headline}</div>
2480
+ </div>
2481
+
2482
+ <div className="flex flex-wrap gap-1.5">
2483
+ {facts.map((fact) => (
2484
+ <RolloutFactChip key={String(fact.label)} label={fact.label} value={fact.value} tone={fact.tone} />
2485
+ ))}
2486
+ </div>
2487
+
2488
+ {progress && <RolloutProgressBar progress={progress} state={state} />}
2489
+
2490
+ {scalerRefs.length > 0 ? (
2491
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
2492
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">Controlled by</div>
2493
+ <div className="flex flex-wrap gap-1.5">
2494
+ {scalerRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
2495
+ <ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
2496
+ ))}
2497
+ </div>
2498
+ {scalerDiagnostics?.map((entry) => (
2499
+ <div key={`${entry.ref.kind}/${entry.ref.namespace}/${entry.ref.name}`} className="mt-2 text-xs text-theme-text-secondary">
2500
+ {entry.loading ? 'Loading autoscaler diagnosis...' : entry.error ? 'Autoscaler diagnosis unavailable' : entry.diagnosis ? hpaStateLabel(entry.diagnosis.state) : 'Autoscaler status unavailable'}
2501
+ {entry.diagnosis?.summary && <span className="ml-1 text-theme-text-tertiary">{entry.diagnosis.summary}</span>}
2502
+ </div>
2503
+ ))}
2504
+ </div>
2505
+ ) : hpaDiagnosis ? (
2506
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
2507
+ {hpaStateLabel(hpaDiagnosis.state)} <span className="text-theme-text-tertiary">{hpaDiagnosis.summary}</span>
2508
+ </div>
2509
+ ) : null}
2510
+ </div>
2511
+
2512
+ <div className="border-t border-theme-border pt-3">
2513
+ <div className="mb-2 text-xs font-medium text-theme-text-tertiary">Pods</div>
2514
+ <PodsListContent
2515
+ namespace={namespace}
2516
+ workloadPods={workloadPods}
2517
+ workloadPodsLoading={workloadPodsLoading}
2518
+ workloadPodsError={workloadPodsError}
2519
+ relationshipPods={relationshipPods}
2520
+ onNavigate={onNavigate}
2521
+ />
2522
+ </div>
2523
+ </div>
2524
+ </OverviewCard>
2525
+ )
2526
+ }
2527
+
2528
+ function PodsAttentionCard({
2529
+ namespace,
2530
+ emptyText,
2531
+ workloadPods,
2532
+ workloadPodsLoading,
2533
+ workloadPodsError,
2534
+ relationshipPods,
2535
+ onNavigate,
2536
+ onSwitchToLogs,
2537
+ }: {
2538
+ namespace: string
2539
+ emptyText?: string
2540
+ workloadPods?: WorkloadPodInfo[]
2541
+ workloadPodsLoading?: boolean
2542
+ workloadPodsError?: Error | null
2543
+ relationshipPods?: ResourceRef[]
2544
+ onNavigate?: NavigateToResource
2545
+ onSwitchToLogs?: () => void
2546
+ }) {
2547
+ return (
2548
+ <OverviewCard
2549
+ title="Pods"
2550
+ icon={Boxes}
2551
+ action={onSwitchToLogs && (
2552
+ <button type="button" onClick={onSwitchToLogs} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
2553
+ <Terminal className="h-3.5 w-3.5" />
2554
+ Logs
2555
+ </button>
2556
+ )}
2557
+ >
2558
+ <PodsListContent
2559
+ namespace={namespace}
2560
+ emptyText={emptyText}
2561
+ workloadPods={workloadPods}
2562
+ workloadPodsLoading={workloadPodsLoading}
2563
+ workloadPodsError={workloadPodsError}
2564
+ relationshipPods={relationshipPods}
2565
+ onNavigate={onNavigate}
2566
+ />
2567
+ </OverviewCard>
2568
+ )
2569
+ }
2570
+
2571
+ function PodsListContent({
2572
+ namespace,
2573
+ emptyText,
2574
+ workloadPods,
2575
+ workloadPodsLoading,
2576
+ workloadPodsError,
2577
+ relationshipPods,
2578
+ onNavigate,
2579
+ }: {
2580
+ namespace: string
2581
+ emptyText?: string
2582
+ workloadPods?: WorkloadPodInfo[]
2583
+ workloadPodsLoading?: boolean
2584
+ workloadPodsError?: Error | null
2585
+ relationshipPods?: ResourceRef[]
2586
+ onNavigate?: NavigateToResource
2587
+ }) {
2588
+ const sortedPods = [...(workloadPods ?? [])].sort(compareWorkloadPods)
2589
+ const [expandedPods, setExpandedPods] = useState(false)
2590
+ const hasPodOverflow = sortedPods.length > POD_VISIBLE_LIMIT
2591
+ const visiblePods = sortedPods.slice(0, POD_VISIBLE_LIMIT)
2592
+ const overflowPods = sortedPods.slice(POD_VISIBLE_LIMIT)
2593
+ const hiddenPodCount = Math.max(0, sortedPods.length - POD_VISIBLE_LIMIT)
2594
+ const fallbackPodRefs = relationshipPods ?? []
2595
+ const hasFallbackOverflow = fallbackPodRefs.length > POD_VISIBLE_LIMIT
2596
+ const fallbackPods = fallbackPodRefs.slice(0, POD_VISIBLE_LIMIT)
2597
+ const overflowFallbackPods = fallbackPodRefs.slice(POD_VISIBLE_LIMIT)
2598
+ const hiddenFallbackCount = Math.max(0, fallbackPodRefs.length - POD_VISIBLE_LIMIT)
2599
+
2600
+ return (
2601
+ <>
2602
+ {workloadPodsLoading ? (
2603
+ <div className="text-sm text-theme-text-tertiary">Loading pod readiness…</div>
2604
+ ) : workloadPodsError ? (
2605
+ <div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-300">
2606
+ Pod readiness unavailable. Use Logs or Timeline for deeper inspection.
2607
+ </div>
2608
+ ) : workloadPods ? (
2609
+ visiblePods.length > 0 ? (
2610
+ <PodListFrame
2611
+ expanded={expandedPods}
2612
+ hasOverflow={hasPodOverflow}
2613
+ overflow={overflowPods.map((pod) => (
2614
+ <PodRow
2615
+ key={pod.name}
2616
+ name={pod.name}
2617
+ namespace={namespace}
2618
+ ready={pod.ready}
2619
+ healthLevel={pod.healthLevel}
2620
+ detail={workloadPodDetail(pod)}
2621
+ onNavigate={onNavigate}
2622
+ />
2623
+ ))}
2624
+ toggle={(
2625
+ <PodListToggle
2626
+ expanded={expandedPods}
2627
+ hiddenCount={hiddenPodCount}
2628
+ label="pods"
2629
+ onToggle={() => setExpandedPods((value) => !value)}
2630
+ />
2631
+ )}
2632
+ >
2633
+ {visiblePods.map((pod) => (
2634
+ <PodRow
2635
+ key={pod.name}
2636
+ name={pod.name}
2637
+ namespace={namespace}
2638
+ ready={pod.ready}
2639
+ healthLevel={pod.healthLevel}
2640
+ detail={workloadPodDetail(pod)}
2641
+ onNavigate={onNavigate}
2642
+ />
2643
+ ))}
2644
+ </PodListFrame>
2645
+ ) : (
2646
+ <div className="text-sm text-theme-text-tertiary">{emptyText || 'No pods currently match this workload.'}</div>
2647
+ )
2648
+ ) : fallbackPods.length > 0 ? (
2649
+ <PodListFrame
2650
+ expanded={expandedPods}
2651
+ hasOverflow={hasFallbackOverflow}
2652
+ overflow={overflowFallbackPods.map((pod) => (
2653
+ <PodRow
2654
+ key={`${pod.namespace}/${pod.name}`}
2655
+ name={pod.name}
2656
+ namespace={pod.namespace || namespace}
2657
+ ready={null}
2658
+ detail="status unavailable"
2659
+ onNavigate={onNavigate}
2660
+ />
2661
+ ))}
2662
+ toggle={(
2663
+ <PodListToggle
2664
+ expanded={expandedPods}
2665
+ hiddenCount={hiddenFallbackCount}
2666
+ label="pods"
2667
+ onToggle={() => setExpandedPods((value) => !value)}
2668
+ />
2669
+ )}
2670
+ >
2671
+ {fallbackPods.map((pod) => (
2672
+ <PodRow
2673
+ key={`${pod.namespace}/${pod.name}`}
2674
+ name={pod.name}
2675
+ namespace={pod.namespace || namespace}
2676
+ ready={null}
2677
+ detail="status unavailable"
2678
+ onNavigate={onNavigate}
2679
+ />
2680
+ ))}
2681
+ </PodListFrame>
2682
+ ) : (
2683
+ <div className="text-sm text-theme-text-tertiary">{emptyText || 'No pod relationships found.'}</div>
2684
+ )}
2685
+ </>
2686
+ )
2687
+ }
2688
+
2689
+ function PodRow({
2690
+ name,
2691
+ namespace,
2692
+ ready,
2693
+ healthLevel,
2694
+ detail,
2695
+ onNavigate,
2696
+ }: {
2697
+ name: string
2698
+ namespace: string
2699
+ ready: boolean | null
2700
+ healthLevel?: WorkloadPodInfo['healthLevel']
2701
+ detail: string
2702
+ onNavigate?: NavigateToResource
2703
+ }) {
2704
+ const statusLabel = podStatusLabel(healthLevel, ready)
2705
+ const statusClass = podStatusClass(healthLevel, ready)
2706
+ const content = (
2707
+ <>
2708
+ <span className={clsx('h-2 w-2 shrink-0 rounded-full', podDotClass(healthLevel, ready))} />
2709
+ <span className="min-w-0 flex-1">
2710
+ <span className="block truncate text-sm font-medium text-theme-text-primary">{name}</span>
2711
+ <span className="block truncate text-xs text-theme-text-tertiary">{detail}</span>
2712
+ </span>
2713
+ <span className={clsx('badge-sm shrink-0', statusClass)}>
2714
+ {statusLabel}
2715
+ </span>
2716
+ </>
2717
+ )
2718
+
2719
+ if (!onNavigate) {
2720
+ return (
2721
+ <div className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left">
2722
+ {content}
2723
+ </div>
2724
+ )
2725
+ }
2726
+
2727
+ return (
2728
+ <button
2729
+ type="button"
2730
+ onClick={() => onNavigate({ kind: 'pods', namespace, name })}
2731
+ className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover"
2732
+ >
2733
+ {content}
2734
+ </button>
2735
+ )
2736
+ }
2737
+
2738
+ function PodListFrame({
2739
+ expanded,
2740
+ hasOverflow,
2741
+ overflow,
2742
+ toggle,
2743
+ children,
2744
+ }: {
2745
+ expanded: boolean
2746
+ hasOverflow: boolean
2747
+ overflow?: ReactNode
2748
+ toggle?: ReactNode
2749
+ children: ReactNode
2750
+ }) {
2751
+ return (
2752
+ <div className="space-y-2">
2753
+ <div className="space-y-2">
2754
+ {children}
2755
+ </div>
2756
+ {hasOverflow && (
2757
+ <div
2758
+ className={clsx(
2759
+ 'grid transition-[grid-template-rows,opacity] duration-200',
2760
+ expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
2761
+ )}
2762
+ style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
2763
+ aria-hidden={!expanded || undefined}
2764
+ >
2765
+ <div className={clsx('min-h-0 overflow-hidden', expanded && 'max-h-[26rem] overflow-y-auto pr-1')} inert={!expanded || undefined}>
2766
+ <div className="space-y-2">
2767
+ {overflow}
2768
+ </div>
2769
+ </div>
2770
+ </div>
2771
+ )}
2772
+ {toggle}
2773
+ </div>
2774
+ )
2775
+ }
2776
+
2777
+ function PodListToggle({
2778
+ expanded,
2779
+ hiddenCount,
2780
+ label,
2781
+ onToggle,
2782
+ }: {
2783
+ expanded: boolean
2784
+ hiddenCount: number
2785
+ label: string
2786
+ onToggle: () => void
2787
+ }) {
2788
+ if (!expanded && hiddenCount <= 0) return null
2789
+
2790
+ return (
2791
+ <div className="pt-1">
2792
+ <button
2793
+ type="button"
2794
+ aria-expanded={expanded}
2795
+ onClick={onToggle}
2796
+ className="text-xs font-medium text-accent-text hover:underline"
2797
+ >
2798
+ {expanded ? `Collapse ${label}` : `Show ${hiddenCount} more ${label}`}
2799
+ </button>
2800
+ </div>
2801
+ )
2802
+ }
2803
+
2804
+ function compareWorkloadPods(a: WorkloadPodInfo, b: WorkloadPodInfo): number {
2805
+ const severity = podSeverityRank(b) - podSeverityRank(a)
2806
+ if (severity !== 0) return severity
2807
+ const restartDiff = (b.restartCount ?? 0) - (a.restartCount ?? 0)
2808
+ if (restartDiff !== 0) return restartDiff
2809
+ return a.name.localeCompare(b.name)
2810
+ }
2811
+
2812
+ function podSeverityRank(pod: WorkloadPodInfo): number {
2813
+ switch (pod.healthLevel) {
2814
+ case 'unhealthy': return 4
2815
+ case 'degraded': return 3
2816
+ case 'unknown': return 2
2817
+ case 'neutral': return pod.ready ? 0 : 1
2818
+ case 'healthy': return pod.ready ? 0 : 1
2819
+ default: return pod.ready ? 0 : 2
2820
+ }
2821
+ }
2822
+
2823
+ function workloadPodDetail(pod: WorkloadPodInfo): string {
2824
+ const parts: string[] = []
2825
+ if (pod.phase) parts.push(pod.reason ? `${pod.phase} / ${pod.reason}` : pod.phase)
2826
+ else if (pod.reason) parts.push(pod.reason)
2827
+ parts.push(`${pod.containers.length} container${pod.containers.length === 1 ? '' : 's'}`)
2828
+ if ((pod.restartCount ?? 0) > 0) parts.push(`${pod.restartCount} restart${pod.restartCount === 1 ? '' : 's'}`)
2829
+ if (pod.lastTerminationReason && !['Unknown', 'Completed'].includes(pod.lastTerminationReason)) {
2830
+ parts.push(`last ${pod.lastTerminationReason}`)
2831
+ }
2832
+ if (pod.createdAt) parts.push(`${formatAge(pod.createdAt)} old`)
2833
+ return parts.join(' · ')
2834
+ }
2835
+
2836
+ function podStatusLabel(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
2837
+ if (healthLevel === 'unhealthy') return 'Unhealthy'
2838
+ if (healthLevel === 'degraded') return 'Degraded'
2839
+ if (healthLevel === 'neutral') return ready ? 'Ready' : 'Neutral'
2840
+ if (healthLevel === 'unknown') return 'Unknown'
2841
+ return ready === null ? 'Unknown' : ready ? 'Ready' : 'Not ready'
2842
+ }
2843
+
2844
+ function podStatusClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
2845
+ if (healthLevel === 'unhealthy') return 'status-unhealthy'
2846
+ if (healthLevel === 'degraded') return 'status-degraded'
2847
+ if (healthLevel === 'neutral') return 'status-neutral'
2848
+ if (healthLevel === 'unknown' || ready === null) return 'status-unknown'
2849
+ return ready ? 'status-healthy' : 'status-degraded'
2850
+ }
2851
+
2852
+ function podDotClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
2853
+ if (healthLevel === 'unhealthy') return 'bg-red-500'
2854
+ if (healthLevel === 'degraded') return 'bg-amber-500'
2855
+ if (healthLevel === 'neutral') return 'bg-skyhook-500'
2856
+ if (healthLevel === 'unknown' || ready === null) return 'bg-theme-text-tertiary'
2857
+ return ready ? 'bg-emerald-500' : 'bg-amber-500'
2858
+ }
2859
+
2860
+ function MoreRows({ count, label }: { count: number; label: string }) {
2861
+ return <div className="text-xs text-theme-text-tertiary">+{count} more {label} hidden from this overview.</div>
2862
+ }
2863
+
2864
+ type RelationshipGroup = { label: string; refs: ResourceRef[] }
2865
+
2866
+ function ServingConfigurationCard({
2867
+ groups,
2868
+ details = [],
2869
+ renderPortAction,
2870
+ renderPortPanel,
2871
+ onNavigate,
2872
+ onSwitchToTopology,
2873
+ }: {
2874
+ groups: RelationshipGroup[]
2875
+ details?: ServingResourceDetail[]
2876
+ renderPortAction?: (props: ServicePortRenderProps) => ReactNode
2877
+ renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
2878
+ onNavigate?: NavigateToResource
2879
+ onSwitchToTopology?: () => void
2880
+ }) {
2881
+ const services = groups.find((group) => group.label === 'Services')?.refs ?? []
2882
+ const entrypoints = groups.find((group) => group.label === 'Entry points')?.refs ?? []
2883
+ const detailByRef = new Map(details.map((detail) => [resourceRefId(detail.ref), detail]))
2884
+ const serviceDetails = services.map((ref) => detailByRef.get(resourceRefId(ref))).filter(Boolean) as ServingResourceDetail[]
2885
+ const entrypointDetails = entrypoints.map((ref) => detailByRef.get(resourceRefId(ref))).filter(Boolean) as ServingResourceDetail[]
2886
+ const serviceResources = serviceDetails.map((detail) => detail.resource).filter(Boolean)
2887
+ const entrypointResources = entrypointDetails.map((detail) => detail.resource).filter(Boolean)
2888
+ const serviceDetailsLoading = serviceDetails.some((detail) => detail.loading)
2889
+ const serviceCount = services.length
2890
+ const entrypointCount = entrypoints.length
2891
+ const serviceTypes = summarizeServiceTypes(serviceResources, serviceCount, serviceDetailsLoading)
2892
+ const externalLabels = summarizeExternalLabels(serviceResources, entrypointResources)
2893
+ const exposure = summarizeServingExposure(serviceResources, serviceCount, entrypointCount, externalLabels, serviceDetailsLoading)
2894
+ const pathDetail =
2895
+ entrypointCount > 0 && serviceCount > 0
2896
+ ? `${entrypointCount} entry point${entrypointCount === 1 ? '' : 's'} route to ${serviceCount} service${serviceCount === 1 ? '' : 's'}.`
2897
+ : entrypointCount > 0
2898
+ ? `${entrypointCount} entry point${entrypointCount === 1 ? '' : 's'} detected. Use Topology to inspect the serving graph.`
2899
+ : `${serviceCount} service${serviceCount === 1 ? ' targets' : 's target'} this workload without an ingress or route.`
2900
+ const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
2901
+
2902
+ return (
2903
+ <OverviewCard
2904
+ title="Serving configuration"
2905
+ icon={Network}
2906
+ action={onSwitchToTopology && (
2907
+ <button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
2908
+ <GitBranch className="h-3.5 w-3.5" />
2909
+ Topology
2910
+ </button>
2911
+ )}
2912
+ >
2913
+ <div className="space-y-3">
2914
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
2915
+ <span className="text-base font-semibold text-theme-text-primary">{exposure.label}</span>
2916
+ <Badge severity={exposure.severity} size="sm">{exposure.badge}</Badge>
2917
+ <span className="min-w-[12rem] flex-1 text-sm text-theme-text-secondary">{pathDetail}</span>
2918
+ </div>
2919
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(18rem,0.8fr)]">
2920
+ <div className="min-w-0 space-y-3">
2921
+ <div className="grid gap-3 sm:grid-cols-2">
2922
+ <OverviewStat label="Service type" value={serviceTypes} />
2923
+ <OverviewStat label="Services" value={serviceCount} />
2924
+ </div>
2925
+ <ServingPortRows
2926
+ services={serviceResources}
2927
+ loading={serviceDetailsLoading}
2928
+ renderPortAction={renderPortAction}
2929
+ renderPortPanel={renderPortPanel}
2930
+ />
2931
+ <ServingRefChips label="Services" refs={services} onNavigate={navigateRef} />
2932
+ </div>
2933
+ <div className="min-w-0 space-y-3 border-t border-theme-border pt-3 lg:border-l lg:border-t-0 lg:pl-4 lg:pt-0">
2934
+ <OverviewStat label="Entry points" value={entrypointCount || '-'} />
2935
+ <ServingFactChips label="Addresses & hosts" values={externalLabels} empty={entrypointCount > 0 ? 'Route details unavailable.' : 'Internal cluster traffic only.'} />
2936
+ {entrypoints.length > 0 && <ServingRefChips label="Entry point resources" refs={entrypoints} onNavigate={navigateRef} />}
2937
+ </div>
2938
+ </div>
2939
+ </div>
2940
+ </OverviewCard>
2941
+ )
2942
+ }
2943
+
2944
+ function ServingPortRows({
2945
+ services,
2946
+ loading,
2947
+ renderPortAction,
2948
+ renderPortPanel,
2949
+ }: {
2950
+ services: any[]
2951
+ loading?: boolean
2952
+ renderPortAction?: (props: ServicePortRenderProps) => ReactNode
2953
+ renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
2954
+ }) {
2955
+ const servicesWithPorts = services.filter((service) => (service?.spec?.ports ?? []).length > 0)
2956
+ const visible = servicesWithPorts.slice(0, 2)
2957
+ const hidden = Math.max(0, servicesWithPorts.length - visible.length)
2958
+ return (
2959
+ <div className="min-w-0">
2960
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">Ports</div>
2961
+ {visible.length > 0 ? (
2962
+ <div className="space-y-2">
2963
+ {visible.map((service) => {
2964
+ const serviceName = service?.metadata?.name || 'service'
2965
+ return (
2966
+ <div key={service?.metadata?.uid || `${service?.metadata?.namespace}/${serviceName}`} className="min-w-0">
2967
+ {servicesWithPorts.length > 1 && (
2968
+ <div className="mb-1 font-mono text-[11px] text-theme-text-tertiary">{serviceName}</div>
2969
+ )}
2970
+ <div className="max-w-2xl">
2971
+ <ServicePortCards service={service} renderPortAction={renderPortAction} renderPortPanel={renderPortPanel} />
2972
+ </div>
2973
+ </div>
2974
+ )
2975
+ })}
2976
+ {hidden > 0 && <Badge tone="structural" size="sm">+{hidden} services</Badge>}
2977
+ </div>
2978
+ ) : (
2979
+ <div className="text-xs text-theme-text-tertiary">{loading ? 'Loading service ports...' : 'No service ports loaded.'}</div>
2980
+ )}
2981
+ </div>
2982
+ )
2983
+ }
2984
+
2985
+ function ServingRefChips({ label, refs, onNavigate }: { label: string; refs: ResourceRef[]; onNavigate?: (ref: ResourceRef) => void }) {
2986
+ const visible = refs.slice(0, RELATIONSHIP_REF_LIMIT)
2987
+ const hidden = Math.max(0, refs.length - visible.length)
2988
+ return (
2989
+ <div className="min-w-0">
2990
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}</div>
2991
+ <div className="flex flex-wrap gap-1.5">
2992
+ {visible.map((ref) => (
2993
+ <ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={onNavigate} wrapAtSeparator />
2994
+ ))}
2995
+ {hidden > 0 && <Badge tone="structural" size="sm">+{hidden}</Badge>}
2996
+ </div>
2997
+ </div>
2998
+ )
2999
+ }
3000
+
3001
+ function ServingFactChips({ label, values, empty }: { label: string; values: string[]; empty: string }) {
3002
+ const visible = values.slice(0, RELATIONSHIP_REF_LIMIT)
3003
+ const hidden = Math.max(0, values.length - visible.length)
3004
+ return (
3005
+ <div>
3006
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}</div>
3007
+ {visible.length > 0 ? (
3008
+ <div className="flex flex-wrap gap-1.5">
3009
+ {visible.map((value) => (
3010
+ <Badge key={value} tone="structural" size="sm" className="max-w-full whitespace-normal break-all text-left">
3011
+ {value}
3012
+ </Badge>
3013
+ ))}
3014
+ {hidden > 0 && <Badge tone="structural" size="sm">+{hidden}</Badge>}
3015
+ </div>
3016
+ ) : (
3017
+ <div className="text-xs text-theme-text-tertiary">{empty}</div>
3018
+ )}
3019
+ </div>
3020
+ )
3021
+ }
3022
+
3023
+ function CronJobRunsCard({
3024
+ resource,
3025
+ namespace,
3026
+ onNavigate,
3027
+ onSwitchToTopology,
3028
+ }: {
3029
+ resource: any
3030
+ namespace: string
3031
+ onNavigate?: NavigateToResource
3032
+ onSwitchToTopology?: () => void
3033
+ }) {
3034
+ const status = resource?.status || {}
3035
+ const spec = resource?.spec || {}
3036
+ const activeJobs = Array.isArray(status.active) ? status.active : []
3037
+ const visibleJobs = activeJobs.slice(0, POD_VISIBLE_LIMIT)
3038
+ const hiddenJobCount = Math.max(0, activeJobs.length - visibleJobs.length)
3039
+ const emptyMessage = spec.suspend
3040
+ ? 'No active Jobs. This CronJob is suspended, so new runs will not be scheduled until it is resumed.'
3041
+ : 'No active Jobs. The CronJob is waiting for the next scheduled run.'
3042
+
3043
+ return (
3044
+ <OverviewCard
3045
+ title="Runs"
3046
+ icon={Activity}
3047
+ action={onSwitchToTopology && (
3048
+ <button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
3049
+ <GitBranch className="h-3.5 w-3.5" />
3050
+ Topology
3051
+ </button>
3052
+ )}
3053
+ >
3054
+ <div className="mb-3 grid gap-x-8 gap-y-3 sm:grid-cols-2">
3055
+ <OverviewStat label="Active jobs" value={activeJobs.length} tone={activeJobs.length > 0 ? 'warn' : 'default'} />
3056
+ <OverviewStat label="Last schedule" value={status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'} />
3057
+ <OverviewStat label="Last success" value={status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'} />
3058
+ <OverviewStat label="Retained history" value={`${spec.successfulJobsHistoryLimit ?? 3} ok / ${spec.failedJobsHistoryLimit ?? 1} failed`} />
3059
+ </div>
3060
+
3061
+ {visibleJobs.length > 0 ? (
3062
+ <div className="space-y-2">
3063
+ {visibleJobs.map((job: any) => {
3064
+ const ref: ResourceRef = {
3065
+ kind: job.kind || 'Job',
3066
+ namespace: job.namespace || namespace,
3067
+ name: job.name,
3068
+ }
3069
+ return (
3070
+ <button
3071
+ key={`${ref.namespace}/${ref.name}`}
3072
+ type="button"
3073
+ onClick={() => onNavigate?.(refToSelectedResource(ref))}
3074
+ className={clsx(
3075
+ 'flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left',
3076
+ onNavigate && 'transition-colors hover:bg-theme-hover',
3077
+ )}
3078
+ >
3079
+ <span className="h-2 w-2 shrink-0 rounded-full bg-skyhook-500" />
3080
+ <span className="min-w-0 flex-1">
3081
+ <span className="block truncate text-sm font-medium text-theme-text-primary">{ref.name}</span>
3082
+ <span className="block truncate text-xs text-theme-text-tertiary">{ref.namespace || 'default namespace'}</span>
3083
+ </span>
3084
+ <span className="badge-sm status-neutral">Active</span>
3085
+ </button>
3086
+ )
3087
+ })}
3088
+ {hiddenJobCount > 0 && <MoreRows count={hiddenJobCount} label="jobs" />}
3089
+ </div>
3090
+ ) : (
3091
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-tertiary">
3092
+ {emptyMessage}
3093
+ </div>
3094
+ )}
3095
+ </OverviewCard>
3096
+ )
3097
+ }
3098
+
3099
+ function ScalingCard({
3100
+ title,
3101
+ icon,
3102
+ scaling,
3103
+ scalerDiagnostics,
3104
+ hpaDiagnosis,
3105
+ relationships,
3106
+ onNavigate,
3107
+ }: {
3108
+ title: string
3109
+ icon: ComponentType<{ className?: string }>
3110
+ scaling: ScalingSummary
3111
+ scalerDiagnostics?: ScalerDiagnosis[]
3112
+ hpaDiagnosis?: HPADiagnosis
3113
+ relationships?: Relationships
3114
+ onNavigate?: NavigateToResource
3115
+ }) {
3116
+ const scalerRefs = relationships?.scalers ?? []
3117
+ const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
3118
+
3119
+ return (
3120
+ <OverviewCard title={title} icon={icon}>
3121
+ <div className="mb-3 grid gap-x-8 gap-y-3 sm:grid-cols-2">
3122
+ {scaling.stats.map((stat) => (
3123
+ <OverviewStat key={String(stat.label)} label={stat.label} value={stat.value} tone={stat.tone} />
3124
+ ))}
3125
+ </div>
3126
+ {scalerRefs.length > 0 ? (
3127
+ <div className="space-y-2">
3128
+ <div className="text-xs font-medium text-theme-text-tertiary">Controlled by</div>
3129
+ <div className="flex flex-wrap gap-1.5">
3130
+ {scalerRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
3131
+ <ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
3132
+ ))}
3133
+ </div>
3134
+ {scalerDiagnostics?.map((entry) => (
3135
+ <div key={`${entry.ref.kind}/${entry.ref.namespace}/${entry.ref.name}`} className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
3136
+ {entry.loading ? 'Loading autoscaler diagnosis…' : entry.error ? 'Autoscaler diagnosis unavailable' : entry.diagnosis ? hpaStateLabel(entry.diagnosis.state) : 'Autoscaler status unavailable'}
3137
+ {entry.diagnosis?.summary && <span className="ml-1 text-theme-text-tertiary">{entry.diagnosis.summary}</span>}
3138
+ </div>
3139
+ ))}
3140
+ </div>
3141
+ ) : hpaDiagnosis ? (
3142
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
3143
+ {hpaStateLabel(hpaDiagnosis.state)} <span className="text-theme-text-tertiary">{hpaDiagnosis.summary}</span>
3144
+ </div>
3145
+ ) : scaling.detail ? (
3146
+ <div className="text-sm text-theme-text-secondary">{scaling.detail}</div>
3147
+ ) : null}
3148
+ </OverviewCard>
3149
+ )
3150
+ }
3151
+
3152
+ interface RolloutProgressSummary {
3153
+ updated: number
3154
+ desired: number
3155
+ ready: number
3156
+ unavailable: number
3157
+ updatedLabel: string
3158
+ readyLabel: string
3159
+ }
3160
+
3161
+ function RolloutProgressBar({ progress, state }: { progress: RolloutProgressSummary; state: ReplicatedWorkloadState }) {
3162
+ const converged = Math.min(progress.updated, progress.ready)
3163
+ const percent = progress.desired > 0 ? Math.max(0, Math.min(100, (converged / progress.desired) * 100)) : 0
3164
+ return (
3165
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
3166
+ <div className="mb-1.5 flex min-w-0 items-center justify-between gap-3 text-xs">
3167
+ <span className="font-medium text-theme-text-secondary">Rollout progress</span>
3168
+ <span className="min-w-0 truncate text-theme-text-tertiary">
3169
+ {progress.updatedLabel} · {progress.readyLabel}
3170
+ </span>
3171
+ </div>
3172
+ <div className="h-1.5 overflow-hidden rounded-full bg-theme-elevated">
3173
+ <div
3174
+ className={clsx('h-full rounded-full transition-[width]', rolloutProgressFillClass(state.level))}
3175
+ style={{ width: `${percent}%` }}
3176
+ aria-label={`${progress.updatedLabel}; ${progress.readyLabel}`}
3177
+ />
3178
+ </div>
3179
+ {progress.unavailable > 0 && (
3180
+ <div className="mt-1.5 text-xs text-amber-600 dark:text-amber-300">
3181
+ {progress.unavailable} unavailable while rollout converges.
3182
+ </div>
3183
+ )}
3184
+ </div>
3185
+ )
3186
+ }
3187
+
3188
+ function rolloutProgressFillClass(level: ReplicatedWorkloadState['level']): string {
3189
+ if (level === 'unhealthy') return 'bg-red-500'
3190
+ if (level === 'degraded') return 'bg-amber-500'
3191
+ return 'bg-accent'
3192
+ }
3193
+
3194
+ function RolloutFactChip({ label, value, tone = 'default' }: { label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }) {
3195
+ return (
3196
+ <span className={clsx(
3197
+ 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs',
3198
+ tone === 'warn'
3199
+ ? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300'
3200
+ : tone === 'good'
3201
+ ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
3202
+ : 'border-theme-border bg-theme-base text-theme-text-secondary',
3203
+ )}>
3204
+ <span className="shrink-0 text-theme-text-tertiary">{label}</span>
3205
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">{value}</span>
3206
+ </span>
3207
+ )
3208
+ }
3209
+
3210
+ function rolloutStateDotClass(level: ReplicatedWorkloadState['level']): string {
3211
+ if (level === 'unhealthy') return 'bg-red-500'
3212
+ if (level === 'degraded') return 'bg-amber-500'
3213
+ if (level === 'neutral') return 'bg-skyhook-500'
3214
+ if (level === 'unknown') return 'bg-theme-text-tertiary'
3215
+ return 'bg-emerald-500'
3216
+ }
3217
+
3218
+ function rolloutFacts(
3219
+ resource: any,
3220
+ apiKind: string,
3221
+ scaling: ScalingSummary,
3222
+ progressVisible = false,
3223
+ ): Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }> {
3224
+ const spec = resource?.spec || {}
3225
+ const status = resource?.status || {}
3226
+ const metadata = resource?.metadata || {}
3227
+ const k = apiKind.toLowerCase()
3228
+ const generation = metadata.generation
3229
+ const observedGeneration = status.observedGeneration
3230
+ const controllerBehind =
3231
+ typeof generation === 'number' &&
3232
+ typeof observedGeneration === 'number' &&
3233
+ observedGeneration < generation
3234
+ const facts: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }> = []
3235
+
3236
+ if (k === 'daemonsets') {
3237
+ const desired = status.desiredNumberScheduled ?? 0
3238
+ const current = status.currentNumberScheduled ?? 0
3239
+ const updated = status.updatedNumberScheduled ?? 0
3240
+ const unavailable = status.numberUnavailable ?? 0
3241
+ const misscheduled = status.numberMisscheduled ?? 0
3242
+ facts.push({ label: 'Target nodes', value: desired })
3243
+ if (current !== desired) facts.push({ label: 'Scheduled', value: current, tone: 'warn' })
3244
+ if (!progressVisible && updated !== desired) facts.push({ label: 'Updated', value: `${updated}/${desired}`, tone: 'warn' })
3245
+ if (unavailable > 0) facts.push({ label: 'Unavailable', value: unavailable, tone: 'warn' })
3246
+ if (misscheduled > 0) facts.push({ label: 'Misscheduled', value: misscheduled, tone: 'warn' })
3247
+ facts.push({ label: 'Strategy', value: spec.updateStrategy?.type || 'RollingUpdate' })
3248
+ if (controllerBehind) facts.push({ label: 'Controller seen', value: observedGeneration, tone: 'warn' })
3249
+ return facts
3250
+ }
3251
+
3252
+ const desired = spec.replicas ?? status.replicas ?? 0
3253
+ const current = status.replicas ?? desired
3254
+ const updated = status.updatedReplicas ?? 0
3255
+ const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? status.readyReplicas ?? 0))
3256
+ facts.push({ label: 'Target replicas', value: desired })
3257
+ if (current !== desired) facts.push({ label: 'Current', value: current, tone: 'warn' })
3258
+ if (!progressVisible && updated !== desired) facts.push({ label: 'Updated', value: `${updated}/${desired}`, tone: 'warn' })
3259
+ if (unavailable > 0) facts.push({ label: 'Unavailable', value: unavailable, tone: 'warn' })
3260
+ facts.push({ label: 'Strategy', value: spec.strategy?.type || spec.updateStrategy?.type || '-' })
3261
+
3262
+ if (k === 'statefulsets') {
3263
+ if (status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision) {
3264
+ facts.push({ label: 'Revision', value: `${midTruncate(status.currentRevision, 18)} -> ${midTruncate(status.updateRevision, 18)}`, tone: 'warn' })
3265
+ } else if (spec.updateStrategy?.rollingUpdate?.partition != null) {
3266
+ facts.push({ label: 'Partition', value: spec.updateStrategy.rollingUpdate.partition })
3267
+ }
3268
+ }
3269
+
3270
+ if (controllerBehind) facts.push({ label: 'Controller seen', value: observedGeneration, tone: 'warn' })
3271
+
3272
+ const externallyScaled = scaling.stats.some((stat) => !['Desired', 'Current', 'Strategy'].includes(String(stat.label)))
3273
+ if (externallyScaled) {
3274
+ for (const stat of scaling.stats) {
3275
+ const label = String(stat.label)
3276
+ if (!facts.some((existing) => existing.label === label)) facts.push(stat)
3277
+ }
3278
+ }
3279
+ return facts
3280
+ }
3281
+
3282
+ function rolloutProgress(resource: any, apiKind: string, state: ReplicatedWorkloadState): RolloutProgressSummary | null {
3283
+ const spec = resource?.spec || {}
3284
+ const status = resource?.status || {}
3285
+ const k = apiKind.toLowerCase()
3286
+
3287
+ if (k === 'daemonsets') {
3288
+ const desired = status.desiredNumberScheduled ?? 0
3289
+ const updated = status.updatedNumberScheduled ?? 0
3290
+ const ready = status.numberReady ?? 0
3291
+ const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
3292
+ if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
3293
+ return {
3294
+ updated,
3295
+ desired,
3296
+ ready,
3297
+ unavailable,
3298
+ updatedLabel: `${updated}/${desired} nodes updated`,
3299
+ readyLabel: `${ready}/${desired} nodes ready`,
3300
+ }
3301
+ }
3302
+
3303
+ const desired = spec.replicas ?? status.replicas ?? 0
3304
+ const updated = status.updatedReplicas ?? 0
3305
+ const ready = status.readyReplicas ?? 0
3306
+ const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? ready))
3307
+ if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
3308
+ return {
3309
+ updated,
3310
+ desired,
3311
+ ready,
3312
+ unavailable,
3313
+ updatedLabel: `${updated}/${desired} updated`,
3314
+ readyLabel: `${ready}/${desired} ready`,
3315
+ }
3316
+ }
3317
+
3318
+ function shouldShowRolloutProgress(
3319
+ desired: number,
3320
+ updated: number,
3321
+ ready: number,
3322
+ unavailable: number,
3323
+ state: ReplicatedWorkloadState,
3324
+ ): boolean {
3325
+ if (desired <= 0) return false
3326
+ if (updated < desired || ready < desired || unavailable > 0) return true
3327
+ return state.label === 'Rolling out' || state.label === 'Applying' || state.label === 'Stalled'
3328
+ }
3329
+
3330
+ function rolloutHeadline(resource: any, apiKind: string, readiness: ReadinessSummary, scaling: ScalingSummary): string {
3331
+ const status = resource?.status || {}
3332
+ const k = apiKind.toLowerCase()
3333
+ if (k === 'daemonsets') {
3334
+ return `${readiness.readyLabel} ready on nodes`
3335
+ }
3336
+ if (k === 'statefulsets') {
3337
+ const revision = status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision
3338
+ ? `${status.currentRevision} -> ${status.updateRevision}`
3339
+ : status.currentRevision || status.updateRevision
3340
+ return `${readiness.readyLabel} ready · ${scaling.label}${revision && status.updateRevision !== status.currentRevision ? ' · updating revision' : ''}`
3341
+ }
3342
+ return `${readiness.readyLabel} ready · ${scaling.label}`
3343
+ }
3344
+
3345
+ function ServingDependenciesCard({
3346
+ title = 'Serving and dependencies',
3347
+ relationships,
3348
+ groups,
3349
+ onNavigate,
3350
+ onSwitchToTopology,
3351
+ }: {
3352
+ title?: string
3353
+ relationships?: Relationships
3354
+ groups?: RelationshipGroup[]
3355
+ onNavigate?: NavigateToResource
3356
+ onSwitchToTopology?: () => void
3357
+ }) {
3358
+ const relationshipGroups = groups ?? buildRelationshipGroups(relationships)
3359
+ const visibleGroups = relationshipGroups.slice(0, RELATIONSHIP_GROUP_LIMIT)
3360
+ const hiddenRefs =
3361
+ visibleGroups.reduce((sum, group) => sum + Math.max(0, group.refs.length - RELATIONSHIP_REF_LIMIT), 0) +
3362
+ relationshipGroups.slice(RELATIONSHIP_GROUP_LIMIT).reduce((sum, group) => sum + group.refs.length, 0)
3363
+ const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
3364
+
3365
+ return (
3366
+ <OverviewCard
3367
+ title={title}
3368
+ icon={Network}
3369
+ action={onSwitchToTopology && (
3370
+ <button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
3371
+ <GitBranch className="h-3.5 w-3.5" />
3372
+ Topology
3373
+ </button>
3374
+ )}
3375
+ >
3376
+ {visibleGroups.length > 0 ? (
3377
+ <div className="space-y-3">
3378
+ {visibleGroups.map((group) => (
3379
+ <div key={group.label}>
3380
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{group.label}</div>
3381
+ <div className="flex flex-wrap gap-1.5">
3382
+ {group.refs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
3383
+ <ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
3384
+ ))}
3385
+ {group.refs.length > RELATIONSHIP_REF_LIMIT && (
3386
+ <span className="badge bg-theme-elevated text-theme-text-secondary">+{group.refs.length - RELATIONSHIP_REF_LIMIT}</span>
3387
+ )}
3388
+ </div>
3389
+ </div>
3390
+ ))}
3391
+ {hiddenRefs > 0 && <div className="text-xs text-theme-text-tertiary">+{hiddenRefs} more relationships; use Topology for the full graph.</div>}
3392
+ </div>
3393
+ ) : (
3394
+ <div className="text-sm text-theme-text-tertiary">No serving or dependency relationships found.</div>
3395
+ )}
3396
+ </OverviewCard>
3397
+ )
3398
+ }
3399
+
3400
+ function ConfigurationInputsCard({
3401
+ serviceAccountName,
3402
+ namespace,
3403
+ relationships,
3404
+ onNavigate,
3405
+ onSwitchToTopology,
3406
+ }: {
3407
+ serviceAccountName: string
3408
+ namespace: string
3409
+ relationships?: Relationships
3410
+ onNavigate?: NavigateToResource
3411
+ onSwitchToTopology?: () => void
3412
+ }) {
3413
+ const configRefs = dedupeResourceRefs(relationships?.configRefs ?? []).filter((ref) =>
3414
+ ['ConfigMap', 'Secret', 'SealedSecret'].includes(ref.kind),
3415
+ )
3416
+ const policyRefs = dedupeResourceRefs([
3417
+ ...(relationships?.pdbs ?? []),
3418
+ ...(relationships?.networkPolicies ?? []),
3419
+ ...(relationships?.resourceClaims ?? []),
3420
+ ])
3421
+ if (!serviceAccountName && configRefs.length === 0 && policyRefs.length === 0) return null
3422
+ const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
3423
+ const serviceAccountRef: ResourceRef = {
3424
+ kind: 'ServiceAccount',
3425
+ namespace,
3426
+ name: serviceAccountName || 'default',
3427
+ }
3428
+
3429
+ return (
3430
+ <OverviewCard
3431
+ title="Configuration inputs"
3432
+ icon={Layers}
3433
+ action={onSwitchToTopology && (
3434
+ <button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
3435
+ <GitBranch className="h-3.5 w-3.5" />
3436
+ Topology
3437
+ </button>
3438
+ )}
3439
+ >
3440
+ <div className="space-y-4">
3441
+ <div className="min-w-0">
3442
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">Identity</div>
3443
+ <ResourceRefBadge resourceRef={serviceAccountRef} onClick={navigateRef} wrapAtSeparator />
3444
+ </div>
3445
+ <div className="min-w-0">
3446
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">Config</div>
3447
+ {configRefs.length > 0 ? (
3448
+ <div className="flex flex-wrap gap-1.5">
3449
+ {configRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
3450
+ <ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
3451
+ ))}
3452
+ {configRefs.length > RELATIONSHIP_REF_LIMIT && <Badge tone="structural" size="sm">+{configRefs.length - RELATIONSHIP_REF_LIMIT}</Badge>}
3453
+ </div>
3454
+ ) : (
3455
+ <div className="text-xs text-theme-text-tertiary">No ConfigMap or Secret refs found.</div>
3456
+ )}
3457
+ </div>
3458
+ <div className="min-w-0">
3459
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">Policy & storage</div>
3460
+ {policyRefs.length > 0 && onSwitchToTopology ? (
3461
+ <button type="button" onClick={onSwitchToTopology} className="text-left text-xs text-theme-text-secondary hover:text-accent-text">
3462
+ {policyRefs.length} related resource{policyRefs.length === 1 ? '' : 's'} in Topology
3463
+ </button>
3464
+ ) : policyRefs.length > 0 ? (
3465
+ <div className="text-xs text-theme-text-secondary">{policyRefs.length} related resource{policyRefs.length === 1 ? '' : 's'} found.</div>
3466
+ ) : (
3467
+ <div className="text-xs text-theme-text-tertiary">No PDB, NetworkPolicy, or storage refs found.</div>
3468
+ )}
3469
+ </div>
3470
+ </div>
3471
+ </OverviewCard>
3472
+ )
3473
+ }
3474
+
3475
+ function ActivityCard({
3476
+ events,
3477
+ updates,
3478
+ loading,
3479
+ eventsError,
3480
+ updatesError,
3481
+ onSwitchToTimeline,
3482
+ }: {
3483
+ events: TimelineEvent[]
3484
+ updates: TimelineEvent[]
3485
+ loading?: boolean
3486
+ eventsError?: Error | null
3487
+ updatesError?: Error | null
3488
+ onSwitchToTimeline?: () => void
3489
+ }) {
3490
+ const visibleEvents = mergeAndRankEvents(events, updates).slice(0, EVENT_VISIBLE_LIMIT)
3491
+
3492
+ return (
3493
+ <OverviewCard
3494
+ title="Recent activity"
3495
+ icon={Clock3}
3496
+ action={onSwitchToTimeline && (
3497
+ <button type="button" onClick={onSwitchToTimeline} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
3498
+ <ExternalLink className="h-3.5 w-3.5" />
3499
+ Timeline
3500
+ </button>
3501
+ )}
3502
+ >
3503
+ {loading ? (
3504
+ <div className="text-sm text-theme-text-tertiary">Loading activity…</div>
3505
+ ) : visibleEvents.length > 0 ? (
3506
+ <div className="space-y-2">
3507
+ {visibleEvents.map((event, i) => (
3508
+ <div key={`${event.id}-${i}`} className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
3509
+ <div className="flex min-w-0 items-start gap-2">
3510
+ <span className={clsx('mt-1 h-2 w-2 shrink-0 rounded-full', isProblematicEvent(event) ? 'bg-amber-500' : 'bg-skyhook-500')} />
3511
+ <div className="min-w-0 flex-1">
3512
+ <div className="flex items-center justify-between gap-2">
3513
+ <span className="truncate text-sm font-medium text-theme-text-primary">{event.reason || event.eventType || 'update'}</span>
3514
+ <span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(event.timestamp)}</span>
3515
+ </div>
3516
+ <div className="mt-0.5 line-clamp-2 text-xs text-theme-text-secondary">
3517
+ {event.message || event.diff?.summary || `${event.kind} ${event.name}`}
3518
+ </div>
3519
+ </div>
3520
+ </div>
3521
+ </div>
3522
+ ))}
3523
+ {(events.length + updates.length) > EVENT_VISIBLE_LIMIT && (
3524
+ <div className="text-xs text-theme-text-tertiary">Showing highest-signal recent events. Timeline has the full history.</div>
3525
+ )}
3526
+ </div>
3527
+ ) : (
3528
+ <div className="text-sm text-theme-text-tertiary">No recent activity captured.</div>
3529
+ )}
3530
+ {eventsError && <div className="mt-2 text-xs text-red-500">Failed to load events: {eventsError.message}</div>}
3531
+ {updatesError && <div className="mt-2 text-xs text-red-500">Failed to load changes: {updatesError.message}</div>}
3532
+ </OverviewCard>
3533
+ )
3534
+ }
3535
+
3536
+ function buildRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
3537
+ return [
3538
+ ...buildServingRelationshipGroups(relationships),
3539
+ ...buildDependencyRelationshipGroups(relationships),
3540
+ ]
3541
+ }
3542
+
3543
+ function buildServingRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
3544
+ if (!relationships) return []
3545
+ const routeRefs = dedupeResourceRefs([
3546
+ ...(relationships.ingresses ?? []),
3547
+ ...(relationships.gateways ?? []),
3548
+ ...(relationships.routes ?? []),
3549
+ ])
3550
+ return [
3551
+ { label: 'Services', refs: dedupeResourceRefs(relationships.services ?? []) },
3552
+ { label: 'Entry points', refs: routeRefs },
3553
+ ].filter((group) => group.refs.length > 0)
3554
+ }
3555
+
3556
+ function buildDependencyRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
3557
+ if (!relationships) return []
3558
+ return [
3559
+ { label: 'Configuration', refs: dedupeResourceRefs(relationships.configRefs ?? []) },
3560
+ { label: 'Autoscalers', refs: dedupeResourceRefs(relationships.scalers ?? []) },
3561
+ { label: 'Disruption budgets', refs: dedupeResourceRefs(relationships.pdbs ?? []) },
3562
+ { label: 'Network policies', refs: dedupeResourceRefs(relationships.networkPolicies ?? []) },
3563
+ { label: 'Storage claims', refs: dedupeResourceRefs(relationships.resourceClaims ?? []) },
3564
+ ].filter((group) => group.refs.length > 0)
3565
+ }
3566
+
3567
+ function summarizeServiceTypes(serviceResources: any[], serviceCount: number, loading: boolean): string {
3568
+ if (loading && serviceResources.length === 0) return 'Loading'
3569
+ const types = Array.from(new Set(
3570
+ serviceResources
3571
+ .map((service) => service?.spec?.type || 'ClusterIP')
3572
+ .filter(Boolean),
3573
+ ))
3574
+ if (types.length === 0) return serviceCount > 0 ? 'Unknown' : '-'
3575
+ if (types.length <= 2) return types.join(' / ')
3576
+ return `${types[0]} +${types.length - 1}`
3577
+ }
3578
+
3579
+ function summarizeExternalLabels(serviceResources: any[], entrypointResources: any[]): string[] {
3580
+ const labels: string[] = []
3581
+ for (const service of serviceResources) {
3582
+ const external = getServiceExternalIP(service)
3583
+ if (external) labels.push(`${service?.metadata?.name || 'service'}: ${external}`)
3584
+ }
3585
+ for (const entrypoint of entrypointResources) {
3586
+ const label = summarizeEntrypointResource(entrypoint)
3587
+ if (label) labels.push(label)
3588
+ }
3589
+ return dedupeStrings(labels)
3590
+ }
3591
+
3592
+ function summarizeEntrypointResource(resource: any): string {
3593
+ const kind = resource?.kind
3594
+ const name = resource?.metadata?.name || kind || 'entrypoint'
3595
+ if (kind === 'Ingress') {
3596
+ const hosts = getIngressHosts(resource)
3597
+ const address = getIngressAddress(resource)
3598
+ return `${name}: ${hosts}${address ? ` @ ${address}` : ' (address pending)'}`
3599
+ }
3600
+ if (kind === 'Gateway') {
3601
+ const listeners = getGatewayListeners(resource)
3602
+ const addresses = getGatewayAddresses(resource)
3603
+ return `${name}: ${listeners}${addresses && addresses !== '-' ? ` @ ${addresses}` : ''}`
3604
+ }
3605
+ const hostnames = resource?.spec?.hostnames
3606
+ if (Array.isArray(hostnames) && hostnames.length > 0) {
3607
+ return `${name}: ${hostnames.slice(0, 2).join(', ')}${hostnames.length > 2 ? ` +${hostnames.length - 2}` : ''}`
3608
+ }
3609
+ const parentRefs = resource?.spec?.parentRefs
3610
+ if (Array.isArray(parentRefs) && parentRefs.length > 0) {
3611
+ return `${name}: ${parentRefs.length} parent${parentRefs.length === 1 ? '' : 's'}`
3612
+ }
3613
+ return name
3614
+ }
3615
+
3616
+ function summarizeServingExposure(
3617
+ serviceResources: any[],
3618
+ serviceCount: number,
3619
+ entrypointCount: number,
3620
+ externalLabels: string[],
3621
+ loading: boolean,
3622
+ ): { label: string; badge: string; severity: BadgeSeverity } {
3623
+ if (loading && serviceResources.length === 0) return { label: 'Checking serving exposure', badge: 'Loading', severity: 'neutral' }
3624
+ const pendingLoadBalancer = serviceResources.some((service) =>
3625
+ service?.spec?.type === 'LoadBalancer' && getServiceExternalIP(service) === 'Pending',
3626
+ )
3627
+ const hasExternalService = serviceResources.some((service) =>
3628
+ ['LoadBalancer', 'NodePort', 'ExternalName'].includes(service?.spec?.type) || Boolean(getServiceExternalIP(service)),
3629
+ )
3630
+ if (entrypointCount > 0) return { label: 'External entrypoint configured', badge: 'External', severity: 'info' }
3631
+ if (pendingLoadBalancer) return { label: 'External service pending', badge: 'Pending', severity: 'warning' }
3632
+ if (hasExternalService || externalLabels.length > 0) return { label: 'External service configured', badge: 'External', severity: 'info' }
3633
+ if (serviceCount > 0) return { label: 'Internal service only', badge: 'Internal', severity: 'neutral' }
3634
+ return { label: 'No serving path detected', badge: 'None', severity: 'neutral' }
3635
+ }
3636
+
3637
+ function dedupeResourceRefs(refs: ResourceRef[]): ResourceRef[] {
3638
+ const seen = new Set<string>()
3639
+ return refs.filter((ref) => {
3640
+ const key = resourceRefId(ref)
3641
+ if (seen.has(key)) return false
3642
+ seen.add(key)
3643
+ return true
3644
+ })
3645
+ }
3646
+
3647
+ function resourceRefId(ref: ResourceRef): string {
3648
+ return `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
3649
+ }
3650
+
3651
+ function dedupeStrings(values: string[]): string[] {
3652
+ return Array.from(new Set(values.filter(Boolean)))
3653
+ }
3654
+
3655
+ function mergeAndRankEvents(events: TimelineEvent[], updates: TimelineEvent[]): TimelineEvent[] {
3656
+ const seen = new Set<string>()
3657
+ return [...events, ...updates]
3658
+ .filter((event) => {
3659
+ const key = event.id || `${event.timestamp}/${event.kind}/${event.name}/${event.reason}/${event.message}/${event.diff?.summary}`
3660
+ if (seen.has(key)) return false
3661
+ seen.add(key)
3662
+ return true
3663
+ })
3664
+ .sort((a, b) => {
3665
+ const aProblem = isProblematicEvent(a) ? 1 : 0
3666
+ const bProblem = isProblematicEvent(b) ? 1 : 0
3667
+ if (aProblem !== bProblem) return bProblem - aProblem
3668
+ return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
3669
+ })
3670
+ }