@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { ResourceRef, TimelineEvent } from '../types/core'
|
|
2
|
+
import { getKindLabel } from './api-resources'
|
|
3
|
+
import type { AppHistory, AppHistoryAnchor, AppSourceRef } from './applications'
|
|
4
|
+
import { isProblematicEvent } from './resource-hierarchy'
|
|
5
|
+
|
|
6
|
+
export type ApplicationHistoryCategory = 'deployment' | 'change' | 'problem'
|
|
7
|
+
export type ApplicationHistoryRange = '24h' | '7d' | '30d' | 'all'
|
|
8
|
+
|
|
9
|
+
export interface ApplicationHistoryItem {
|
|
10
|
+
id: string
|
|
11
|
+
category: ApplicationHistoryCategory
|
|
12
|
+
title: string
|
|
13
|
+
timestamp: string
|
|
14
|
+
detail?: string
|
|
15
|
+
status?: string
|
|
16
|
+
revision?: string
|
|
17
|
+
initiatedBy?: string
|
|
18
|
+
count?: number
|
|
19
|
+
resource?: ResourceRef
|
|
20
|
+
sourceRef?: AppSourceRef
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ROOT_RUNTIME_KINDS = new Set([
|
|
24
|
+
'Deployment',
|
|
25
|
+
'DaemonSet',
|
|
26
|
+
'StatefulSet',
|
|
27
|
+
'Rollout',
|
|
28
|
+
'Job',
|
|
29
|
+
'CronJob',
|
|
30
|
+
'Workflow',
|
|
31
|
+
'CronWorkflow',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const RELATED_RUNTIME_KINDS = new Set([
|
|
35
|
+
'Service',
|
|
36
|
+
'Ingress',
|
|
37
|
+
'Gateway',
|
|
38
|
+
'HTTPRoute',
|
|
39
|
+
'GRPCRoute',
|
|
40
|
+
'TCPRoute',
|
|
41
|
+
'TLSRoute',
|
|
42
|
+
'ConfigMap',
|
|
43
|
+
'Secret',
|
|
44
|
+
'SealedSecret',
|
|
45
|
+
'HorizontalPodAutoscaler',
|
|
46
|
+
'ScaledObject',
|
|
47
|
+
'ScaledJob',
|
|
48
|
+
'PersistentVolumeClaim',
|
|
49
|
+
'PodDisruptionBudget',
|
|
50
|
+
'NetworkPolicy',
|
|
51
|
+
'ServiceAccount',
|
|
52
|
+
'ServiceMonitor',
|
|
53
|
+
'PodMonitor',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const NORMAL_BATCH_LIFECYCLE_REASONS = new Set([
|
|
57
|
+
'started',
|
|
58
|
+
'created',
|
|
59
|
+
'completed',
|
|
60
|
+
'complete',
|
|
61
|
+
'successcriteriamet',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
const CONFIG_DATA_KINDS = new Set(['ConfigMap', 'Secret', 'SealedSecret'])
|
|
65
|
+
const CONFIG_DATA_PATH = /^(?:spec\.)?(?:data|stringData|binaryData|encryptedData)(\W|$)/
|
|
66
|
+
const EPHEMERAL_RUN_KINDS = new Set(['Job', 'Workflow'])
|
|
67
|
+
const SOURCE_BURST_WINDOW_MS = 2 * 60 * 1000
|
|
68
|
+
|
|
69
|
+
const PROBLEM_TITLES: Record<string, string> = {
|
|
70
|
+
failedscheduling: "Can't be scheduled",
|
|
71
|
+
failedmount: "Volume can't be mounted",
|
|
72
|
+
failedattachvolume: "Volume can't be attached",
|
|
73
|
+
failedpull: 'Image pull failed',
|
|
74
|
+
errimagepull: 'Image pull failed',
|
|
75
|
+
imagepullbackoff: 'Image pull back-off',
|
|
76
|
+
unhealthy: 'Health check failed',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validTimestamp(value: string | undefined): value is string {
|
|
80
|
+
return Boolean(value && !value.startsWith('0001-01-01T00:00:00') && Number.isFinite(new Date(value).getTime()))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sourceMatchesEvent(source: AppSourceRef | undefined, event: TimelineEvent): boolean {
|
|
84
|
+
if (!source) return false
|
|
85
|
+
return source.kind.toLowerCase() === event.kind.toLowerCase()
|
|
86
|
+
&& source.namespace === event.namespace
|
|
87
|
+
&& source.name === event.name
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function anchorItem(anchor: AppHistoryAnchor, sourceRef: AppSourceRef | undefined, index: number): ApplicationHistoryItem | null {
|
|
91
|
+
if (!validTimestamp(anchor.timestamp)) return null
|
|
92
|
+
return {
|
|
93
|
+
id: `source:${anchor.type}:${anchor.timestamp}:${anchor.revision ?? index}`,
|
|
94
|
+
category: 'deployment',
|
|
95
|
+
title: anchor.title,
|
|
96
|
+
timestamp: anchor.timestamp,
|
|
97
|
+
detail: anchor.source || anchor.message,
|
|
98
|
+
status: anchor.status,
|
|
99
|
+
revision: anchor.revision,
|
|
100
|
+
initiatedBy: anchor.initiatedBy,
|
|
101
|
+
sourceRef,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function eventTitle(event: TimelineEvent, problem: boolean): string {
|
|
106
|
+
const kind = getKindLabel(event.kind)
|
|
107
|
+
if (problem) {
|
|
108
|
+
const reason = event.reason?.toLowerCase()
|
|
109
|
+
if (reason === 'podscheduled' && event.message?.includes('nodes are available')) return "Can't be scheduled"
|
|
110
|
+
if (reason === 'failedcreate') {
|
|
111
|
+
if (event.kind === 'CronJob' || event.kind === 'CronWorkflow') return 'Scheduled run could not be created'
|
|
112
|
+
if (ROOT_RUNTIME_KINDS.has(event.kind)) return 'Pod could not be created'
|
|
113
|
+
return `${kind} could not create a resource`
|
|
114
|
+
}
|
|
115
|
+
if (EPHEMERAL_RUN_KINDS.has(event.kind)) {
|
|
116
|
+
if (reason === 'backofflimitexceeded') return `${kind} failed after repeated retries`
|
|
117
|
+
if (reason === 'deadlineexceeded') return `${kind} exceeded its deadline`
|
|
118
|
+
if (reason === 'failed') return `${kind} failed`
|
|
119
|
+
}
|
|
120
|
+
return PROBLEM_TITLES[reason ?? ''] ?? event.reason ?? `${kind} needs attention`
|
|
121
|
+
}
|
|
122
|
+
if (event.eventType === 'add') return `${kind} created`
|
|
123
|
+
if (event.eventType === 'delete') return `${kind} deleted`
|
|
124
|
+
if (event.reason && event.reason !== 'Update' && event.reason !== 'Updated') return event.reason
|
|
125
|
+
return `${kind} updated`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function eventDetail(event: TimelineEvent): string | undefined {
|
|
129
|
+
const message = event.message?.trim()
|
|
130
|
+
const summary = event.diff?.summary?.trim()
|
|
131
|
+
if (message && summary && message !== summary) return `${message} · ${summary}`
|
|
132
|
+
return message || summary || undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hasDesiredStateChange(event: TimelineEvent): boolean {
|
|
136
|
+
return Boolean(event.diff?.fields.some(({ path }) => {
|
|
137
|
+
if (path === 'spec' || path.startsWith('spec.')) return true
|
|
138
|
+
if (!CONFIG_DATA_KINDS.has(event.kind)) return false
|
|
139
|
+
return path === 'immutable' || (event.kind === 'Secret' && path === 'type') || CONFIG_DATA_PATH.test(path)
|
|
140
|
+
}))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasConfigurationDataChange(event: TimelineEvent): boolean {
|
|
144
|
+
return CONFIG_DATA_KINDS.has(event.kind) && Boolean(event.diff?.fields.some(({ path }) =>
|
|
145
|
+
path === 'immutable' || CONFIG_DATA_PATH.test(path),
|
|
146
|
+
))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isEphemeralRun(event: TimelineEvent): boolean {
|
|
150
|
+
return EPHEMERAL_RUN_KINDS.has(event.kind) && Boolean(event.owner)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function historyEventItem(event: TimelineEvent): ApplicationHistoryItem | null {
|
|
154
|
+
if (!validTimestamp(event.timestamp)) return null
|
|
155
|
+
const unhealthy = event.healthState === 'degraded' || event.healthState === 'unhealthy'
|
|
156
|
+
const leafRuntimeResource = event.kind === 'Pod' || event.kind === 'ReplicaSet'
|
|
157
|
+
const reason = event.reason?.toLowerCase()
|
|
158
|
+
const normalBatchLifecycle = EPHEMERAL_RUN_KINDS.has(event.kind)
|
|
159
|
+
&& event.eventType !== 'Warning'
|
|
160
|
+
&& Boolean(reason && NORMAL_BATCH_LIFECYCLE_REASONS.has(reason))
|
|
161
|
+
const problem = !normalBatchLifecycle
|
|
162
|
+
&& (isProblematicEvent(event) || (unhealthy && (!leafRuntimeResource || Boolean(event.message?.trim()))))
|
|
163
|
+
if (!problem && event.source === 'k8s_event') return null
|
|
164
|
+
if (event.kind === 'Pod' || event.kind === 'ReplicaSet') {
|
|
165
|
+
if (!problem) return null
|
|
166
|
+
} else if (!problem && !ROOT_RUNTIME_KINDS.has(event.kind) && !RELATED_RUNTIME_KINDS.has(event.kind)) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
if (!problem && event.eventType === 'update' && !hasDesiredStateChange(event)) return null
|
|
170
|
+
if (!problem && (event.eventType === 'add' || event.eventType === 'delete') && isEphemeralRun(event)) return null
|
|
171
|
+
const resource = problem && event.owner
|
|
172
|
+
? {
|
|
173
|
+
kind: event.owner.kind,
|
|
174
|
+
namespace: event.namespace,
|
|
175
|
+
name: event.owner.name,
|
|
176
|
+
}
|
|
177
|
+
: {
|
|
178
|
+
kind: event.kind,
|
|
179
|
+
namespace: event.namespace,
|
|
180
|
+
name: event.name,
|
|
181
|
+
group: event.apiVersion?.includes('/') ? event.apiVersion.split('/')[0] : undefined,
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
id: `event:${event.id}`,
|
|
185
|
+
category: problem ? 'problem' : 'change',
|
|
186
|
+
title: eventTitle(event, problem),
|
|
187
|
+
timestamp: event.timestamp,
|
|
188
|
+
detail: eventDetail(event),
|
|
189
|
+
count: event.count,
|
|
190
|
+
resource,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function warningKey(item: ApplicationHistoryItem): string {
|
|
195
|
+
const resource = item.resource
|
|
196
|
+
return `${item.title}\u0000${resource?.kind ?? ''}\u0000${resource?.namespace ?? ''}\u0000${resource?.name ?? ''}\u0000${item.detail ?? ''}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildApplicationHistoryItems(
|
|
200
|
+
history: AppHistory | undefined,
|
|
201
|
+
events: TimelineEvent[],
|
|
202
|
+
): ApplicationHistoryItem[] {
|
|
203
|
+
const anchorsByChange = new Map<string, AppHistoryAnchor>()
|
|
204
|
+
for (const anchor of history?.anchors ?? []) {
|
|
205
|
+
if (!validTimestamp(anchor.timestamp)) continue
|
|
206
|
+
const key = `${anchor.type}\u0000${anchor.title}\u0000${anchor.timestamp}\u0000${anchor.revision ?? ''}`
|
|
207
|
+
const existing = anchorsByChange.get(key)
|
|
208
|
+
if (!existing) {
|
|
209
|
+
anchorsByChange.set(key, anchor)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
anchorsByChange.set(key, {
|
|
213
|
+
...existing,
|
|
214
|
+
status: anchor.status ?? existing.status,
|
|
215
|
+
source: existing.source ?? anchor.source,
|
|
216
|
+
message: anchor.message ?? existing.message,
|
|
217
|
+
initiatedBy: anchor.initiatedBy ?? existing.initiatedBy,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
const anchors = [...anchorsByChange.values()]
|
|
221
|
+
.map((anchor, index) => anchorItem(anchor, history?.sourceRef, index))
|
|
222
|
+
.filter((item): item is ApplicationHistoryItem => item !== null)
|
|
223
|
+
const hasAnchors = anchors.length > 0
|
|
224
|
+
const anchorTimes = anchors.map(({ timestamp }) => new Date(timestamp).getTime())
|
|
225
|
+
const warningItems = new Map<string, ApplicationHistoryItem>()
|
|
226
|
+
const runtimeItems: ApplicationHistoryItem[] = []
|
|
227
|
+
|
|
228
|
+
for (const event of events) {
|
|
229
|
+
const item = historyEventItem(event)
|
|
230
|
+
if (!item) continue
|
|
231
|
+
if (item.category !== 'problem' && sourceMatchesEvent(history?.sourceRef, event)) continue
|
|
232
|
+
if (item.category !== 'problem') {
|
|
233
|
+
const eventTime = new Date(item.timestamp).getTime()
|
|
234
|
+
if (!hasConfigurationDataChange(event)
|
|
235
|
+
&& hasAnchors
|
|
236
|
+
&& anchorTimes.some((anchorTime) => Math.abs(eventTime - anchorTime) <= SOURCE_BURST_WINDOW_MS)) continue
|
|
237
|
+
runtimeItems.push(item)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const key = warningKey(item)
|
|
241
|
+
const existing = warningItems.get(key)
|
|
242
|
+
if (!existing) {
|
|
243
|
+
warningItems.set(key, item)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
existing.count = Math.max(existing.count ?? 1, item.count ?? 1)
|
|
247
|
+
if (new Date(item.timestamp).getTime() > new Date(existing.timestamp).getTime()) {
|
|
248
|
+
existing.timestamp = item.timestamp
|
|
249
|
+
existing.id = item.id
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return [...anchors, ...runtimeItems, ...warningItems.values()].sort((a, b) => {
|
|
254
|
+
const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
255
|
+
return byTime || a.id.localeCompare(b.id)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { GitOpsResourceTree, Topology } from '../types'
|
|
3
|
+
import { collapseStableReplicaSets, deploymentInventoryFromGitOps, deploymentInventoryFromHelm, layerDeploymentInventory, topologyGroup } from './application-topology'
|
|
4
|
+
|
|
5
|
+
const topology: Topology = {
|
|
6
|
+
nodes: [
|
|
7
|
+
{ id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'apps/v1' } },
|
|
8
|
+
{ id: 'service/default/external', kind: 'Service', name: 'external', status: 'healthy', data: { namespace: 'default', apiVersion: 'v1' } },
|
|
9
|
+
],
|
|
10
|
+
edges: [],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
it('resolves the stored API group for source-only CRD nodes', () => {
|
|
14
|
+
expect(topologyGroup({
|
|
15
|
+
id: 'source/prometheusrule',
|
|
16
|
+
kind: 'PrometheusRule',
|
|
17
|
+
name: 'alerts',
|
|
18
|
+
status: 'healthy',
|
|
19
|
+
data: { namespace: 'monitoring', sourceGroup: 'monitoring.coreos.com' },
|
|
20
|
+
})).toBe('monitoring.coreos.com')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function inventory(warnings?: string[]): GitOpsResourceTree {
|
|
24
|
+
const root = { id: 'root', ref: { group: 'argoproj.io', kind: 'Application', namespace: 'argocd', name: 'api' }, role: 'root' as const, tool: 'argocd' as const }
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
warnings,
|
|
28
|
+
edges: [],
|
|
29
|
+
nodes: [
|
|
30
|
+
root,
|
|
31
|
+
{ id: 'deployment', ref: { group: 'apps', kind: 'Deployment', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
|
|
32
|
+
{ id: 'config', ref: { kind: 'ConfigMap', namespace: 'default', name: 'api-config' }, role: 'declared', tool: 'argocd' },
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('layerDeploymentInventory', () => {
|
|
38
|
+
it('separates overlapping, runtime-only, and managed-only resources', () => {
|
|
39
|
+
const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
|
|
40
|
+
|
|
41
|
+
expect(result.managedRuntimeCount).toBe(1)
|
|
42
|
+
expect(result.runtimeOnlyCount).toBe(1)
|
|
43
|
+
expect(result.managedOnly.map((node) => node.name)).toEqual(['api-config'])
|
|
44
|
+
expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
|
|
45
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBe('runtime-only')
|
|
46
|
+
expect(result.topology.nodes[2]).toMatchObject({ kind: 'ConfigMap', name: 'api-config', data: { deploymentMembership: 'source-only' } })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not claim runtime-only membership for a partial inventory', () => {
|
|
50
|
+
const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory(['Some resources are hidden']))!, 'Argo CD')
|
|
51
|
+
|
|
52
|
+
expect(result.inventoryComplete).toBe(false)
|
|
53
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
54
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not claim runtime-only membership for Helm without a completeness signal', () => {
|
|
58
|
+
const helmInventory = deploymentInventoryFromHelm([{
|
|
59
|
+
apiVersion: 'apps/v1',
|
|
60
|
+
kind: 'Deployment',
|
|
61
|
+
namespace: 'default',
|
|
62
|
+
name: 'api',
|
|
63
|
+
status: 'ready',
|
|
64
|
+
}])!
|
|
65
|
+
const result = layerDeploymentInventory(topology, helmInventory, 'Helm')
|
|
66
|
+
|
|
67
|
+
expect(result.inventoryComplete).toBe(false)
|
|
68
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
69
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('adds no membership markers when source and runtime agree', () => {
|
|
73
|
+
const matchingTopology: Topology = {
|
|
74
|
+
nodes: [topology.nodes[0]],
|
|
75
|
+
edges: [],
|
|
76
|
+
}
|
|
77
|
+
const matchingInventory = inventory()
|
|
78
|
+
matchingInventory.nodes = matchingInventory.nodes.filter((node) => node.id !== 'config')
|
|
79
|
+
|
|
80
|
+
const result = layerDeploymentInventory(matchingTopology, deploymentInventoryFromGitOps(matchingInventory)!, 'Argo CD')
|
|
81
|
+
|
|
82
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
83
|
+
expect(result.managedOnly).toEqual([])
|
|
84
|
+
expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not merge same-named resources from different CRD groups', () => {
|
|
88
|
+
const collisionTopology: Topology = {
|
|
89
|
+
nodes: [{ id: 'service/default/api', kind: 'Service', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'serving.knative.dev/v1' } }],
|
|
90
|
+
edges: [],
|
|
91
|
+
}
|
|
92
|
+
const result = layerDeploymentInventory(collisionTopology, deploymentInventoryFromGitOps({
|
|
93
|
+
root: inventory().root,
|
|
94
|
+
edges: [],
|
|
95
|
+
nodes: [
|
|
96
|
+
inventory().root,
|
|
97
|
+
{ id: 'core-service', ref: { kind: 'Service', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
|
|
98
|
+
],
|
|
99
|
+
})!, 'Argo CD')
|
|
100
|
+
|
|
101
|
+
expect(result.managedRuntimeCount).toBe(0)
|
|
102
|
+
expect(result.runtimeOnlyCount).toBe(1)
|
|
103
|
+
expect(result.managedOnly.map((node) => node.id)).toEqual(['core-service'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not classify synthetic graph nodes as runtime-only resources', () => {
|
|
107
|
+
const syntheticTopology: Topology = {
|
|
108
|
+
nodes: [
|
|
109
|
+
{ id: 'podgroup/default/api', kind: 'PodGroup', name: 'api', status: 'healthy', data: { namespace: 'default' } },
|
|
110
|
+
{ id: 'internet', kind: 'Internet', name: 'Internet', status: 'healthy', data: {} },
|
|
111
|
+
],
|
|
112
|
+
edges: [],
|
|
113
|
+
}
|
|
114
|
+
const result = layerDeploymentInventory(syntheticTopology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
|
|
115
|
+
|
|
116
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
117
|
+
expect(result.topology.nodes.slice(0, 2).every((node) => node.data?.deploymentMembership === undefined)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('groups repeated source-only resources so large bundles remain readable', () => {
|
|
121
|
+
const largeInventory = inventory()
|
|
122
|
+
largeInventory.nodes = [
|
|
123
|
+
largeInventory.root,
|
|
124
|
+
...Array.from({ length: 5 }, (_, index) => ({
|
|
125
|
+
id: `rule-${index}`,
|
|
126
|
+
ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
|
|
127
|
+
role: 'declared' as const,
|
|
128
|
+
tool: 'argocd' as const,
|
|
129
|
+
})),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
|
|
133
|
+
|
|
134
|
+
expect(result.managedOnly).toHaveLength(5)
|
|
135
|
+
expect(result.topology.nodes).toHaveLength(1)
|
|
136
|
+
expect(result.topology.nodes[0]).toMatchObject({ kind: 'PrometheusRule', data: { sourceInventoryGroup: true, sourceInventoryCount: 5 } })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('summarizes a large source-only inventory outside the graph', () => {
|
|
140
|
+
const largeInventory = inventory()
|
|
141
|
+
largeInventory.nodes = [
|
|
142
|
+
largeInventory.root,
|
|
143
|
+
...Array.from({ length: 10 }, (_, index) => ({
|
|
144
|
+
id: `rule-${index}`,
|
|
145
|
+
ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
|
|
146
|
+
role: 'declared' as const,
|
|
147
|
+
tool: 'argocd' as const,
|
|
148
|
+
})),
|
|
149
|
+
...Array.from({ length: 5 }, (_, index) => ({
|
|
150
|
+
id: `config-${index}`,
|
|
151
|
+
ref: { kind: 'ConfigMap', namespace: 'monitoring', name: `config-${index}` },
|
|
152
|
+
role: 'declared' as const,
|
|
153
|
+
tool: 'argocd' as const,
|
|
154
|
+
})),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
|
|
158
|
+
|
|
159
|
+
expect(result.managedOnly).toHaveLength(15)
|
|
160
|
+
expect(result.topology.nodes).toHaveLength(0)
|
|
161
|
+
expect(result.managedOnlySummary).toBe('10 PrometheusRules · 5 ConfigMaps')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('collapseStableReplicaSets', () => {
|
|
166
|
+
const replicaSetTopology: Topology = {
|
|
167
|
+
nodes: [
|
|
168
|
+
{ id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default' } },
|
|
169
|
+
{ id: 'replicaset/default/api-abc', kind: 'ReplicaSet', name: 'api-abc', status: 'healthy', data: { namespace: 'default', readyReplicas: 2, totalReplicas: 2 } },
|
|
170
|
+
{ id: 'pod/default/api-1', kind: 'Pod', name: 'api-1', status: 'healthy', data: { namespace: 'default' } },
|
|
171
|
+
],
|
|
172
|
+
edges: [
|
|
173
|
+
{ id: 'deployment-rs', source: 'deployment/default/api', target: 'replicaset/default/api-abc', type: 'manages' },
|
|
174
|
+
{ id: 'rs-pod', source: 'replicaset/default/api-abc', target: 'pod/default/api-1', type: 'manages' },
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('collapses a stable single ReplicaSet and rewires its pods', () => {
|
|
179
|
+
const result = collapseStableReplicaSets(replicaSetTopology, new Set())
|
|
180
|
+
expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'Pod'])
|
|
181
|
+
expect(result.edges).toContainEqual(expect.objectContaining({ source: 'deployment/default/api', target: 'pod/default/api-1' }))
|
|
182
|
+
expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: true })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('shows the ReplicaSet when the developer expands it', () => {
|
|
186
|
+
const result = collapseStableReplicaSets(replicaSetTopology, new Set(['deployment/default/api']))
|
|
187
|
+
expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'ReplicaSet', 'Pod'])
|
|
188
|
+
expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: false })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps an unhealthy ReplicaSet visible automatically', () => {
|
|
192
|
+
const unhealthy = {
|
|
193
|
+
...replicaSetTopology,
|
|
194
|
+
nodes: replicaSetTopology.nodes.map((node) => node.kind === 'ReplicaSet' ? { ...node, status: 'degraded' as const } : node),
|
|
195
|
+
}
|
|
196
|
+
expect(collapseStableReplicaSets(unhealthy, new Set()).nodes.map((node) => node.kind)).toContain('ReplicaSet')
|
|
197
|
+
})
|
|
198
|
+
})
|