@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,1329 @@
1
+ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'
2
+ import { Activity, GitBranch, Terminal } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Collapse, CollapseChevron, EmptyState, FetchResult, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
5
+ import { buildWorkflowExecutionModel, flattenWorkflowExecution, type WorkflowExecutionActivity, type WorkflowExecutionModel, type WorkflowExecutionNode, type WorkflowExecutionRow, type WorkflowTemplateReference } from '@skyhook-io/k8s-ui/utils/workflow-execution'
6
+ import { midTruncate } from '@skyhook-io/k8s-ui/utils/format'
7
+ import { useResource, useWorkloadPods, useWorkloadRuns, type WorkloadRun } from '../../api/client'
8
+ import { getScaledJobStatus } from '../resources/resource-utils-keda'
9
+ import { Tooltip } from '../ui/Tooltip'
10
+ import { ImageFilesystemModal } from '../resources/ImageFilesystemModal'
11
+ import { executionDefinitionFingerprint, executionDefinitionSummary, type ExecutionDefinitionSummary, type ExecutionUnitSummary } from './execution-definition'
12
+ import { batchRunHasContainerOutcome, batchRunNextStep, isFailedRunPhase, type BatchRunNextStep } from './batch-run-actions'
13
+
14
+ const EMPTY_RUNS: WorkloadRun[] = []
15
+ const SCHEDULED_KINDS = new Set(['CronJob', 'CronWorkflow', 'WorkflowTemplate', 'ClusterWorkflowTemplate', 'ScaledJob'])
16
+ const RUN_KIND_LABEL: Record<string, string> = {
17
+ jobs: 'Job',
18
+ workflows: 'Workflow',
19
+ }
20
+
21
+ export function workloadRunKey(run: Pick<WorkloadRun, 'kind' | 'namespace' | 'name'>): string {
22
+ return `${run.kind}/${run.namespace}/${run.name}`
23
+ }
24
+
25
+ function isTemplateKind(kind: string): boolean {
26
+ return kind === 'WorkflowTemplate' || kind === 'ClusterWorkflowTemplate'
27
+ }
28
+
29
+ function configurationTitle(kind: string): string {
30
+ if (kind === 'CronJob') return 'Schedule & job definition'
31
+ if (kind === 'CronWorkflow') return 'Schedule & workflow definition'
32
+ if (kind === 'ScaledJob') return 'Trigger & job definition'
33
+ if (isTemplateKind(kind)) return 'Current definition'
34
+ if (kind === 'Job') return 'Job definition'
35
+ return 'Workflow definition'
36
+ }
37
+
38
+ export function workflowDefinitionTarget(kind: string, resource: any): { kind: string; namespace: string; name: string; group: string } | null {
39
+ if (kind !== 'CronWorkflow') return null
40
+ const ref = resource?.spec?.workflowSpec?.workflowTemplateRef
41
+ if (!ref?.name) return null
42
+ return {
43
+ kind: ref.clusterScope ? 'clusterworkflowtemplates' : 'workflowtemplates',
44
+ namespace: ref.clusterScope ? '' : resource?.metadata?.namespace || '',
45
+ name: ref.name,
46
+ group: 'argoproj.io',
47
+ }
48
+ }
49
+
50
+ export function effectiveDefinitionResource(kind: string, resource: any, referencedDefinition: any): any {
51
+ if (kind !== 'CronWorkflow' || !referencedDefinition) return resource
52
+ const base = referencedDefinition.spec ?? {}
53
+ const overlay = resource?.spec?.workflowSpec ?? {}
54
+ return {
55
+ ...resource,
56
+ spec: {
57
+ ...resource.spec,
58
+ workflowSpec: {
59
+ ...base,
60
+ ...overlay,
61
+ templates: overlay.templates ?? base.templates,
62
+ arguments: mergeWorkflowArguments(base.arguments, overlay.arguments),
63
+ },
64
+ },
65
+ }
66
+ }
67
+
68
+ function mergeWorkflowArguments(base: any, overlay: any): any {
69
+ const parameters = new Map<string, any>()
70
+ for (const parameter of base?.parameters ?? []) {
71
+ if (parameter?.name) parameters.set(parameter.name, parameter)
72
+ }
73
+ for (const parameter of overlay?.parameters ?? []) {
74
+ if (!parameter?.name) continue
75
+ const merged = { ...parameters.get(parameter.name), ...parameter }
76
+ if (Object.prototype.hasOwnProperty.call(parameter, 'value')) delete merged.valueFrom
77
+ else if (Object.prototype.hasOwnProperty.call(parameter, 'valueFrom')) delete merged.value
78
+ parameters.set(parameter.name, merged)
79
+ }
80
+ return { ...base, ...overlay, parameters: [...parameters.values()] }
81
+ }
82
+
83
+ interface BatchExecutionProps {
84
+ kind: string
85
+ apiKind: string
86
+ namespace: string
87
+ name: string
88
+ resource: any
89
+ selectedRunKey?: string
90
+ canViewLogs?: boolean
91
+ onSelectRun?: (runKey: string) => void
92
+ onSwitchToLogs?: () => void
93
+ onSwitchToTimeline?: () => void
94
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
95
+ }
96
+
97
+ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resource, selectedRunKey = '', canViewLogs = false, onSelectRun, onSwitchToLogs, onSwitchToTimeline, onNavigateToResource }: BatchExecutionProps) {
98
+ const scheduled = SCHEDULED_KINDS.has(kind)
99
+ const clusterScoped = kind === 'ClusterWorkflowTemplate'
100
+ const runsQuery = useWorkloadRuns(apiKind, namespace, name, true, { refetchActive: true, clusterScoped })
101
+ const runs = runsQuery.data?.runs ?? EMPTY_RUNS
102
+ const defaultRun = useMemo(() => pickDefaultRun(runs), [runs])
103
+ const [runFilter, setRunFilter] = useState<'all' | 'active' | 'failed'>('all')
104
+ const [runSearch, setRunSearch] = useState('')
105
+ const referencedDefinitionTarget = workflowDefinitionTarget(kind, resource)
106
+ const referencedDefinitionQuery = useResource<any>(
107
+ referencedDefinitionTarget?.kind ?? '',
108
+ referencedDefinitionTarget?.namespace ?? '',
109
+ referencedDefinitionTarget?.name ?? '',
110
+ referencedDefinitionTarget?.group,
111
+ { enabled: Boolean(referencedDefinitionTarget) },
112
+ )
113
+ const definitionResource = useMemo(
114
+ () => effectiveDefinitionResource(kind, resource, referencedDefinitionQuery.data),
115
+ [kind, resource, referencedDefinitionQuery.data],
116
+ )
117
+
118
+ useEffect(() => {
119
+ if (!runsQuery.data) return
120
+ if (runs.length === 0) {
121
+ if (selectedRunKey) onSelectRun?.('')
122
+ return
123
+ }
124
+ if (!runs.some((run) => workloadRunKey(run) === selectedRunKey)) {
125
+ onSelectRun?.(workloadRunKey(defaultRun ?? runs[0]))
126
+ }
127
+ }, [runsQuery.data, runs, selectedRunKey, defaultRun, onSelectRun])
128
+
129
+ const selectedRun = runs.find((run) => workloadRunKey(run) === selectedRunKey) ?? defaultRun
130
+ const shouldResolveLivePods = Boolean(selectedRun && canViewLogs && isFailedRunPhase(selectedRun.phase) && batchRunHasContainerOutcome(selectedRun))
131
+ const selectedRunPodsQuery = useWorkloadPods(
132
+ shouldResolveLivePods ? selectedRun?.kind ?? '' : '',
133
+ selectedRun?.namespace ?? '',
134
+ selectedRun?.name ?? '',
135
+ )
136
+ const hasLivePods = shouldResolveLivePods && selectedRunPodsQuery.isLoading
137
+ ? undefined
138
+ : Boolean(selectedRunPodsQuery.data?.pods.length)
139
+ const nextStep = selectedRun ? batchRunNextStep(selectedRun, canViewLogs, hasLivePods) : null
140
+ const visibleRuns = useMemo(() => runs.filter((run) => {
141
+ if (runFilter === 'active' && !run.active) return false
142
+ if (runFilter === 'failed' && run.phase !== 'Failed' && run.phase !== 'Error') return false
143
+ return !runSearch || run.name.toLowerCase().includes(runSearch.toLowerCase())
144
+ }), [runs, runFilter, runSearch])
145
+ const source = sourceFacts(kind, definitionResource, runs)
146
+ const phaseCounts = countPhases(runs)
147
+ const retentionCopy = retentionHistoryCopy(kind, resource, phaseCounts)
148
+ const fetchTarget = selectedRun && scheduled ? resourceTargetForRun(selectedRun) : null
149
+ const selectedResourceQuery = useResource<any>(
150
+ fetchTarget?.kind ?? '',
151
+ fetchTarget?.namespace ?? '',
152
+ fetchTarget?.name ?? '',
153
+ fetchTarget?.group,
154
+ { enabled: Boolean(fetchTarget), refetchInterval: selectedRun?.active ? 5000 : false },
155
+ )
156
+ const selectedResource = scheduled ? selectedResourceQuery.data : resource
157
+ const workflowExecution = useMemo(
158
+ () => selectedResource && selectedRun?.kind === 'workflows' ? buildWorkflowExecutionModel(selectedResource) : null,
159
+ [selectedResource, selectedRun?.kind],
160
+ )
161
+
162
+ if (runsQuery.isLoading) {
163
+ return <FetchResult loading className="h-full" />
164
+ }
165
+
166
+ if (runsQuery.error) {
167
+ return (
168
+ <div className="p-4">
169
+ <EmptyState tone="neutral" variant="card" headline="Run history unavailable" body={runsQuery.error instanceof Error ? runsQuery.error.message : 'Radar could not load retained runs.'} />
170
+ </div>
171
+ )
172
+ }
173
+
174
+ return (
175
+ <div className="flex h-full min-h-0 bg-theme-base">
176
+ {scheduled && (
177
+ <aside className="flex w-72 shrink-0 flex-col border-r border-theme-border bg-theme-surface">
178
+ <div className="border-b border-theme-border px-3 py-3">
179
+ <div className="flex items-center justify-between gap-2">
180
+ <div>
181
+ <div className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">{isTemplateKind(kind) ? 'Workflows using this definition' : 'Run history'}</div>
182
+ <div className="mt-1 text-sm font-semibold text-theme-text-primary">{pluralizeRuns(runs.length)}</div>
183
+ </div>
184
+ </div>
185
+ <div className="mt-2 flex flex-wrap gap-1 text-[10px] text-theme-text-tertiary">
186
+ {phaseCounts.running > 0 && <span className="rounded bg-theme-hover px-1.5 py-0.5">{phaseCounts.running} running</span>}
187
+ {phaseCounts.failed > 0 && <span className="rounded bg-theme-hover px-1.5 py-0.5">{phaseCounts.failed} failed</span>}
188
+ {phaseCounts.succeeded > 0 && <span className="rounded bg-theme-hover px-1.5 py-0.5">{phaseCounts.succeeded} succeeded</span>}
189
+ </div>
190
+ <p className="mt-2 text-[10px] leading-4 text-theme-text-tertiary">Retained Kubernetes objects, not all-time history.</p>
191
+ {retentionCopy && <p className="mt-1 text-[10px] leading-4 text-theme-text-secondary">{retentionCopy}</p>}
192
+ {(runs.length > 8 || phaseCounts.failed > 0) && (
193
+ <div className="mt-3 space-y-2">
194
+ <div className="flex gap-1">
195
+ {(['all', 'active', 'failed'] as const).map((filter) => (
196
+ <button key={filter} type="button" onClick={() => setRunFilter(filter)} className={clsx('rounded px-2 py-1 text-[10px] font-medium capitalize', runFilter === filter ? 'selection' : 'text-theme-text-tertiary hover:bg-theme-hover')}>{filter}</button>
197
+ ))}
198
+ </div>
199
+ {runs.length > 20 && <input value={runSearch} onChange={(event) => setRunSearch(event.target.value)} placeholder="Filter run names" className="w-full rounded-md border border-theme-border bg-theme-elevated px-2 py-1.5 text-xs text-theme-text-primary placeholder:text-theme-text-tertiary" />}
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
205
+ {runs.length === 0 ? (
206
+ <div className="p-2">
207
+ <EmptyState
208
+ tone="neutral"
209
+ variant="card"
210
+ headline={emptyRunsCopy(kind, resource).headline}
211
+ body={emptyRunsCopy(kind, resource).body}
212
+ />
213
+ </div>
214
+ ) : visibleRuns.length === 0 ? (
215
+ <EmptyState tone="filtered" variant="card" headline="No runs match these filters" body="Change the status filter or run-name search." />
216
+ ) : (
217
+ <div className="space-y-1">
218
+ {visibleRuns.map((run) => (
219
+ <RunRailButton
220
+ key={`${run.kind}/${run.namespace}/${run.name}`}
221
+ run={run}
222
+ showNamespace={clusterScoped}
223
+ selected={workloadRunKey(selectedRun ?? run) === workloadRunKey(run)}
224
+ onClick={() => onSelectRun?.(workloadRunKey(run))}
225
+ />
226
+ ))}
227
+ </div>
228
+ )}
229
+ </div>
230
+ </aside>
231
+ )}
232
+
233
+ <main className="min-w-0 flex-1 overflow-auto">
234
+ <div className="space-y-4 p-4">
235
+ {scheduled && selectedResourceQuery.error && (
236
+ <EmptyState tone="neutral" variant="card" headline="Selected run unavailable" body={selectedResourceQuery.error instanceof Error ? selectedResourceQuery.error.message : 'Radar could not load this retained run.'} />
237
+ )}
238
+ <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)]">
239
+ <section className="min-w-0 space-y-4">
240
+ {selectedRun ? (
241
+ <section className="rounded-lg border border-theme-border bg-theme-surface">
242
+ <div className="flex items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
243
+ <div className="min-w-0">
244
+ <div className="flex items-center gap-2">
245
+ <StatusDot tone={mapHealthToTone(phaseHealth(selectedRun.phase))} />
246
+ <div className="min-w-0">
247
+ <div className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Selected run</div>
248
+ {onNavigateToResource ? (
249
+ <button type="button" className="block max-w-full truncate text-base font-semibold text-accent-text hover:underline" onClick={() => onNavigateToResource(resourceTargetForRun(selectedRun))}>{selectedRun.name}</button>
250
+ ) : <h3 className="truncate text-base font-semibold text-theme-text-primary">{selectedRun.name}</h3>}
251
+ </div>
252
+ </div>
253
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
254
+ <span>{RUN_KIND_LABEL[selectedRun.kind] ?? selectedRun.kind}</span>
255
+ {selectedRun.startedAt && <span>started {formatAge(selectedRun.startedAt)}</span>}
256
+ {selectedRun.trigger === 'manual' && <span>manual trigger</span>}
257
+ {selectedRun.trigger === 'event' && <span>event triggered</span>}
258
+ {selectedRun.scheduledAt && <span>scheduled {formatAge(selectedRun.scheduledAt)}</span>}
259
+ {selectedResourceQuery.isFetching && <span>refreshing</span>}
260
+ </div>
261
+ </div>
262
+ <span className={clsx('badge', phaseBadgeClass(selectedRun.phase))}>{selectedRun.phase}</span>
263
+ </div>
264
+ <RunDetailList run={selectedRun} resource={selectedResource} workflowExecution={workflowExecution} scheduledParent={scheduled} />
265
+ <RunContext run={selectedRun} resource={selectedResource} definitionResource={definitionResource} workflowExecution={workflowExecution} currentWorkload={{ kind, namespace, name }} onNavigateToResource={onNavigateToResource} />
266
+ {(selectedRun.message || isFailedRunPhase(selectedRun.phase)) && (
267
+ <RunMessageDetails
268
+ run={selectedRun}
269
+ nextStep={nextStep}
270
+ onNextStep={nextStep === 'logs' ? onSwitchToLogs : nextStep === 'timeline' ? onSwitchToTimeline : undefined}
271
+ />
272
+ )}
273
+ </section>
274
+ ) : (
275
+ <EmptyState
276
+ tone="neutral"
277
+ variant="card"
278
+ headline="No selected run"
279
+ body={`There are no retained ${runKindPluralForSchedule(kind)} to inspect.`}
280
+ />
281
+ )}
282
+
283
+ {selectedRun?.kind === 'workflows' && (
284
+ <RunExecutionPanel run={selectedRun} workflowExecution={workflowExecution} loading={selectedResourceQuery.isLoading} onNavigateToResource={onNavigateToResource} />
285
+ )}
286
+ </section>
287
+
288
+ <section className="min-w-0 space-y-4">
289
+ <section className="rounded-lg border border-theme-border bg-theme-surface">
290
+ <div className="border-b border-theme-border px-4 py-3">
291
+ <h3 className="text-sm font-semibold text-theme-text-primary">{configurationTitle(kind)}</h3>
292
+ </div>
293
+ <div className="space-y-3 p-4">
294
+ <SourceFacts
295
+ source={source}
296
+ namespace={selectedRun?.namespace || namespace}
297
+ definitionLoading={Boolean(referencedDefinitionTarget) && referencedDefinitionQuery.isLoading}
298
+ definitionError={referencedDefinitionTarget ? referencedDefinitionQuery.error : undefined}
299
+ />
300
+ </div>
301
+ </section>
302
+
303
+ {selectedRun && (
304
+ <RunActivityPanel run={selectedRun} resource={selectedResource} workflowExecution={workflowExecution} />
305
+ )}
306
+ </section>
307
+ </div>
308
+ </div>
309
+ </main>
310
+ </div>
311
+ )
312
+ }
313
+
314
+ export function pickDefaultRun(runs: WorkloadRun[]): WorkloadRun | undefined {
315
+ return [...runs].sort(compareRuns)[0]
316
+ }
317
+
318
+ function pickLatestRun(runs: WorkloadRun[]): WorkloadRun | undefined {
319
+ return [...runs].sort((a, b) => {
320
+ const timeDiff = runTime(b) - runTime(a)
321
+ if (timeDiff !== 0) return timeDiff
322
+ return compareRuns(a, b)
323
+ })[0]
324
+ }
325
+
326
+ function compareRuns(a: WorkloadRun, b: WorkloadRun): number {
327
+ if (a.active !== b.active) return a.active ? -1 : 1
328
+ const timeDiff = runTime(b) - runTime(a)
329
+ if (timeDiff !== 0) return timeDiff
330
+ const phaseDiff = phaseRank(a.phase) - phaseRank(b.phase)
331
+ if (phaseDiff !== 0) return phaseDiff
332
+ return a.name.localeCompare(b.name)
333
+ }
334
+
335
+ function phaseRank(phase: string): number {
336
+ switch (phase) {
337
+ case 'Failed':
338
+ case 'Error':
339
+ return 0
340
+ case 'Running':
341
+ case 'Pending':
342
+ return 1
343
+ case 'Succeeded':
344
+ return 2
345
+ default:
346
+ return 3
347
+ }
348
+ }
349
+
350
+ function runTime(run: WorkloadRun): number {
351
+ let out = 0
352
+ for (const value of [run.startedAt, run.scheduledAt, run.finishedAt]) {
353
+ if (!value) continue
354
+ const t = Date.parse(value)
355
+ if (!Number.isNaN(t) && t > out) out = t
356
+ }
357
+ return out
358
+ }
359
+
360
+ function jobPhase(job: any): string {
361
+ if (!job) return 'Pending'
362
+ const conditions = job.status?.conditions ?? []
363
+ if (job.spec?.suspend === true || conditions.some((c: any) => c.type === 'Suspended' && c.status === 'True')) return 'Suspended'
364
+ if ((job.status?.active ?? 0) > 0) return 'Running'
365
+ if (conditions.some((c: any) => c.type === 'Complete' && c.status === 'True')) return 'Succeeded'
366
+ if (conditions.some((c: any) => c.type === 'Failed' && c.status === 'True')) return 'Failed'
367
+ return 'Pending'
368
+ }
369
+
370
+ function scaledJobState(resource: any): string {
371
+ return getScaledJobStatus(resource).text
372
+ }
373
+
374
+ function scaledJobTone(resource: any): 'info' | 'warning' | 'success' | 'error' {
375
+ switch (getScaledJobStatus(resource).level) {
376
+ case 'healthy':
377
+ return 'success'
378
+ case 'unhealthy':
379
+ case 'alert':
380
+ return 'error'
381
+ case 'degraded':
382
+ return 'warning'
383
+ default:
384
+ return 'info'
385
+ }
386
+ }
387
+
388
+ function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) {
389
+ const spec = resource?.spec ?? {}
390
+ const status = resource?.status ?? {}
391
+ const latest = pickLatestRun(runs)
392
+ const definition = executionDefinitionSummary(kind, resource)
393
+ if (kind === 'CronJob') {
394
+ return {
395
+ state: spec.suspend ? 'Suspended' : (status.active?.length ?? 0) > 0 ? 'Active' : 'Scheduled',
396
+ stateTone: spec.suspend ? 'warning' : 'info',
397
+ schedule: spec.schedule,
398
+ concurrency: spec.concurrencyPolicy || 'Allow',
399
+ progress: latest?.progress,
400
+ duration: latest ? formatRunDuration(latest) : '',
401
+ work: `${status.active?.length ?? 0} active`,
402
+ facts: [
403
+ ['Last schedule', status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'],
404
+ ['Last success', status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'],
405
+ ['Starting deadline', spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'],
406
+ ],
407
+ definition,
408
+ }
409
+ }
410
+ if (kind === 'CronWorkflow') {
411
+ const schedules = Array.isArray(spec.schedules) ? spec.schedules.join(', ') : spec.schedule
412
+ const template = spec.workflowSpec?.workflowTemplateRef?.name || spec.workflowSpec?.entrypoint
413
+ return {
414
+ state: spec.suspend ? 'Suspended' : (status.active?.length ?? 0) > 0 ? 'Active' : 'Scheduled',
415
+ stateTone: spec.suspend ? 'warning' : 'info',
416
+ schedule: schedules,
417
+ concurrency: spec.concurrencyPolicy || 'Allow',
418
+ progress: latest?.progress,
419
+ duration: latest ? formatRunDuration(latest) : '',
420
+ work: `${runs.filter((run) => run.active).length} active`,
421
+ facts: [
422
+ ['Timezone', spec.timezone || 'Cluster default'],
423
+ ['Template', template || '-'],
424
+ ['Starting deadline', spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None'],
425
+ ],
426
+ parameters: workflowDefinitionParameters(kind, resource),
427
+ definition,
428
+ }
429
+ }
430
+ if (kind === 'WorkflowTemplate') {
431
+ return {
432
+ state: 'Definition',
433
+ stateTone: 'info',
434
+ progress: latest?.progress,
435
+ duration: latest ? formatRunDuration(latest) : '',
436
+ work: `${runs.filter((run) => run.active).length} active`,
437
+ facts: [],
438
+ parameters: workflowDefinitionParameters(kind, resource),
439
+ definition,
440
+ }
441
+ }
442
+ if (kind === 'ClusterWorkflowTemplate') {
443
+ return {
444
+ state: 'Definition',
445
+ stateTone: 'info',
446
+ progress: latest?.progress,
447
+ duration: latest ? formatRunDuration(latest) : '',
448
+ work: `${runs.filter((run) => run.active).length} active`,
449
+ facts: [['Scope', 'Cluster']],
450
+ parameters: workflowDefinitionParameters(kind, resource),
451
+ definition,
452
+ }
453
+ }
454
+ if (kind === 'ScaledJob') {
455
+ const active = runs.filter((run) => run.active).length
456
+ const triggers = Array.isArray(spec.triggers) ? spec.triggers : []
457
+ return {
458
+ state: scaledJobState(resource),
459
+ stateTone: scaledJobTone(resource),
460
+ progress: latest?.progress,
461
+ duration: latest ? formatRunDuration(latest) : '',
462
+ work: `${active} active`,
463
+ facts: [
464
+ ['Triggers', triggers.length ? triggers.map((trigger: any) => trigger.type || 'trigger').join(', ') : '-'],
465
+ ['Polling interval', spec.pollingInterval != null ? `${spec.pollingInterval}s` : 'Default'],
466
+ ['Replica range', `${spec.minReplicaCount ?? 0} min / ${spec.maxReplicaCount ?? '-'} max`],
467
+ ],
468
+ definition,
469
+ }
470
+ }
471
+ if (kind === 'Job') {
472
+ return {
473
+ state: jobPhase(resource),
474
+ stateTone: phaseTone(jobPhase(resource)),
475
+ progress: latest?.progress,
476
+ duration: latest ? formatRunDuration(latest) : '',
477
+ work: latest ? workCount(latest) : '',
478
+ facts: [
479
+ ['TTL after finish', spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None'],
480
+ ],
481
+ definition,
482
+ }
483
+ }
484
+ return {
485
+ state: status.phase || 'Pending',
486
+ stateTone: phaseTone(status.phase || 'Pending'),
487
+ progress: status.progress,
488
+ duration: latest ? formatRunDuration(latest) : '',
489
+ work: latest ? workCount(latest) : '',
490
+ facts: [
491
+ ['Template', spec.workflowTemplateRef?.name || '-'],
492
+ ['Priority', spec.priority != null ? String(spec.priority) : '-'],
493
+ ],
494
+ definition,
495
+ }
496
+ }
497
+
498
+ function SourceFacts({ source, namespace, definitionLoading = false, definitionError }: { source: ReturnType<typeof sourceFacts>; namespace: string; definitionLoading?: boolean; definitionError?: unknown }) {
499
+ const parameters = 'parameters' in source ? source.parameters : undefined
500
+ return (
501
+ <>
502
+ <div className="grid grid-cols-2 gap-2">
503
+ {source.state !== 'Definition' && <FactTile label="State" value={source.state} tone={source.stateTone} />}
504
+ {source.schedule && <FactTile label="Schedule" value={source.schedule} mono />}
505
+ {source.concurrency && <FactTile label="Concurrency" value={source.concurrency} />}
506
+ </div>
507
+ {definitionLoading ? (
508
+ <div className="border-t border-theme-border pt-3"><FetchResult loading /></div>
509
+ ) : definitionError ? (
510
+ <EmptyState tone="neutral" variant="card" headline="Referenced definition unavailable" body={definitionError instanceof Error ? definitionError.message : 'Radar could not load the referenced workflow definition.'} />
511
+ ) : source.definition ? (
512
+ <ExecutionDefinitionDetails summary={source.definition} namespace={namespace} />
513
+ ) : null}
514
+ <div className="space-y-2">
515
+ {source.facts.map(([label, value]) => (
516
+ <div key={label} className="flex items-start justify-between gap-3 text-sm">
517
+ <span className="text-theme-text-tertiary">{label}</span>
518
+ <span className="min-w-0 truncate text-right text-theme-text-primary">{value}</span>
519
+ </div>
520
+ ))}
521
+ </div>
522
+ {parameters && parameters.length > 0 && (
523
+ <ParameterSection title="Inputs" parameters={parameters} showDescription />
524
+ )}
525
+ </>
526
+ )
527
+ }
528
+
529
+ function ExecutionDefinitionDetails({ summary, namespace, compact = false }: { summary: ExecutionDefinitionSummary; namespace: string; compact?: boolean }) {
530
+ const visibleUnits = summary.units.slice(0, compact ? 1 : 3)
531
+ return (
532
+ <div className={clsx(!compact && 'border-t border-theme-border pt-3')}>
533
+ {!compact && <div className="mb-2 text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">What it runs</div>}
534
+ <div className="space-y-2">
535
+ <DefinitionFact label="Execution" value={summary.shape} />
536
+ {visibleUnits.map((unit) => <ExecutionUnitDetails key={`${unit.type}/${unit.name}`} unit={unit} namespace={namespace} pullSecrets={summary.imagePullSecrets} compact={compact} />)}
537
+ {summary.units.length > visibleUnits.length && <div className="text-right text-xs text-theme-text-tertiary">+{summary.units.length - visibleUnits.length} more executable {summary.units.length - visibleUnits.length === 1 ? 'template' : 'templates'}</div>}
538
+ {summary.externalTemplates.length > 0 && <DefinitionFact label="Uses" value={summary.externalTemplates.join(', ')} mono />}
539
+ {!compact && (
540
+ <>
541
+ <DefinitionFact label="Retries" value={summary.retry} />
542
+ {summary.deadline && <DefinitionFact label="Deadline" value={summary.deadline} />}
543
+ {summary.parallelism && <DefinitionFact label="Parallelism" value={summary.parallelism} />}
544
+ <DefinitionFact label="Service account" value={summary.serviceAccount} mono />
545
+ {summary.configMaps.length > 0 && <DefinitionFact label="ConfigMaps" value={summary.configMaps.join(', ')} mono />}
546
+ {summary.secrets.length > 0 && <DefinitionFact label="Secrets" value={summary.secrets.join(', ')} mono />}
547
+ </>
548
+ )}
549
+ </div>
550
+ </div>
551
+ )
552
+ }
553
+
554
+ function ExecutionUnitDetails({ unit, namespace, pullSecrets, compact }: { unit: ExecutionUnitSummary; namespace: string; pullSecrets: string[]; compact: boolean }) {
555
+ const [browseImage, setBrowseImage] = useState(false)
556
+ return (
557
+ <>
558
+ <div className={clsx(!compact && 'rounded-md bg-theme-elevated/40 px-3 py-2')}>
559
+ {!compact && <div className="mb-1 flex items-center justify-between gap-3"><span className="truncate text-xs font-medium text-theme-text-primary">{unit.name}</span><span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{unit.type}</span></div>}
560
+ <div className="space-y-1">
561
+ {unit.image && <DefinitionFact label="Image" value={unit.image} mono onClick={() => setBrowseImage(true)} tooltip="Browse image filesystem from registry" />}
562
+ {unit.command && <DefinitionFact label="Command" value={unit.command} mono />}
563
+ {!compact && unit.requests && <DefinitionFact label="Requests" value={unit.requests} />}
564
+ {!compact && unit.limits && <DefinitionFact label="Limits" value={unit.limits} />}
565
+ </div>
566
+ </div>
567
+ {unit.image && (
568
+ <ImageFilesystemModal
569
+ open={browseImage}
570
+ onClose={() => setBrowseImage(false)}
571
+ image={unit.image}
572
+ namespace={namespace}
573
+ podName=""
574
+ pullSecrets={pullSecrets}
575
+ />
576
+ )}
577
+ </>
578
+ )
579
+ }
580
+
581
+ function DefinitionFact({ label, value, mono = false, onClick, tooltip }: { label: string; value: string; mono?: boolean; onClick?: () => void; tooltip?: string }) {
582
+ const valueClassName = clsx('break-words text-theme-text-primary', mono && 'font-mono text-xs', onClick && 'text-accent-text hover:underline')
583
+ return (
584
+ <div className="flex items-start justify-between gap-3 text-sm">
585
+ <span className="shrink-0 text-theme-text-tertiary">{label}</span>
586
+ <Tooltip content={tooltip ?? value} delay={300} wrapperClassName="min-w-0 text-right">
587
+ {onClick
588
+ ? <button type="button" onClick={onClick} className={clsx(valueClassName, 'text-right')}>{value}</button>
589
+ : <span className={valueClassName}>{value}</span>}
590
+ </Tooltip>
591
+ </div>
592
+ )
593
+ }
594
+
595
+ interface WorkflowParameter {
596
+ name: string
597
+ value?: unknown
598
+ valueFrom?: unknown
599
+ description?: string
600
+ enum?: unknown[]
601
+ }
602
+
603
+ export function workflowDefinitionParameters(kind: string, resource: any): WorkflowParameter[] {
604
+ const parameters = kind === 'CronWorkflow'
605
+ ? resource?.spec?.workflowSpec?.arguments?.parameters
606
+ : isTemplateKind(kind)
607
+ ? resource?.spec?.arguments?.parameters
608
+ : undefined
609
+ return Array.isArray(parameters) ? parameters.filter((parameter) => parameter?.name) : []
610
+ }
611
+
612
+ function ParameterSection({ title, parameters, showDescription = false, divided = true }: { title: string; parameters: WorkflowParameter[]; showDescription?: boolean; divided?: boolean }) {
613
+ return (
614
+ <div className={clsx(divided && 'border-t border-theme-border pt-3')}>
615
+ <div className="mb-2 text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">{title}</div>
616
+ <div className="space-y-2">
617
+ {parameters.map((parameter) => (
618
+ <div key={parameter.name} className="rounded-md bg-theme-elevated/40 px-3 py-2 text-sm">
619
+ <div className="flex items-start justify-between gap-3">
620
+ <span className="min-w-0 break-words font-mono text-xs text-theme-text-primary">{parameter.name}</span>
621
+ <span className="min-w-0 break-words text-right text-xs text-theme-text-secondary">
622
+ {showDescription && parameter.value !== undefined && parameter.value !== null ? `Default · ${parameterValue(parameter)}` : parameterValue(parameter)}
623
+ </span>
624
+ </div>
625
+ {showDescription && parameter.description && <div className="mt-1 text-xs leading-4 text-theme-text-tertiary">{parameter.description}</div>}
626
+ {showDescription && Array.isArray(parameter.enum) && parameter.enum.length > 0 && <div className="mt-1 text-xs text-theme-text-tertiary">Allowed: {parameter.enum.map(String).join(', ')}</div>}
627
+ </div>
628
+ ))}
629
+ </div>
630
+ </div>
631
+ )
632
+ }
633
+
634
+ function parameterValue(parameter: WorkflowParameter): string {
635
+ if (parameter.value !== undefined && parameter.value !== null) {
636
+ return typeof parameter.value === 'string' ? parameter.value : (JSON.stringify(parameter.value) ?? String(parameter.value))
637
+ }
638
+ if (parameter.valueFrom) return 'Resolved at runtime'
639
+ return 'Required'
640
+ }
641
+
642
+ function RunDetailList({ run, resource, workflowExecution, scheduledParent }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null; scheduledParent: boolean }) {
643
+ const isWorkflowRun = run.kind === 'workflows' || !!workflowExecution
644
+ const rows: Array<[string, string]> = [
645
+ ['Started', run.startedAt ? formatAge(run.startedAt) : '-'],
646
+ ['Finished', run.finishedAt ? formatAge(run.finishedAt) : run.active ? 'Running' : '-'],
647
+ ['Duration', formatRunDuration(run) || '-'],
648
+ ...(run.progress ? [['Progress', run.progress]] as Array<[string, string]> : []),
649
+ ...(scheduledParent || run.trigger || run.scheduledAt ? [
650
+ ['Trigger', run.trigger === 'manual' ? 'Manual' : run.trigger === 'event' ? 'Event' : run.scheduledAt ? 'Cron schedule' : '-'],
651
+ ] as Array<[string, string]> : []),
652
+ ...(run.scheduledAt ? [
653
+ ['Cron scheduled', formatAge(run.scheduledAt)],
654
+ ['Start delay', formatScheduleDelay(run) || '-'],
655
+ ] as Array<[string, string]> : []),
656
+ ['Parallelism', run.parallelism ? String(run.parallelism) : '-'],
657
+ ['Pods', podBreakdown(run, workflowExecution) || '-'],
658
+ ...(isWorkflowRun ? [
659
+ ['Execution nodes', executionNodeBreakdown(workflowExecution) || '-'],
660
+ ...(workflowExecution?.resourcesDuration ? [['Resource duration', formatResourceDuration(workflowExecution.resourcesDuration)]] as Array<[string, string]> : []),
661
+ ] as Array<[string, string]> : [
662
+ ['Retry limit', jobRetryLimitValue(resource)],
663
+ ] as Array<[string, string]>),
664
+ ]
665
+ return (
666
+ <div className="grid gap-x-8 gap-y-2 p-4 xl:grid-cols-2">
667
+ {rows.map(([label, value]) => (
668
+ <div key={label} className="flex items-start justify-between gap-3 text-sm">
669
+ <span className="text-theme-text-tertiary">{label}</span>
670
+ <span className="min-w-0 break-words text-right text-theme-text-primary">{value}</span>
671
+ </div>
672
+ ))}
673
+ </div>
674
+ )
675
+ }
676
+
677
+ function RunMessageDetails({ run, nextStep, onNextStep }: { run: WorkloadRun; nextStep: BatchRunNextStep | null; onNextStep?: () => void }) {
678
+ const [open, setOpen] = useState(false)
679
+ const failed = isFailedRunPhase(run.phase)
680
+ const action = nextStep && onNextStep ? <RunNextStep step={nextStep} onClick={onNextStep} /> : null
681
+ const message = run.message ?? ''
682
+ if (!runMessageNeedsDisclosure(message)) {
683
+ return (
684
+ <div className="flex flex-wrap items-start gap-x-3 gap-y-2 border-t border-theme-border px-4 py-3 text-sm">
685
+ <div className="flex min-w-0 flex-1 items-start gap-3">
686
+ <span className={clsx('shrink-0 font-medium', failed ? 'text-red-700 dark:text-red-300' : 'text-theme-text-primary')}>{failed ? 'Failure' : 'Run message'}</span>
687
+ {message && <span className="min-w-0 break-words text-theme-text-tertiary">{message}</span>}
688
+ </div>
689
+ {action}
690
+ </div>
691
+ )
692
+ }
693
+ return (
694
+ <div className="border-t border-theme-border">
695
+ <div className="flex items-center gap-2 pr-4 hover:bg-theme-hover/50">
696
+ <button type="button" aria-expanded={open} onClick={() => setOpen((value) => !value)} className="flex min-w-0 flex-1 items-center gap-2 px-4 py-3 text-left">
697
+ <CollapseChevron open={open} className="h-4 w-4" />
698
+ <span className={clsx('shrink-0 text-sm font-medium', failed ? 'text-red-700 dark:text-red-300' : 'text-theme-text-primary')}>
699
+ {failed ? 'Failure details' : 'Run message'}
700
+ </span>
701
+ <span className="min-w-0 truncate text-xs text-theme-text-tertiary">{message}</span>
702
+ </button>
703
+ {action}
704
+ </div>
705
+ <Collapse open={open}>
706
+ <div className="p-4">
707
+ <div className={clsx('whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm', failed ? 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300' : 'border-theme-border bg-theme-elevated/40 text-theme-text-secondary')}>
708
+ {message}
709
+ </div>
710
+ </div>
711
+ </Collapse>
712
+ </div>
713
+ )
714
+ }
715
+
716
+ function RunNextStep({ step, onClick }: { step: BatchRunNextStep; onClick: () => void }) {
717
+ const Icon = step === 'logs' ? Terminal : Activity
718
+ const label = step === 'logs' ? 'View logs' : 'View timeline'
719
+ return (
720
+ <button type="button" onClick={onClick} className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-text hover:underline">
721
+ <Icon className="h-3.5 w-3.5" />
722
+ {label}
723
+ </button>
724
+ )
725
+ }
726
+
727
+ export function runMessageNeedsDisclosure(message: string): boolean {
728
+ return message.includes('\n') || message.length > 140
729
+ }
730
+
731
+ function RunExecutionPanel({ run, workflowExecution, loading, onNavigateToResource }: { run: WorkloadRun; workflowExecution: WorkflowExecutionModel | null; loading: boolean; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) {
732
+ const [showAll, setShowAll] = useState(false)
733
+ if (run.kind === 'workflows') {
734
+ if (loading && !workflowExecution) {
735
+ return <Panel title="Run execution" icon={GitBranch}><FetchResult loading /></Panel>
736
+ }
737
+ if (!workflowExecution || workflowExecution.executionNodes.length === 0) {
738
+ return (
739
+ <Panel title="Run execution" icon={GitBranch}>
740
+ <EmptyState tone="neutral" variant="card" headline="Execution detail unavailable" body={run.active ? 'This Workflow has not reported execution nodes yet.' : 'This retained Workflow no longer has execution-node detail.'} />
741
+ </Panel>
742
+ )
743
+ }
744
+ const rows = flattenWorkflowExecution(workflowExecution)
745
+ const visibleRows = showAll || !workflowExecution.isLarge ? rows : executionPreviewRows(rows, workflowExecution)
746
+ const messageOwners = new Map<string, { id: string; depth: number; leaf: boolean }>()
747
+ for (const row of visibleRows) {
748
+ const message = row.node.message?.trim()
749
+ if (!message) continue
750
+ const candidate = { id: row.node.id, depth: row.depth, leaf: row.node.childIds.length === 0 }
751
+ const current = messageOwners.get(message)
752
+ if (!current || (candidate.leaf && !current.leaf) || candidate.leaf === current.leaf && candidate.depth > current.depth) messageOwners.set(message, candidate)
753
+ }
754
+ const runMessage = run.message?.trim()
755
+ const renderedRows = visibleRows.map((row) => {
756
+ const nodeMessage = row.node.message?.trim()
757
+ const repeatedRunMessage = Boolean(nodeMessage && runMessage && (runMessage.includes(nodeMessage) || nodeMessage.includes(runMessage)))
758
+ return { ...row, showMessage: Boolean(nodeMessage) && !repeatedRunMessage && messageOwners.get(nodeMessage!)?.id === row.node.id }
759
+ })
760
+ return (
761
+ <Panel title="Run execution" icon={GitBranch} detail={`${workflowExecution.executionNodes.length} ${workflowExecution.executionNodes.length === 1 ? 'node' : 'nodes'}`}>
762
+ <div className="divide-y divide-theme-border rounded-md border border-theme-border">
763
+ {renderedRows.map(({ node, depth, showMessage }) => <ExecutionNodeRow key={node.id} node={node} depth={depth} showMessage={showMessage} namespace={run.namespace} onNavigateToResource={onNavigateToResource} />)}
764
+ </div>
765
+ {visibleRows.length < rows.length && <button type="button" className="mt-3 text-sm font-medium text-accent-text hover:underline" onClick={() => setShowAll(true)}>Show all {rows.length} nodes</button>}
766
+ </Panel>
767
+ )
768
+ }
769
+
770
+ return null
771
+ }
772
+
773
+ function executionPreviewRows(rows: WorkflowExecutionRow[], workflowExecution: WorkflowExecutionModel): WorkflowExecutionRow[] {
774
+ const preview = rows.slice(0, 40)
775
+ const included = new Set(preview.map((row) => row.node.id))
776
+ const rowByID = new Map(rows.map((row) => [row.node.id, row]))
777
+ for (const path of workflowExecution.focusPaths) {
778
+ for (const node of path.nodes) {
779
+ const row = rowByID.get(node.id)
780
+ if (!row || included.has(node.id)) continue
781
+ preview.push(row)
782
+ included.add(node.id)
783
+ }
784
+ }
785
+ return preview
786
+ }
787
+
788
+ function RunActivityPanel({ run, resource, workflowExecution }: { run: WorkloadRun; resource: any; workflowExecution: WorkflowExecutionModel | null }) {
789
+ const [showAll, setShowAll] = useState(false)
790
+ const activity = run.kind === 'workflows' ? workflowExecution?.activity ?? [] : jobActivity(run, resource)
791
+ const defaultItems = activity.length <= 10 ? activity : activityPreviewItems(activity)
792
+ const overflowItems = activity.slice(defaultItems.length)
793
+ return (
794
+ <Panel title="Run activity" icon={Activity} detail={activity.length ? `${activity.length} events` : undefined}>
795
+ {activity.length === 0 ? (
796
+ <EmptyState tone="neutral" variant="card" headline="No activity yet" body="This run has not reported timing details yet." />
797
+ ) : (
798
+ <div className="divide-y divide-theme-border border-y border-theme-border">
799
+ {defaultItems.map((item) => <RunActivityRow key={item.id} item={item} runMessage={run.message} />)}
800
+ {overflowItems.length > 0 && (
801
+ <Collapse open={showAll} mountLazily>
802
+ <div className="divide-y divide-theme-border border-t border-theme-border">
803
+ {overflowItems.map((item) => <RunActivityRow key={item.id} item={item} runMessage={run.message} />)}
804
+ </div>
805
+ </Collapse>
806
+ )}
807
+ </div>
808
+ )}
809
+ {overflowItems.length > 0 && (
810
+ <button type="button" className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-accent-text hover:underline" onClick={() => setShowAll((open) => !open)} aria-expanded={showAll}>
811
+ <CollapseChevron open={showAll} className="h-3.5 w-3.5" />
812
+ {showAll ? 'Show fewer events' : `Show all ${activity.length} events`}
813
+ </button>
814
+ )}
815
+ </Panel>
816
+ )
817
+ }
818
+
819
+ export function activityPreviewItems(activity: WorkflowExecutionActivity[], limit = 8): WorkflowExecutionActivity[] {
820
+ return activity.slice(0, Math.max(0, limit))
821
+ }
822
+
823
+ function RunActivityRow({ item, runMessage }: { item: WorkflowExecutionActivity; runMessage?: string }) {
824
+ return (
825
+ <div className="flex gap-3 px-2 py-2">
826
+ <ActivityDot tone={item.tone} />
827
+ <div className="min-w-0 flex-1">
828
+ <div className="flex items-start justify-between gap-3">
829
+ <div className="truncate text-sm font-medium text-theme-text-primary">{item.label}</div>
830
+ <div className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(item.at)}</div>
831
+ </div>
832
+ {item.detail && item.detail !== runMessage && item.tone !== 'danger' && <div className="mt-0.5 break-words text-xs text-theme-text-secondary">{item.detail}</div>}
833
+ </div>
834
+ </div>
835
+ )
836
+ }
837
+
838
+ function RunContext({ run, resource, definitionResource, workflowExecution, currentWorkload, onNavigateToResource }: { run: WorkloadRun; resource: any; definitionResource: any; workflowExecution: WorkflowExecutionModel | null; currentWorkload: { kind: string; namespace: string; name: string }; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) {
839
+ const launcher = run.launcher && !sameResource(run.launcher, currentWorkload) ? run.launcher : null
840
+ const workflowDefinition = workflowExecution?.templateRefs.find((ref) => ref.source === 'workflow')
841
+ const definition = workflowDefinition && !sameResource({ kind: workflowDefinition.kind, namespace: workflowDefinition.namespace, name: workflowDefinition.name }, currentWorkload) ? workflowDefinition : null
842
+ const uses = dedupeResourceRefs(workflowExecution?.templateRefs.filter((ref) => ref.source === 'task') ?? [])
843
+ const arguments_ = workflowRunArguments(resource, currentWorkload.kind, definitionResource)
844
+ const outputs = workflowOutputParameters(resource)
845
+ const runDefinition = executionDefinitionSummary(run.kind, resource)
846
+ const currentDefinition = executionDefinitionSummary(currentWorkload.kind, definitionResource)
847
+ const parentDefinesRun = !isDirectRunKind(currentWorkload.kind, run.kind)
848
+ const showRunConfiguration = parentDefinesRun && Boolean(runDefinition)
849
+ const definitionDiffers = showRunConfiguration && executionDefinitionFingerprint(runDefinition) !== executionDefinitionFingerprint(currentDefinition)
850
+ if (!launcher && !definition && uses.length === 0 && arguments_.length === 0 && outputs.length === 0 && !showRunConfiguration) return null
851
+ return (
852
+ <div className="border-t border-theme-border p-4">
853
+ {(launcher || definition || uses.length > 0) && (
854
+ <div>
855
+ <h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">Run context</h4>
856
+ <div className="space-y-3">
857
+ {launcher && <ContextRow label={launcher.kind === 'ScaledJob' ? 'Triggered by' : 'Scheduled by'}><GenericResourceButton refInfo={launcher} onNavigateToResource={onNavigateToResource} /></ContextRow>}
858
+ {definition && <ContextRow label="Definition"><ResourceButton refInfo={definition} onNavigateToResource={onNavigateToResource} /></ContextRow>}
859
+ {uses.length > 0 && (
860
+ <ContextRow label="Uses">
861
+ <span className="flex flex-wrap justify-end gap-2">
862
+ {uses.map((ref) => <ResourceButton key={`${ref.resourceKind}/${ref.namespace}/${ref.name}`} refInfo={ref} onNavigateToResource={onNavigateToResource} />)}
863
+ </span>
864
+ </ContextRow>
865
+ )}
866
+ </div>
867
+ </div>
868
+ )}
869
+ {arguments_.length > 0 && <div className={clsx((launcher || definition || uses.length > 0) && 'mt-4')}><ParameterSection title="Run arguments" parameters={arguments_} divided={Boolean(launcher || definition || uses.length > 0)} /></div>}
870
+ {outputs.length > 0 && <div className={clsx((launcher || definition || uses.length > 0 || arguments_.length > 0) && 'mt-4')}><ParameterSection title="Outputs" parameters={outputs} divided={Boolean(launcher || definition || uses.length > 0 || arguments_.length > 0)} /></div>}
871
+ {showRunConfiguration && runDefinition && (
872
+ <div className={clsx((launcher || definition || uses.length > 0 || arguments_.length > 0 || outputs.length > 0) && 'mt-4 border-t border-theme-border pt-3')}>
873
+ <div className="mb-2 flex items-center justify-between gap-3">
874
+ <h4 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">Run configuration</h4>
875
+ <span className={clsx('text-[10px] font-medium', definitionDiffers ? phaseTextClass('warning') : 'text-theme-text-tertiary')}>
876
+ {definitionDiffers ? 'Execution differs from current definition' : 'Execution captured for this run'}
877
+ </span>
878
+ </div>
879
+ <ExecutionDefinitionDetails summary={runDefinition} namespace={run.namespace} compact />
880
+ </div>
881
+ )}
882
+ </div>
883
+ )
884
+ }
885
+
886
+ export function isDirectRunKind(currentKind: string, runKind: string): boolean {
887
+ const current = currentKind.toLowerCase()
888
+ const run = runKind.toLowerCase()
889
+ return (current === 'job' || current === 'jobs') && (run === 'job' || run === 'jobs')
890
+ || (current === 'workflow' || current === 'workflows') && (run === 'workflow' || run === 'workflows')
891
+ }
892
+
893
+ export function workflowRunArguments(resource: any, currentKind: string, definitionResource: any): WorkflowParameter[] {
894
+ const parameters = resource?.spec?.arguments?.parameters
895
+ if (!Array.isArray(parameters)) return []
896
+ const values = parameters.filter((parameter) => parameter?.name)
897
+ const defaults = new Map(workflowDefinitionParameters(currentKind, definitionResource).map((parameter) => [parameter.name, parameterValue(parameter)]))
898
+ if (defaults.size === 0) return values
899
+ return values.filter((parameter) => defaults.get(parameter.name) !== parameterValue(parameter))
900
+ }
901
+
902
+ function workflowOutputParameters(resource: any): WorkflowParameter[] {
903
+ const parameters = resource?.status?.outputs?.parameters
904
+ return Array.isArray(parameters) ? parameters.filter((parameter) => parameter?.name) : []
905
+ }
906
+
907
+ function ContextRow({ label, children }: { label: string; children: ReactNode }) {
908
+ return <div className="flex items-start justify-between gap-3 text-sm"><span className="shrink-0 text-theme-text-tertiary">{label}</span><span className="min-w-0 break-words text-right text-theme-text-primary">{children}</span></div>
909
+ }
910
+
911
+ function sameResource(ref: { kind: string; namespace?: string; name: string }, current: { kind: string; namespace: string; name: string }): boolean {
912
+ return ref.kind === current.kind && (ref.namespace ?? '') === current.namespace && ref.name === current.name
913
+ }
914
+
915
+ function GenericResourceButton({ refInfo, onNavigateToResource }: { refInfo: NonNullable<WorkloadRun['launcher']>; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) {
916
+ const label = `${refInfo.kind} · ${refInfo.namespace ? `${refInfo.namespace}/` : ''}${refInfo.name}`
917
+ if (!onNavigateToResource) return <span>{label}</span>
918
+ return <button type="button" className="text-accent-text hover:underline" onClick={() => onNavigateToResource({ kind: pluralKind(refInfo.kind), namespace: refInfo.namespace ?? '', name: refInfo.name, group: refInfo.group })}>{label}</button>
919
+ }
920
+
921
+ function dedupeResourceRefs(refs: WorkflowTemplateReference[]): WorkflowTemplateReference[] {
922
+ const seen = new Set<string>()
923
+ return refs.filter((ref) => {
924
+ const key = `${ref.resourceKind}/${ref.namespace}/${ref.name}`
925
+ if (seen.has(key)) return false
926
+ seen.add(key)
927
+ return true
928
+ })
929
+ }
930
+
931
+ function Panel({ title, icon: Icon, detail, children }: { title: string; icon: ComponentType<{ className?: string }>; detail?: string; children: ReactNode }) {
932
+ return (
933
+ <section className="rounded-lg border border-theme-border bg-theme-surface">
934
+ <div className="flex items-center justify-between gap-3 border-b border-theme-border px-4 py-3">
935
+ <div className="flex items-center gap-2 text-sm font-semibold text-theme-text-primary">
936
+ <Icon className="h-4 w-4 text-theme-text-secondary" />
937
+ {title}
938
+ </div>
939
+ {detail && <span className="text-xs text-theme-text-tertiary">{detail}</span>}
940
+ </div>
941
+ <div className="p-4">{children}</div>
942
+ </section>
943
+ )
944
+ }
945
+
946
+ function ExecutionNodeRow({ node, depth, showMessage, namespace, onNavigateToResource }: { node: WorkflowExecutionNode; depth: number; showMessage: boolean; namespace: string; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) {
947
+ return (
948
+ <div className="flex items-start gap-3 px-3 py-2" style={{ paddingLeft: `${12 + Math.min(depth, 8) * 18}px` }}>
949
+ <StatusDot tone={mapHealthToTone(phaseHealth(node.phase))} className="mt-1" />
950
+ <div className="min-w-0 flex-1">
951
+ <div className="flex flex-wrap items-center gap-2">
952
+ <span className="truncate text-sm font-medium text-theme-text-primary">{node.displayLabel}</span>
953
+ <span className="text-[10px] tracking-wide text-theme-text-tertiary">{node.displayType}</span>
954
+ </div>
955
+ {showMessage && (node.phase === 'Failed' || node.phase === 'Error') && <div className="mt-0.5 line-clamp-2 text-xs text-red-600 dark:text-red-400">{node.message}</div>}
956
+ <div className="mt-1 flex flex-wrap gap-2 text-xs">
957
+ {node.podName && <GenericResourceButton refInfo={{ kind: 'Pod', namespace, name: node.podName }} onNavigateToResource={onNavigateToResource} />}
958
+ {node.templateRef && <ResourceButton refInfo={node.templateRef} onNavigateToResource={onNavigateToResource} />}
959
+ {!node.templateRef && node.templateName && node.templateName !== node.displayLabel && <span className="font-mono text-theme-text-tertiary">{node.templateName}</span>}
960
+ </div>
961
+ </div>
962
+ <span className={clsx('badge-sm shrink-0', phaseBadgeClass(node.phase))}>{node.phase}</span>
963
+ </div>
964
+ )
965
+ }
966
+
967
+ function ActivityDot({ tone }: { tone: string }) {
968
+ return <span className={clsx('mt-1 h-2.5 w-2.5 shrink-0 rounded-full', toneDotClass(tone))} />
969
+ }
970
+
971
+ function ResourceButton({ refInfo, onNavigateToResource }: { refInfo: WorkflowTemplateReference; onNavigateToResource?: BatchExecutionProps['onNavigateToResource'] }) {
972
+ const label = refInfo.clusterScope ? `${refInfo.name} (cluster)` : refInfo.name
973
+ if (!onNavigateToResource) return <span className="text-sm font-medium text-theme-text-primary">{label}</span>
974
+ return (
975
+ <button
976
+ type="button"
977
+ className="truncate text-sm font-medium text-accent-text hover:underline"
978
+ onClick={() => onNavigateToResource({ kind: refInfo.resourceKind, namespace: refInfo.namespace, name: refInfo.name, group: 'argoproj.io' })}
979
+ >
980
+ {label}
981
+ </button>
982
+ )
983
+ }
984
+
985
+ function RunRailButton({ run, selected, showNamespace, onClick }: { run: WorkloadRun; selected: boolean; showNamespace: boolean; onClick: () => void }) {
986
+ return (
987
+ <button
988
+ type="button"
989
+ onClick={onClick}
990
+ className={clsx(
991
+ 'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors',
992
+ selected ? 'selection selection-ring' : 'hover:bg-theme-hover',
993
+ )}
994
+ >
995
+ <span className="min-w-0 flex-1">
996
+ <Tooltip content={run.name} delay={300} wrapperClassName="block min-w-0">
997
+ <span className="block truncate text-xs font-medium text-theme-text-primary">{showNamespace ? `${run.namespace}/` : ''}{midTruncate(run.name, 34)}</span>
998
+ </Tooltip>
999
+ <span className="mt-0.5 block truncate text-[10px] text-theme-text-tertiary">{formatRunTime(run) || 'time unknown'}{formatRunDuration(run) ? ` · ${formatRunDuration(run)}` : ''} · {workCount(run)}</span>
1000
+ </span>
1001
+ <span className={clsx('badge-sm shrink-0', phaseBadgeClass(run.phase))}>{shortPhase(run.phase)}</span>
1002
+ </button>
1003
+ )
1004
+ }
1005
+
1006
+ function FactTile({ label, value, tone, mono }: { label: string; value: string | number; tone?: string; mono?: boolean }) {
1007
+ return (
1008
+ <div className="rounded-md border border-theme-border bg-theme-surface px-3 py-2">
1009
+ <div className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</div>
1010
+ <div className={clsx('mt-1 truncate text-sm font-semibold text-theme-text-primary', mono && 'font-mono', tone && phaseTextClass(tone))}>{value}</div>
1011
+ </div>
1012
+ )
1013
+ }
1014
+
1015
+ function phaseBadgeClass(phase: string): string {
1016
+ switch (phase) {
1017
+ case 'Succeeded':
1018
+ case 'Complete':
1019
+ return 'status-healthy'
1020
+ case 'Running':
1021
+ return 'status-neutral'
1022
+ case 'Failed':
1023
+ case 'Error':
1024
+ return 'status-unhealthy'
1025
+ case 'Pending':
1026
+ case 'Suspended':
1027
+ return 'status-degraded'
1028
+ default:
1029
+ return 'status-unknown'
1030
+ }
1031
+ }
1032
+
1033
+ function phaseTone(phase: string): string {
1034
+ switch (phase) {
1035
+ case 'Succeeded':
1036
+ case 'Complete':
1037
+ return 'success'
1038
+ case 'Running':
1039
+ case 'Idle':
1040
+ return 'info'
1041
+ case 'Failed':
1042
+ case 'Error':
1043
+ return 'error'
1044
+ case 'Pending':
1045
+ case 'Suspended':
1046
+ return 'warning'
1047
+ default:
1048
+ return 'neutral'
1049
+ }
1050
+ }
1051
+
1052
+ function phaseTextClass(tone: string): string {
1053
+ switch (tone) {
1054
+ case 'success':
1055
+ return 'text-emerald-600 dark:text-emerald-400'
1056
+ case 'warning':
1057
+ return 'text-amber-600 dark:text-amber-400'
1058
+ case 'error':
1059
+ return 'text-red-600 dark:text-red-400'
1060
+ case 'info':
1061
+ return 'text-sky-600 dark:text-sky-400'
1062
+ default:
1063
+ return ''
1064
+ }
1065
+ }
1066
+
1067
+ function phaseHealth(phase: string): 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' {
1068
+ switch (phase) {
1069
+ case 'Succeeded':
1070
+ case 'Complete':
1071
+ return 'healthy'
1072
+ case 'Running':
1073
+ return 'neutral'
1074
+ case 'Failed':
1075
+ case 'Error':
1076
+ return 'unhealthy'
1077
+ case 'Pending':
1078
+ case 'Suspended':
1079
+ return 'degraded'
1080
+ default:
1081
+ return 'unknown'
1082
+ }
1083
+ }
1084
+
1085
+ function shortPhase(phase: string): string {
1086
+ if (phase === 'Succeeded') return 'OK'
1087
+ if (phase === 'Running') return 'Run'
1088
+ if (phase === 'Pending') return 'Pend'
1089
+ return phase
1090
+ }
1091
+
1092
+ function countPhases(runs: WorkloadRun[]) {
1093
+ return {
1094
+ running: runs.filter((run) => run.active).length,
1095
+ failed: runs.filter((run) => run.phase === 'Failed' || run.phase === 'Error').length,
1096
+ succeeded: runs.filter((run) => run.phase === 'Succeeded').length,
1097
+ }
1098
+ }
1099
+
1100
+ export function retentionHistoryCopy(kind: string, resource: any, counts: { succeeded: number; failed: number }): string | null {
1101
+ const spec = resource?.spec ?? {}
1102
+ const defaults = kind === 'CronJob' || kind === 'CronWorkflow'
1103
+ const succeeded = numericLimit(spec.successfulJobsHistoryLimit, defaults ? 3 : undefined)
1104
+ const failed = numericLimit(spec.failedJobsHistoryLimit, defaults ? 1 : undefined)
1105
+ if (kind !== 'CronJob' && kind !== 'CronWorkflow' && kind !== 'ScaledJob') return null
1106
+ if (succeeded == null && failed == null) return null
1107
+
1108
+ const retained = [retentionLimitCopy(succeeded, 'succeeded'), retentionLimitCopy(failed, 'failed')].filter(Boolean)
1109
+ const reached = [
1110
+ succeeded != null && succeeded > 0 && counts.succeeded >= succeeded ? 'success' : '',
1111
+ failed != null && failed > 0 && counts.failed >= failed ? 'failure' : '',
1112
+ ].filter(Boolean)
1113
+ const reachedCopy = reached.length === 2 ? ' Success and failure limits reached.' : reached.length === 1 ? ` ${reached[0] === 'success' ? 'Success' : 'Failure'} limit reached.` : ''
1114
+ return `Keeps ${retained.join(' / ')}.${reachedCopy}`
1115
+ }
1116
+
1117
+ function numericLimit(value: unknown, fallback?: number): number | undefined {
1118
+ return typeof value === 'number' ? value : fallback
1119
+ }
1120
+
1121
+ function retentionLimitCopy(limit: number | undefined, phase: string): string {
1122
+ if (limit == null) return ''
1123
+ return limit === 0 ? `no ${phase}` : `latest ${limit} ${phase}`
1124
+ }
1125
+
1126
+ function resourceTargetForRun(run: WorkloadRun) {
1127
+ return {
1128
+ kind: run.kind,
1129
+ namespace: run.namespace,
1130
+ name: run.name,
1131
+ group: run.kind === 'workflows' ? 'argoproj.io' : undefined,
1132
+ }
1133
+ }
1134
+
1135
+ function pluralKind(kind: string): string {
1136
+ switch (kind) {
1137
+ case 'Pod': return 'pods'
1138
+ case 'Job': return 'jobs'
1139
+ case 'CronJob': return 'cronjobs'
1140
+ case 'Workflow': return 'workflows'
1141
+ case 'CronWorkflow': return 'cronworkflows'
1142
+ case 'WorkflowTemplate': return 'workflowtemplates'
1143
+ case 'ClusterWorkflowTemplate': return 'clusterworkflowtemplates'
1144
+ case 'ScaledJob': return 'scaledjobs'
1145
+ default: return kind.toLowerCase()
1146
+ }
1147
+ }
1148
+
1149
+ function workCount(run: WorkloadRun): string {
1150
+ if (run.podTotal) {
1151
+ if (run.podRunning) return `${run.podRunning} running ${pluralize('pod', run.podRunning)}`
1152
+ if (run.podPending && !run.podSucceeded && !run.podFailed) return `${run.podPending} pending ${pluralize('pod', run.podPending)}`
1153
+ if (run.podFailed && run.phase !== 'Succeeded') return `${run.podFailed}/${run.podTotal} pods failed`
1154
+ return `${run.podSucceeded ?? 0}/${run.podTotal} pods`
1155
+ }
1156
+ if (run.desired) return `${run.succeeded ?? 0}/${run.desired} completions`
1157
+ return 'work unknown'
1158
+ }
1159
+
1160
+ function jobRetryLimitValue(resource: any): string {
1161
+ const limit = resource?.spec?.backoffLimit
1162
+ const n = Number(limit ?? 6)
1163
+ return `${n} ${n === 1 ? 'retry' : 'retries'}`
1164
+ }
1165
+
1166
+ function podBreakdown(run: WorkloadRun, workflowExecution?: WorkflowExecutionModel | null): string | null {
1167
+ const counts = workflowExecution?.counts
1168
+ const podTotal = counts?.podTotal ?? run.podTotal
1169
+ if (!podTotal) return null
1170
+ const parts = [
1171
+ (counts?.podRunning ?? run.podRunning) ? `${counts?.podRunning ?? run.podRunning} running` : '',
1172
+ (counts?.podSucceeded ?? run.podSucceeded) ? `${counts?.podSucceeded ?? run.podSucceeded} succeeded` : '',
1173
+ (counts?.podFailed ?? run.podFailed) ? `${counts?.podFailed ?? run.podFailed} failed` : '',
1174
+ (counts?.podPending ?? run.podPending) ? `${counts?.podPending ?? run.podPending} pending` : '',
1175
+ ].filter(Boolean)
1176
+ return parts.join(' · ') || `${podTotal} total`
1177
+ }
1178
+
1179
+ function executionNodeBreakdown(workflowExecution?: WorkflowExecutionModel | null): string | null {
1180
+ const counts = workflowExecution?.counts
1181
+ if (!counts?.nodeTotal) return null
1182
+ const parts = [
1183
+ counts.nodeRunning ? `${counts.nodeRunning} running` : '',
1184
+ counts.nodeSucceeded ? `${counts.nodeSucceeded} succeeded` : '',
1185
+ counts.nodeFailed ? `${counts.nodeFailed} failed` : '',
1186
+ counts.nodeSkipped ? `${counts.nodeSkipped} skipped` : '',
1187
+ ].filter(Boolean)
1188
+ return parts.join(' · ') || `${counts.nodeTotal} total`
1189
+ }
1190
+
1191
+ function formatScheduleDelay(run: WorkloadRun): string {
1192
+ if (!run.scheduledAt || !run.startedAt) return ''
1193
+ const scheduled = Date.parse(run.scheduledAt)
1194
+ const started = Date.parse(run.startedAt)
1195
+ if (Number.isNaN(scheduled) || Number.isNaN(started) || started < scheduled) return ''
1196
+ return formatDuration(started - scheduled)
1197
+ }
1198
+
1199
+ function formatResourceDuration(resources: Record<string, number>): string {
1200
+ return Object.entries(resources)
1201
+ .map(([resource, seconds]) => `${resource}: ${formatDuration(seconds * 1000)}`)
1202
+ .join(' · ')
1203
+ }
1204
+
1205
+ function jobActivity(run: WorkloadRun, resource: any): WorkflowExecutionActivity[] {
1206
+ const items: WorkflowExecutionActivity[] = []
1207
+ if (run.scheduledAt) items.push({ id: 'scheduled', at: run.scheduledAt, label: 'Cron scheduled', tone: 'info' })
1208
+ if (run.startedAt) items.push({ id: 'started', at: run.startedAt, label: 'Job started', tone: 'info' })
1209
+ const conditions = Array.isArray(resource?.status?.conditions) ? resource.status.conditions : []
1210
+ const hasComplete = hasJobCondition(conditions, 'Complete')
1211
+ const hasFailed = hasJobCondition(conditions, 'Failed')
1212
+ for (const condition of conditions) {
1213
+ const at = condition.lastTransitionTime || condition.lastProbeTime
1214
+ if (!at || condition.status !== 'True') continue
1215
+ if (condition.type === 'SuccessCriteriaMet' && hasComplete) continue
1216
+ if (condition.type === 'FailureTarget' && hasFailed) continue
1217
+ const mapped = jobConditionActivity(condition.type)
1218
+ items.push({
1219
+ id: `condition-${condition.type}`,
1220
+ at,
1221
+ label: mapped.label,
1222
+ detail: condition.message || condition.reason,
1223
+ tone: mapped.tone,
1224
+ })
1225
+ }
1226
+ if (items.length === 0 && run.finishedAt) {
1227
+ items.push({ id: 'finished', at: run.finishedAt, label: `Job ${run.phase.toLowerCase()}`, detail: run.message, tone: run.phase === 'Failed' ? 'danger' : 'success' })
1228
+ }
1229
+ return items.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
1230
+ }
1231
+
1232
+ function hasJobCondition(conditions: any[], type: string): boolean {
1233
+ return conditions.some((condition) => condition?.type === type && condition?.status === 'True')
1234
+ }
1235
+
1236
+ function jobConditionActivity(type: string): { label: string; tone: WorkflowExecutionActivity['tone'] } {
1237
+ switch (type) {
1238
+ case 'Complete':
1239
+ return { label: 'Job completed', tone: 'success' }
1240
+ case 'Failed':
1241
+ return { label: 'Job failed', tone: 'danger' }
1242
+ case 'FailureTarget':
1243
+ return { label: 'Job is failing', tone: 'danger' }
1244
+ case 'SuccessCriteriaMet':
1245
+ return { label: 'Completion target reached', tone: 'success' }
1246
+ case 'Suspended':
1247
+ return { label: 'Job suspended', tone: 'warning' }
1248
+ default:
1249
+ return { label: splitConditionName(type), tone: 'info' }
1250
+ }
1251
+ }
1252
+
1253
+ function splitConditionName(type: string): string {
1254
+ return type
1255
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1256
+ .replace(/_/g, ' ')
1257
+ }
1258
+
1259
+ function toneDotClass(tone: string): string {
1260
+ switch (tone) {
1261
+ case 'success':
1262
+ return 'bg-emerald-500'
1263
+ case 'danger':
1264
+ return 'bg-red-500'
1265
+ case 'warning':
1266
+ return 'bg-amber-500'
1267
+ case 'info':
1268
+ return 'bg-accent'
1269
+ default:
1270
+ return 'bg-theme-border'
1271
+ }
1272
+ }
1273
+
1274
+ function formatRunDuration(run: WorkloadRun): string {
1275
+ if (!run.startedAt) return ''
1276
+ const start = Date.parse(run.startedAt)
1277
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.now()
1278
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return ''
1279
+ return formatDuration(end - start)
1280
+ }
1281
+
1282
+ function formatRunTime(run: WorkloadRun): string {
1283
+ const raw = run.startedAt || run.scheduledAt || run.finishedAt
1284
+ return raw ? formatAge(raw) : ''
1285
+ }
1286
+
1287
+ function formatAge(value: string): string {
1288
+ const t = Date.parse(value)
1289
+ if (Number.isNaN(t)) return value
1290
+ const diff = Date.now() - t
1291
+ if (diff < 60_000) return 'just now'
1292
+ return `${formatDuration(diff)} ago`
1293
+ }
1294
+
1295
+ function formatDuration(ms: number): string {
1296
+ const seconds = Math.max(0, Math.floor(ms / 1000))
1297
+ if (seconds < 60) return `${seconds}s`
1298
+ const minutes = Math.floor(seconds / 60)
1299
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
1300
+ const hours = Math.floor(minutes / 60)
1301
+ if (hours < 48) return `${hours}h ${minutes % 60}m`
1302
+ const days = Math.floor(hours / 24)
1303
+ return `${days}d ${hours % 24}h`
1304
+ }
1305
+
1306
+ function pluralizeRuns(count: number): string {
1307
+ return count === 1 ? '1 retained run' : `${count} retained runs`
1308
+ }
1309
+
1310
+ function runKindPluralForSchedule(kind: string): string {
1311
+ if (kind === 'CronJob' || kind === 'ScaledJob') return 'Jobs'
1312
+ if (kind === 'CronWorkflow' || kind === 'WorkflowTemplate' || kind === 'ClusterWorkflowTemplate') return 'Workflows'
1313
+ return 'runs'
1314
+ }
1315
+
1316
+ function emptyRunsCopy(kind: string, resource: any): { headline: string; body: string } {
1317
+ if (isTemplateKind(kind)) {
1318
+ return { headline: 'No retained Workflows use this definition', body: 'No readable Workflow objects currently reference this definition.' }
1319
+ }
1320
+ const lastScheduled = resource?.status?.lastScheduleTime || resource?.status?.lastScheduledTime
1321
+ if (lastScheduled) {
1322
+ return { headline: 'No retained runs', body: `This resource last scheduled work ${formatAge(lastScheduled)}, but its ${runKindPluralForSchedule(kind)} are no longer retained in Kubernetes.` }
1323
+ }
1324
+ return { headline: 'No runs yet', body: `Kubernetes does not currently have retained ${runKindPluralForSchedule(kind)} for this resource.` }
1325
+ }
1326
+
1327
+ function pluralize(word: string, count: number): string {
1328
+ return count === 1 ? word : `${word}s`
1329
+ }