@skyhook-io/k8s-ui 1.5.13 → 1.6.0
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 +3 -3
- 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/index.ts +4 -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/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +4 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesView.tsx +71 -18
- 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 +1 -1
- 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 +19 -2
- 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 +6 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +136 -0
- package/src/utils/gitops-owner.ts +92 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -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,799 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Background,
|
|
4
|
+
BackgroundVariant,
|
|
5
|
+
Controls,
|
|
6
|
+
Handle,
|
|
7
|
+
MarkerType,
|
|
8
|
+
Position,
|
|
9
|
+
ReactFlow,
|
|
10
|
+
ReactFlowProvider,
|
|
11
|
+
useReactFlow,
|
|
12
|
+
type Edge,
|
|
13
|
+
type Node,
|
|
14
|
+
type NodeProps,
|
|
15
|
+
type NodeTypes,
|
|
16
|
+
} from '@xyflow/react'
|
|
17
|
+
import '@xyflow/react/dist/style.css'
|
|
18
|
+
import { AlertTriangle, ChevronRight, Loader2, Maximize, Search, X } from 'lucide-react'
|
|
19
|
+
import { clsx } from 'clsx'
|
|
20
|
+
|
|
21
|
+
import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
22
|
+
import { displayKind } from '../../../types'
|
|
23
|
+
import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
24
|
+
import { formatCompactAge } from '../../../utils/format'
|
|
25
|
+
import { Tooltip } from '../../ui/Tooltip'
|
|
26
|
+
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
27
|
+
|
|
28
|
+
export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
|
|
29
|
+
|
|
30
|
+
const RANK_GAP = 390
|
|
31
|
+
const ROW_GAP = 118
|
|
32
|
+
const NODE_WIDTH = 320
|
|
33
|
+
const NODE_HEIGHT = 84
|
|
34
|
+
const GROUP_WIDTH = 230
|
|
35
|
+
const GROUP_HEIGHT = 64
|
|
36
|
+
const ROOT_Y_OFFSET = 28
|
|
37
|
+
|
|
38
|
+
const WORKLOAD_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'Job', 'CronJob'])
|
|
39
|
+
const WORKLOAD_CHILD_KINDS = new Set(['ReplicaSet', 'Pod', 'PodGroup'])
|
|
40
|
+
const COMPACT_INFRA_KINDS = new Set([
|
|
41
|
+
'AppProject',
|
|
42
|
+
'ClusterRole',
|
|
43
|
+
'ClusterRoleBinding',
|
|
44
|
+
'ConfigMap',
|
|
45
|
+
'CustomResourceDefinition',
|
|
46
|
+
'Role',
|
|
47
|
+
'RoleBinding',
|
|
48
|
+
'Secret',
|
|
49
|
+
'SealedSecret',
|
|
50
|
+
'ServiceAccount',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
interface GitOpsTreeGraphProps {
|
|
54
|
+
tree: GitOpsResourceTree | null
|
|
55
|
+
loading?: boolean
|
|
56
|
+
error?: Error | null
|
|
57
|
+
onNodeClick?: (ref: GitOpsTreeRef, node: GitOpsTreeNode) => void
|
|
58
|
+
preset?: GitOpsTreePreset
|
|
59
|
+
onPresetChange?: (preset: GitOpsTreePreset) => void
|
|
60
|
+
query?: string
|
|
61
|
+
onQueryChange?: (query: string) => void
|
|
62
|
+
filters?: GitOpsTreeFilters
|
|
63
|
+
showToolbar?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function GitOpsTreeGraph(props: GitOpsTreeGraphProps) {
|
|
67
|
+
return (
|
|
68
|
+
<ReactFlowProvider>
|
|
69
|
+
<GitOpsTreeGraphInner {...props} />
|
|
70
|
+
</ReactFlowProvider>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function GitOpsTreeGraphInner({
|
|
75
|
+
tree,
|
|
76
|
+
loading = false,
|
|
77
|
+
error = null,
|
|
78
|
+
onNodeClick,
|
|
79
|
+
preset: controlledPreset,
|
|
80
|
+
onPresetChange,
|
|
81
|
+
query: controlledQuery,
|
|
82
|
+
onQueryChange,
|
|
83
|
+
filters,
|
|
84
|
+
showToolbar = true,
|
|
85
|
+
}: GitOpsTreeGraphProps) {
|
|
86
|
+
const [internalPreset, setInternalPreset] = useState<GitOpsTreePreset>('compact')
|
|
87
|
+
const [internalQuery, setInternalQuery] = useState('')
|
|
88
|
+
const preset = controlledPreset ?? internalPreset
|
|
89
|
+
const query = controlledQuery ?? internalQuery
|
|
90
|
+
const setPreset = onPresetChange ?? setInternalPreset
|
|
91
|
+
const setQuery = onQueryChange ?? setInternalQuery
|
|
92
|
+
const reactFlow = useReactFlow()
|
|
93
|
+
// Per-group expand state (keyed by group node ID, e.g. "<parent>/compact/<kind>").
|
|
94
|
+
// When a group ID is in this set, compactInfra leaves the children visible
|
|
95
|
+
// instead of collapsing them into a single "N <kind>s" node. Collapses
|
|
96
|
+
// back to the default when the user toggles off or switches preset.
|
|
97
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
98
|
+
// Reset expansion when leaving compact preset — there are no groups to
|
|
99
|
+
// expand in 'full', and stale state would surprise the user when they
|
|
100
|
+
// toggle back.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (preset !== 'compact') setExpandedGroups(new Set())
|
|
103
|
+
}, [preset])
|
|
104
|
+
const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups), [tree, preset, query, filters, expandedGroups])
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (nodes.length === 0) return
|
|
108
|
+
const id = window.setTimeout(() => reactFlow.fitView({ padding: 0.18, maxZoom: 1.15, duration: 180 }), 0)
|
|
109
|
+
return () => window.clearTimeout(id)
|
|
110
|
+
}, [nodes.length, edges.length, preset, query, reactFlow])
|
|
111
|
+
|
|
112
|
+
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
|
113
|
+
const gitOpsNode = node.data.node as GitOpsTreeNode | undefined
|
|
114
|
+
if (!gitOpsNode) return
|
|
115
|
+
if (gitOpsNode.role === 'group') {
|
|
116
|
+
// Toggle expand/collapse for this group. Re-render replaces the
|
|
117
|
+
// synthetic group node with the original children (or vice versa).
|
|
118
|
+
setExpandedGroups((prev) => {
|
|
119
|
+
const next = new Set(prev)
|
|
120
|
+
if (next.has(gitOpsNode.id)) next.delete(gitOpsNode.id)
|
|
121
|
+
else next.add(gitOpsNode.id)
|
|
122
|
+
return next
|
|
123
|
+
})
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
onNodeClick?.(gitOpsNode.ref, gitOpsNode)
|
|
127
|
+
}, [onNodeClick])
|
|
128
|
+
|
|
129
|
+
if (loading) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex h-full items-center justify-center text-theme-text-secondary">
|
|
132
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
133
|
+
Loading GitOps resource tree...
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (error) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="flex h-full items-center justify-center p-6 text-sm text-red-500">
|
|
141
|
+
<AlertTriangle className="mr-2 h-4 w-4" />
|
|
142
|
+
Failed to load GitOps tree: {error.message}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!tree || nodes.length === 0) {
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
150
|
+
No managed resources found for this GitOps object.
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="relative h-full min-h-0 min-w-0">
|
|
157
|
+
{showToolbar && (
|
|
158
|
+
<GitOpsTreeToolbar
|
|
159
|
+
preset={preset}
|
|
160
|
+
onPresetChange={setPreset}
|
|
161
|
+
query={query}
|
|
162
|
+
onQueryChange={setQuery}
|
|
163
|
+
onFit={() => reactFlow.fitView({ padding: 0.18, maxZoom: 1.15, duration: 180 })}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
<ReactFlow
|
|
167
|
+
nodes={nodes}
|
|
168
|
+
edges={edges}
|
|
169
|
+
nodeTypes={nodeTypes}
|
|
170
|
+
onNodeClick={handleNodeClick}
|
|
171
|
+
nodesDraggable={false}
|
|
172
|
+
nodesConnectable={false}
|
|
173
|
+
elementsSelectable
|
|
174
|
+
fitView
|
|
175
|
+
fitViewOptions={{ padding: 0.18, maxZoom: 1.15 }}
|
|
176
|
+
minZoom={0.15}
|
|
177
|
+
maxZoom={1.5}
|
|
178
|
+
proOptions={{ hideAttribution: true }}
|
|
179
|
+
className="bg-theme-base"
|
|
180
|
+
>
|
|
181
|
+
<Background
|
|
182
|
+
variant={BackgroundVariant.Dots}
|
|
183
|
+
gap={20}
|
|
184
|
+
size={1}
|
|
185
|
+
className="opacity-40"
|
|
186
|
+
/>
|
|
187
|
+
<Controls
|
|
188
|
+
className="!border-theme-border !bg-theme-surface"
|
|
189
|
+
showInteractive={false}
|
|
190
|
+
/>
|
|
191
|
+
</ReactFlow>
|
|
192
|
+
<EdgeLegend edges={edges} />
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// EdgeLegend documents the color encoding on the GitOps tree graph. Only
|
|
198
|
+
// renders rows for edge types actually present in the current view —
|
|
199
|
+
// hides the legend entirely when the graph has only `owns` edges (the
|
|
200
|
+
// default for Argo apps without dependencies).
|
|
201
|
+
function EdgeLegend({ edges }: { edges: Edge[] }) {
|
|
202
|
+
const types = new Set(edges.map((e) => e.data?.type as string).filter(Boolean))
|
|
203
|
+
const rows: Array<{ key: string; color: string; label: string }> = []
|
|
204
|
+
if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source repo' })
|
|
205
|
+
if (types.has('dependsOn')) rows.push({ key: 'dependsOn', color: getEdgeColor('dependsOn'), label: 'Depends on' })
|
|
206
|
+
// Always show the default ownership color when there are 2+ edge kinds —
|
|
207
|
+
// operators need to compare against it. Hide when "owns" is the only kind.
|
|
208
|
+
if (rows.length > 0) rows.unshift({ key: 'owns', color: getEdgeColor('owns'), label: 'Ownership' })
|
|
209
|
+
if (rows.length === 0) return null
|
|
210
|
+
return (
|
|
211
|
+
<div className="absolute bottom-4 left-4 z-10 rounded-md border border-theme-border bg-theme-surface/90 px-2.5 py-1.5 text-[10px] backdrop-blur">
|
|
212
|
+
<div className="mb-1 font-medium uppercase tracking-wide text-theme-text-tertiary">Edges</div>
|
|
213
|
+
<div className="flex flex-col gap-1">
|
|
214
|
+
{rows.map((row) => (
|
|
215
|
+
<div key={row.key} className="flex items-center gap-2 text-theme-text-secondary">
|
|
216
|
+
<span className="inline-block h-[2px] w-5 rounded" style={{ backgroundColor: row.color }} />
|
|
217
|
+
{row.label}
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function GitOpsTreeToolbar({
|
|
226
|
+
preset,
|
|
227
|
+
onPresetChange,
|
|
228
|
+
query,
|
|
229
|
+
onQueryChange,
|
|
230
|
+
onFit,
|
|
231
|
+
}: {
|
|
232
|
+
preset: GitOpsTreePreset
|
|
233
|
+
onPresetChange: (preset: GitOpsTreePreset) => void
|
|
234
|
+
query: string
|
|
235
|
+
onQueryChange: (query: string) => void
|
|
236
|
+
onFit: () => void
|
|
237
|
+
}) {
|
|
238
|
+
return (
|
|
239
|
+
<div className="absolute right-4 top-4 z-10 flex flex-wrap items-center justify-end gap-2">
|
|
240
|
+
<div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 p-1 backdrop-blur">
|
|
241
|
+
{(['compact', 'workloads', 'app', 'full'] as const).map(value => (
|
|
242
|
+
<button
|
|
243
|
+
key={value}
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => onPresetChange(value)}
|
|
246
|
+
className={clsx(
|
|
247
|
+
'rounded-md px-2.5 py-1 text-xs transition-colors',
|
|
248
|
+
preset === value
|
|
249
|
+
? 'bg-skyhook-600 text-white'
|
|
250
|
+
: 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
{getPresetLabel(value)}
|
|
254
|
+
</button>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
<div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
|
|
258
|
+
<Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
|
|
259
|
+
<input
|
|
260
|
+
value={query}
|
|
261
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
262
|
+
placeholder="Find node..."
|
|
263
|
+
className="w-36 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
|
|
264
|
+
/>
|
|
265
|
+
{query && (
|
|
266
|
+
<button type="button" onClick={() => onQueryChange('')} className="text-theme-text-tertiary hover:text-theme-text-primary">
|
|
267
|
+
<X className="h-3.5 w-3.5" />
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
<Tooltip content="Fit tree" delay={120}>
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={onFit}
|
|
275
|
+
className="flex items-center gap-1.5 rounded-lg border border-theme-border bg-theme-surface/90 px-2.5 py-1.5 text-xs text-theme-text-secondary backdrop-blur transition-colors hover:text-theme-text-primary"
|
|
276
|
+
>
|
|
277
|
+
<Maximize className="h-3.5 w-3.5" />
|
|
278
|
+
Fit
|
|
279
|
+
</button>
|
|
280
|
+
</Tooltip>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getPresetLabel(preset: GitOpsTreePreset): string {
|
|
286
|
+
switch (preset) {
|
|
287
|
+
case 'compact': return 'Compact'
|
|
288
|
+
case 'workloads': return 'Workloads'
|
|
289
|
+
case 'app': return 'Declared'
|
|
290
|
+
case 'full': return 'Full'
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getEdgeColor(type: string): string {
|
|
295
|
+
switch (type) {
|
|
296
|
+
case 'source': return '#0ea5e9'
|
|
297
|
+
case 'dependsOn': return '#f59e0b'
|
|
298
|
+
default: return '#64748b'
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>): { nodes: Node[]; edges: Edge[] } {
|
|
303
|
+
if (!tree) return { nodes: [], edges: [] }
|
|
304
|
+
const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
|
|
305
|
+
const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
|
|
306
|
+
const children = new Map<string, string[]>()
|
|
307
|
+
const incoming = new Map<string, number>()
|
|
308
|
+
for (const edge of visibleTree.edges) {
|
|
309
|
+
if (!byID.has(edge.source) || !byID.has(edge.target)) continue
|
|
310
|
+
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
311
|
+
incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ranks = assignRanks(visibleTree.root.id, byID, children, incoming)
|
|
315
|
+
const positioned = positionRanks(ranks, byID)
|
|
316
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
317
|
+
const nodes = Array.from(positioned.entries()).map(([id, position]) => {
|
|
318
|
+
const node = byID.get(id)!
|
|
319
|
+
return {
|
|
320
|
+
id,
|
|
321
|
+
type: 'gitopsResource',
|
|
322
|
+
position,
|
|
323
|
+
data: {
|
|
324
|
+
node,
|
|
325
|
+
highlighted: normalizedQuery !== '' && matchesQuery(node, normalizedQuery),
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const edges = visibleTree.edges
|
|
331
|
+
.filter(edge => positioned.has(edge.source) && positioned.has(edge.target))
|
|
332
|
+
.map(edge => ({
|
|
333
|
+
id: `${edge.source}->${edge.target}`,
|
|
334
|
+
source: edge.source,
|
|
335
|
+
target: edge.target,
|
|
336
|
+
type: 'smoothstep',
|
|
337
|
+
// The legend reads back edge.data?.type to decide which rows to render —
|
|
338
|
+
// ReactFlow doesn't preserve our custom `type` field at the top level
|
|
339
|
+
// because it overloads `type` for the edge component identity ("smoothstep").
|
|
340
|
+
data: { type: edge.type },
|
|
341
|
+
markerEnd: {
|
|
342
|
+
type: MarkerType.ArrowClosed,
|
|
343
|
+
width: 16,
|
|
344
|
+
height: 16,
|
|
345
|
+
color: getEdgeColor(edge.type),
|
|
346
|
+
},
|
|
347
|
+
style: {
|
|
348
|
+
stroke: getEdgeColor(edge.type),
|
|
349
|
+
strokeWidth: edge.type === 'owns' ? 1.5 : 1.75,
|
|
350
|
+
},
|
|
351
|
+
}))
|
|
352
|
+
|
|
353
|
+
return { nodes, edges }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function applyPreset(tree: GitOpsResourceTree, preset: GitOpsTreePreset, expandedGroups?: Set<string>): GitOpsResourceTree {
|
|
357
|
+
if (preset === 'full') return tree
|
|
358
|
+
if (preset === 'compact') return compactInfra(tree, expandedGroups)
|
|
359
|
+
|
|
360
|
+
const byID = new Map(tree.nodes.map(node => [node.id, node]))
|
|
361
|
+
const children = new Map<string, string[]>()
|
|
362
|
+
for (const edge of tree.edges) {
|
|
363
|
+
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const keep = new Set<string>([tree.root.id])
|
|
367
|
+
if (preset === 'app') {
|
|
368
|
+
for (const edge of tree.edges) {
|
|
369
|
+
if (edge.source === tree.root.id) keep.add(edge.target)
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
for (const node of tree.nodes) {
|
|
373
|
+
if (node.role === 'root' || WORKLOAD_KINDS.has(node.ref.kind) || WORKLOAD_CHILD_KINDS.has(node.ref.kind)) {
|
|
374
|
+
keep.add(node.id)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
let changed = true
|
|
378
|
+
while (changed) {
|
|
379
|
+
changed = false
|
|
380
|
+
for (const edge of tree.edges) {
|
|
381
|
+
if (keep.has(edge.target) && !keep.has(edge.source)) {
|
|
382
|
+
keep.add(edge.source)
|
|
383
|
+
changed = true
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
...tree,
|
|
391
|
+
nodes: tree.nodes.filter(node => keep.has(node.id)),
|
|
392
|
+
edges: tree.edges.filter(edge => keep.has(edge.source) && keep.has(edge.target)),
|
|
393
|
+
root: byID.get(tree.root.id) ?? tree.root,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function applyGraphFilters(tree: GitOpsResourceTree, filters?: GitOpsTreeFilters): GitOpsResourceTree {
|
|
398
|
+
if (!hasGitOpsTreeFilters(filters)) return tree
|
|
399
|
+
|
|
400
|
+
const parent = new Map<string, string>()
|
|
401
|
+
for (const edge of tree.edges) {
|
|
402
|
+
if (!parent.has(edge.target)) parent.set(edge.target, edge.source)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const keep = new Set<string>([tree.root.id])
|
|
406
|
+
for (const node of tree.nodes) {
|
|
407
|
+
if (node.id === tree.root.id) continue
|
|
408
|
+
if (matchesGitOpsTreeFilters(node, filters)) {
|
|
409
|
+
let current: string | undefined = node.id
|
|
410
|
+
while (current) {
|
|
411
|
+
keep.add(current)
|
|
412
|
+
current = parent.get(current)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
...tree,
|
|
419
|
+
nodes: tree.nodes.filter(node => keep.has(node.id)),
|
|
420
|
+
edges: tree.edges.filter(edge => keep.has(edge.source) && keep.has(edge.target)),
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function compactInfra(tree: GitOpsResourceTree, expandedGroups?: Set<string>): GitOpsResourceTree {
|
|
425
|
+
const children = new Map<string, string[]>()
|
|
426
|
+
const parent = new Map<string, string>()
|
|
427
|
+
for (const edge of tree.edges) {
|
|
428
|
+
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
429
|
+
parent.set(edge.target, edge.source)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const groups = new Map<string, GitOpsTreeNode[]>()
|
|
433
|
+
for (const node of tree.nodes) {
|
|
434
|
+
if (node.id === tree.root.id || node.role === 'group' || !COMPACT_INFRA_KINDS.has(node.ref.kind)) continue
|
|
435
|
+
if ((children.get(node.id) ?? []).length > 0) continue
|
|
436
|
+
const p = parent.get(node.id)
|
|
437
|
+
if (!p) continue
|
|
438
|
+
const key = `${p}|${node.ref.kind}`
|
|
439
|
+
groups.set(key, [...(groups.get(key) ?? []), node])
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const remove = new Set<string>()
|
|
443
|
+
const additions: GitOpsTreeNode[] = []
|
|
444
|
+
const edgeAdditions: GitOpsResourceTree['edges'] = []
|
|
445
|
+
for (const [key, nodes] of groups) {
|
|
446
|
+
if (nodes.length < 2) continue
|
|
447
|
+
const [p, kind] = key.split('|')
|
|
448
|
+
const id = `${p}/compact/${kind}`
|
|
449
|
+
// Per-group expand: when the user has clicked this group's bubble,
|
|
450
|
+
// skip the collapse step for that group only — leave the children
|
|
451
|
+
// visible alongside any other still-collapsed groups in the tree.
|
|
452
|
+
if (expandedGroups?.has(id)) continue
|
|
453
|
+
for (const node of nodes) remove.add(node.id)
|
|
454
|
+
additions.push({
|
|
455
|
+
id,
|
|
456
|
+
ref: { kind, namespace: '', name: `${nodes.length} ${pluralize(kind)}` },
|
|
457
|
+
role: 'group',
|
|
458
|
+
tool: nodes[0].tool,
|
|
459
|
+
topologyStatus: 'unknown',
|
|
460
|
+
groupedNodeIDs: nodes.map(node => node.id),
|
|
461
|
+
count: nodes.length,
|
|
462
|
+
data: { groupedKind: kind },
|
|
463
|
+
})
|
|
464
|
+
edgeAdditions.push({ source: p, target: id, type: 'owns' })
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (remove.size === 0) return tree
|
|
468
|
+
return {
|
|
469
|
+
...tree,
|
|
470
|
+
nodes: [...tree.nodes.filter(node => !remove.has(node.id)), ...additions],
|
|
471
|
+
edges: [
|
|
472
|
+
...tree.edges.filter(edge => !remove.has(edge.source) && !remove.has(edge.target)),
|
|
473
|
+
...edgeAdditions,
|
|
474
|
+
],
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function assignRanks(
|
|
479
|
+
rootID: string,
|
|
480
|
+
nodes: Map<string, GitOpsTreeNode>,
|
|
481
|
+
children: Map<string, string[]>,
|
|
482
|
+
incoming: Map<string, number>
|
|
483
|
+
): Map<number, string[]> {
|
|
484
|
+
const rankByID = new Map<string, number>()
|
|
485
|
+
const queue = [{ id: rootID, rank: 0 }]
|
|
486
|
+
// Cycle/explosion guard: GitOps trees are owner-ref DAGs in practice, but
|
|
487
|
+
// the EdgeManages set we feed in could pathologically include a cycle
|
|
488
|
+
// (e.g. a CRD whose owner ref points back at one of its own children, or
|
|
489
|
+
// a malformed inventory). Without this cap, the "previous >= rank" skip
|
|
490
|
+
// never fires for cyclic edges and the loop runs forever, freezing the
|
|
491
|
+
// UI thread. Cap at a large multiple of node count so legitimate trees
|
|
492
|
+
// never hit it; cycles bail with the partial layout we have so far.
|
|
493
|
+
let iterations = 0
|
|
494
|
+
const maxIterations = nodes.size * 8 + 64
|
|
495
|
+
|
|
496
|
+
while (queue.length > 0) {
|
|
497
|
+
if (++iterations > maxIterations) break
|
|
498
|
+
const current = queue.shift()!
|
|
499
|
+
const previous = rankByID.get(current.id)
|
|
500
|
+
if (previous !== undefined && previous >= current.rank) continue
|
|
501
|
+
rankByID.set(current.id, current.rank)
|
|
502
|
+
for (const child of children.get(current.id) ?? []) {
|
|
503
|
+
queue.push({ id: child, rank: current.rank + 1 })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const id of nodes.keys()) {
|
|
508
|
+
if (!rankByID.has(id)) {
|
|
509
|
+
rankByID.set(id, incoming.get(id) ? 1 : 0)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const ranks = new Map<number, string[]>()
|
|
514
|
+
for (const [id, rank] of rankByID.entries()) {
|
|
515
|
+
ranks.set(rank, [...(ranks.get(rank) ?? []), id])
|
|
516
|
+
}
|
|
517
|
+
for (const ids of ranks.values()) {
|
|
518
|
+
ids.sort((a, b) => compareTreeNodes(nodes.get(a), nodes.get(b)))
|
|
519
|
+
}
|
|
520
|
+
return ranks
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTreeNode>): Map<string, { x: number; y: number }> {
|
|
524
|
+
const positioned = new Map<string, { x: number; y: number }>()
|
|
525
|
+
const sortedRanks = Array.from(ranks.keys()).sort((a, b) => a - b)
|
|
526
|
+
|
|
527
|
+
for (const rank of sortedRanks) {
|
|
528
|
+
const ids = ranks.get(rank) ?? []
|
|
529
|
+
ids.forEach((id, row) => {
|
|
530
|
+
positioned.set(id, { x: rank * RANK_GAP, y: row * ROW_GAP })
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const rootRank = ranks.get(0) ?? []
|
|
535
|
+
if (rootRank.length === 1) {
|
|
536
|
+
const rootID = rootRank[0]
|
|
537
|
+
const nextRank = ranks.get(1) ?? []
|
|
538
|
+
if (nextRank.length > 0) {
|
|
539
|
+
const maxY = (nextRank.length - 1) * ROW_GAP
|
|
540
|
+
const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
|
|
541
|
+
positioned.set(rootID, { x: 0, y: Math.max(0, (maxY - rootHeight) / 2 - ROOT_Y_OFFSET) })
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return positioned
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean }>>) {
|
|
549
|
+
const node = data.node
|
|
550
|
+
const kind = normalizeDisplayKind(node)
|
|
551
|
+
const status = normalizeHealth(node.topologyStatus)
|
|
552
|
+
const terminating = isNodeTerminating(node)
|
|
553
|
+
const gitopsTool = gitopsToolForNode(node)
|
|
554
|
+
const chips = buildChips(node)
|
|
555
|
+
const dim = getNodeDimensions(node)
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
<>
|
|
559
|
+
<Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
|
|
560
|
+
<div
|
|
561
|
+
className={clsx(
|
|
562
|
+
'relative overflow-hidden rounded-lg border bg-theme-surface shadow-md transition-colors',
|
|
563
|
+
data.highlighted ? 'border-skyhook-400 ring-2 ring-skyhook-400/40' : 'border-theme-border',
|
|
564
|
+
// Lifecycle dominates the left-stripe color: a Terminating node
|
|
565
|
+
// paints orange regardless of its frozen sync/health state. Same
|
|
566
|
+
// logic as the fleet-row statusStripe — sync/health shouldn't
|
|
567
|
+
// signal "fix me" when the resource is being deleted.
|
|
568
|
+
terminating
|
|
569
|
+
? 'border-l-orange-500'
|
|
570
|
+
: status === 'healthy' && 'border-l-green-500',
|
|
571
|
+
!terminating && status === 'degraded' && 'border-l-yellow-500',
|
|
572
|
+
!terminating && status === 'unhealthy' && 'border-l-red-500',
|
|
573
|
+
!terminating && status === 'unknown' && 'border-l-slate-500',
|
|
574
|
+
// GitOps portal nodes get a slightly stronger ring so the user's
|
|
575
|
+
// eye picks them out from generic K8s leaves. Combined with the
|
|
576
|
+
// tool-icon overlay + "→ Open" affordance below, this signals
|
|
577
|
+
// "click me to dive into this CR's own GitOps view" without
|
|
578
|
+
// making the node look broken or alarming.
|
|
579
|
+
gitopsTool && !data.highlighted && 'ring-1 ring-skyhook-500/30 hover:ring-skyhook-500/60',
|
|
580
|
+
gitopsTool && 'cursor-pointer',
|
|
581
|
+
// Group nodes are interactive (click toggles expand) but the
|
|
582
|
+
// default ReactFlow handler doesn't add a hover state. A subtle
|
|
583
|
+
// border lift telegraphs "this responds to clicks" without
|
|
584
|
+
// looking like a primary action.
|
|
585
|
+
node.role === 'group' && 'cursor-pointer hover:border-theme-text-tertiary/50 hover:bg-theme-hover',
|
|
586
|
+
)}
|
|
587
|
+
style={{ width: dim.width, minHeight: dim.height, borderLeftWidth: 4 }}
|
|
588
|
+
>
|
|
589
|
+
<div className="px-3 py-2.5">
|
|
590
|
+
<div className="mb-1 flex items-center gap-1.5">
|
|
591
|
+
<span className={`topology-icon topology-icon-${kind.toLowerCase()}`} />
|
|
592
|
+
<span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
593
|
+
{node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
|
|
594
|
+
</span>
|
|
595
|
+
{/* Tool badge for GitOps portal nodes — sits between the kind
|
|
596
|
+
label and the status dot, signaling "this is a managed-
|
|
597
|
+
GitOps-app, not a leaf K8s resource". Keeps the dot's
|
|
598
|
+
position so non-portal nodes look unchanged. */}
|
|
599
|
+
{gitopsTool && (
|
|
600
|
+
<span className="rounded-sm border border-skyhook-500/30 bg-skyhook-500/10 px-1 py-px text-[9px] font-semibold uppercase tracking-wide text-skyhook-300">
|
|
601
|
+
{gitopsTool === 'argocd' ? 'Argo' : 'Flux'}
|
|
602
|
+
</span>
|
|
603
|
+
)}
|
|
604
|
+
<span className={clsx('ml-auto h-1.5 w-1.5 rounded-full', getStatusDotColor(status))} />
|
|
605
|
+
</div>
|
|
606
|
+
<div className="truncate pr-1 text-sm font-medium text-theme-text-primary">{node.ref.name}</div>
|
|
607
|
+
{/* Group nodes get a chevron at the bottom-right to advertise the
|
|
608
|
+
click-to-expand affordance. GitOps portal nodes get a similar
|
|
609
|
+
chevron at the right of the subtitle to signal "→ opens its
|
|
610
|
+
own page". The subtitle text alone wasn't enough — users were
|
|
611
|
+
treating the count as an immutable fact rather than a button. */}
|
|
612
|
+
<div className="mt-0.5 flex items-center gap-1 text-xs text-theme-text-secondary">
|
|
613
|
+
<span className="truncate">{getSubtitle(node)}</span>
|
|
614
|
+
{(node.role === 'group' || gitopsTool) && <ChevronRight className="ml-auto h-3 w-3 shrink-0 text-theme-text-tertiary" />}
|
|
615
|
+
</div>
|
|
616
|
+
{chips.length > 0 && (
|
|
617
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
618
|
+
{chips.slice(0, 4).map(chip => (
|
|
619
|
+
<span
|
|
620
|
+
key={`${chip.label}:${chip.value}`}
|
|
621
|
+
className={clsx(
|
|
622
|
+
'max-w-[145px] truncate rounded border px-1.5 py-0.5 text-[10px] leading-3',
|
|
623
|
+
chip.tone === 'warning'
|
|
624
|
+
? 'border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300'
|
|
625
|
+
: chip.tone === 'danger'
|
|
626
|
+
? 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300'
|
|
627
|
+
: 'border-theme-border bg-theme-elevated/70 text-theme-text-secondary'
|
|
628
|
+
)}
|
|
629
|
+
>
|
|
630
|
+
{chip.label ? `${chip.label}: ` : ''}{chip.value}
|
|
631
|
+
</span>
|
|
632
|
+
))}
|
|
633
|
+
</div>
|
|
634
|
+
)}
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
<Handle type="source" position={Position.Right} className="!h-0 !w-0 !border-0 !bg-transparent" />
|
|
638
|
+
</>
|
|
639
|
+
)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const nodeTypes: NodeTypes = {
|
|
643
|
+
gitopsResource: GitOpsResourceNode,
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// isNodeTerminating returns true when the backend tagged the node with
|
|
647
|
+
// metadata.deletionTimestamp (see enrichNodeFromObject in pkg/gitops/tree).
|
|
648
|
+
// The check tolerates older payloads that don't carry the field — they
|
|
649
|
+
// simply read as not-terminating, which is the safe default.
|
|
650
|
+
function isNodeTerminating(node: GitOpsTreeNode): boolean {
|
|
651
|
+
return Boolean(node.data?.deletionTimestamp)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// gitopsToolForNode returns 'argocd' | 'fluxcd' | undefined depending on
|
|
655
|
+
// whether the backend classified this node as itself a GitOps detail-page
|
|
656
|
+
// CR (Argo Application/Set/Project, Flux Kustomization/HelmRelease/source).
|
|
657
|
+
// These nodes are *portals* — clicking them opens that CR's own GitOps
|
|
658
|
+
// detail page rather than the standard resource drawer. The visual
|
|
659
|
+
// treatment around them (tool icon overlay, "→" affordance, stronger
|
|
660
|
+
// border) advertises that they're not just leaves.
|
|
661
|
+
function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
|
|
662
|
+
const t = node.data?.gitopsTool
|
|
663
|
+
if (t === 'argocd' || t === 'fluxcd') return t
|
|
664
|
+
return undefined
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string; tone?: 'neutral' | 'warning' | 'danger' }> {
|
|
668
|
+
const data = node.data ?? {}
|
|
669
|
+
const chips: Array<{ label?: string; value: string; tone?: 'neutral' | 'warning' | 'danger' }> = []
|
|
670
|
+
// Terminating dominates: surface a single Terminating chip and skip the
|
|
671
|
+
// sync/health chips entirely. Showing "OutOfSync" or "Degraded" alongside
|
|
672
|
+
// a deletion-in-progress tag makes a stuck zombie look like a routine
|
|
673
|
+
// sync problem — same misleading-state pattern we removed from the
|
|
674
|
+
// detail title row and fleet rows.
|
|
675
|
+
if (isNodeTerminating(node)) {
|
|
676
|
+
chips.push({ value: 'Terminating', tone: 'warning' })
|
|
677
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
|
|
678
|
+
return chips
|
|
679
|
+
}
|
|
680
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatAge(data.createdAt) })
|
|
681
|
+
const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
|
|
682
|
+
if (revision) chips.push({ label: 'rev', value: revision })
|
|
683
|
+
const attempted = stringData(data.attemptedRevision)
|
|
684
|
+
if (attempted && attempted !== revision) chips.push({ label: 'attempted', value: attempted, tone: 'warning' })
|
|
685
|
+
const wave = stringData(data.syncWave)
|
|
686
|
+
if (wave) chips.push({ label: 'wave', value: wave })
|
|
687
|
+
const hook = stringData(data.hook)
|
|
688
|
+
if (hook) chips.push({ value: hook })
|
|
689
|
+
const relationship = stringData(data.relationship)
|
|
690
|
+
if (relationship) chips.push({ value: relationship })
|
|
691
|
+
if (node.sync === 'OutOfSync') chips.push({ value: 'OutOfSync', tone: 'warning' })
|
|
692
|
+
if (node.health === 'Degraded' || node.health === 'Missing') chips.push({ value: node.health, tone: 'danger' })
|
|
693
|
+
return chips
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function getSubtitle(node: GitOpsTreeNode): string {
|
|
697
|
+
if (node.role === 'group') {
|
|
698
|
+
// Action-oriented copy invites the click; "collapsed" alone reads as
|
|
699
|
+
// a state description, not an affordance.
|
|
700
|
+
return 'Click to expand'
|
|
701
|
+
}
|
|
702
|
+
if (isNodeTerminating(node)) {
|
|
703
|
+
return 'Pending deletion'
|
|
704
|
+
}
|
|
705
|
+
if (node.sync || node.health) {
|
|
706
|
+
return [node.sync, node.health].filter(Boolean).join(' • ')
|
|
707
|
+
}
|
|
708
|
+
if (node.info?.[0]?.value) return node.info[0].value
|
|
709
|
+
return node.ref.namespace || ''
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function normalizeDisplayKind(node: GitOpsTreeNode): string {
|
|
713
|
+
if (node.role === 'group' && node.ref.kind === 'Pod') return 'PodGroup'
|
|
714
|
+
return node.ref.kind || 'PodGroup'
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function normalizeHealth(status?: string): HealthStatus {
|
|
718
|
+
if (status === 'healthy' || status === 'degraded' || status === 'unhealthy') return status
|
|
719
|
+
return 'unknown'
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function getStatusDotColor(status: HealthStatus): string {
|
|
723
|
+
return SEVERITY_DOT[healthToSeverity(status)]
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function getNodeDimensions(node: GitOpsTreeNode): { width: number; height: number } {
|
|
727
|
+
if (node.role === 'group') return { width: GROUP_WIDTH, height: GROUP_HEIGHT }
|
|
728
|
+
return { width: NODE_WIDTH, height: NODE_HEIGHT }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function matchesQuery(node: GitOpsTreeNode, query: string): boolean {
|
|
732
|
+
return [
|
|
733
|
+
node.ref.kind,
|
|
734
|
+
node.ref.name,
|
|
735
|
+
node.ref.namespace,
|
|
736
|
+
node.ref.group,
|
|
737
|
+
node.sync,
|
|
738
|
+
node.health,
|
|
739
|
+
].some(value => String(value ?? '').toLowerCase().includes(query))
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function compareTreeNodes(a?: GitOpsTreeNode, b?: GitOpsTreeNode): number {
|
|
743
|
+
if (!a || !b) return 0
|
|
744
|
+
const roleDiff = rolePriority(a.role) - rolePriority(b.role)
|
|
745
|
+
if (roleDiff !== 0) return roleDiff
|
|
746
|
+
const kindDiff = kindPriority(a.ref.kind) - kindPriority(b.ref.kind)
|
|
747
|
+
if (kindDiff !== 0) return kindDiff
|
|
748
|
+
return `${a.ref.namespace}/${a.ref.name}`.localeCompare(`${b.ref.namespace}/${b.ref.name}`)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function rolePriority(role: string): number {
|
|
752
|
+
switch (role) {
|
|
753
|
+
case 'root': return 0
|
|
754
|
+
case 'declared': return 1
|
|
755
|
+
case 'generated': return 2
|
|
756
|
+
case 'group': return 3
|
|
757
|
+
default: return 4
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function kindPriority(kind: string): number {
|
|
762
|
+
const priorities: Record<string, number> = {
|
|
763
|
+
Namespace: 0,
|
|
764
|
+
AppProject: 1,
|
|
765
|
+
ServiceAccount: 2,
|
|
766
|
+
Secret: 3,
|
|
767
|
+
SealedSecret: 3,
|
|
768
|
+
ConfigMap: 4,
|
|
769
|
+
CustomResourceDefinition: 5,
|
|
770
|
+
ClusterRole: 6,
|
|
771
|
+
ClusterRoleBinding: 7,
|
|
772
|
+
Role: 8,
|
|
773
|
+
RoleBinding: 9,
|
|
774
|
+
Service: 10,
|
|
775
|
+
Deployment: 11,
|
|
776
|
+
StatefulSet: 11,
|
|
777
|
+
DaemonSet: 11,
|
|
778
|
+
ReplicaSet: 12,
|
|
779
|
+
Pod: 13,
|
|
780
|
+
Ingress: 14,
|
|
781
|
+
Gateway: 14,
|
|
782
|
+
HTTPRoute: 15,
|
|
783
|
+
}
|
|
784
|
+
return priorities[kind] ?? 20
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function stringData(value: unknown): string {
|
|
788
|
+
return typeof value === 'string' ? value : ''
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function pluralize(kind: string): string {
|
|
792
|
+
if (kind.endsWith('s')) return kind
|
|
793
|
+
if (kind.endsWith('y')) return `${kind.slice(0, -1)}ies`
|
|
794
|
+
return `${kind}s`
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function formatAge(timestamp: string): string {
|
|
798
|
+
return formatCompactAge(timestamp)
|
|
799
|
+
}
|