@skyhook-io/k8s-ui 1.8.10 → 1.8.11
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 +5 -4
- 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 +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- 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/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +3 -3
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +51 -38
- package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
- package/src/components/issues/ResourceIssuesSection.tsx +28 -146
- 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 +2 -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/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- 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-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 +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -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 +64 -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/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- 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/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +1 -1
- package/src/types/core.ts +57 -4
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -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
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { WorkloadView, type WorkloadTabType } from './WorkloadView'
|
|
1
|
+
export { WorkloadView, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
|
|
2
2
|
export { ResourceDetailDrawer } from './ResourceDetailDrawer'
|
package/src/theme/variables.css
CHANGED
|
@@ -156,6 +156,6 @@
|
|
|
156
156
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.06);
|
|
157
157
|
--shadow-md: 0 4px 12px -2px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.15), 0 0 0 1px rgba(255,255,255,0.06);
|
|
158
158
|
--shadow-lg: 0 12px 32px -6px rgba(0,0,0,0.3), 0 4px 8px rgba(0,0,0,0.1);
|
|
159
|
-
--shadow-drawer: -
|
|
159
|
+
--shadow-drawer: -12px 0 36px rgba(0,0,0,0.45), -4px 0 14px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.06);
|
|
160
160
|
--topology-node-shadow: 0 2px 6px rgba(0,0,0,0.2), 0 6px 16px rgba(0,0,0,0.15);
|
|
161
161
|
}
|
package/src/types/core.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ResourcePermissions {
|
|
|
11
11
|
statefulSets: boolean
|
|
12
12
|
replicaSets: boolean
|
|
13
13
|
ingresses: boolean
|
|
14
|
+
ingressClasses: boolean
|
|
14
15
|
configMaps: boolean
|
|
15
16
|
secrets: boolean
|
|
16
17
|
events: boolean
|
|
@@ -94,7 +95,6 @@ export interface Deployment {
|
|
|
94
95
|
// When adding a new kind here, also update:
|
|
95
96
|
// - ALL_NODE_KINDS in App.tsx
|
|
96
97
|
// - TopologyFilterSidebar.tsx RESOURCE_KINDS array
|
|
97
|
-
// - index.css .topology-icon-* class
|
|
98
98
|
// - K8sResourceNode.tsx NODE_DIMENSIONS
|
|
99
99
|
// - resource-icons.ts KIND_ICON_MAP
|
|
100
100
|
// - kindToPlural in navigation.ts (if irregular plural)
|
|
@@ -123,9 +123,17 @@ export type CoreNodeKind =
|
|
|
123
123
|
| 'PodGroup'
|
|
124
124
|
| 'ConfigMap'
|
|
125
125
|
| 'Secret'
|
|
126
|
+
| 'ServiceAccount'
|
|
127
|
+
| 'SealedSecret'
|
|
128
|
+
| 'ServiceMonitor'
|
|
129
|
+
| 'PodMonitor'
|
|
126
130
|
| 'HorizontalPodAutoscaler'
|
|
127
131
|
| 'Job'
|
|
128
132
|
| 'CronJob'
|
|
133
|
+
| 'Workflow'
|
|
134
|
+
| 'CronWorkflow'
|
|
135
|
+
| 'WorkflowTemplate'
|
|
136
|
+
| 'ClusterWorkflowTemplate'
|
|
129
137
|
| 'PersistentVolumeClaim'
|
|
130
138
|
| 'Node'
|
|
131
139
|
| 'Namespace'
|
|
@@ -168,6 +176,10 @@ export function displayKind(kind: string): string {
|
|
|
168
176
|
const shortNames: Record<string, string> = {
|
|
169
177
|
HorizontalPodAutoscaler: 'HPA',
|
|
170
178
|
PersistentVolumeClaim: 'PVC',
|
|
179
|
+
ServiceAccount: 'Service Account',
|
|
180
|
+
SealedSecret: 'Sealed Secret',
|
|
181
|
+
ServiceMonitor: 'Service Monitor',
|
|
182
|
+
PodMonitor: 'Pod Monitor',
|
|
171
183
|
EC2NodeClass: 'NodeClass',
|
|
172
184
|
KnativeService: 'Knative Svc',
|
|
173
185
|
KnativeConfiguration: 'Knative Config',
|
|
@@ -275,6 +287,10 @@ export interface TimelineEvent {
|
|
|
275
287
|
id: string
|
|
276
288
|
timestamp: string // ISO date string
|
|
277
289
|
source: EventSource // Where event originated: 'informer', 'k8s_event', 'historical'
|
|
290
|
+
// Store-assigned arrival number (monotonic per store instance). The delta
|
|
291
|
+
// cursor keys on it — arrival order, not event time, so late-arriving
|
|
292
|
+
// events can't slip behind a client's cursor.
|
|
293
|
+
seq?: number
|
|
278
294
|
|
|
279
295
|
// Resource identity
|
|
280
296
|
kind: string
|
|
@@ -339,6 +355,20 @@ export function isWorkloadKind(kind: string): boolean {
|
|
|
339
355
|
].includes(kind)
|
|
340
356
|
}
|
|
341
357
|
|
|
358
|
+
export function isDeploymentLikeWorkloadKind(kind: string, group = ''): boolean {
|
|
359
|
+
switch (kind) {
|
|
360
|
+
case 'Deployment':
|
|
361
|
+
case 'ReplicaSet':
|
|
362
|
+
case 'StatefulSet':
|
|
363
|
+
case 'DaemonSet':
|
|
364
|
+
return group === '' || group === 'apps'
|
|
365
|
+
case 'Rollout':
|
|
366
|
+
return group === '' || group === 'argoproj.io'
|
|
367
|
+
default:
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
// Check if a resource kind is typically managed by another
|
|
343
373
|
export function isManagedKind(kind: string): boolean {
|
|
344
374
|
return ['ReplicaSet', 'Pod', 'Event'].includes(kind)
|
|
@@ -353,7 +383,7 @@ export interface TimelineFilters {
|
|
|
353
383
|
timeRange: TimeRange
|
|
354
384
|
}
|
|
355
385
|
|
|
356
|
-
export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | 'all'
|
|
386
|
+
export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d' | 'all'
|
|
357
387
|
|
|
358
388
|
// Cluster info
|
|
359
389
|
export interface ClusterInfo {
|
|
@@ -445,6 +475,7 @@ export interface Relationships {
|
|
|
445
475
|
configRefs?: ResourceRef[]
|
|
446
476
|
consumers?: ResourceRef[]
|
|
447
477
|
scalers?: ResourceRef[]
|
|
478
|
+
storageRefs?: ResourceRef[]
|
|
448
479
|
scaleTarget?: ResourceRef
|
|
449
480
|
pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
|
|
450
481
|
networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
|
|
@@ -822,9 +853,11 @@ export interface UpgradeInfo {
|
|
|
822
853
|
// oci:// chart reference an OCI-sourced upgrade lives at (display only).
|
|
823
854
|
chartRef?: string
|
|
824
855
|
error?: string
|
|
856
|
+
// Machine-readable source-resolution failure, used to make the Helm drawer
|
|
857
|
+
// explain whether tracking an OCI source can help.
|
|
858
|
+
sourceIssue?: 'untracked' | 'repo_index_error' | 'ambiguous_repository'
|
|
825
859
|
// True only when the error is a genuinely untracked source (registering a
|
|
826
|
-
// chart source could fix it)
|
|
827
|
-
// classic ambiguity. Gates the "track source" affordance.
|
|
860
|
+
// chart source could fix it). Kept for compatibility; prefer sourceIssue.
|
|
828
861
|
untracked?: boolean
|
|
829
862
|
}
|
|
830
863
|
|
|
@@ -836,6 +869,8 @@ export interface BatchUpgradeInfo {
|
|
|
836
869
|
// Request body for applying new values to a release
|
|
837
870
|
export interface ApplyValuesRequest {
|
|
838
871
|
values: Record<string, unknown>
|
|
872
|
+
version?: string
|
|
873
|
+
repository?: string
|
|
839
874
|
}
|
|
840
875
|
|
|
841
876
|
// Response for previewing values changes
|
|
@@ -1234,10 +1269,28 @@ export interface ImageMetadata {
|
|
|
1234
1269
|
// ============================================================================
|
|
1235
1270
|
|
|
1236
1271
|
// Pod info returned from workload pods endpoint
|
|
1272
|
+
export interface WorkloadPodContainerInfo {
|
|
1273
|
+
name: string
|
|
1274
|
+
init?: boolean
|
|
1275
|
+
ready: boolean
|
|
1276
|
+
restartCount: number
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1237
1279
|
export interface WorkloadPodInfo {
|
|
1238
1280
|
name: string
|
|
1239
1281
|
containers: string[]
|
|
1240
1282
|
ready: boolean
|
|
1283
|
+
phase?: string
|
|
1284
|
+
healthLevel?: HealthStatus
|
|
1285
|
+
reason?: string
|
|
1286
|
+
message?: string
|
|
1287
|
+
restartCount?: number
|
|
1288
|
+
lastTerminationReason?: string
|
|
1289
|
+
createdAt?: string
|
|
1290
|
+
containerStatuses?: WorkloadPodContainerInfo[]
|
|
1291
|
+
stepID?: string
|
|
1292
|
+
stepName?: string
|
|
1293
|
+
stepPhase?: string
|
|
1241
1294
|
}
|
|
1242
1295
|
|
|
1243
1296
|
// SSE event types for workload log streaming
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { GitOpsResourceTree, HealthStatus, HelmOwnedResource, Topology, TopologyNode } from '../types'
|
|
2
|
+
import { apiVersionToGroup, groupQualifiesLaneId } from './navigation'
|
|
3
|
+
import { pluralize } from './pluralize'
|
|
4
|
+
|
|
5
|
+
export type DeploymentMembership = 'runtime-only' | 'source-only'
|
|
6
|
+
|
|
7
|
+
export interface DeploymentInventoryNode {
|
|
8
|
+
id: string
|
|
9
|
+
kind: string
|
|
10
|
+
group?: string
|
|
11
|
+
namespace: string
|
|
12
|
+
name: string
|
|
13
|
+
role: 'declared' | 'generated'
|
|
14
|
+
status?: HealthStatus
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeploymentInventory {
|
|
18
|
+
nodes: DeploymentInventoryNode[]
|
|
19
|
+
warnings?: string[]
|
|
20
|
+
complete?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DeploymentTopologyLayer {
|
|
24
|
+
topology: Topology
|
|
25
|
+
managedRuntimeCount: number
|
|
26
|
+
runtimeOnlyCount: number
|
|
27
|
+
managedOnly: DeploymentInventoryNode[]
|
|
28
|
+
managedOnlySummary?: string
|
|
29
|
+
inventoryComplete: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collapseStableReplicaSets(topology: Topology, expandedOwners: ReadonlySet<string>): Topology {
|
|
33
|
+
const nodesByID = new Map(topology.nodes.map((node) => [node.id, node]))
|
|
34
|
+
const replicaSetsByOwner = new Map<string, string[]>()
|
|
35
|
+
for (const edge of topology.edges) {
|
|
36
|
+
if (edge.type !== 'manages' || nodesByID.get(edge.target)?.kind !== 'ReplicaSet') continue
|
|
37
|
+
replicaSetsByOwner.set(edge.source, [...(replicaSetsByOwner.get(edge.source) ?? []), edge.target])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const collapsedReplicaSets = new Set<string>()
|
|
41
|
+
const rewiredEdges = [] as Topology['edges']
|
|
42
|
+
const parentData = new Map<string, Record<string, unknown>>()
|
|
43
|
+
|
|
44
|
+
for (const [ownerID, replicaSetIDs] of replicaSetsByOwner) {
|
|
45
|
+
const owner = nodesByID.get(ownerID)
|
|
46
|
+
const replicaSet = replicaSetIDs.length === 1 ? nodesByID.get(replicaSetIDs[0]) : undefined
|
|
47
|
+
const ready = Number(replicaSet?.data?.readyReplicas ?? 0)
|
|
48
|
+
const total = Number(replicaSet?.data?.totalReplicas ?? 0)
|
|
49
|
+
const incoming = topology.edges.filter((edge) => edge.target === replicaSet?.id)
|
|
50
|
+
const stable = owner?.status === 'healthy'
|
|
51
|
+
&& replicaSet?.status === 'healthy'
|
|
52
|
+
&& total > 0
|
|
53
|
+
&& ready >= total
|
|
54
|
+
&& incoming.every((edge) => edge.source === ownerID && edge.type === 'manages')
|
|
55
|
+
const shouldCollapse = replicaSetIDs.length === 1 && stable && !expandedOwners.has(ownerID)
|
|
56
|
+
parentData.set(ownerID, {
|
|
57
|
+
replicaSetCount: replicaSetIDs.length,
|
|
58
|
+
replicaSetsCollapsed: shouldCollapse,
|
|
59
|
+
replicaSetsExpandable: stable && replicaSetIDs.length === 1,
|
|
60
|
+
})
|
|
61
|
+
if (shouldCollapse && replicaSet) collapsedReplicaSets.add(replicaSet.id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const edge of topology.edges) {
|
|
65
|
+
if (collapsedReplicaSets.has(edge.target)) continue
|
|
66
|
+
if (!collapsedReplicaSets.has(edge.source)) {
|
|
67
|
+
rewiredEdges.push(edge)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
const ownerEdge = topology.edges.find((candidate) => candidate.target === edge.source && candidate.type === 'manages')
|
|
71
|
+
if (!ownerEdge) continue
|
|
72
|
+
rewiredEdges.push({
|
|
73
|
+
...edge,
|
|
74
|
+
id: `${ownerEdge.source}-to-${edge.target}`,
|
|
75
|
+
source: ownerEdge.source,
|
|
76
|
+
type: 'manages',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...topology,
|
|
82
|
+
nodes: topology.nodes
|
|
83
|
+
.filter((node) => !collapsedReplicaSets.has(node.id))
|
|
84
|
+
.map((node) => parentData.has(node.id) ? { ...node, data: { ...node.data, ...parentData.get(node.id) } } : node),
|
|
85
|
+
edges: rewiredEdges,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GENERATED_RUNTIME_KINDS = new Set(['pod', 'podgroup', 'replicaset'])
|
|
90
|
+
const MAX_INDIVIDUAL_SOURCE_ONLY_RESOURCES = 12
|
|
91
|
+
|
|
92
|
+
function identityKey(kind: string, group: string | undefined, namespace: string, name: string): string {
|
|
93
|
+
const identityGroup = groupQualifiesLaneId(group) ? group!.toLowerCase() : ''
|
|
94
|
+
return `${identityGroup}/${kind.toLowerCase()}/${namespace}/${name}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function topologyGroup(node: TopologyNode): string | undefined {
|
|
98
|
+
return apiVersionToGroup(node.data?.apiVersion as string | undefined) || (node.data?.sourceGroup as string | undefined)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function deploymentInventoryFromGitOps(tree: GitOpsResourceTree | undefined): DeploymentInventory | undefined {
|
|
102
|
+
if (!tree) return undefined
|
|
103
|
+
return {
|
|
104
|
+
nodes: tree.nodes
|
|
105
|
+
.filter((node) => node.role === 'declared' || node.role === 'generated')
|
|
106
|
+
.map((node) => ({
|
|
107
|
+
id: node.id,
|
|
108
|
+
kind: node.ref.kind,
|
|
109
|
+
group: node.ref.group,
|
|
110
|
+
namespace: node.ref.namespace,
|
|
111
|
+
name: node.ref.name,
|
|
112
|
+
role: node.role as 'declared' | 'generated',
|
|
113
|
+
status: node.topologyStatus,
|
|
114
|
+
})),
|
|
115
|
+
warnings: tree.warnings,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function deploymentInventoryFromHelm(resources: HelmOwnedResource[] | undefined): DeploymentInventory | undefined {
|
|
120
|
+
if (!resources) return undefined
|
|
121
|
+
return {
|
|
122
|
+
complete: false,
|
|
123
|
+
nodes: resources.map((resource) => ({
|
|
124
|
+
id: `helm/${resource.kind.toLowerCase()}/${resource.namespace}/${resource.name}`,
|
|
125
|
+
kind: resource.kind,
|
|
126
|
+
group: apiVersionToGroup(resource.apiVersion),
|
|
127
|
+
namespace: resource.namespace,
|
|
128
|
+
name: resource.name,
|
|
129
|
+
role: 'declared',
|
|
130
|
+
status: helmResourceStatus(resource.status),
|
|
131
|
+
})),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function helmResourceStatus(status: string | undefined): HealthStatus {
|
|
136
|
+
const normalized = status?.toLowerCase() ?? ''
|
|
137
|
+
if (['running', 'active', 'ready', 'succeeded', 'completed', 'bound'].includes(normalized)) return 'healthy'
|
|
138
|
+
if (['failed', 'error', 'unhealthy'].includes(normalized)) return 'unhealthy'
|
|
139
|
+
if (['pending', 'degraded'].includes(normalized)) return 'degraded'
|
|
140
|
+
return 'unknown'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function layerDeploymentInventory(
|
|
144
|
+
topology: Topology,
|
|
145
|
+
inventory: DeploymentInventory,
|
|
146
|
+
sourceLabel: string,
|
|
147
|
+
): DeploymentTopologyLayer {
|
|
148
|
+
const inventoryByExact = new Map(inventory.nodes.map((node) => [
|
|
149
|
+
identityKey(node.kind, node.group, node.namespace, node.name),
|
|
150
|
+
node,
|
|
151
|
+
]))
|
|
152
|
+
const matchedInventoryIds = new Set<string>()
|
|
153
|
+
let managedRuntimeCount = 0
|
|
154
|
+
let runtimeOnlyCount = 0
|
|
155
|
+
const inventoryComplete = inventory.complete !== false && (inventory.warnings?.length ?? 0) === 0
|
|
156
|
+
|
|
157
|
+
const runtimeNodes = topology.nodes.map((node) => {
|
|
158
|
+
if (node.kind === 'PodGroup' || node.kind === 'Internet') return node
|
|
159
|
+
const namespace = (node.data?.namespace as string) || ''
|
|
160
|
+
const match = inventoryByExact.get(identityKey(node.kind, topologyGroup(node), namespace, node.name))
|
|
161
|
+
if (match) {
|
|
162
|
+
matchedInventoryIds.add(match.id)
|
|
163
|
+
managedRuntimeCount += 1
|
|
164
|
+
return node
|
|
165
|
+
}
|
|
166
|
+
if (!inventoryComplete || GENERATED_RUNTIME_KINDS.has(node.kind.toLowerCase())) return node
|
|
167
|
+
runtimeOnlyCount += 1
|
|
168
|
+
return {
|
|
169
|
+
...node,
|
|
170
|
+
data: {
|
|
171
|
+
...node.data,
|
|
172
|
+
deploymentMembership: 'runtime-only' satisfies DeploymentMembership,
|
|
173
|
+
deploymentSourceLabel: sourceLabel,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const managedOnly = inventory.nodes.filter((node) => node.role === 'declared' && !matchedInventoryIds.has(node.id))
|
|
179
|
+
const managedOnlySummary = managedOnly.length > MAX_INDIVIDUAL_SOURCE_ONLY_RESOURCES
|
|
180
|
+
? summarizeInventory(managedOnly)
|
|
181
|
+
: undefined
|
|
182
|
+
const sourceOnlyNodes = managedOnlySummary
|
|
183
|
+
? []
|
|
184
|
+
: groupedSourceOnlyNodes(managedOnly, sourceLabel)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
topology: { ...topology, nodes: [...runtimeNodes, ...sourceOnlyNodes] },
|
|
188
|
+
managedRuntimeCount,
|
|
189
|
+
runtimeOnlyCount,
|
|
190
|
+
managedOnly,
|
|
191
|
+
managedOnlySummary,
|
|
192
|
+
inventoryComplete,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function groupedSourceOnlyNodes(nodes: DeploymentInventoryNode[], sourceLabel: string): TopologyNode[] {
|
|
197
|
+
const groups = new Map<string, DeploymentInventoryNode[]>()
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const key = `${node.group ?? ''}/${node.kind}/${node.namespace}`
|
|
200
|
+
groups.set(key, [...(groups.get(key) ?? []), node])
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const result: TopologyNode[] = []
|
|
204
|
+
for (const [groupKey, group] of groups) {
|
|
205
|
+
if (group.length <= 3) {
|
|
206
|
+
result.push(...group.map((node) => sourceOnlyNode(node, sourceLabel)))
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
const representative = group[0]
|
|
210
|
+
result.push({
|
|
211
|
+
id: `source-group/${groupKey}`,
|
|
212
|
+
kind: representative.kind,
|
|
213
|
+
name: pluralize(group.length, representative.kind),
|
|
214
|
+
status: worstTopologyStatus(group.map((node) => node.status)),
|
|
215
|
+
data: {
|
|
216
|
+
namespace: representative.namespace,
|
|
217
|
+
sourceGroup: representative.group,
|
|
218
|
+
sourceInventoryGroup: true,
|
|
219
|
+
sourceInventoryCount: group.length,
|
|
220
|
+
deploymentMembership: 'source-only' satisfies DeploymentMembership,
|
|
221
|
+
deploymentSourceLabel: sourceLabel,
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function summarizeInventory(nodes: DeploymentInventoryNode[]): string {
|
|
229
|
+
const counts = new Map<string, number>()
|
|
230
|
+
for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1)
|
|
231
|
+
const leadingKinds = [...counts.entries()]
|
|
232
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
233
|
+
.slice(0, 2)
|
|
234
|
+
const summarizedCount = leadingKinds.reduce((total, [, count]) => total + count, 0)
|
|
235
|
+
const summary = [
|
|
236
|
+
...leadingKinds.map(([kind, count]) => pluralize(count, kind)),
|
|
237
|
+
...(nodes.length > summarizedCount ? [`${nodes.length - summarizedCount} more`] : []),
|
|
238
|
+
].join(' · ')
|
|
239
|
+
|
|
240
|
+
return summary
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function sourceOnlyNode(node: DeploymentInventoryNode, sourceLabel: string): TopologyNode {
|
|
244
|
+
return {
|
|
245
|
+
id: `source/${node.id}`,
|
|
246
|
+
kind: node.kind,
|
|
247
|
+
name: node.name,
|
|
248
|
+
status: node.status ?? 'unknown',
|
|
249
|
+
data: {
|
|
250
|
+
namespace: node.namespace,
|
|
251
|
+
sourceGroup: node.group,
|
|
252
|
+
deploymentMembership: 'source-only' satisfies DeploymentMembership,
|
|
253
|
+
deploymentSourceLabel: sourceLabel,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function worstTopologyStatus(statuses: Array<HealthStatus | undefined>): HealthStatus {
|
|
259
|
+
const rank: Record<HealthStatus, number> = { unknown: 0, neutral: 1, healthy: 2, degraded: 3, unhealthy: 4 }
|
|
260
|
+
return statuses.reduce<HealthStatus>((worst, status) => status && rank[status] > rank[worst] ? status : worst, 'unknown')
|
|
261
|
+
}
|