@skyhook-io/radar-app 1.8.6 → 1.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- 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.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- 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 +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- 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 +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- 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 +66 -116
- package/src/components/settings/SettingsDialog.tsx +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { WorkloadRun } from '../../api/client'
|
|
3
|
+
import { batchRunNextStep } from './batch-run-actions'
|
|
4
|
+
|
|
5
|
+
function run(phase: string, counts: Partial<Pick<WorkloadRun, 'podFailed' | 'podSucceeded' | 'podRunning' | 'podPending'>> = {}): WorkloadRun {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'jobs',
|
|
8
|
+
namespace: 'jobs',
|
|
9
|
+
name: 'example',
|
|
10
|
+
phase,
|
|
11
|
+
active: phase === 'Running' || phase === 'Pending',
|
|
12
|
+
...counts,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('batchRunNextStep', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
['failed Pod', { podFailed: 1 }],
|
|
19
|
+
['succeeded Pod', { podSucceeded: 1 }],
|
|
20
|
+
['running Pod', { podRunning: 1 }],
|
|
21
|
+
])('sends a failed run with a %s to logs', (_label, counts) => {
|
|
22
|
+
expect(batchRunNextStep(run('Failed', counts), true, true)).toBe('logs')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('uses timeline for an error without a container outcome', () => {
|
|
26
|
+
expect(batchRunNextStep(run('Error'), true, false)).toBe('timeline')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not treat a pending Pod as log evidence', () => {
|
|
30
|
+
expect(batchRunNextStep(run('Failed', { podPending: 1 }), true, true)).toBe('timeline')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('uses timeline when historical Pods have been garbage-collected', () => {
|
|
34
|
+
expect(batchRunNextStep(run('Failed', { podFailed: 1 }), true, false)).toBe('timeline')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('waits for live Pod evidence before recommending logs', () => {
|
|
38
|
+
expect(batchRunNextStep(run('Failed', { podFailed: 1 }), true, undefined)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses timeline when log access is unavailable', () => {
|
|
42
|
+
expect(batchRunNextStep(run('Failed', { podFailed: 1 }), false, true)).toBe('timeline')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it.each(['Running', 'Pending', 'Succeeded', 'Suspended', 'Unknown'])('does not suggest an action for %s runs', (phase) => {
|
|
46
|
+
expect(batchRunNextStep(run(phase, { podFailed: 1, podRunning: 1 }), true, true)).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WorkloadRun } from '../../api/client'
|
|
2
|
+
|
|
3
|
+
export type BatchRunNextStep = 'logs' | 'timeline'
|
|
4
|
+
|
|
5
|
+
export function isFailedRunPhase(phase: string): boolean {
|
|
6
|
+
return phase === 'Failed' || phase === 'Error'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function batchRunHasContainerOutcome(run: WorkloadRun): boolean {
|
|
10
|
+
return (
|
|
11
|
+
(run.podFailed ?? 0) > 0 ||
|
|
12
|
+
(run.podSucceeded ?? 0) > 0 ||
|
|
13
|
+
(run.podRunning ?? 0) > 0
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function batchRunNextStep(run: WorkloadRun, canViewLogs: boolean, hasLivePods: boolean | undefined): BatchRunNextStep | null {
|
|
18
|
+
if (!isFailedRunPhase(run.phase)) return null
|
|
19
|
+
|
|
20
|
+
const hasContainerOutcome = batchRunHasContainerOutcome(run)
|
|
21
|
+
if (canViewLogs && hasContainerOutcome && hasLivePods === undefined) return null
|
|
22
|
+
|
|
23
|
+
return canViewLogs && hasContainerOutcome && hasLivePods ? 'logs' : 'timeline'
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { WorkloadRun } from '../../api/client'
|
|
3
|
+
import { workloadRunTimelineEvents } from './batch-timeline'
|
|
4
|
+
|
|
5
|
+
describe('workloadRunTimelineEvents', () => {
|
|
6
|
+
it('builds lifecycle events for every retained run', () => {
|
|
7
|
+
const runs: WorkloadRun[] = [
|
|
8
|
+
{
|
|
9
|
+
kind: 'jobs',
|
|
10
|
+
namespace: 'dev',
|
|
11
|
+
name: 'nightly-1',
|
|
12
|
+
phase: 'Succeeded',
|
|
13
|
+
active: false,
|
|
14
|
+
scheduledAt: '2026-01-01T00:00:00Z',
|
|
15
|
+
startedAt: '2026-01-01T00:00:02Z',
|
|
16
|
+
finishedAt: '2026-01-01T00:01:00Z',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
kind: 'jobs',
|
|
20
|
+
namespace: 'dev',
|
|
21
|
+
name: 'nightly-2',
|
|
22
|
+
phase: 'Failed',
|
|
23
|
+
active: false,
|
|
24
|
+
startedAt: '2026-01-02T00:00:00Z',
|
|
25
|
+
finishedAt: '2026-01-02T00:00:10Z',
|
|
26
|
+
message: 'backoff limit reached',
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const events = workloadRunTimelineEvents(runs)
|
|
31
|
+
|
|
32
|
+
expect(events).toHaveLength(5)
|
|
33
|
+
expect(events.map((event) => event.reason)).toEqual([
|
|
34
|
+
'Job scheduled',
|
|
35
|
+
'Job started',
|
|
36
|
+
'Job succeeded',
|
|
37
|
+
'Job started',
|
|
38
|
+
'Job failed',
|
|
39
|
+
])
|
|
40
|
+
expect(events.at(-1)).toMatchObject({ eventType: 'Warning', message: 'backoff limit reached', name: 'nightly-2' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses Workflow resource identity for Argo runs', () => {
|
|
44
|
+
const events = workloadRunTimelineEvents([{
|
|
45
|
+
kind: 'workflows',
|
|
46
|
+
namespace: 'dev',
|
|
47
|
+
name: 'migration-abc',
|
|
48
|
+
phase: 'Error',
|
|
49
|
+
active: false,
|
|
50
|
+
startedAt: '2026-01-01T00:00:00Z',
|
|
51
|
+
finishedAt: '2026-01-01T00:00:01Z',
|
|
52
|
+
}])
|
|
53
|
+
|
|
54
|
+
expect(events[0]).toMatchObject({ kind: 'Workflow', apiVersion: 'argoproj.io/v1alpha1', reason: 'Workflow started' })
|
|
55
|
+
expect(events[1]).toMatchObject({ eventType: 'Warning', reason: 'Workflow errored' })
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { TimelineEvent } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { WorkloadRun } from '../../api/client'
|
|
3
|
+
|
|
4
|
+
export function workloadRunTimelineEvents(runs: WorkloadRun[]): TimelineEvent[] {
|
|
5
|
+
const events: TimelineEvent[] = []
|
|
6
|
+
for (const run of runs) {
|
|
7
|
+
const kind = run.kind === 'workflows' ? 'Workflow' : 'Job'
|
|
8
|
+
const apiVersion = run.kind === 'workflows' ? 'argoproj.io/v1alpha1' : 'batch/v1'
|
|
9
|
+
const prefix = `batch-run:${run.kind}:${run.namespace}:${run.name}`
|
|
10
|
+
const createdAt = run.startedAt || run.scheduledAt
|
|
11
|
+
if (run.scheduledAt) {
|
|
12
|
+
events.push(runTimelineEvent(run, kind, apiVersion, `${prefix}:scheduled`, run.scheduledAt, 'Normal', `${kind} scheduled`, createdAt))
|
|
13
|
+
}
|
|
14
|
+
if (run.startedAt) {
|
|
15
|
+
events.push(runTimelineEvent(run, kind, apiVersion, `${prefix}:started`, run.startedAt, 'Normal', `${kind} started`, createdAt))
|
|
16
|
+
}
|
|
17
|
+
if (run.finishedAt) {
|
|
18
|
+
const failed = run.phase === 'Failed' || run.phase === 'Error'
|
|
19
|
+
events.push(runTimelineEvent(run, kind, apiVersion, `${prefix}:finished`, run.finishedAt, failed ? 'Warning' : 'Normal', `${kind} ${runPhaseVerb(run.phase)}`, createdAt, run.message))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.id.localeCompare(b.id))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runTimelineEvent(run: WorkloadRun, kind: string, apiVersion: string, id: string, timestamp: string, eventType: 'Normal' | 'Warning', reason: string, createdAt?: string, message?: string): TimelineEvent {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
timestamp,
|
|
29
|
+
source: 'historical',
|
|
30
|
+
kind,
|
|
31
|
+
apiVersion,
|
|
32
|
+
namespace: run.namespace,
|
|
33
|
+
name: run.name,
|
|
34
|
+
createdAt,
|
|
35
|
+
eventType,
|
|
36
|
+
reason,
|
|
37
|
+
message,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runPhaseVerb(phase: string): string {
|
|
42
|
+
if (phase === 'Succeeded') return 'succeeded'
|
|
43
|
+
if (phase === 'Failed') return 'failed'
|
|
44
|
+
if (phase === 'Error') return 'errored'
|
|
45
|
+
return phase.toLowerCase()
|
|
46
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { executionDefinitionFingerprint, executionDefinitionSummary } from './execution-definition'
|
|
3
|
+
|
|
4
|
+
describe('executionDefinitionSummary', () => {
|
|
5
|
+
it('explains a Kubernetes Job pod template and effective defaults', () => {
|
|
6
|
+
const summary = executionDefinitionSummary('CronJob', {
|
|
7
|
+
spec: {
|
|
8
|
+
jobTemplate: {
|
|
9
|
+
spec: {
|
|
10
|
+
completions: 2,
|
|
11
|
+
template: {
|
|
12
|
+
spec: {
|
|
13
|
+
containers: [{
|
|
14
|
+
name: 'backup',
|
|
15
|
+
image: 'example/backup:v2',
|
|
16
|
+
command: ['/bin/backup'],
|
|
17
|
+
args: ['--full'],
|
|
18
|
+
envFrom: [{ configMapRef: { name: 'backup-config' } }],
|
|
19
|
+
resources: { requests: { cpu: '100m', memory: '256Mi' }, limits: { cpu: '1', memory: '1Gi' } },
|
|
20
|
+
}],
|
|
21
|
+
restartPolicy: 'OnFailure',
|
|
22
|
+
imagePullSecrets: [{ name: 'registry-credentials' }],
|
|
23
|
+
volumes: [{ name: 'projected', projected: { sources: [{ secret: { name: 'projected-secret' } }, { configMap: { name: 'projected-config' } }] } }],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(summary).toMatchObject({
|
|
32
|
+
shape: 'Pod · 1 container',
|
|
33
|
+
serviceAccount: 'default',
|
|
34
|
+
retry: 'Backoff limit 6 · restart OnFailure',
|
|
35
|
+
parallelism: '1 parallel · 2 completions · NonIndexed',
|
|
36
|
+
configMaps: ['backup-config', 'projected-config'],
|
|
37
|
+
secrets: ['projected-secret', 'registry-credentials'],
|
|
38
|
+
imagePullSecrets: ['registry-credentials'],
|
|
39
|
+
externalTemplates: [],
|
|
40
|
+
units: [{
|
|
41
|
+
name: 'backup',
|
|
42
|
+
type: 'Container',
|
|
43
|
+
image: 'example/backup:v2',
|
|
44
|
+
command: '/bin/backup --full',
|
|
45
|
+
requests: 'CPU 0.10 cores · memory 256 MiB',
|
|
46
|
+
limits: 'CPU 1 core · memory 1.00 GiB',
|
|
47
|
+
}],
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('explains Argo DAG shape, executable templates, policy, and dependencies', () => {
|
|
52
|
+
const summary = executionDefinitionSummary('WorkflowTemplate', {
|
|
53
|
+
spec: {
|
|
54
|
+
entrypoint: 'main',
|
|
55
|
+
serviceAccountName: 'migrator',
|
|
56
|
+
parallelism: 3,
|
|
57
|
+
activeDeadlineSeconds: 600,
|
|
58
|
+
imagePullSecrets: [{ name: 'workflow-registry' }],
|
|
59
|
+
templates: [
|
|
60
|
+
{ name: 'main', dag: { tasks: [{ name: 'prepare' }, { name: 'publish' }] } },
|
|
61
|
+
{
|
|
62
|
+
name: 'worker',
|
|
63
|
+
imagePullSecrets: [{ name: 'template-registry' }],
|
|
64
|
+
retryStrategy: { limit: 2, retryPolicy: 'OnError', backoff: { duration: '5s' } },
|
|
65
|
+
container: {
|
|
66
|
+
image: 'example/worker:v3',
|
|
67
|
+
command: ['worker'],
|
|
68
|
+
env: [{ name: 'TOKEN', valueFrom: { secretKeyRef: { name: 'worker-secret', key: 'token' } } }],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(summary).toMatchObject({
|
|
76
|
+
shape: 'DAG · main · 2 tasks',
|
|
77
|
+
serviceAccount: 'migrator',
|
|
78
|
+
retry: 'worker: 2 retries · OnError · 5s backoff',
|
|
79
|
+
deadline: '600s',
|
|
80
|
+
parallelism: '3 maximum',
|
|
81
|
+
secrets: ['template-registry', 'worker-secret', 'workflow-registry'],
|
|
82
|
+
imagePullSecrets: ['template-registry', 'workflow-registry'],
|
|
83
|
+
units: [{ name: 'worker', type: 'Container', image: 'example/worker:v3', command: 'worker' }],
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('uses the stored WorkflowTemplate snapshot for a retained Workflow run', () => {
|
|
88
|
+
const run = {
|
|
89
|
+
spec: { workflowTemplateRef: { name: 'current-definition' } },
|
|
90
|
+
status: {
|
|
91
|
+
storedWorkflowTemplateSpec: {
|
|
92
|
+
entrypoint: 'main',
|
|
93
|
+
templates: [{ name: 'main', container: { image: 'example/job:old', command: ['old-command'] } }],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
const current = executionDefinitionSummary('WorkflowTemplate', {
|
|
98
|
+
spec: { entrypoint: 'main', templates: [{ name: 'main', container: { image: 'example/job:new', command: ['new-command'] } }] },
|
|
99
|
+
})
|
|
100
|
+
const stored = executionDefinitionSummary('Workflow', run)
|
|
101
|
+
|
|
102
|
+
expect(stored?.units[0]).toMatchObject({ image: 'example/job:old', command: 'old-command' })
|
|
103
|
+
expect(executionDefinitionFingerprint(stored)).not.toBe(executionDefinitionFingerprint(current))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not report drift for an equivalent stored template snapshot', () => {
|
|
107
|
+
const current = executionDefinitionSummary('WorkflowTemplate', {
|
|
108
|
+
spec: { entrypoint: 'main', templates: [{ name: 'main', container: { name: 'job', image: 'example/job:v1', resources: { limits: { cpu: '1000m' } } } }] },
|
|
109
|
+
})
|
|
110
|
+
const stored = executionDefinitionSummary('Workflow', {
|
|
111
|
+
status: {
|
|
112
|
+
storedWorkflowTemplateSpec: {
|
|
113
|
+
entrypoint: 'main',
|
|
114
|
+
workflowTemplateRef: { name: 'definition' },
|
|
115
|
+
templates: [{ name: 'main', container: { name: 'job', image: 'example/job:v1', resources: { limits: { cpu: '1' } } } }],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(executionDefinitionFingerprint(stored)).toBe(executionDefinitionFingerprint(current))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reads a ScaledJob embedded Job target', () => {
|
|
124
|
+
const summary = executionDefinitionSummary('ScaledJob', {
|
|
125
|
+
spec: {
|
|
126
|
+
jobTargetRef: {
|
|
127
|
+
backoffLimit: 2,
|
|
128
|
+
activeDeadlineSeconds: 90,
|
|
129
|
+
template: { spec: { serviceAccountName: 'worker', containers: [{ name: 'worker', image: 'example/worker:v4' }] } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(summary).toMatchObject({
|
|
135
|
+
shape: 'Pod · 1 container',
|
|
136
|
+
serviceAccount: 'worker',
|
|
137
|
+
retry: 'Backoff limit 2 · restart Never',
|
|
138
|
+
deadline: '90s',
|
|
139
|
+
units: [{ image: 'example/worker:v4' }],
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('summarizes init and multiple application containers', () => {
|
|
144
|
+
const summary = executionDefinitionSummary('Job', {
|
|
145
|
+
spec: {
|
|
146
|
+
template: {
|
|
147
|
+
spec: {
|
|
148
|
+
initContainers: [{ name: 'prepare', image: 'example/prepare:v1' }],
|
|
149
|
+
containers: [{ name: 'worker', image: 'example/worker:v1' }, { name: 'sidecar', image: 'example/sidecar:v1' }],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(summary).toMatchObject({
|
|
156
|
+
shape: 'Pod · 2 containers · 1 init',
|
|
157
|
+
units: [
|
|
158
|
+
{ name: 'prepare', type: 'Init container' },
|
|
159
|
+
{ name: 'worker', type: 'Container' },
|
|
160
|
+
{ name: 'sidecar', type: 'Container' },
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('summarizes an inline CronWorkflow steps entrypoint', () => {
|
|
166
|
+
const summary = executionDefinitionSummary('CronWorkflow', {
|
|
167
|
+
spec: {
|
|
168
|
+
workflowSpec: {
|
|
169
|
+
entrypoint: 'main',
|
|
170
|
+
templates: [
|
|
171
|
+
{ name: 'main', steps: [[{ name: 'first', template: 'run' }, { name: 'second', template: 'run' }], [{ name: 'third', template: 'run' }]] },
|
|
172
|
+
{ name: 'run', script: { image: 'example/script:v1', command: ['sh'], source: 'echo done' } },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
expect(summary).toMatchObject({
|
|
179
|
+
shape: 'Steps · main · 3 steps in 2 groups',
|
|
180
|
+
units: [{ name: 'run', type: 'Script', image: 'example/script:v1', command: 'sh' }],
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('summarizes a container set entrypoint', () => {
|
|
185
|
+
const summary = executionDefinitionSummary('WorkflowTemplate', {
|
|
186
|
+
spec: {
|
|
187
|
+
entrypoint: 'main',
|
|
188
|
+
templates: [{
|
|
189
|
+
name: 'main',
|
|
190
|
+
containerSet: {
|
|
191
|
+
containers: [
|
|
192
|
+
{ name: 'first', image: 'example/first:v1' },
|
|
193
|
+
{ name: 'second', image: 'example/second:v1', command: ['second'] },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
}],
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(summary).toMatchObject({
|
|
201
|
+
shape: 'Container set · main · 2 containers',
|
|
202
|
+
units: [
|
|
203
|
+
{ name: 'first', type: 'Container', image: 'example/first:v1' },
|
|
204
|
+
{ name: 'second', type: 'Container', image: 'example/second:v1', command: 'second' },
|
|
205
|
+
],
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { formatCPUString, formatMemoryString } from '@skyhook-io/k8s-ui/utils/format'
|
|
2
|
+
|
|
3
|
+
export interface ExecutionUnitSummary {
|
|
4
|
+
name: string
|
|
5
|
+
type: string
|
|
6
|
+
image?: string
|
|
7
|
+
command?: string
|
|
8
|
+
requests?: string
|
|
9
|
+
limits?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExecutionDefinitionSummary {
|
|
13
|
+
shape: string
|
|
14
|
+
units: ExecutionUnitSummary[]
|
|
15
|
+
externalTemplates: string[]
|
|
16
|
+
configMaps: string[]
|
|
17
|
+
secrets: string[]
|
|
18
|
+
imagePullSecrets: string[]
|
|
19
|
+
serviceAccount: string
|
|
20
|
+
retry: string
|
|
21
|
+
deadline?: string
|
|
22
|
+
parallelism?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function executionDefinitionSummary(kind: string, resource: any): ExecutionDefinitionSummary | null {
|
|
26
|
+
if (!resource) return null
|
|
27
|
+
switch (kind.toLowerCase()) {
|
|
28
|
+
case 'job':
|
|
29
|
+
case 'jobs':
|
|
30
|
+
return kubernetesJobSummary(resource.spec ?? {})
|
|
31
|
+
case 'cronjob':
|
|
32
|
+
case 'cronjobs':
|
|
33
|
+
return kubernetesJobSummary(resource.spec?.jobTemplate?.spec ?? {})
|
|
34
|
+
case 'scaledjob':
|
|
35
|
+
case 'scaledjobs':
|
|
36
|
+
return kubernetesJobSummary(resource.spec?.jobTargetRef ?? {})
|
|
37
|
+
case 'cronworkflow':
|
|
38
|
+
case 'cronworkflows':
|
|
39
|
+
return argoWorkflowSummary(resource.spec?.workflowSpec ?? {})
|
|
40
|
+
case 'workflowtemplate':
|
|
41
|
+
case 'workflowtemplates':
|
|
42
|
+
case 'clusterworkflowtemplate':
|
|
43
|
+
case 'clusterworkflowtemplates':
|
|
44
|
+
return argoWorkflowSummary(resource.spec ?? {})
|
|
45
|
+
case 'workflow':
|
|
46
|
+
case 'workflows':
|
|
47
|
+
return argoWorkflowSummary(resource.status?.storedWorkflowTemplateSpec ?? resource.spec ?? {})
|
|
48
|
+
default:
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function executionDefinitionFingerprint(summary: ExecutionDefinitionSummary | null): string {
|
|
54
|
+
if (!summary) return ''
|
|
55
|
+
return JSON.stringify(summary)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function kubernetesJobSummary(jobSpec: any): ExecutionDefinitionSummary {
|
|
59
|
+
const podSpec = jobSpec.template?.spec ?? {}
|
|
60
|
+
const containers = Array.isArray(podSpec.containers) ? podSpec.containers : []
|
|
61
|
+
const initContainers = Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []
|
|
62
|
+
const units = [
|
|
63
|
+
...initContainers.map((container: any) => containerSummary(container, 'Init container')),
|
|
64
|
+
...containers.map((container: any) => containerSummary(container, 'Container')),
|
|
65
|
+
]
|
|
66
|
+
const dependencies = podDependencies(podSpec, [...initContainers, ...containers])
|
|
67
|
+
const shapeParts = [`${containers.length} ${containers.length === 1 ? 'container' : 'containers'}`]
|
|
68
|
+
if (initContainers.length > 0) shapeParts.push(`${initContainers.length} init`)
|
|
69
|
+
const parallelism = Number(jobSpec.parallelism ?? 1)
|
|
70
|
+
const completions = Number(jobSpec.completions ?? 1)
|
|
71
|
+
const completionMode = jobSpec.completionMode || 'NonIndexed'
|
|
72
|
+
return {
|
|
73
|
+
shape: `Pod · ${shapeParts.join(' · ')}`,
|
|
74
|
+
units,
|
|
75
|
+
externalTemplates: [],
|
|
76
|
+
configMaps: dependencies.configMaps,
|
|
77
|
+
secrets: dependencies.secrets,
|
|
78
|
+
imagePullSecrets: pullSecretNames(podSpec),
|
|
79
|
+
serviceAccount: podSpec.serviceAccountName || 'default',
|
|
80
|
+
retry: `Backoff limit ${jobSpec.backoffLimit ?? 6} · restart ${podSpec.restartPolicy || 'Never'}`,
|
|
81
|
+
...(jobSpec.activeDeadlineSeconds != null ? { deadline: `${jobSpec.activeDeadlineSeconds}s` } : {}),
|
|
82
|
+
parallelism: `${parallelism} parallel · ${completions} ${completions === 1 ? 'completion' : 'completions'} · ${completionMode}`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function argoWorkflowSummary(spec: any): ExecutionDefinitionSummary {
|
|
87
|
+
const templates = Array.isArray(spec.templates) ? spec.templates : []
|
|
88
|
+
const entrypoint = templates.find((template: any) => template?.name === spec.entrypoint)
|
|
89
|
+
const units = templates.flatMap((template: any) => argoTemplateUnits(template))
|
|
90
|
+
const dependencies = podDependencies(spec, unitsSourceContainers(templates))
|
|
91
|
+
for (const template of templates) {
|
|
92
|
+
const templateDependencies = podDependencies(template, [])
|
|
93
|
+
dependencies.configMaps.push(...templateDependencies.configMaps)
|
|
94
|
+
dependencies.secrets.push(...templateDependencies.secrets)
|
|
95
|
+
}
|
|
96
|
+
const imagePullSecrets = unique([
|
|
97
|
+
...pullSecretNames(spec),
|
|
98
|
+
...templates.flatMap((template: any) => pullSecretNames(template)),
|
|
99
|
+
])
|
|
100
|
+
const retries = templates
|
|
101
|
+
.filter((template: any) => template?.retryStrategy != null)
|
|
102
|
+
.map((template: any) => formatArgoRetry(template.name || 'template', template.retryStrategy))
|
|
103
|
+
if (spec.retryStrategy != null) retries.unshift(formatArgoRetry('Workflow', spec.retryStrategy))
|
|
104
|
+
if (spec.templateDefaults?.retryStrategy != null) retries.unshift(formatArgoRetry('Defaults', spec.templateDefaults.retryStrategy))
|
|
105
|
+
return {
|
|
106
|
+
shape: entrypoint
|
|
107
|
+
? argoTemplateShape(entrypoint)
|
|
108
|
+
: spec.workflowTemplateRef?.name
|
|
109
|
+
? `${spec.workflowTemplateRef.clusterScope ? 'ClusterWorkflowTemplate' : 'WorkflowTemplate'} · ${spec.workflowTemplateRef.name}`
|
|
110
|
+
: templates.length > 0
|
|
111
|
+
? `${templates.length} defined ${templates.length === 1 ? 'template' : 'templates'}`
|
|
112
|
+
: 'Inline workflow',
|
|
113
|
+
units,
|
|
114
|
+
externalTemplates: argoExternalTemplateRefs(spec, templates),
|
|
115
|
+
configMaps: unique(dependencies.configMaps),
|
|
116
|
+
secrets: unique(dependencies.secrets),
|
|
117
|
+
imagePullSecrets,
|
|
118
|
+
serviceAccount: spec.serviceAccountName || 'default',
|
|
119
|
+
retry: retries.length > 0 ? retries.join(' · ') : 'Not configured',
|
|
120
|
+
...(spec.activeDeadlineSeconds != null ? { deadline: `${spec.activeDeadlineSeconds}s` } : {}),
|
|
121
|
+
...(spec.parallelism != null ? { parallelism: `${spec.parallelism} maximum` } : argoShapePolicy(entrypoint)),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pullSecretNames(podSpec: any): string[] {
|
|
126
|
+
return unique((podSpec?.imagePullSecrets ?? []).flatMap((secret: any) => secret?.name ? [String(secret.name)] : []))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function argoTemplateShape(template: any): string {
|
|
130
|
+
const name = template.name || 'entrypoint'
|
|
131
|
+
if (template.dag) {
|
|
132
|
+
const tasks = Array.isArray(template.dag.tasks) ? template.dag.tasks.length : 0
|
|
133
|
+
return `DAG · ${name} · ${tasks} ${tasks === 1 ? 'task' : 'tasks'}`
|
|
134
|
+
}
|
|
135
|
+
if (template.steps) {
|
|
136
|
+
const groups = Array.isArray(template.steps) ? template.steps.length : 0
|
|
137
|
+
const steps = Array.isArray(template.steps) ? template.steps.reduce((sum: number, group: any) => sum + (Array.isArray(group) ? group.length : 0), 0) : 0
|
|
138
|
+
return `Steps · ${name} · ${steps} ${steps === 1 ? 'step' : 'steps'} in ${groups} ${groups === 1 ? 'group' : 'groups'}`
|
|
139
|
+
}
|
|
140
|
+
if (template.containerSet) {
|
|
141
|
+
const containers = Array.isArray(template.containerSet.containers) ? template.containerSet.containers.length : 0
|
|
142
|
+
return `Container set · ${name} · ${containers} ${containers === 1 ? 'container' : 'containers'}`
|
|
143
|
+
}
|
|
144
|
+
if (template.script) return `Script · ${name}`
|
|
145
|
+
if (template.container) return `Container · ${name}`
|
|
146
|
+
if (template.resource) return `Resource · ${name} · ${template.resource.action || 'operation'}`
|
|
147
|
+
if (template.suspend != null) return `Suspend · ${name}`
|
|
148
|
+
if (template.http) return `HTTP · ${name}`
|
|
149
|
+
return `Template · ${name}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function argoTemplateUnits(template: any): ExecutionUnitSummary[] {
|
|
153
|
+
if (template.container) return [containerSummary({ ...template.container, resources: template.container.resources ?? template.resources }, 'Container', template.name)]
|
|
154
|
+
if (template.script) return [containerSummary({ ...template.script, resources: template.script.resources ?? template.resources }, 'Script', template.name)]
|
|
155
|
+
if (template.containerSet?.containers) {
|
|
156
|
+
return template.containerSet.containers.map((container: any) => containerSummary(container, 'Container', `${template.name}/${container.name}`))
|
|
157
|
+
}
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function argoExternalTemplateRefs(spec: any, templates: any[]): string[] {
|
|
162
|
+
const refs: string[] = []
|
|
163
|
+
if (templates.length === 0 && spec.workflowTemplateRef?.name) refs.push(`${spec.workflowTemplateRef.clusterScope ? 'ClusterWorkflowTemplate' : 'WorkflowTemplate'}/${spec.workflowTemplateRef.name}`)
|
|
164
|
+
for (const template of templates) {
|
|
165
|
+
const tasks = Array.isArray(template?.dag?.tasks) ? template.dag.tasks : []
|
|
166
|
+
const steps = Array.isArray(template?.steps) ? template.steps.flatMap((group: any) => Array.isArray(group) ? group : []) : []
|
|
167
|
+
for (const item of [...tasks, ...steps]) {
|
|
168
|
+
if (item?.templateRef?.name) refs.push(`${item.templateRef.clusterScope ? 'ClusterWorkflowTemplate' : 'WorkflowTemplate'}/${item.templateRef.name}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return unique(refs)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function unitsSourceContainers(templates: any[]): any[] {
|
|
175
|
+
return templates.flatMap((template) => {
|
|
176
|
+
if (template?.container) return [template.container]
|
|
177
|
+
if (template?.script) return [template.script]
|
|
178
|
+
return Array.isArray(template?.containerSet?.containers) ? template.containerSet.containers : []
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function containerSummary(container: any, type: string, fallbackName?: string): ExecutionUnitSummary {
|
|
183
|
+
const command = [...(Array.isArray(container.command) ? container.command : []), ...(Array.isArray(container.args) ? container.args : [])]
|
|
184
|
+
const requests = formatResources(container.resources?.requests)
|
|
185
|
+
const limits = formatResources(container.resources?.limits)
|
|
186
|
+
return {
|
|
187
|
+
name: container.name || fallbackName || type,
|
|
188
|
+
type,
|
|
189
|
+
...(container.image ? { image: String(container.image) } : {}),
|
|
190
|
+
...(command.length > 0 ? { command: command.map(String).join(' ') } : {}),
|
|
191
|
+
...(requests && requests !== '-' ? { requests } : {}),
|
|
192
|
+
...(limits && limits !== '-' ? { limits } : {}),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function podDependencies(podSpec: any, containers: any[]): { configMaps: string[]; secrets: string[] } {
|
|
197
|
+
const configMaps: string[] = []
|
|
198
|
+
const secrets: string[] = []
|
|
199
|
+
for (const pullSecret of podSpec?.imagePullSecrets ?? []) {
|
|
200
|
+
if (pullSecret?.name) secrets.push(pullSecret.name)
|
|
201
|
+
}
|
|
202
|
+
for (const container of containers) {
|
|
203
|
+
for (const source of container?.envFrom ?? []) {
|
|
204
|
+
if (source?.configMapRef?.name) configMaps.push(source.configMapRef.name)
|
|
205
|
+
if (source?.secretRef?.name) secrets.push(source.secretRef.name)
|
|
206
|
+
}
|
|
207
|
+
for (const variable of container?.env ?? []) {
|
|
208
|
+
if (variable?.valueFrom?.configMapKeyRef?.name) configMaps.push(variable.valueFrom.configMapKeyRef.name)
|
|
209
|
+
if (variable?.valueFrom?.secretKeyRef?.name) secrets.push(variable.valueFrom.secretKeyRef.name)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const volume of podSpec?.volumes ?? []) {
|
|
213
|
+
if (volume?.configMap?.name) configMaps.push(volume.configMap.name)
|
|
214
|
+
if (volume?.secret?.secretName) secrets.push(volume.secret.secretName)
|
|
215
|
+
for (const source of volume?.projected?.sources ?? []) {
|
|
216
|
+
if (source?.configMap?.name) configMaps.push(source.configMap.name)
|
|
217
|
+
if (source?.secret?.name) secrets.push(source.secret.name)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { configMaps: unique(configMaps), secrets: unique(secrets) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatArgoRetry(name: string, retry: any): string {
|
|
224
|
+
if (retry && Object.keys(retry).length === 0) return `${name}: until completion`
|
|
225
|
+
const limit = retry?.limit != null ? `${retry.limit} retries` : 'controller limit'
|
|
226
|
+
const policy = retry?.retryPolicy || 'OnFailure'
|
|
227
|
+
const backoff = retry?.backoff?.duration ? ` · ${retry.backoff.duration} backoff` : ''
|
|
228
|
+
return `${name}: ${limit} · ${policy}${backoff}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function argoShapePolicy(entrypoint: any): { parallelism?: string } {
|
|
232
|
+
if (entrypoint?.dag?.failFast != null) return { parallelism: `DAG fail-fast ${entrypoint.dag.failFast ? 'on' : 'off'}` }
|
|
233
|
+
return {}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function unique(values: string[]): string[] {
|
|
237
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatResources(resources: any): string | undefined {
|
|
241
|
+
const parts = []
|
|
242
|
+
if (resources?.cpu) parts.push(`CPU ${formatCPUString(String(resources.cpu)).replace(/^1 cores$/, '1 core')}`)
|
|
243
|
+
if (resources?.memory) parts.push(`memory ${formatMemoryString(String(resources.memory))}`)
|
|
244
|
+
return parts.length > 0 ? parts.join(' · ') : undefined
|
|
245
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ArgoResourceDiff } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { GitOpsInsightRef } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useArgoResourceDiff } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
interface ArgoResourceDiffLoaderProps {
|
|
6
|
+
appNamespace: string
|
|
7
|
+
appName: string
|
|
8
|
+
resourceRef: GitOpsInsightRef
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Host wrapper: fetches the Argo CD resource diff and hands the data to the
|
|
12
|
+
// pure k8s-ui presentation component. Mirrors the data-in-web / render-in-
|
|
13
|
+
// k8s-ui split the resource renderers use for their host-wired data.
|
|
14
|
+
export function ArgoResourceDiffLoader({ appNamespace, appName, resourceRef }: ArgoResourceDiffLoaderProps) {
|
|
15
|
+
const { data, isLoading, error } = useArgoResourceDiff(appNamespace, appName, resourceRef)
|
|
16
|
+
return (
|
|
17
|
+
<ArgoResourceDiff
|
|
18
|
+
diff={data ?? null}
|
|
19
|
+
loading={isLoading}
|
|
20
|
+
error={(error as Error | null)?.message ?? null}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|