@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
+
}
|