@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,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
+ }