@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.
Files changed (57) hide show
  1. package/package.json +4 -4
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  8. package/src/components/gitops/RollbackDialog.tsx +117 -0
  9. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  10. package/src/components/gitops/detail-helpers.test.ts +97 -0
  11. package/src/components/gitops/detail-helpers.ts +112 -0
  12. package/src/components/gitops/index.ts +55 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  14. package/src/components/gitops/insights/index.ts +6 -0
  15. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  16. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  17. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  18. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  19. package/src/components/gitops/tree/index.ts +6 -0
  20. package/src/components/gitops/tree/merge.test.ts +240 -0
  21. package/src/components/gitops/tree/merge.ts +160 -0
  22. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  23. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  24. package/src/components/resources/ResourcesView.tsx +136 -30
  25. package/src/components/resources/index.ts +1 -1
  26. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  28. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  30. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  31. package/src/components/shared/EditableYamlView.tsx +28 -17
  32. package/src/components/shared/ManagedByChip.tsx +45 -0
  33. package/src/components/shared/index.ts +1 -0
  34. package/src/components/timeline/TimelineList.tsx +3 -3
  35. package/src/components/topology/TopologyGraph.tsx +3 -2
  36. package/src/components/ui/Tooltip.tsx +10 -1
  37. package/src/components/ui/drawer-components.tsx +9 -21
  38. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  39. package/src/components/workload/WorkloadView.tsx +66 -0
  40. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  41. package/src/index.ts +3 -0
  42. package/src/types/core.ts +48 -6
  43. package/src/types/gitops-insights.ts +193 -0
  44. package/src/types/gitops-tree.ts +57 -0
  45. package/src/types/index.ts +2 -0
  46. package/src/utils/badge-colors.ts +31 -1
  47. package/src/utils/format.ts +28 -0
  48. package/src/utils/gitops-owner.test.ts +95 -0
  49. package/src/utils/gitops-owner.ts +55 -0
  50. package/src/utils/gitops-route.test.ts +78 -0
  51. package/src/utils/gitops-route.ts +104 -0
  52. package/src/utils/helm-status.test.ts +50 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.ts +14 -0
  55. package/src/utils/resource-hierarchy.ts +47 -3
  56. package/src/utils/yaml.test.ts +101 -0
  57. 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: number
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 < 1000 ? 'w-8' : 'w-9'
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
- if (!resourceCounts) return {} as Record<string, number>
258
- const results: Record<string, number> = {}
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
- results[key] = resourceCounts[key] ?? 0
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?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0),
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, or showEmptyKinds is true
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?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0
311
+ const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
287
312
  const isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
288
- const shouldShow = count > 0 || isCore || showEmptyKinds
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?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
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?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
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?.[kindKey] ?? 0
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
  )