@skyhook-io/k8s-ui 1.8.10 → 1.8.12
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/package.json +7 -6
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +697 -0
- package/src/components/applications/ApplicationDetail.tsx +3269 -474
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +13 -6
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
- package/src/components/gitops/insights/insights-helpers.ts +74 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
- package/src/components/issues/IssuesView.tsx +91 -52
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
- package/src/components/issues/ResourceIssuesSection.tsx +46 -144
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +3 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +142 -111
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +375 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +149 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +25 -17
- package/src/components/topology/index.ts +2 -0
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/drawer-components.tsx +48 -32
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2706 -475
- package/src/components/workload/index.ts +1 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +2 -1
- package/src/types/core.ts +57 -4
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +201 -1
- package/src/utils/applications.ts +1182 -393
- package/src/utils/index.ts +3 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +1024 -1
- package/src/utils/resource-hierarchy.ts +925 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
|
@@ -129,7 +129,9 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
129
129
|
|
|
130
130
|
// Argo
|
|
131
131
|
workflow: Activity,
|
|
132
|
+
cronworkflow: Activity,
|
|
132
133
|
workflowtemplate: Activity,
|
|
134
|
+
clusterworkflowtemplate: Activity,
|
|
133
135
|
application: GitBranch, // ArgoCD Application
|
|
134
136
|
applicationset: GitBranch, // ArgoCD ApplicationSet
|
|
135
137
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { neighborhoodFor, tagWorkloadOwnership } from './topology-neighborhood'
|
|
2
|
+
import { batchRunParentNodes, neighborhoodFor, tagWorkloadOwnership } from './topology-neighborhood'
|
|
3
3
|
import type { Topology, NodeKind, EdgeType } from '../types/core'
|
|
4
4
|
|
|
5
5
|
function node(id: string, kind: string, ns: string, name: string): Topology['nodes'][number] {
|
|
6
6
|
return { id, kind: kind as NodeKind, name, status: 'healthy' as Topology['nodes'][number]['status'], data: { namespace: ns } }
|
|
7
7
|
}
|
|
8
|
+
function crdNode(id: string, kind: string, ns: string, name: string, apiVersion: string): Topology['nodes'][number] {
|
|
9
|
+
return { id, kind: kind as NodeKind, name, status: 'healthy' as Topology['nodes'][number]['status'], data: { namespace: ns, apiVersion } }
|
|
10
|
+
}
|
|
8
11
|
function edge(source: string, target: string, type: EdgeType): Topology['edges'][number] {
|
|
9
12
|
return { id: `${source}->${target}`, source, target, type }
|
|
10
13
|
}
|
|
@@ -32,6 +35,27 @@ describe('neighborhoodFor', () => {
|
|
|
32
35
|
expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['dep', 'rs', 'pod', 'svc', 'cm']))
|
|
33
36
|
})
|
|
34
37
|
|
|
38
|
+
it('includes the complete upstream serving path without crossing through a shared Service', () => {
|
|
39
|
+
const topo: Topology = {
|
|
40
|
+
nodes: [
|
|
41
|
+
node('route', 'HTTPRoute', 'app', 'web'),
|
|
42
|
+
node('ingress', 'Ingress', 'app', 'web'),
|
|
43
|
+
node('svc', 'Service', 'app', 'web'),
|
|
44
|
+
node('depA', 'Deployment', 'app', 'a'),
|
|
45
|
+
node('depB', 'Deployment', 'app', 'b'),
|
|
46
|
+
],
|
|
47
|
+
edges: [
|
|
48
|
+
edge('route', 'svc', 'routes-to'),
|
|
49
|
+
edge('ingress', 'svc', 'routes-to'),
|
|
50
|
+
edge('svc', 'depA', 'exposes'),
|
|
51
|
+
edge('svc', 'depB', 'exposes'),
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
|
|
56
|
+
expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['route', 'ingress', 'svc', 'depA']))
|
|
57
|
+
})
|
|
58
|
+
|
|
35
59
|
// The leaf rule: a ConfigMap shared by two unrelated Deployments must NOT
|
|
36
60
|
// bridge the second Deployment into the first's neighborhood.
|
|
37
61
|
it('does not bleed through a shared ConfigMap', () => {
|
|
@@ -53,6 +77,29 @@ describe('neighborhoodFor', () => {
|
|
|
53
77
|
expect(ids.has('depB')).toBe(false) // …but it doesn't drag in the other app
|
|
54
78
|
})
|
|
55
79
|
|
|
80
|
+
it('includes resources that produce a workload Secret without crossing through it', () => {
|
|
81
|
+
const topo: Topology = {
|
|
82
|
+
nodes: [
|
|
83
|
+
node('depA', 'Deployment', 'app', 'a'),
|
|
84
|
+
node('depB', 'Deployment', 'app', 'b'),
|
|
85
|
+
node('secret', 'Secret', 'app', 'generated'),
|
|
86
|
+
node('sealed', 'SealedSecret', 'app', 'encrypted'),
|
|
87
|
+
node('cert', 'Certificate', 'app', 'tls'),
|
|
88
|
+
],
|
|
89
|
+
edges: [
|
|
90
|
+
edge('sealed', 'secret', 'manages'),
|
|
91
|
+
edge('cert', 'secret', 'manages'),
|
|
92
|
+
edge('secret', 'depA', 'configures'),
|
|
93
|
+
edge('secret', 'depB', 'configures'),
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
|
|
98
|
+
expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['depA', 'secret', 'sealed', 'cert']))
|
|
99
|
+
expect(out.edges).toEqual(expect.arrayContaining([expect.objectContaining({ source: 'sealed', target: 'secret' })]))
|
|
100
|
+
expect(out.edges).toEqual(expect.arrayContaining([expect.objectContaining({ source: 'cert', target: 'secret' })]))
|
|
101
|
+
})
|
|
102
|
+
|
|
56
103
|
// A GitOps manager reached upward is a leaf: "managed by" is shown, but its
|
|
57
104
|
// sibling workloads are not pulled in.
|
|
58
105
|
it('does not expand through a GitOps manager to its siblings', () => {
|
|
@@ -119,6 +166,139 @@ describe('neighborhoodFor', () => {
|
|
|
119
166
|
expect(out.nodes).toHaveLength(0)
|
|
120
167
|
expect(out.warnings?.some((w) => w.includes('No topology nodes matched'))).toBe(true)
|
|
121
168
|
})
|
|
169
|
+
|
|
170
|
+
it('uses seed group to disambiguate same-kind nodes', () => {
|
|
171
|
+
const topo: Topology = {
|
|
172
|
+
nodes: [
|
|
173
|
+
crdNode('argo', 'Workflow', 'app', 'run', 'argoproj.io/v1alpha1'),
|
|
174
|
+
crdNode('other', 'Workflow', 'app', 'run', 'example.com/v1'),
|
|
175
|
+
],
|
|
176
|
+
edges: [],
|
|
177
|
+
}
|
|
178
|
+
const out = neighborhoodFor(topo, [{ kind: 'Workflow', group: 'argoproj.io', namespace: 'app', name: 'run' }])
|
|
179
|
+
expect(out.nodes.map((n) => n.id)).toEqual(['argo'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('expands from an Argo WorkflowTemplate to its runs and run pods', () => {
|
|
183
|
+
const topo: Topology = {
|
|
184
|
+
nodes: [
|
|
185
|
+
crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'),
|
|
186
|
+
crdNode('wf', 'Workflow', 'app', 'migration-abc', 'argoproj.io/v1alpha1'),
|
|
187
|
+
node('pod', 'Pod', 'app', 'migration-abc-main'),
|
|
188
|
+
],
|
|
189
|
+
edges: [
|
|
190
|
+
edge('wt', 'wf', 'configures'),
|
|
191
|
+
edge('wf', 'pod', 'manages'),
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
|
|
195
|
+
expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['wt', 'wf', 'pod']))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('keeps active plus representative failed and successful retained runs', () => {
|
|
199
|
+
const runs = Array.from({ length: 18 }, (_, i) => ({
|
|
200
|
+
...crdNode(`wf${i}`, 'Workflow', 'app', `migration-${i}`, 'argoproj.io/v1alpha1'),
|
|
201
|
+
status: i === 4 ? 'unhealthy' as const : i === 17 ? 'neutral' as const : 'healthy' as const,
|
|
202
|
+
data: { namespace: 'app', apiVersion: 'argoproj.io/v1alpha1', phase: i === 4 ? 'Failed' : i === 17 ? 'Running' : 'Succeeded', startedAt: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z` },
|
|
203
|
+
}))
|
|
204
|
+
const topo: Topology = {
|
|
205
|
+
nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
|
|
206
|
+
edges: runs.map((run) => edge('wt', run.id, 'configures')),
|
|
207
|
+
}
|
|
208
|
+
const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
|
|
209
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
210
|
+
expect(ids.has('wt')).toBe(true)
|
|
211
|
+
expect(ids.has('wf0')).toBe(false)
|
|
212
|
+
expect(ids.has('wf4')).toBe(true)
|
|
213
|
+
expect(ids.has('wf16')).toBe(true)
|
|
214
|
+
expect(ids.has('wf17')).toBe(true)
|
|
215
|
+
expect(out.warnings?.some((w) => w.includes('active and representative completed runs from 18 retained Workflow runs'))).toBe(true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('keeps the newest active runs plus the oldest potentially stuck run', () => {
|
|
219
|
+
const active = Array.from({ length: 12 }, (_, i) => ({
|
|
220
|
+
...crdNode(`active${i}`, 'Workflow', 'app', `migration-active-${i}`, 'argoproj.io/v1alpha1'),
|
|
221
|
+
data: { namespace: 'app', apiVersion: 'argoproj.io/v1alpha1', phase: 'Running', startedAt: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z` },
|
|
222
|
+
}))
|
|
223
|
+
const failed = { ...crdNode('failed', 'Workflow', 'app', 'migration-failed', 'argoproj.io/v1alpha1'), status: 'unhealthy' as const, data: { namespace: 'app', phase: 'Failed', startedAt: '2026-02-01T00:00:00Z' } }
|
|
224
|
+
const succeeded = { ...crdNode('succeeded', 'Workflow', 'app', 'migration-succeeded', 'argoproj.io/v1alpha1'), status: 'healthy' as const, data: { namespace: 'app', phase: 'Succeeded', startedAt: '2026-02-02T00:00:00Z' } }
|
|
225
|
+
const runs = [...active, failed, succeeded]
|
|
226
|
+
const topo: Topology = {
|
|
227
|
+
nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
|
|
228
|
+
edges: runs.map((run) => edge('wt', run.id, 'configures')),
|
|
229
|
+
}
|
|
230
|
+
const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
|
|
231
|
+
const ids = new Set(out.nodes.map((node) => node.id))
|
|
232
|
+
expect(ids.has('active0')).toBe(true)
|
|
233
|
+
for (let i = 1; i < 5; i++) expect(ids.has(`active${i}`)).toBe(false)
|
|
234
|
+
for (let i = 5; i < 12; i++) expect(ids.has(`active${i}`)).toBe(true)
|
|
235
|
+
expect(ids.has('failed')).toBe(true)
|
|
236
|
+
expect(ids.has('succeeded')).toBe(true)
|
|
237
|
+
expect(out.warnings?.some((warning) => warning.includes('7 newest and oldest of 12 active runs'))).toBe(true)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('uses the name tiebreak for runs without timestamps', () => {
|
|
241
|
+
const runs = Array.from({ length: 10 }, (_, i) => ({
|
|
242
|
+
...crdNode(`wf${i}`, 'Workflow', 'app', `migration-${i}`, 'argoproj.io/v1alpha1'),
|
|
243
|
+
status: 'healthy' as const,
|
|
244
|
+
data: { namespace: 'app', phase: 'Succeeded' },
|
|
245
|
+
}))
|
|
246
|
+
const topo: Topology = {
|
|
247
|
+
nodes: [crdNode('wt', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1'), ...runs],
|
|
248
|
+
edges: runs.map((run) => edge('wt', run.id, 'configures')),
|
|
249
|
+
}
|
|
250
|
+
const out = neighborhoodFor(topo, [{ kind: 'WorkflowTemplate', group: 'argoproj.io', namespace: 'app', name: 'migration' }])
|
|
251
|
+
expect(out.nodes.some((node) => node.id === 'wf9')).toBe(true)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('batchRunParentNodes', () => {
|
|
256
|
+
it('finds scheduled and template parents for generated runs only', () => {
|
|
257
|
+
const cronJob = node('cron', 'CronJob', 'app', 'backup')
|
|
258
|
+
const job = node('job', 'Job', 'app', 'backup-123')
|
|
259
|
+
const template = crdNode('template', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1')
|
|
260
|
+
const workflow = crdNode('workflow', 'Workflow', 'app', 'migration-abc', 'argoproj.io/v1alpha1')
|
|
261
|
+
const deployment = node('deployment', 'Deployment', 'app', 'api')
|
|
262
|
+
const pod = node('pod', 'Pod', 'app', 'api-123')
|
|
263
|
+
const topology: Topology = {
|
|
264
|
+
nodes: [cronJob, job, template, workflow, deployment, pod],
|
|
265
|
+
edges: [
|
|
266
|
+
edge('cron', 'job', 'manages'),
|
|
267
|
+
edge('template', 'workflow', 'configures'),
|
|
268
|
+
edge('deployment', 'pod', 'manages'),
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
expect(batchRunParentNodes(topology, job)).toEqual([cronJob])
|
|
273
|
+
expect(batchRunParentNodes(topology, workflow)).toEqual([template])
|
|
274
|
+
expect(batchRunParentNodes(topology, pod)).toEqual([])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('prefers a scheduler over template provenance when a Workflow has both', () => {
|
|
278
|
+
const cronWorkflow = crdNode('cron', 'CronWorkflow', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
|
|
279
|
+
const template = crdNode('template', 'WorkflowTemplate', 'app', 'migration', 'argoproj.io/v1alpha1')
|
|
280
|
+
const workflow = crdNode('workflow', 'Workflow', 'app', 'scheduled-migration-123', 'argoproj.io/v1alpha1')
|
|
281
|
+
const topology: Topology = {
|
|
282
|
+
nodes: [cronWorkflow, template, workflow],
|
|
283
|
+
edges: [
|
|
284
|
+
edge('template', 'workflow', 'configures'),
|
|
285
|
+
edge('cron', 'workflow', 'manages'),
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
expect(batchRunParentNodes(topology, workflow)).toEqual([cronWorkflow, template])
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('finds template provenance for a CronWorkflow', () => {
|
|
293
|
+
const template = crdNode('template', 'WorkflowTemplate', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
|
|
294
|
+
const cronWorkflow = crdNode('cron', 'CronWorkflow', 'app', 'scheduled-migration', 'argoproj.io/v1alpha1')
|
|
295
|
+
const topology: Topology = {
|
|
296
|
+
nodes: [template, cronWorkflow],
|
|
297
|
+
edges: [edge('template', 'cron', 'configures')],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
expect(batchRunParentNodes(topology, cronWorkflow)).toEqual([template])
|
|
301
|
+
})
|
|
122
302
|
})
|
|
123
303
|
|
|
124
304
|
describe('tagWorkloadOwnership', () => {
|
|
@@ -132,6 +312,7 @@ describe('tagWorkloadOwnership', () => {
|
|
|
132
312
|
node('depA', 'Deployment', 'app', 'a'),
|
|
133
313
|
node('podA', 'Pod', 'app', 'a-1'),
|
|
134
314
|
node('svcA', 'Service', 'app', 'a'),
|
|
315
|
+
node('routeA', 'HTTPRoute', 'app', 'a'),
|
|
135
316
|
node('depB', 'Deployment', 'app', 'b'),
|
|
136
317
|
node('podB', 'Pod', 'app', 'b-1'),
|
|
137
318
|
node('shared', 'ConfigMap', 'app', 'shared'),
|
|
@@ -139,6 +320,7 @@ describe('tagWorkloadOwnership', () => {
|
|
|
139
320
|
edges: [
|
|
140
321
|
edge('depA', 'podA', 'manages'),
|
|
141
322
|
edge('svcA', 'depA', 'exposes'),
|
|
323
|
+
edge('routeA', 'svcA', 'routes-to'),
|
|
142
324
|
edge('depB', 'podB', 'manages'),
|
|
143
325
|
edge('shared', 'depA', 'configures'),
|
|
144
326
|
edge('shared', 'depB', 'configures'),
|
|
@@ -155,6 +337,7 @@ describe('tagWorkloadOwnership', () => {
|
|
|
155
337
|
expect(dataOf(topology, 'depA').ownerWorkloadId).toBe('Deployment/app/a')
|
|
156
338
|
expect(dataOf(topology, 'podA').ownerColorIndex).toBe(a)
|
|
157
339
|
expect(dataOf(topology, 'svcA').ownerColorIndex).toBe(a)
|
|
340
|
+
expect(dataOf(topology, 'routeA').ownerColorIndex).toBe(a)
|
|
158
341
|
expect(dataOf(topology, 'podB').ownerColorIndex).toBe(b)
|
|
159
342
|
// the ConfigMap touches both workloads → neutral color…
|
|
160
343
|
expect(dataOf(topology, 'shared').ownerWorkloadId).toBeNull()
|
|
@@ -165,6 +348,7 @@ describe('tagWorkloadOwnership', () => {
|
|
|
165
348
|
)
|
|
166
349
|
// an exclusive satellite's focus set is just its own workload.
|
|
167
350
|
expect(dataOf(topology, 'svcA').focusWorkloadIds).toEqual(['Deployment/app/a'])
|
|
351
|
+
expect(dataOf(topology, 'routeA').focusWorkloadIds).toEqual(['Deployment/app/a'])
|
|
168
352
|
})
|
|
169
353
|
|
|
170
354
|
// A GitOps manager is context, not membership — it never claims a color even
|
|
@@ -182,4 +366,21 @@ describe('tagWorkloadOwnership', () => {
|
|
|
182
366
|
expect(dataOf(topology, 'ks').ownerWorkloadId).toBeNull()
|
|
183
367
|
expect(dataOf(topology, 'pod').ownerWorkloadId).toBe('Deployment/app/web')
|
|
184
368
|
})
|
|
369
|
+
|
|
370
|
+
it('attributes Secret producers through the Secret they produce', () => {
|
|
371
|
+
const topo: Topology = {
|
|
372
|
+
nodes: [
|
|
373
|
+
node('dep', 'Deployment', 'app', 'web'),
|
|
374
|
+
node('secret', 'Secret', 'app', 'generated'),
|
|
375
|
+
node('sealed', 'SealedSecret', 'app', 'encrypted'),
|
|
376
|
+
node('cert', 'Certificate', 'app', 'tls'),
|
|
377
|
+
],
|
|
378
|
+
edges: [edge('sealed', 'secret', 'manages'), edge('cert', 'secret', 'manages'), edge('secret', 'dep', 'configures')],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const { topology } = tagWorkloadOwnership(topo, [{ kind: 'Deployment', namespace: 'app', name: 'web' }])
|
|
382
|
+
expect(dataOf(topology, 'secret').ownerWorkloadId).toBe('Deployment/app/web')
|
|
383
|
+
expect(dataOf(topology, 'sealed').ownerWorkloadId).toBe('Deployment/app/web')
|
|
384
|
+
expect(dataOf(topology, 'cert').ownerWorkloadId).toBe('Deployment/app/web')
|
|
385
|
+
})
|
|
185
386
|
})
|
|
@@ -12,10 +12,10 @@ import type { Topology, TopologyNode, TopologyEdge, EdgeType, NodeKind } from '.
|
|
|
12
12
|
// identity (`manages`) — the ownerRef / controller chain
|
|
13
13
|
// (Deployment→ReplicaSet→Pod). Walk it: a
|
|
14
14
|
// workload's pods ARE the workload.
|
|
15
|
-
// routing (`exposes`,`routes-to`)—
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// routing (`exposes`,`routes-to`)— walk UPSTREAM from workload to Service to
|
|
16
|
+
// Ingress/Route, then stop. Never walk back
|
|
17
|
+
// DOWN through a shared Service into another
|
|
18
|
+
// workload.
|
|
19
19
|
// context (`configures`,`uses`, — a ConfigMap/Secret/HPA/PDB attached to the
|
|
20
20
|
// `protects`) workload. INCLUDE as a LEAF: a shared
|
|
21
21
|
// ConfigMap mounted by two apps must not glue
|
|
@@ -30,12 +30,14 @@ import type { Topology, TopologyNode, TopologyEdge, EdgeType, NodeKind } from '.
|
|
|
30
30
|
|
|
31
31
|
export interface NeighborhoodSeed {
|
|
32
32
|
kind: string
|
|
33
|
+
group?: string
|
|
33
34
|
namespace: string
|
|
34
35
|
name: string
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const IDENTITY_EDGES = new Set<EdgeType>(['manages'])
|
|
38
39
|
const ROUTING_EDGES = new Set<EdgeType>(['exposes', 'routes-to'])
|
|
40
|
+
const BATCH_RUN_FANOUT_LIMIT = 8
|
|
39
41
|
// context: 'configures' | 'uses' | 'protects' — everything else is leaf-attached.
|
|
40
42
|
|
|
41
43
|
// GitOps managers: included as context ("managed by"), never expanded through.
|
|
@@ -45,12 +47,101 @@ const GITOPS_MANAGER_KINDS = new Set<NodeKind>([
|
|
|
45
47
|
'HelmRelease',
|
|
46
48
|
'GitRepository',
|
|
47
49
|
] as NodeKind[])
|
|
50
|
+
const SECRET_PRODUCER_KINDS = new Set<NodeKind>(['SealedSecret', 'Certificate'])
|
|
48
51
|
|
|
49
52
|
function nodeNamespace(node: TopologyNode): string {
|
|
50
53
|
const ns = node.data?.namespace
|
|
51
54
|
return typeof ns === 'string' ? ns : ''
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
function nodeGroup(node: TopologyNode): string {
|
|
58
|
+
const apiVersion = node.data?.apiVersion
|
|
59
|
+
return typeof apiVersion === 'string' && apiVersion.includes('/') ? apiVersion.split('/')[0] : ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isWorkflowTemplateKind(kind: NodeKind | string): boolean {
|
|
63
|
+
return kind === 'WorkflowTemplate' || kind === 'ClusterWorkflowTemplate'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isTemplateConfiguredRun(kind: NodeKind | string): boolean {
|
|
67
|
+
return kind === 'Workflow' || kind === 'CronWorkflow'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isTemplateToRunEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
|
|
71
|
+
return edge.type === 'configures'
|
|
72
|
+
&& isWorkflowTemplateKind(nodeById.get(edge.source)?.kind ?? '')
|
|
73
|
+
&& isTemplateConfiguredRun(nodeById.get(edge.target)?.kind ?? '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isBatchRunFanoutEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
|
|
77
|
+
const source = nodeById.get(edge.source)
|
|
78
|
+
const target = nodeById.get(edge.target)
|
|
79
|
+
if (!source || !target) return false
|
|
80
|
+
if (edge.type === 'manages') {
|
|
81
|
+
return (source.kind === 'CronJob' || source.kind === 'ScaledJob') && target.kind === 'Job'
|
|
82
|
+
|| source.kind === 'CronWorkflow' && target.kind === 'Workflow'
|
|
83
|
+
}
|
|
84
|
+
return isTemplateToRunEdge(edge, nodeById)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function batchRunParentNodes(topology: Topology, run: TopologyNode): TopologyNode[] {
|
|
88
|
+
const nodeById = new Map(topology.nodes.map((node) => [node.id, node]))
|
|
89
|
+
const parentEdges = topology.edges.filter((edge) => edge.target === run.id && isBatchRunFanoutEdge(edge, nodeById))
|
|
90
|
+
parentEdges.sort((left, right) => Number(right.type === 'manages') - Number(left.type === 'manages'))
|
|
91
|
+
return parentEdges.flatMap((edge) => {
|
|
92
|
+
const parent = nodeById.get(edge.source)
|
|
93
|
+
return parent ? [parent] : []
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function nodeRunTime(node: TopologyNode): number {
|
|
98
|
+
const data = node.data ?? {}
|
|
99
|
+
for (const key of ['startedAt', 'finishedAt', 'startTime', 'completionTime', 'creationTimestamp']) {
|
|
100
|
+
const value = data[key]
|
|
101
|
+
if (typeof value !== 'string' || value === '') continue
|
|
102
|
+
const parsed = Date.parse(value)
|
|
103
|
+
if (Number.isFinite(parsed)) return parsed
|
|
104
|
+
}
|
|
105
|
+
return Number.NEGATIVE_INFINITY
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function compareRunsNewestFirst(a: TopologyNode, b: TopologyNode): number {
|
|
109
|
+
const aTime = nodeRunTime(a)
|
|
110
|
+
const bTime = nodeRunTime(b)
|
|
111
|
+
if (bTime !== aTime) return bTime - aTime
|
|
112
|
+
return b.name.localeCompare(a.name)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runDisposition(node: TopologyNode): 'active' | 'failed' | 'succeeded' | 'other' {
|
|
116
|
+
const data = node.data ?? {}
|
|
117
|
+
const phase = typeof data.phase === 'string' ? data.phase : ''
|
|
118
|
+
if (phase === 'Running' || phase === 'Pending' || Number(data.active ?? 0) > 0) return 'active'
|
|
119
|
+
if (phase === 'Failed' || phase === 'Error' || node.status === 'unhealthy') return 'failed'
|
|
120
|
+
if (phase === 'Succeeded' || node.status === 'healthy' || Number(data.succeeded ?? 0) > 0) return 'succeeded'
|
|
121
|
+
if (node.kind === 'Job' && !data.completionTime) return 'active'
|
|
122
|
+
return 'other'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function representativeRuns(targets: TopologyNode[]): TopologyNode[] {
|
|
126
|
+
const sorted = [...targets].sort(compareRunsNewestFirst)
|
|
127
|
+
const active = sorted.filter((node) => runDisposition(node) === 'active')
|
|
128
|
+
const selected: TopologyNode[] = active.length > BATCH_RUN_FANOUT_LIMIT
|
|
129
|
+
? [...active.slice(0, BATCH_RUN_FANOUT_LIMIT - 1), active[active.length - 1]]
|
|
130
|
+
: active
|
|
131
|
+
const addLatest = (disposition: 'failed' | 'succeeded') => {
|
|
132
|
+
const node = sorted.find((candidate) => runDisposition(candidate) === disposition)
|
|
133
|
+
if (node && !selected.some((candidate) => candidate.id === node.id)) selected.push(node)
|
|
134
|
+
}
|
|
135
|
+
addLatest('failed')
|
|
136
|
+
addLatest('succeeded')
|
|
137
|
+
if (selected.length === 0 && sorted[0]) selected.push(sorted[0])
|
|
138
|
+
return selected
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function batchFanoutLimit(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): number | null {
|
|
142
|
+
return isBatchRunFanoutEdge(edge, nodeById) ? BATCH_RUN_FANOUT_LIMIT : null
|
|
143
|
+
}
|
|
144
|
+
|
|
54
145
|
/** The identity string for a workload/seed — `kind/namespace/name`. This format
|
|
55
146
|
* is a cross-module contract: rail rows, the `?workload=` URL param, hover
|
|
56
147
|
* focus, and the ownership stamp all compare these strings. Always construct
|
|
@@ -61,7 +152,10 @@ export function workloadKey(ref: NeighborhoodSeed): string {
|
|
|
61
152
|
}
|
|
62
153
|
|
|
63
154
|
function matchSeedNode(node: TopologyNode, seeds: NeighborhoodSeed[]): boolean {
|
|
64
|
-
return seeds.some((s) =>
|
|
155
|
+
return seeds.some((s) => {
|
|
156
|
+
if (s.kind !== node.kind || s.name !== node.name || s.namespace !== nodeNamespace(node)) return false
|
|
157
|
+
return !s.group || s.group === nodeGroup(node)
|
|
158
|
+
})
|
|
65
159
|
}
|
|
66
160
|
|
|
67
161
|
/** Filter a topology to the neighborhood of `seeds`. Returns the subgraph; an
|
|
@@ -92,18 +186,43 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
92
186
|
}
|
|
93
187
|
}
|
|
94
188
|
|
|
189
|
+
const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number }>()
|
|
190
|
+
for (const [sourceId, edges] of adjacency) {
|
|
191
|
+
const runEdges = edges.filter((edge) => edge.source === sourceId && batchFanoutLimit(edge, nodeById) !== null)
|
|
192
|
+
if (runEdges.length <= BATCH_RUN_FANOUT_LIMIT) continue
|
|
193
|
+
const targets = runEdges
|
|
194
|
+
.map((edge) => nodeById.get(edge.target))
|
|
195
|
+
.filter((node): node is TopologyNode => !!node)
|
|
196
|
+
.sort(compareRunsNewestFirst)
|
|
197
|
+
const representatives = representativeRuns(targets)
|
|
198
|
+
limitedFanouts.set(sourceId, {
|
|
199
|
+
allowed: new Set(representatives.map((node) => node.id)),
|
|
200
|
+
total: targets.length,
|
|
201
|
+
kind: targets[0]?.kind === 'Job' ? 'Job' : 'Workflow',
|
|
202
|
+
activeTotal: targets.filter((node) => runDisposition(node) === 'active').length,
|
|
203
|
+
activeShown: representatives.filter((node) => runDisposition(node) === 'active').length,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
95
207
|
const keep = new Set(seedIds)
|
|
96
208
|
// Nodes included for context but never expanded THROUGH.
|
|
97
209
|
const leaf = new Set<string>()
|
|
98
210
|
const queue: string[] = Array.from(seedIds)
|
|
211
|
+
const cappedSources = new Set<string>()
|
|
99
212
|
|
|
100
213
|
while (queue.length) {
|
|
101
214
|
const id = queue.shift()!
|
|
102
215
|
if (leaf.has(id)) continue // a leaf is a dead end — don't traverse out of it
|
|
216
|
+
const currentNode = nodeById.get(id)
|
|
103
217
|
for (const e of adjacency.get(id) ?? []) {
|
|
104
218
|
const nextId = e.source === id ? e.target : e.source
|
|
105
219
|
const nextNode = nodeById.get(nextId)
|
|
106
220
|
if (!nextNode) continue
|
|
221
|
+
const fanout = e.source === id ? limitedFanouts.get(id) : undefined
|
|
222
|
+
if (fanout && !fanout.allowed.has(nextId)) {
|
|
223
|
+
cappedSources.add(id)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
107
226
|
|
|
108
227
|
let asLeaf: boolean
|
|
109
228
|
if (IDENTITY_EDGES.has(e.type)) {
|
|
@@ -114,7 +233,17 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
114
233
|
// every sibling Job its CronJob owns).
|
|
115
234
|
asLeaf = nextId === e.source
|
|
116
235
|
} else if (ROUTING_EDGES.has(e.type)) {
|
|
117
|
-
|
|
236
|
+
if (e.type === 'exposes' && id === e.source) {
|
|
237
|
+
// We reached a Service from one of its workloads. Only continue
|
|
238
|
+
// upstream to its routes; following the Service's other targets would
|
|
239
|
+
// pull sibling or unrelated workloads into this neighborhood.
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
// A Service reached from a workload may expand once more to the
|
|
243
|
+
// Ingress/Route in front of it. The entrypoint itself remains a leaf.
|
|
244
|
+
asLeaf = e.type === 'routes-to' || nextNode.kind !== 'Service'
|
|
245
|
+
} else if (currentNode && isTemplateToRunEdge(e, nodeById) && e.source === currentNode.id) {
|
|
246
|
+
asLeaf = false
|
|
118
247
|
} else {
|
|
119
248
|
asLeaf = true // configures / uses / protects — leaf
|
|
120
249
|
}
|
|
@@ -123,6 +252,16 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
123
252
|
if (asLeaf) leaf.add(nextId)
|
|
124
253
|
queue.push(nextId)
|
|
125
254
|
}
|
|
255
|
+
|
|
256
|
+
if (asLeaf && nextNode.kind === 'Secret') {
|
|
257
|
+
for (const producerEdge of adjacency.get(nextId) ?? []) {
|
|
258
|
+
if (producerEdge.type !== 'manages' || producerEdge.target !== nextId) continue
|
|
259
|
+
const producer = nodeById.get(producerEdge.source)
|
|
260
|
+
if (!producer || !SECRET_PRODUCER_KINDS.has(producer.kind)) continue
|
|
261
|
+
keep.add(producer.id)
|
|
262
|
+
leaf.add(producer.id)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
126
265
|
}
|
|
127
266
|
}
|
|
128
267
|
|
|
@@ -130,6 +269,15 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
130
269
|
...topology,
|
|
131
270
|
nodes: topology.nodes.filter((n) => keep.has(n.id)),
|
|
132
271
|
edges: topology.edges.filter((e) => keep.has(e.source) && keep.has(e.target)),
|
|
272
|
+
warnings: [
|
|
273
|
+
...(topology.warnings ?? []),
|
|
274
|
+
...Array.from(cappedSources).map((sourceId) => {
|
|
275
|
+
const source = nodeById.get(sourceId)
|
|
276
|
+
const fanout = limitedFanouts.get(sourceId)
|
|
277
|
+
const activeNote = fanout && fanout.activeTotal > fanout.activeShown ? ` showing the ${fanout.activeShown - 1} newest and oldest of ${fanout.activeTotal} active runs plus representative completed runs` : ' showing active and representative completed runs'
|
|
278
|
+
return `Topology view:${activeNote} from ${fanout?.total ?? BATCH_RUN_FANOUT_LIMIT} retained ${fanout?.kind ?? 'batch'} runs for ${source?.kind ?? 'workload'}/${source?.name ?? sourceId}. Use Run history for the full retained set.`
|
|
279
|
+
}),
|
|
280
|
+
],
|
|
133
281
|
}
|
|
134
282
|
}
|
|
135
283
|
|
|
@@ -190,19 +338,26 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
|
|
|
190
338
|
|
|
191
339
|
// The seed nodes present in the subgraph, by their workload key.
|
|
192
340
|
const seedKeyById = new Map<string, string>()
|
|
341
|
+
const subNodeById = new Map(sub.nodes.map((node) => [node.id, node]))
|
|
193
342
|
for (const n of sub.nodes) {
|
|
194
343
|
if (matchSeedNode(n, seeds)) {
|
|
195
344
|
seedKeyById.set(n.id, workloadKey({ kind: n.kind, namespace: nodeNamespace(n), name: n.name }))
|
|
196
345
|
}
|
|
197
346
|
}
|
|
198
347
|
|
|
199
|
-
// manages-DOWN children
|
|
200
|
-
const
|
|
348
|
+
// manages-DOWN children, template-to-run provenance, and undirected neighbors.
|
|
349
|
+
const nodeById = new Map<string, TopologyNode>()
|
|
350
|
+
for (const n of sub.nodes) nodeById.set(n.id, n)
|
|
351
|
+
const managedChildren = new Map<string, string[]>()
|
|
352
|
+
const templateRunChildren = new Map<string, string[]>()
|
|
201
353
|
const neighbors = new Map<string, Set<string>>()
|
|
202
354
|
for (const e of sub.edges) {
|
|
203
355
|
if (e.type === 'manages') {
|
|
204
|
-
if (!
|
|
205
|
-
|
|
356
|
+
if (!managedChildren.has(e.source)) managedChildren.set(e.source, [])
|
|
357
|
+
managedChildren.get(e.source)!.push(e.target)
|
|
358
|
+
} else if (isTemplateToRunEdge(e, nodeById)) {
|
|
359
|
+
if (!templateRunChildren.has(e.source)) templateRunChildren.set(e.source, [])
|
|
360
|
+
templateRunChildren.get(e.source)!.push(e.target)
|
|
206
361
|
}
|
|
207
362
|
for (const [a, b] of [[e.source, e.target], [e.target, e.source]] as const) {
|
|
208
363
|
if (!neighbors.has(a)) neighbors.set(a, new Set())
|
|
@@ -210,8 +365,10 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
|
|
|
210
365
|
}
|
|
211
366
|
}
|
|
212
367
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
368
|
+
// Scheduler/controller ownership is authoritative. Claim every seed and its
|
|
369
|
+
// manages descendants first, then let template seeds claim only unowned runs
|
|
370
|
+
// and their descendants. A shared template remains provenance when a
|
|
371
|
+
// CronWorkflow in the same app already owns the Workflow.
|
|
215
372
|
const coreOwner = new Map<string, string>()
|
|
216
373
|
for (const [seedId, key] of seedKeyById) {
|
|
217
374
|
const queue = [seedId]
|
|
@@ -219,7 +376,18 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
|
|
|
219
376
|
const id = queue.shift()!
|
|
220
377
|
if (coreOwner.has(id)) continue
|
|
221
378
|
coreOwner.set(id, key)
|
|
222
|
-
for (const c of
|
|
379
|
+
for (const c of managedChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const [seedId, key] of seedKeyById) {
|
|
383
|
+
const seed = nodeById.get(seedId)
|
|
384
|
+
if (!seed || !isWorkflowTemplateKind(seed.kind)) continue
|
|
385
|
+
const queue = [...(templateRunChildren.get(seedId) ?? [])]
|
|
386
|
+
while (queue.length) {
|
|
387
|
+
const id = queue.shift()!
|
|
388
|
+
if (coreOwner.has(id)) continue
|
|
389
|
+
coreOwner.set(id, key)
|
|
390
|
+
for (const c of managedChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
|
|
223
391
|
}
|
|
224
392
|
}
|
|
225
393
|
|
|
@@ -240,6 +408,19 @@ export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed
|
|
|
240
408
|
for (const nb of neighbors.get(n.id) ?? []) {
|
|
241
409
|
const o = coreOwner.get(nb)
|
|
242
410
|
if (o) related.add(o)
|
|
411
|
+
const neighborNode = subNodeById.get(nb)
|
|
412
|
+
if (neighborNode?.kind === 'Service') {
|
|
413
|
+
for (const serviceNeighbor of neighbors.get(nb) ?? []) {
|
|
414
|
+
const serviceOwner = coreOwner.get(serviceNeighbor)
|
|
415
|
+
if (serviceOwner) related.add(serviceOwner)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (SECRET_PRODUCER_KINDS.has(n.kind) && neighborNode?.kind === 'Secret') {
|
|
419
|
+
for (const secretNeighbor of neighbors.get(nb) ?? []) {
|
|
420
|
+
const secretOwner = coreOwner.get(secretNeighbor)
|
|
421
|
+
if (secretOwner) related.add(secretOwner)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
243
424
|
}
|
|
244
425
|
focusWorkloadIds = [...related]
|
|
245
426
|
owner = related.size === 1 && !GITOPS_MANAGER_KINDS.has(n.kind) ? [...related][0] : null
|