@skyhook-io/k8s-ui 1.5.13 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
- package/src/components/dock/BottomDock.tsx +24 -17
- package/src/components/dock/DockContext.tsx +39 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
- package/src/components/gitops/insights/index.ts +6 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
- package/src/components/gitops/insights/insights-helpers.ts +99 -0
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +6 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +136 -30
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -3
- package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
- package/src/components/shared/EditableYamlView.tsx +28 -17
- package/src/components/shared/ManagedByChip.tsx +45 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +3 -3
- package/src/components/topology/TopologyGraph.tsx +3 -2
- package/src/components/ui/Tooltip.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +9 -21
- package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +66 -0
- package/src/hooks/useKeyboardShortcuts.tsx +3 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +48 -6
- package/src/types/gitops-insights.ts +193 -0
- package/src/types/gitops-tree.ts +57 -0
- package/src/types/index.ts +2 -0
- package/src/utils/badge-colors.ts +31 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +95 -0
- package/src/utils/gitops-owner.ts +55 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.ts +14 -0
- package/src/utils/resource-hierarchy.ts +47 -3
- package/src/utils/yaml.test.ts +101 -0
- package/src/utils/yaml.ts +26 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { GitOpsTreeGraph } from './GitOpsTreeGraph'
|
|
2
|
+
export type { GitOpsTreePreset } from './GitOpsTreeGraph'
|
|
3
|
+
export type { GitOpsTreeFilters } from './tree-helpers'
|
|
4
|
+
export { gitOpsFilterSet, hasGitOpsTreeFilters, matchesGitOpsTreeFilters } from './tree-helpers'
|
|
5
|
+
export { mergeGitOpsTrees, MERGED_NODE_SOURCE_KEY } from './merge'
|
|
6
|
+
export type { MergedNodeSource } from './merge'
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { mergeGitOpsTrees, MERGED_NODE_SOURCE_KEY } from './merge'
|
|
4
|
+
import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
|
|
5
|
+
|
|
6
|
+
// Helper: build a controller-side tree node. IDs follow the controller
|
|
7
|
+
// scheme (no prefix) so the merge function's destination-prefixing logic
|
|
8
|
+
// is what we actually test against.
|
|
9
|
+
function ctrlNode(
|
|
10
|
+
id: string,
|
|
11
|
+
kind: string,
|
|
12
|
+
name: string,
|
|
13
|
+
namespace: string,
|
|
14
|
+
extras: Partial<GitOpsTreeNode> = {},
|
|
15
|
+
): GitOpsTreeNode {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
ref: { kind, name, namespace },
|
|
19
|
+
role: 'declared',
|
|
20
|
+
tool: 'argocd',
|
|
21
|
+
...extras,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Helper: build a destination-side tree node. Same shape as a controller
|
|
26
|
+
// node; the merge function looks at refs + ids, not provenance.
|
|
27
|
+
function destNode(
|
|
28
|
+
id: string,
|
|
29
|
+
kind: string,
|
|
30
|
+
name: string,
|
|
31
|
+
namespace: string,
|
|
32
|
+
extras: Partial<GitOpsTreeNode> = {},
|
|
33
|
+
): GitOpsTreeNode {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
ref: { kind, name, namespace },
|
|
37
|
+
role: 'declared',
|
|
38
|
+
tool: 'argocd',
|
|
39
|
+
...extras,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('mergeGitOpsTrees', () => {
|
|
44
|
+
// The classic in-cluster case — caller didn't fetch a destination tree
|
|
45
|
+
// (single-cluster app) so destination is null. Returning the controller
|
|
46
|
+
// tree unchanged is what preserves Argo's status + summary tile counts
|
|
47
|
+
// on the in-cluster code path.
|
|
48
|
+
test('null destination returns controller unchanged', () => {
|
|
49
|
+
const controller: GitOpsResourceTree = {
|
|
50
|
+
root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
51
|
+
nodes: [
|
|
52
|
+
ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
53
|
+
ctrlNode('dep1', 'Deployment', 'web', 'prod'),
|
|
54
|
+
],
|
|
55
|
+
edges: [{ source: 'app1', target: 'dep1', type: 'owns' }],
|
|
56
|
+
warnings: ['ctrl-warn'],
|
|
57
|
+
}
|
|
58
|
+
const merged = mergeGitOpsTrees(controller, null)
|
|
59
|
+
expect(merged).toEqual(controller)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Matched node: destination provides live health (informer-side, more
|
|
63
|
+
// authoritative than Argo's polled status). The merge must overlay
|
|
64
|
+
// health/info without dropping the controller's per-resource sync state.
|
|
65
|
+
test('matched nodes get destination health overlaid; controller sync preserved', () => {
|
|
66
|
+
const controller: GitOpsResourceTree = {
|
|
67
|
+
root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
68
|
+
nodes: [
|
|
69
|
+
ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
70
|
+
ctrlNode('dep1', 'Deployment', 'web', 'prod', { sync: 'Synced', health: 'Unknown' }),
|
|
71
|
+
],
|
|
72
|
+
edges: [],
|
|
73
|
+
}
|
|
74
|
+
const destination: GitOpsResourceTree = {
|
|
75
|
+
root: destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
76
|
+
nodes: [
|
|
77
|
+
destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
78
|
+
destNode('d1', 'Deployment', 'web', 'prod', { health: 'Healthy', info: [{ name: 'replicas', value: '3/3' }] }),
|
|
79
|
+
],
|
|
80
|
+
edges: [],
|
|
81
|
+
}
|
|
82
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
83
|
+
const dep = merged.nodes.find((n) => n.ref.kind === 'Deployment')
|
|
84
|
+
expect(dep?.sync).toBe('Synced') // controller's sync wins
|
|
85
|
+
expect(dep?.health).toBe('Healthy') // destination's health overlaid
|
|
86
|
+
expect(dep?.info).toEqual([{ name: 'replicas', value: '3/3' }])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// The whole point of the merge: destination-side descendants (ReplicaSet,
|
|
90
|
+
// Pod) that Argo never sees get appended to the tree. Their ids get a
|
|
91
|
+
// `dest:` prefix to avoid collision with controller ids.
|
|
92
|
+
test('destination-only descendants get prefixed ids + edges follow', () => {
|
|
93
|
+
const controller: GitOpsResourceTree = {
|
|
94
|
+
root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
95
|
+
nodes: [
|
|
96
|
+
ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
97
|
+
ctrlNode('dep1', 'Deployment', 'web', 'prod'),
|
|
98
|
+
],
|
|
99
|
+
edges: [{ source: 'app1', target: 'dep1', type: 'owns' }],
|
|
100
|
+
}
|
|
101
|
+
const destination: GitOpsResourceTree = {
|
|
102
|
+
root: destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
103
|
+
nodes: [
|
|
104
|
+
destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
105
|
+
destNode('d1', 'Deployment', 'web', 'prod'),
|
|
106
|
+
destNode('rs1', 'ReplicaSet', 'web-abc', 'prod'),
|
|
107
|
+
destNode('p1', 'Pod', 'web-abc-xy', 'prod'),
|
|
108
|
+
],
|
|
109
|
+
edges: [
|
|
110
|
+
{ source: 'd1', target: 'rs1', type: 'owns' },
|
|
111
|
+
{ source: 'rs1', target: 'p1', type: 'owns' },
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
115
|
+
// 2 controller nodes + 2 destination-only descendants.
|
|
116
|
+
expect(merged.nodes).toHaveLength(4)
|
|
117
|
+
// Destination-only nodes carry the dest: prefix.
|
|
118
|
+
const rs = merged.nodes.find((n) => n.ref.kind === 'ReplicaSet')
|
|
119
|
+
const pod = merged.nodes.find((n) => n.ref.kind === 'Pod')
|
|
120
|
+
expect(rs?.id).toBe('dest:rs1')
|
|
121
|
+
expect(pod?.id).toBe('dest:p1')
|
|
122
|
+
// Edge from Deployment (matched: id becomes controller's `dep1`) to
|
|
123
|
+
// the prefixed ReplicaSet — proves the matched-id remap entry wires
|
|
124
|
+
// descendant edges to the correct parent.
|
|
125
|
+
expect(merged.edges).toContainEqual({ source: 'dep1', target: 'dest:rs1', type: 'owns' })
|
|
126
|
+
expect(merged.edges).toContainEqual({ source: 'dest:rs1', target: 'dest:p1', type: 'owns' })
|
|
127
|
+
// Controller's original edge survives.
|
|
128
|
+
expect(merged.edges).toContainEqual({ source: 'app1', target: 'dep1', type: 'owns' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Edge dedup must key on type — different edge types between the same
|
|
132
|
+
// nodes are distinct and both should survive. A regression that drops
|
|
133
|
+
// the type from the key would silently lose `owns` vs `dependsOn`.
|
|
134
|
+
test('edges deduped by source+target+type tuple, not just source+target', () => {
|
|
135
|
+
const controller: GitOpsResourceTree = {
|
|
136
|
+
root: ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
137
|
+
nodes: [
|
|
138
|
+
ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
139
|
+
ctrlNode('svc', 'Service', 'web', 'prod'),
|
|
140
|
+
],
|
|
141
|
+
edges: [{ source: 'app', target: 'svc', type: 'owns' }],
|
|
142
|
+
}
|
|
143
|
+
const destination: GitOpsResourceTree = {
|
|
144
|
+
root: destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
145
|
+
nodes: [
|
|
146
|
+
destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
147
|
+
destNode('s', 'Service', 'web', 'prod'),
|
|
148
|
+
],
|
|
149
|
+
edges: [
|
|
150
|
+
{ source: 'dr', target: 's', type: 'owns' }, // duplicate (different ids but same matched node) — should dedup
|
|
151
|
+
{ source: 'dr', target: 's', type: 'dependsOn' }, // distinct type — should survive
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
155
|
+
// dr is the synthetic dest root — dropped. So both edges in the
|
|
156
|
+
// destination tree have an undefined remapped source (the dropped
|
|
157
|
+
// root) and get filtered out. The controller edge survives.
|
|
158
|
+
expect(merged.edges).toHaveLength(1)
|
|
159
|
+
expect(merged.edges[0]).toEqual({ source: 'app', target: 'svc', type: 'owns' })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Warnings concat from both sides. Hub-only concern: per-cluster RBAC
|
|
163
|
+
// denials surface as destination warnings; controller-side scan errors
|
|
164
|
+
// surface as controller warnings. Both must reach the consumer.
|
|
165
|
+
test('warnings concat from both sides', () => {
|
|
166
|
+
const controller: GitOpsResourceTree = {
|
|
167
|
+
root: ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
168
|
+
nodes: [ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' })],
|
|
169
|
+
edges: [],
|
|
170
|
+
warnings: ['ctrl-warn'],
|
|
171
|
+
}
|
|
172
|
+
const destination: GitOpsResourceTree = {
|
|
173
|
+
root: destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
174
|
+
nodes: [destNode('dr', 'Application', 'app', 'argocd', { role: 'root' })],
|
|
175
|
+
edges: [],
|
|
176
|
+
warnings: ['dest-warn-1', 'dest-warn-2'],
|
|
177
|
+
}
|
|
178
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
179
|
+
expect(merged.warnings).toEqual(['ctrl-warn', 'dest-warn-1', 'dest-warn-2'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Summary recomputation: the merge sets summary to undefined so
|
|
183
|
+
// GitOpsTreeGraph recomputes from the merged node list (avoids
|
|
184
|
+
// double-counting overlay matches in declared/generated counts).
|
|
185
|
+
test('summary cleared to defer recomputation by GitOpsTreeGraph', () => {
|
|
186
|
+
const controller: GitOpsResourceTree = {
|
|
187
|
+
root: ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
188
|
+
nodes: [ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' })],
|
|
189
|
+
edges: [],
|
|
190
|
+
summary: { declared: 5, generated: 2, grouped: 0, degraded: 1, outOfSync: 0 },
|
|
191
|
+
}
|
|
192
|
+
const destination: GitOpsResourceTree = {
|
|
193
|
+
root: destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
194
|
+
nodes: [destNode('dr', 'Application', 'app', 'argocd', { role: 'root' })],
|
|
195
|
+
edges: [],
|
|
196
|
+
summary: { declared: 3, generated: 0, grouped: 0, degraded: 0, outOfSync: 0 },
|
|
197
|
+
}
|
|
198
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
199
|
+
expect(merged.summary).toBeUndefined()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Routing signal: every node carries `data._source` so consumers
|
|
203
|
+
// (Radar Hub's fleet detail page) can route resource-viewer clicks
|
|
204
|
+
// to the correct cluster without depending on the `dest:` ID prefix
|
|
205
|
+
// string convention. A future change to the ID format must NOT break
|
|
206
|
+
// routing — this test pins that separation.
|
|
207
|
+
test('every output node carries data._source for routing', () => {
|
|
208
|
+
const controller: GitOpsResourceTree = {
|
|
209
|
+
root: ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
210
|
+
nodes: [
|
|
211
|
+
ctrlNode('app', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
212
|
+
ctrlNode('dep', 'Deployment', 'web', 'prod'),
|
|
213
|
+
],
|
|
214
|
+
edges: [],
|
|
215
|
+
}
|
|
216
|
+
const destination: GitOpsResourceTree = {
|
|
217
|
+
root: destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
218
|
+
nodes: [
|
|
219
|
+
destNode('dr', 'Application', 'app', 'argocd', { role: 'root' }),
|
|
220
|
+
destNode('dep', 'Deployment', 'web', 'prod'), // matches controller node
|
|
221
|
+
destNode('pod', 'Pod', 'web-xyz', 'prod'), // destination-only
|
|
222
|
+
],
|
|
223
|
+
edges: [],
|
|
224
|
+
}
|
|
225
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
226
|
+
for (const n of merged.nodes) {
|
|
227
|
+
const source = (n.data ?? {})[MERGED_NODE_SOURCE_KEY]
|
|
228
|
+
expect(source).toBeDefined()
|
|
229
|
+
}
|
|
230
|
+
// `root` carries the same routing tag — consumers that read merged.root
|
|
231
|
+
// directly (rather than walking nodes) must not get undefined.
|
|
232
|
+
expect(merged.root.data?.[MERGED_NODE_SOURCE_KEY]).toBe('controller')
|
|
233
|
+
const app = merged.nodes.find((n) => n.ref.kind === 'Application')
|
|
234
|
+
const dep = merged.nodes.find((n) => n.ref.kind === 'Deployment')
|
|
235
|
+
const pod = merged.nodes.find((n) => n.ref.kind === 'Pod')
|
|
236
|
+
expect(app?.data?.[MERGED_NODE_SOURCE_KEY]).toBe('controller')
|
|
237
|
+
expect(dep?.data?.[MERGED_NODE_SOURCE_KEY]).toBe('controller') // matched: controller wins
|
|
238
|
+
expect(pod?.data?.[MERGED_NODE_SOURCE_KEY]).toBe('destination')
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { GitOpsResourceTree, GitOpsTreeEdge, GitOpsTreeNode, GitOpsTreeRef } from '../../../types/gitops-tree'
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// mergeGitOpsTrees — used by Radar Hub's fleet GitOps detail page to compose
|
|
5
|
+
// a complete cross-cluster tree from two independent fetches:
|
|
6
|
+
//
|
|
7
|
+
// 1. The controller cluster's /api/gitops/tree/{kind}/{ns}/{name} response
|
|
8
|
+
// ("controller tree"). Argo CD's view of the Application: the declared
|
|
9
|
+
// resources from .status.resources + Argo's server-computed sync state
|
|
10
|
+
// per resource. Truthful for "what does Argo think should exist + how
|
|
11
|
+
// does it think those resources are doing", but Argo doesn't walk
|
|
12
|
+
// ownerReferences past the resources it manages directly — it never
|
|
13
|
+
// sees ReplicaSets, Pods, EndpointSlices, etc.
|
|
14
|
+
//
|
|
15
|
+
// 2. The destination cluster's /api/gitops/managed-resources?app=...
|
|
16
|
+
// response ("destination tree"). Discovered by Argo's tracking
|
|
17
|
+
// annotation in the destination cluster's actual workloads. Carries
|
|
18
|
+
// the LIVE health/info from the in-cluster informer (more authoritative
|
|
19
|
+
// for "is this pod actually running") AND the walked descendant
|
|
20
|
+
// subtree (Deployment → ReplicaSet → Pod) that the controller can't see.
|
|
21
|
+
//
|
|
22
|
+
// Single-cluster Radar never needs this — for in-cluster apps the controller
|
|
23
|
+
// tree already has everything because Radar runs in the same cluster as
|
|
24
|
+
// the workloads. The merge is what makes Radar Hub's cross-cluster view
|
|
25
|
+
// strictly more complete than self-hosted Radar, not just a list of apps.
|
|
26
|
+
//
|
|
27
|
+
// Strategy: controller is the spine (preserves Argo's declared view +
|
|
28
|
+
// per-resource sync state); destination provides (a) live health/info
|
|
29
|
+
// overlay onto matching nodes, (b) descendant nodes Argo doesn't track.
|
|
30
|
+
// Destination's synthetic root is dropped; its non-root nodes that have
|
|
31
|
+
// no controller match are added with prefixed IDs to avoid ID collisions
|
|
32
|
+
// with the controller's node ids.
|
|
33
|
+
//
|
|
34
|
+
// Each output node carries an explicit `data._source` field set to
|
|
35
|
+
// `'controller'` or `'destination'`, which downstream consumers
|
|
36
|
+
// (Radar Hub's fleet detail page) use to route resource-viewer clicks
|
|
37
|
+
// to the correct cluster. The `dest:` ID prefix is preserved for graph
|
|
38
|
+
// rendering (duplicate IDs would break edge resolution) but NOT used as
|
|
39
|
+
// the routing signal — that's the explicit `data._source` field. This
|
|
40
|
+
// separation keeps a future ID-format change from silently breaking
|
|
41
|
+
// cross-cluster navigation.
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
// MergedNodeSource is the value of `node.data._source` after merge.
|
|
45
|
+
// Reading it from `data` (the documented extension point on
|
|
46
|
+
// GitOpsTreeNode) keeps the canonical type stable while giving
|
|
47
|
+
// consumers an explicit, type-checkable routing signal.
|
|
48
|
+
export type MergedNodeSource = 'controller' | 'destination'
|
|
49
|
+
|
|
50
|
+
// Key in node.data for the source tag. Exported so consumers don't
|
|
51
|
+
// hard-code the string; if it ever changes, both sides update together.
|
|
52
|
+
export const MERGED_NODE_SOURCE_KEY = '_source' as const
|
|
53
|
+
|
|
54
|
+
function refKey(ref: GitOpsTreeRef): string {
|
|
55
|
+
return `${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// withSource returns a shallow copy of `node` whose data field carries
|
|
59
|
+
// the given source tag. Other data keys pass through unchanged.
|
|
60
|
+
function withSource(node: GitOpsTreeNode, source: MergedNodeSource): GitOpsTreeNode {
|
|
61
|
+
return {
|
|
62
|
+
...node,
|
|
63
|
+
data: { ...(node.data ?? {}), [MERGED_NODE_SOURCE_KEY]: source },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mergeGitOpsTrees(
|
|
68
|
+
controller: GitOpsResourceTree,
|
|
69
|
+
destination: GitOpsResourceTree | null | undefined,
|
|
70
|
+
): GitOpsResourceTree {
|
|
71
|
+
if (!destination) return controller
|
|
72
|
+
|
|
73
|
+
// Index destination's non-root nodes by ref-key for overlay lookup.
|
|
74
|
+
const destByKey = new Map<string, GitOpsTreeNode>()
|
|
75
|
+
for (const n of destination.nodes) {
|
|
76
|
+
if (n.role === 'root') continue
|
|
77
|
+
destByKey.set(refKey(n.ref), n)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pass 1: walk controller nodes; for any match in destination, overlay
|
|
81
|
+
// its live health, info, and topologyStatus onto the controller node.
|
|
82
|
+
// Argo's per-resource sync state stays controller-side — it's
|
|
83
|
+
// Argo-internal and the destination informer doesn't compute it.
|
|
84
|
+
// Every controller node is tagged with _source='controller' for
|
|
85
|
+
// resource-viewer routing.
|
|
86
|
+
const nodes: GitOpsTreeNode[] = controller.nodes.map((n) => {
|
|
87
|
+
const dest = destByKey.get(refKey(n.ref))
|
|
88
|
+
const merged = dest
|
|
89
|
+
? {
|
|
90
|
+
...n,
|
|
91
|
+
health: dest.health ?? n.health,
|
|
92
|
+
info: dest.info ?? n.info,
|
|
93
|
+
topologyStatus: dest.topologyStatus ?? n.topologyStatus,
|
|
94
|
+
}
|
|
95
|
+
: n
|
|
96
|
+
return withSource(merged, 'controller')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Pass 2: any destination node WITHOUT a controller match is a descendant
|
|
100
|
+
// Argo doesn't track (ReplicaSet, Pod, EndpointSlice, etc.) — include
|
|
101
|
+
// them with a remapped ID to avoid colliding with controller IDs.
|
|
102
|
+
// destIdRemap is also used to rewrite destination edges in pass 3.
|
|
103
|
+
const controllerKeys = new Set(controller.nodes.map((n) => refKey(n.ref)))
|
|
104
|
+
const destOnly: GitOpsTreeNode[] = []
|
|
105
|
+
const destIdRemap = new Map<string, string>()
|
|
106
|
+
|
|
107
|
+
for (const n of destination.nodes) {
|
|
108
|
+
if (n.role === 'root') continue
|
|
109
|
+
const k = refKey(n.ref)
|
|
110
|
+
if (controllerKeys.has(k)) {
|
|
111
|
+
// Match — point destination's id at the controller's id for edge rewriting.
|
|
112
|
+
const ctrlNode = nodes.find((c) => refKey(c.ref) === k)
|
|
113
|
+
if (ctrlNode) destIdRemap.set(n.id, ctrlNode.id)
|
|
114
|
+
} else {
|
|
115
|
+
const newId = `dest:${n.id}`
|
|
116
|
+
destIdRemap.set(n.id, newId)
|
|
117
|
+
// dest: ID prefix is the collision-avoidance mechanism (graph
|
|
118
|
+
// rendering breaks on duplicate IDs); _source='destination' is
|
|
119
|
+
// the routing signal consumers actually read. Two distinct
|
|
120
|
+
// concerns, two distinct fields.
|
|
121
|
+
destOnly.push(withSource({ ...n, id: newId }, 'destination'))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pass 3: edge merge. Controller edges stay as-is (they describe Argo's
|
|
126
|
+
// declared topology). Destination edges between non-root nodes are
|
|
127
|
+
// remapped via destIdRemap; skip any whose endpoint is the (dropped)
|
|
128
|
+
// synthetic dest root, and skip duplicates of controller edges.
|
|
129
|
+
const edgeKey = (e: GitOpsTreeEdge) => `${e.source}->${e.target}:${e.type}`
|
|
130
|
+
const seen = new Set(controller.edges.map(edgeKey))
|
|
131
|
+
const edges: GitOpsTreeEdge[] = [...controller.edges]
|
|
132
|
+
|
|
133
|
+
for (const e of destination.edges) {
|
|
134
|
+
const src = destIdRemap.get(e.source)
|
|
135
|
+
const tgt = destIdRemap.get(e.target)
|
|
136
|
+
if (!src || !tgt) continue // endpoint was the synthetic root — drop.
|
|
137
|
+
const merged: GitOpsTreeEdge = { source: src, target: tgt, type: e.type }
|
|
138
|
+
const k = edgeKey(merged)
|
|
139
|
+
if (seen.has(k)) continue
|
|
140
|
+
seen.add(k)
|
|
141
|
+
edges.push(merged)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Warnings concat — destination's warnings (e.g. partial RBAC denials on
|
|
145
|
+
// some kinds during scan) surface alongside controller's.
|
|
146
|
+
const warnings = [...(controller.warnings ?? []), ...(destination.warnings ?? [])]
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
// `root` is tagged the same way nodes are — consumers reading
|
|
150
|
+
// `merged.root.data._source` rely on the same contract as nodes.
|
|
151
|
+
root: withSource(controller.root, 'controller'),
|
|
152
|
+
nodes: [...nodes, ...destOnly],
|
|
153
|
+
edges,
|
|
154
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
155
|
+
// Summary is derived UI metadata — let GitOpsTreeGraph recompute from
|
|
156
|
+
// the merged node list rather than trying to add controller +
|
|
157
|
+
// destination summaries (would double-count the overlay matches).
|
|
158
|
+
summary: undefined,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GitOpsTreeNode } from '../../../types'
|
|
2
|
+
|
|
3
|
+
export interface GitOpsTreeFilters {
|
|
4
|
+
kinds?: Set<string> | string[]
|
|
5
|
+
namespaces?: Set<string> | string[]
|
|
6
|
+
sync?: Set<string> | string[]
|
|
7
|
+
health?: Set<string> | string[]
|
|
8
|
+
roles?: Set<string> | string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function gitOpsFilterSet(values?: Set<string> | string[]): Set<string> | undefined {
|
|
12
|
+
if (!values) return undefined
|
|
13
|
+
const set = values instanceof Set ? values : new Set(values)
|
|
14
|
+
return set.size > 0 ? set : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
|
|
18
|
+
if (!filters) return true
|
|
19
|
+
const kinds = gitOpsFilterSet(filters.kinds)
|
|
20
|
+
const namespaces = gitOpsFilterSet(filters.namespaces)
|
|
21
|
+
const sync = gitOpsFilterSet(filters.sync)
|
|
22
|
+
const health = gitOpsFilterSet(filters.health)
|
|
23
|
+
const roles = gitOpsFilterSet(filters.roles)
|
|
24
|
+
|
|
25
|
+
if (kinds && !kinds.has(node.ref.kind)) return false
|
|
26
|
+
if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
|
|
27
|
+
if (sync && !sync.has(node.sync || 'Unknown')) return false
|
|
28
|
+
if (health && !health.has(node.health || 'Unknown')) return false
|
|
29
|
+
if (roles && !roles.has(node.role)) return false
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasGitOpsTreeFilters(filters?: GitOpsTreeFilters): boolean {
|
|
34
|
+
if (!filters) return false
|
|
35
|
+
return Boolean(
|
|
36
|
+
gitOpsFilterSet(filters.kinds) ||
|
|
37
|
+
gitOpsFilterSet(filters.namespaces) ||
|
|
38
|
+
gitOpsFilterSet(filters.sync) ||
|
|
39
|
+
gitOpsFilterSet(filters.health) ||
|
|
40
|
+
gitOpsFilterSet(filters.roles),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -94,7 +94,9 @@ const CORE_RESOURCE_TYPES = [
|
|
|
94
94
|
// Resource type button in sidebar
|
|
95
95
|
interface ResourceTypeButtonProps {
|
|
96
96
|
resource: APIResource
|
|
97
|
-
count
|
|
97
|
+
/** `null` means "count not loaded yet" — rendered as a placeholder so
|
|
98
|
+
* the badge doesn't flicker to "0" while the API call is in flight. */
|
|
99
|
+
count: number | null
|
|
98
100
|
isSelected: boolean
|
|
99
101
|
/** Keyboard-highlight state (arrow nav in the filter input). */
|
|
100
102
|
isHighlighted?: boolean
|
|
@@ -155,9 +157,11 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
|
|
|
155
157
|
<span className={clsx(
|
|
156
158
|
'text-xs py-0.5 rounded text-center font-mono',
|
|
157
159
|
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
158
|
-
count
|
|
160
|
+
count === null
|
|
161
|
+
? 'w-8 text-theme-text-disabled'
|
|
162
|
+
: count < 1000 ? 'w-8' : 'w-9',
|
|
159
163
|
)}>
|
|
160
|
-
{count}
|
|
164
|
+
{count === null ? '–' : count}
|
|
161
165
|
</span>
|
|
162
166
|
)}
|
|
163
167
|
</div>
|
|
@@ -253,12 +257,28 @@ export function ResourcesSidebar({
|
|
|
253
257
|
}))
|
|
254
258
|
}, [categories])
|
|
255
259
|
|
|
260
|
+
// null for a key means "not loaded yet" (rendered as a placeholder
|
|
261
|
+
// dash in the badge). 0 means "the API replied and confirmed there
|
|
262
|
+
// are zero of this kind". Don't conflate them — otherwise the
|
|
263
|
+
// sidebar shows a confident "0" for every kind while the count
|
|
264
|
+
// payload is still in flight.
|
|
256
265
|
const counts = useMemo(() => {
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
const results: Record<string, number | null> = {}
|
|
267
|
+
if (!resourceCounts) {
|
|
268
|
+
for (const resource of resourcesToCount) {
|
|
269
|
+
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
270
|
+
results[key] = null
|
|
271
|
+
}
|
|
272
|
+
return results
|
|
273
|
+
}
|
|
259
274
|
for (const resource of resourcesToCount) {
|
|
260
275
|
const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
|
|
261
|
-
|
|
276
|
+
const v = resourceCounts[key]
|
|
277
|
+
// Treat missing keys in a present resourceCounts payload as
|
|
278
|
+
// "the API replied and didn't include this kind" → 0. Treat the
|
|
279
|
+
// entire payload being absent as "not loaded" → null (handled
|
|
280
|
+
// above).
|
|
281
|
+
results[key] = v ?? 0
|
|
262
282
|
}
|
|
263
283
|
return results
|
|
264
284
|
}, [resourcesToCount, resourceCounts])
|
|
@@ -276,16 +296,22 @@ export function ResourcesSidebar({
|
|
|
276
296
|
let totalHiddenGroups = 0
|
|
277
297
|
|
|
278
298
|
const withTotals = categories.map(category => {
|
|
299
|
+
// Coerce nulls (loading) to 0 for the category total — we still
|
|
300
|
+
// want to show *some* number on collapsed categories during
|
|
301
|
+
// load, just not "0" badges on every individual kind.
|
|
279
302
|
const total = category.resources.reduce(
|
|
280
|
-
(sum, resource) => sum + (counts
|
|
303
|
+
(sum, resource) => sum + (counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? 0),
|
|
281
304
|
0
|
|
282
305
|
)
|
|
283
306
|
|
|
284
|
-
// Filter resources: show if has instances, is core kind,
|
|
307
|
+
// Filter resources: show if has instances, is core kind, has an
|
|
308
|
+
// unknown count (loading — don't pre-emptively hide), or
|
|
309
|
+
// showEmptyKinds is true.
|
|
285
310
|
const visibleResources = category.resources.filter(resource => {
|
|
286
|
-
const count = counts
|
|
311
|
+
const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
|
|
287
312
|
const isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
|
|
288
|
-
const
|
|
313
|
+
const isLoading = count === null
|
|
314
|
+
const shouldShow = (count ?? 0) > 0 || isCore || isLoading || showEmptyKinds
|
|
289
315
|
if (!shouldShow) totalHiddenKinds++
|
|
290
316
|
return shouldShow
|
|
291
317
|
})
|
|
@@ -500,7 +526,7 @@ export function ResourcesSidebar({
|
|
|
500
526
|
key={`${p.name}-${p.group}`}
|
|
501
527
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
502
528
|
resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
|
|
503
|
-
count={counts
|
|
529
|
+
count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
|
|
504
530
|
isSelected={isResourceSelected}
|
|
505
531
|
isHighlighted={highlighted}
|
|
506
532
|
isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
|
|
@@ -556,7 +582,7 @@ export function ResourcesSidebar({
|
|
|
556
582
|
key={resource.name}
|
|
557
583
|
ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
|
|
558
584
|
resource={resource}
|
|
559
|
-
count={counts
|
|
585
|
+
count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
|
|
560
586
|
isSelected={showSelected}
|
|
561
587
|
isHighlighted={highlighted}
|
|
562
588
|
isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
|
|
@@ -581,7 +607,7 @@ export function ResourcesSidebar({
|
|
|
581
607
|
? type.label.slice(0, -1)
|
|
582
608
|
: type.label
|
|
583
609
|
const Icon = getResourceIcon(kindKey)
|
|
584
|
-
const count = counts
|
|
610
|
+
const count = counts[kindKey] ?? null
|
|
585
611
|
const isSelected = effectiveSelectedKind.name === type.kind && !effectiveSelectedKind.group
|
|
586
612
|
return (
|
|
587
613
|
<button
|
|
@@ -600,9 +626,10 @@ export function ResourcesSidebar({
|
|
|
600
626
|
<span className="flex-1 text-left">{type.label}</span>
|
|
601
627
|
<span className={clsx(
|
|
602
628
|
'badge font-mono',
|
|
603
|
-
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated'
|
|
629
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
630
|
+
count === null && 'text-theme-text-disabled',
|
|
604
631
|
)}>
|
|
605
|
-
{count}
|
|
632
|
+
{count === null ? '–' : count}
|
|
606
633
|
</span>
|
|
607
634
|
</button>
|
|
608
635
|
)
|