@skyhook-io/k8s-ui 1.8.9 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { WorkloadView, type WorkloadTabType } from './WorkloadView'
|
|
1
|
+
export { WorkloadView, type ResourceOwnershipContext, type ServingResourceDetail, type WorkloadTabType } from './WorkloadView'
|
|
2
2
|
export { ResourceDetailDrawer } from './ResourceDetailDrawer'
|
package/src/theme/variables.css
CHANGED
|
@@ -54,6 +54,10 @@
|
|
|
54
54
|
--border-default: light-dark(#D8E0EE, #222840);
|
|
55
55
|
--border-light: light-dark(#CDD6E8, #2C324A);
|
|
56
56
|
--border-subtle: light-dark(rgba(74, 100, 180, 0.07), rgba(255, 255, 255, 0.04));
|
|
57
|
+
/* Emphasis edge for the focused/expanded card in a queue — must read clearly
|
|
58
|
+
against sibling cards' --border-default, unlike --border-light's one-notch
|
|
59
|
+
step. */
|
|
60
|
+
--border-emphasis: light-dark(#96A8CA, rgba(255, 255, 255, 0.22));
|
|
57
61
|
--border-focus: var(--color-brand);
|
|
58
62
|
|
|
59
63
|
/* ── Accent ── */
|
|
@@ -108,6 +112,16 @@
|
|
|
108
112
|
--group-icon-app: light-dark(#059669, #34d399);
|
|
109
113
|
--group-icon-label: light-dark(#d97706, #fbbf24);
|
|
110
114
|
|
|
115
|
+
/* ── Terminal / console surface (intentionally dark in BOTH themes — it's a
|
|
116
|
+
console, not chrome). The outer border darkens on a light page and lightens
|
|
117
|
+
on a dark one so the block stays defined; the inner divider and text stay
|
|
118
|
+
light because the surface is always dark. ── */
|
|
119
|
+
--terminal-bg: #0F172A;
|
|
120
|
+
--terminal-border: light-dark(#1E293B, rgba(255, 255, 255, 0.10));
|
|
121
|
+
--terminal-divider: rgba(255, 255, 255, 0.10);
|
|
122
|
+
--terminal-label: #94A3B8;
|
|
123
|
+
--terminal-text: #CBD5E1;
|
|
124
|
+
|
|
111
125
|
/* ── ANSI terminal colors (theme-aware for log viewer) ── */
|
|
112
126
|
--ansi-black: light-dark(#3b3b3b, #4c4c4c);
|
|
113
127
|
--ansi-red: light-dark(#cd3131, #cd3131);
|
|
@@ -142,6 +156,6 @@
|
|
|
142
156
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.06);
|
|
143
157
|
--shadow-md: 0 4px 12px -2px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.15), 0 0 0 1px rgba(255,255,255,0.06);
|
|
144
158
|
--shadow-lg: 0 12px 32px -6px rgba(0,0,0,0.3), 0 4px 8px rgba(0,0,0,0.1);
|
|
145
|
-
--shadow-drawer: -
|
|
159
|
+
--shadow-drawer: -12px 0 36px rgba(0,0,0,0.45), -4px 0 14px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.06);
|
|
146
160
|
--topology-node-shadow: 0 2px 6px rgba(0,0,0,0.2), 0 6px 16px rgba(0,0,0,0.15);
|
|
147
161
|
}
|
package/src/types/core.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ResourcePermissions {
|
|
|
11
11
|
statefulSets: boolean
|
|
12
12
|
replicaSets: boolean
|
|
13
13
|
ingresses: boolean
|
|
14
|
+
ingressClasses: boolean
|
|
14
15
|
configMaps: boolean
|
|
15
16
|
secrets: boolean
|
|
16
17
|
events: boolean
|
|
@@ -94,7 +95,6 @@ export interface Deployment {
|
|
|
94
95
|
// When adding a new kind here, also update:
|
|
95
96
|
// - ALL_NODE_KINDS in App.tsx
|
|
96
97
|
// - TopologyFilterSidebar.tsx RESOURCE_KINDS array
|
|
97
|
-
// - index.css .topology-icon-* class
|
|
98
98
|
// - K8sResourceNode.tsx NODE_DIMENSIONS
|
|
99
99
|
// - resource-icons.ts KIND_ICON_MAP
|
|
100
100
|
// - kindToPlural in navigation.ts (if irregular plural)
|
|
@@ -123,9 +123,17 @@ export type CoreNodeKind =
|
|
|
123
123
|
| 'PodGroup'
|
|
124
124
|
| 'ConfigMap'
|
|
125
125
|
| 'Secret'
|
|
126
|
+
| 'ServiceAccount'
|
|
127
|
+
| 'SealedSecret'
|
|
128
|
+
| 'ServiceMonitor'
|
|
129
|
+
| 'PodMonitor'
|
|
126
130
|
| 'HorizontalPodAutoscaler'
|
|
127
131
|
| 'Job'
|
|
128
132
|
| 'CronJob'
|
|
133
|
+
| 'Workflow'
|
|
134
|
+
| 'CronWorkflow'
|
|
135
|
+
| 'WorkflowTemplate'
|
|
136
|
+
| 'ClusterWorkflowTemplate'
|
|
129
137
|
| 'PersistentVolumeClaim'
|
|
130
138
|
| 'Node'
|
|
131
139
|
| 'Namespace'
|
|
@@ -168,6 +176,10 @@ export function displayKind(kind: string): string {
|
|
|
168
176
|
const shortNames: Record<string, string> = {
|
|
169
177
|
HorizontalPodAutoscaler: 'HPA',
|
|
170
178
|
PersistentVolumeClaim: 'PVC',
|
|
179
|
+
ServiceAccount: 'Service Account',
|
|
180
|
+
SealedSecret: 'Sealed Secret',
|
|
181
|
+
ServiceMonitor: 'Service Monitor',
|
|
182
|
+
PodMonitor: 'Pod Monitor',
|
|
171
183
|
EC2NodeClass: 'NodeClass',
|
|
172
184
|
KnativeService: 'Knative Svc',
|
|
173
185
|
KnativeConfiguration: 'Knative Config',
|
|
@@ -275,6 +287,10 @@ export interface TimelineEvent {
|
|
|
275
287
|
id: string
|
|
276
288
|
timestamp: string // ISO date string
|
|
277
289
|
source: EventSource // Where event originated: 'informer', 'k8s_event', 'historical'
|
|
290
|
+
// Store-assigned arrival number (monotonic per store instance). The delta
|
|
291
|
+
// cursor keys on it — arrival order, not event time, so late-arriving
|
|
292
|
+
// events can't slip behind a client's cursor.
|
|
293
|
+
seq?: number
|
|
278
294
|
|
|
279
295
|
// Resource identity
|
|
280
296
|
kind: string
|
|
@@ -339,6 +355,20 @@ export function isWorkloadKind(kind: string): boolean {
|
|
|
339
355
|
].includes(kind)
|
|
340
356
|
}
|
|
341
357
|
|
|
358
|
+
export function isDeploymentLikeWorkloadKind(kind: string, group = ''): boolean {
|
|
359
|
+
switch (kind) {
|
|
360
|
+
case 'Deployment':
|
|
361
|
+
case 'ReplicaSet':
|
|
362
|
+
case 'StatefulSet':
|
|
363
|
+
case 'DaemonSet':
|
|
364
|
+
return group === '' || group === 'apps'
|
|
365
|
+
case 'Rollout':
|
|
366
|
+
return group === '' || group === 'argoproj.io'
|
|
367
|
+
default:
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
// Check if a resource kind is typically managed by another
|
|
343
373
|
export function isManagedKind(kind: string): boolean {
|
|
344
374
|
return ['ReplicaSet', 'Pod', 'Event'].includes(kind)
|
|
@@ -353,7 +383,7 @@ export interface TimelineFilters {
|
|
|
353
383
|
timeRange: TimeRange
|
|
354
384
|
}
|
|
355
385
|
|
|
356
|
-
export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | 'all'
|
|
386
|
+
export type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d' | 'all'
|
|
357
387
|
|
|
358
388
|
// Cluster info
|
|
359
389
|
export interface ClusterInfo {
|
|
@@ -445,6 +475,7 @@ export interface Relationships {
|
|
|
445
475
|
configRefs?: ResourceRef[]
|
|
446
476
|
consumers?: ResourceRef[]
|
|
447
477
|
scalers?: ResourceRef[]
|
|
478
|
+
storageRefs?: ResourceRef[]
|
|
448
479
|
scaleTarget?: ResourceRef
|
|
449
480
|
pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
|
|
450
481
|
networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
|
|
@@ -822,9 +853,11 @@ export interface UpgradeInfo {
|
|
|
822
853
|
// oci:// chart reference an OCI-sourced upgrade lives at (display only).
|
|
823
854
|
chartRef?: string
|
|
824
855
|
error?: string
|
|
856
|
+
// Machine-readable source-resolution failure, used to make the Helm drawer
|
|
857
|
+
// explain whether tracking an OCI source can help.
|
|
858
|
+
sourceIssue?: 'untracked' | 'repo_index_error' | 'ambiguous_repository'
|
|
825
859
|
// True only when the error is a genuinely untracked source (registering a
|
|
826
|
-
// chart source could fix it)
|
|
827
|
-
// classic ambiguity. Gates the "track source" affordance.
|
|
860
|
+
// chart source could fix it). Kept for compatibility; prefer sourceIssue.
|
|
828
861
|
untracked?: boolean
|
|
829
862
|
}
|
|
830
863
|
|
|
@@ -836,6 +869,8 @@ export interface BatchUpgradeInfo {
|
|
|
836
869
|
// Request body for applying new values to a release
|
|
837
870
|
export interface ApplyValuesRequest {
|
|
838
871
|
values: Record<string, unknown>
|
|
872
|
+
version?: string
|
|
873
|
+
repository?: string
|
|
839
874
|
}
|
|
840
875
|
|
|
841
876
|
// Response for previewing values changes
|
|
@@ -1234,10 +1269,28 @@ export interface ImageMetadata {
|
|
|
1234
1269
|
// ============================================================================
|
|
1235
1270
|
|
|
1236
1271
|
// Pod info returned from workload pods endpoint
|
|
1272
|
+
export interface WorkloadPodContainerInfo {
|
|
1273
|
+
name: string
|
|
1274
|
+
init?: boolean
|
|
1275
|
+
ready: boolean
|
|
1276
|
+
restartCount: number
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1237
1279
|
export interface WorkloadPodInfo {
|
|
1238
1280
|
name: string
|
|
1239
1281
|
containers: string[]
|
|
1240
1282
|
ready: boolean
|
|
1283
|
+
phase?: string
|
|
1284
|
+
healthLevel?: HealthStatus
|
|
1285
|
+
reason?: string
|
|
1286
|
+
message?: string
|
|
1287
|
+
restartCount?: number
|
|
1288
|
+
lastTerminationReason?: string
|
|
1289
|
+
createdAt?: string
|
|
1290
|
+
containerStatuses?: WorkloadPodContainerInfo[]
|
|
1291
|
+
stepID?: string
|
|
1292
|
+
stepName?: string
|
|
1293
|
+
stepPhase?: string
|
|
1241
1294
|
}
|
|
1242
1295
|
|
|
1243
1296
|
// SSE event types for workload log streaming
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { categorizeResources, formatGroupName, shortenGroupName } from './api-resources'
|
|
3
|
+
|
|
4
|
+
describe('formatGroupName', () => {
|
|
5
|
+
it('uses friendly names for common CRD groups seen in clusters', () => {
|
|
6
|
+
expect(formatGroupName('policies.kyverno.io')).toBe('Kyverno')
|
|
7
|
+
expect(formatGroupName('networking.gke.io')).toBe('GKE Networking')
|
|
8
|
+
expect(formatGroupName('apiregistration.k8s.io')).toBe('API Registration')
|
|
9
|
+
expect(formatGroupName('monitoring.googleapis.com')).toBe('Google Cloud Monitoring')
|
|
10
|
+
expect(formatGroupName('sql.cnrm.cloud.google.com')).toBe('Config Connector')
|
|
11
|
+
expect(formatGroupName('crd.k8s.amazonaws.com')).toBe('AWS VPC CNI')
|
|
12
|
+
expect(formatGroupName('acid.zalan.do')).toBe('Zalando Postgres')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('formats unmapped API groups without exposing raw domain strings', () => {
|
|
16
|
+
expect(formatGroupName('widgets.example.io')).toBe('Example Widgets')
|
|
17
|
+
expect(formatGroupName('api.my-company.dev')).toBe('My Company API')
|
|
18
|
+
expect(formatGroupName('dns.gke.example.io')).toBe('Example DNS GKE')
|
|
19
|
+
expect(formatGroupName('widgets.example.io')).not.toMatch(/\./)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not promote Kubernetes domain-family suffixes into visible labels', () => {
|
|
23
|
+
expect(formatGroupName('flowcontrol.apiserver.k8s.io')).toBe('Apiserver Flowcontrol')
|
|
24
|
+
expect(formatGroupName('addons.cluster.x-k8s.io')).toBe('Cluster API')
|
|
25
|
+
expect(formatGroupName('ipam.cluster.x-k8s.io')).toBe('Cluster API')
|
|
26
|
+
expect(formatGroupName('nfd.k8s-sigs.io')).toBe('NFD')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps unmapped CRD groups separate from core categories', () => {
|
|
30
|
+
expect(formatGroupName('networking.io')).toBe('Networking APIs')
|
|
31
|
+
expect(formatGroupName('storage.io')).toBe('Storage APIs')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps shortenGroupName as the legacy suffix-stripping helper', () => {
|
|
35
|
+
expect(shortenGroupName('networking.gke.io')).toBe('networking.gke')
|
|
36
|
+
expect(shortenGroupName('rbac.authorization.k8s.io')).toBe('rbac.authorization')
|
|
37
|
+
expect(shortenGroupName('widgets.example.dev')).toBe('widgets.example')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('categorizeResources', () => {
|
|
42
|
+
it('does not merge unmapped CRDs into matching core categories', () => {
|
|
43
|
+
const categories = categorizeResources([
|
|
44
|
+
{ group: 'networking.io', version: 'v1', kind: 'WidgetRoute', name: 'widgetroutes', namespaced: true, isCrd: true, verbs: ['list'] },
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
const networking = categories.find(c => c.name === 'Networking')
|
|
48
|
+
const networkingAPIs = categories.find(c => c.name === 'Networking APIs')
|
|
49
|
+
|
|
50
|
+
expect(networking?.resources.some(r => r.kind === 'Service')).toBe(true)
|
|
51
|
+
expect(networking?.resources.some(r => r.kind === 'WidgetRoute')).toBe(false)
|
|
52
|
+
expect(networkingAPIs?.resources.map(r => r.kind)).toEqual(['WidgetRoute'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -13,6 +13,7 @@ const CONFIG_KINDS = ['ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'PodDisr
|
|
|
13
13
|
const STORAGE_KINDS = ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass', 'VolumeAttachment']
|
|
14
14
|
const ACCESS_CONTROL_KINDS = ['ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding']
|
|
15
15
|
const CLUSTER_KINDS = ['Node', 'Namespace', 'Event']
|
|
16
|
+
const CORE_CATEGORY_NAMES = new Set(['Workloads', 'Networking', 'Configuration', 'Storage', 'Access Control', 'Cluster'])
|
|
16
17
|
|
|
17
18
|
// Core resources that must always be present (fallback if API discovery misses them)
|
|
18
19
|
export const CORE_RESOURCES: APIResource[] = [
|
|
@@ -112,6 +113,7 @@ export function categorizeResources(resources: APIResource[]): ResourceCategory[
|
|
|
112
113
|
export function formatGroupName(group: string): string {
|
|
113
114
|
const knownGroups: Record<string, string> = {
|
|
114
115
|
'argoproj.io': 'Argo',
|
|
116
|
+
'apiregistration.k8s.io': 'API Registration',
|
|
115
117
|
'cert-manager.io': 'Cert Manager',
|
|
116
118
|
'acme.cert-manager.io': 'Cert Manager',
|
|
117
119
|
'istio.io': 'Istio',
|
|
@@ -119,10 +121,12 @@ export function formatGroupName(group: string): string {
|
|
|
119
121
|
'security.istio.io': 'Istio',
|
|
120
122
|
'telemetry.istio.io': 'Istio',
|
|
121
123
|
'monitoring.coreos.com': 'Prometheus',
|
|
124
|
+
'monitoring.googleapis.com': 'Google Cloud Monitoring',
|
|
122
125
|
'velero.io': 'Velero',
|
|
123
126
|
'external-secrets.io': 'External Secrets',
|
|
124
127
|
'keda.sh': 'KEDA',
|
|
125
128
|
'gateway.networking.k8s.io': 'Gateway API',
|
|
129
|
+
'gateway.envoyproxy.io': 'Envoy Gateway',
|
|
126
130
|
'traefik.io': 'Traefik',
|
|
127
131
|
'traefik.containo.us': 'Traefik',
|
|
128
132
|
'crossplane.io': 'Crossplane',
|
|
@@ -166,7 +170,11 @@ export function formatGroupName(group: string): string {
|
|
|
166
170
|
'infrastructure.cluster.x-k8s.io': 'Cluster API',
|
|
167
171
|
'ceph.rook.io': 'Rook',
|
|
168
172
|
'kyverno.io': 'Kyverno',
|
|
173
|
+
'policies.kyverno.io': 'Kyverno',
|
|
169
174
|
'k8s.nginx.org': 'NGINX',
|
|
175
|
+
'networking.gke.io': 'GKE Networking',
|
|
176
|
+
'warden.gke.io': 'GKE Warden',
|
|
177
|
+
'cloud.google.com': 'Google Cloud',
|
|
170
178
|
'sparkoperator.k8s.io': 'Spark',
|
|
171
179
|
'kubeflow.org': 'Kubeflow',
|
|
172
180
|
'snapshot.storage.k8s.io': 'Snapshots',
|
|
@@ -177,6 +185,12 @@ export function formatGroupName(group: string): string {
|
|
|
177
185
|
'resource.k8s.io': 'Dynamic Resource Allocation',
|
|
178
186
|
'kueue.x-k8s.io': 'Kueue',
|
|
179
187
|
'autoscaling.x-k8s.io': 'Cluster Autoscaler',
|
|
188
|
+
'crd.k8s.amazonaws.com': 'AWS VPC CNI',
|
|
189
|
+
'vpcresources.k8s.aws': 'AWS VPC CNI',
|
|
190
|
+
'elbv2.k8s.aws': 'AWS Load Balancer',
|
|
191
|
+
'eks.amazonaws.com': 'EKS',
|
|
192
|
+
'networking.k8s.aws': 'AWS Networking',
|
|
193
|
+
'acid.zalan.do': 'Zalando Postgres',
|
|
180
194
|
'serving.kserve.io': 'KServe',
|
|
181
195
|
'ray.io': 'KubeRay',
|
|
182
196
|
'leaderworkerset.x-k8s.io': 'LeaderWorkerSet',
|
|
@@ -197,7 +211,10 @@ export function formatGroupName(group: string): string {
|
|
|
197
211
|
// their own per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
|
|
198
212
|
if (group.endsWith('.upbound.io')) return 'Crossplane'
|
|
199
213
|
if (group.endsWith('.crossplane.io')) return 'Crossplane'
|
|
200
|
-
return
|
|
214
|
+
if (group === 'cluster.x-k8s.io' || group.endsWith('.cluster.x-k8s.io')) return 'Cluster API'
|
|
215
|
+
if (group.endsWith('.cnrm.cloud.google.com')) return 'Config Connector'
|
|
216
|
+
if (group.endsWith('.openshift.io')) return 'OpenShift'
|
|
217
|
+
return formatUnmappedGroupName(group)
|
|
201
218
|
}
|
|
202
219
|
|
|
203
220
|
export function shortenGroupName(group: string): string {
|
|
@@ -206,6 +223,56 @@ export function shortenGroupName(group: string): string {
|
|
|
206
223
|
.replace(/\.k8s$/, '')
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
const GROUP_SUFFIX_LABELS = new Set([
|
|
227
|
+
'io', 'com', 'org', 'dev', 'sh', 'net', 'do', 'ai', 'cloud', 'co', 'app',
|
|
228
|
+
'k8s', 'x-k8s', 'k8s-sigs', 'sigs',
|
|
229
|
+
])
|
|
230
|
+
const GROUP_ACRONYMS: Record<string, string> = {
|
|
231
|
+
api: 'API',
|
|
232
|
+
aws: 'AWS',
|
|
233
|
+
amazonaws: 'AWS',
|
|
234
|
+
cnrm: 'CNRM',
|
|
235
|
+
crd: 'CRD',
|
|
236
|
+
csi: 'CSI',
|
|
237
|
+
dns: 'DNS',
|
|
238
|
+
gcp: 'GCP',
|
|
239
|
+
gke: 'GKE',
|
|
240
|
+
gpu: 'GPU',
|
|
241
|
+
hpa: 'HPA',
|
|
242
|
+
ipam: 'IPAM',
|
|
243
|
+
k8s: 'K8s',
|
|
244
|
+
nfd: 'NFD',
|
|
245
|
+
tcp: 'TCP',
|
|
246
|
+
tls: 'TLS',
|
|
247
|
+
udp: 'UDP',
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatUnmappedGroupName(group: string): string {
|
|
251
|
+
const labels = group
|
|
252
|
+
.split('.')
|
|
253
|
+
.map(label => label.trim().toLowerCase())
|
|
254
|
+
.filter(label => label && !GROUP_SUFFIX_LABELS.has(label))
|
|
255
|
+
|
|
256
|
+
if (labels.length === 0) return group
|
|
257
|
+
if (labels.length === 1) return avoidCoreCategoryCollision(formatGroupToken(labels[0]))
|
|
258
|
+
|
|
259
|
+
const owner = labels[labels.length - 1]
|
|
260
|
+
const descriptors = labels.slice(0, -1)
|
|
261
|
+
return avoidCoreCategoryCollision([owner, ...descriptors].map(formatGroupToken).join(' '))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function avoidCoreCategoryCollision(label: string): string {
|
|
265
|
+
return CORE_CATEGORY_NAMES.has(label) ? `${label} APIs` : label
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function formatGroupToken(token: string): string {
|
|
269
|
+
return token
|
|
270
|
+
.split(/[-_]/)
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.map(part => GROUP_ACRONYMS[part] ?? part.charAt(0).toUpperCase() + part.slice(1))
|
|
273
|
+
.join(' ')
|
|
274
|
+
}
|
|
275
|
+
|
|
209
276
|
function sortResources(resources: APIResource[]): APIResource[] {
|
|
210
277
|
return [...resources].sort((a, b) => a.kind.localeCompare(b.kind))
|
|
211
278
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { GitOpsResourceTree, Topology } from '../types'
|
|
3
|
+
import { collapseStableReplicaSets, deploymentInventoryFromGitOps, deploymentInventoryFromHelm, layerDeploymentInventory, topologyGroup } from './application-topology'
|
|
4
|
+
|
|
5
|
+
const topology: Topology = {
|
|
6
|
+
nodes: [
|
|
7
|
+
{ id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'apps/v1' } },
|
|
8
|
+
{ id: 'service/default/external', kind: 'Service', name: 'external', status: 'healthy', data: { namespace: 'default', apiVersion: 'v1' } },
|
|
9
|
+
],
|
|
10
|
+
edges: [],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
it('resolves the stored API group for source-only CRD nodes', () => {
|
|
14
|
+
expect(topologyGroup({
|
|
15
|
+
id: 'source/prometheusrule',
|
|
16
|
+
kind: 'PrometheusRule',
|
|
17
|
+
name: 'alerts',
|
|
18
|
+
status: 'healthy',
|
|
19
|
+
data: { namespace: 'monitoring', sourceGroup: 'monitoring.coreos.com' },
|
|
20
|
+
})).toBe('monitoring.coreos.com')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function inventory(warnings?: string[]): GitOpsResourceTree {
|
|
24
|
+
const root = { id: 'root', ref: { group: 'argoproj.io', kind: 'Application', namespace: 'argocd', name: 'api' }, role: 'root' as const, tool: 'argocd' as const }
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
warnings,
|
|
28
|
+
edges: [],
|
|
29
|
+
nodes: [
|
|
30
|
+
root,
|
|
31
|
+
{ id: 'deployment', ref: { group: 'apps', kind: 'Deployment', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
|
|
32
|
+
{ id: 'config', ref: { kind: 'ConfigMap', namespace: 'default', name: 'api-config' }, role: 'declared', tool: 'argocd' },
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('layerDeploymentInventory', () => {
|
|
38
|
+
it('separates overlapping, runtime-only, and managed-only resources', () => {
|
|
39
|
+
const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
|
|
40
|
+
|
|
41
|
+
expect(result.managedRuntimeCount).toBe(1)
|
|
42
|
+
expect(result.runtimeOnlyCount).toBe(1)
|
|
43
|
+
expect(result.managedOnly.map((node) => node.name)).toEqual(['api-config'])
|
|
44
|
+
expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
|
|
45
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBe('runtime-only')
|
|
46
|
+
expect(result.topology.nodes[2]).toMatchObject({ kind: 'ConfigMap', name: 'api-config', data: { deploymentMembership: 'source-only' } })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not claim runtime-only membership for a partial inventory', () => {
|
|
50
|
+
const result = layerDeploymentInventory(topology, deploymentInventoryFromGitOps(inventory(['Some resources are hidden']))!, 'Argo CD')
|
|
51
|
+
|
|
52
|
+
expect(result.inventoryComplete).toBe(false)
|
|
53
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
54
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not claim runtime-only membership for Helm without a completeness signal', () => {
|
|
58
|
+
const helmInventory = deploymentInventoryFromHelm([{
|
|
59
|
+
apiVersion: 'apps/v1',
|
|
60
|
+
kind: 'Deployment',
|
|
61
|
+
namespace: 'default',
|
|
62
|
+
name: 'api',
|
|
63
|
+
status: 'ready',
|
|
64
|
+
}])!
|
|
65
|
+
const result = layerDeploymentInventory(topology, helmInventory, 'Helm')
|
|
66
|
+
|
|
67
|
+
expect(result.inventoryComplete).toBe(false)
|
|
68
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
69
|
+
expect(result.topology.nodes[1].data?.deploymentMembership).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('adds no membership markers when source and runtime agree', () => {
|
|
73
|
+
const matchingTopology: Topology = {
|
|
74
|
+
nodes: [topology.nodes[0]],
|
|
75
|
+
edges: [],
|
|
76
|
+
}
|
|
77
|
+
const matchingInventory = inventory()
|
|
78
|
+
matchingInventory.nodes = matchingInventory.nodes.filter((node) => node.id !== 'config')
|
|
79
|
+
|
|
80
|
+
const result = layerDeploymentInventory(matchingTopology, deploymentInventoryFromGitOps(matchingInventory)!, 'Argo CD')
|
|
81
|
+
|
|
82
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
83
|
+
expect(result.managedOnly).toEqual([])
|
|
84
|
+
expect(result.topology.nodes[0].data?.deploymentMembership).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not merge same-named resources from different CRD groups', () => {
|
|
88
|
+
const collisionTopology: Topology = {
|
|
89
|
+
nodes: [{ id: 'service/default/api', kind: 'Service', name: 'api', status: 'healthy', data: { namespace: 'default', apiVersion: 'serving.knative.dev/v1' } }],
|
|
90
|
+
edges: [],
|
|
91
|
+
}
|
|
92
|
+
const result = layerDeploymentInventory(collisionTopology, deploymentInventoryFromGitOps({
|
|
93
|
+
root: inventory().root,
|
|
94
|
+
edges: [],
|
|
95
|
+
nodes: [
|
|
96
|
+
inventory().root,
|
|
97
|
+
{ id: 'core-service', ref: { kind: 'Service', namespace: 'default', name: 'api' }, role: 'declared', tool: 'argocd' },
|
|
98
|
+
],
|
|
99
|
+
})!, 'Argo CD')
|
|
100
|
+
|
|
101
|
+
expect(result.managedRuntimeCount).toBe(0)
|
|
102
|
+
expect(result.runtimeOnlyCount).toBe(1)
|
|
103
|
+
expect(result.managedOnly.map((node) => node.id)).toEqual(['core-service'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not classify synthetic graph nodes as runtime-only resources', () => {
|
|
107
|
+
const syntheticTopology: Topology = {
|
|
108
|
+
nodes: [
|
|
109
|
+
{ id: 'podgroup/default/api', kind: 'PodGroup', name: 'api', status: 'healthy', data: { namespace: 'default' } },
|
|
110
|
+
{ id: 'internet', kind: 'Internet', name: 'Internet', status: 'healthy', data: {} },
|
|
111
|
+
],
|
|
112
|
+
edges: [],
|
|
113
|
+
}
|
|
114
|
+
const result = layerDeploymentInventory(syntheticTopology, deploymentInventoryFromGitOps(inventory())!, 'Argo CD')
|
|
115
|
+
|
|
116
|
+
expect(result.runtimeOnlyCount).toBe(0)
|
|
117
|
+
expect(result.topology.nodes.slice(0, 2).every((node) => node.data?.deploymentMembership === undefined)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('groups repeated source-only resources so large bundles remain readable', () => {
|
|
121
|
+
const largeInventory = inventory()
|
|
122
|
+
largeInventory.nodes = [
|
|
123
|
+
largeInventory.root,
|
|
124
|
+
...Array.from({ length: 5 }, (_, index) => ({
|
|
125
|
+
id: `rule-${index}`,
|
|
126
|
+
ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
|
|
127
|
+
role: 'declared' as const,
|
|
128
|
+
tool: 'argocd' as const,
|
|
129
|
+
})),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
|
|
133
|
+
|
|
134
|
+
expect(result.managedOnly).toHaveLength(5)
|
|
135
|
+
expect(result.topology.nodes).toHaveLength(1)
|
|
136
|
+
expect(result.topology.nodes[0]).toMatchObject({ kind: 'PrometheusRule', data: { sourceInventoryGroup: true, sourceInventoryCount: 5 } })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('summarizes a large source-only inventory outside the graph', () => {
|
|
140
|
+
const largeInventory = inventory()
|
|
141
|
+
largeInventory.nodes = [
|
|
142
|
+
largeInventory.root,
|
|
143
|
+
...Array.from({ length: 10 }, (_, index) => ({
|
|
144
|
+
id: `rule-${index}`,
|
|
145
|
+
ref: { group: 'monitoring.coreos.com', kind: 'PrometheusRule', namespace: 'monitoring', name: `rule-${index}` },
|
|
146
|
+
role: 'declared' as const,
|
|
147
|
+
tool: 'argocd' as const,
|
|
148
|
+
})),
|
|
149
|
+
...Array.from({ length: 5 }, (_, index) => ({
|
|
150
|
+
id: `config-${index}`,
|
|
151
|
+
ref: { kind: 'ConfigMap', namespace: 'monitoring', name: `config-${index}` },
|
|
152
|
+
role: 'declared' as const,
|
|
153
|
+
tool: 'argocd' as const,
|
|
154
|
+
})),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
const result = layerDeploymentInventory({ nodes: [], edges: [] }, deploymentInventoryFromGitOps(largeInventory)!, 'Argo CD')
|
|
158
|
+
|
|
159
|
+
expect(result.managedOnly).toHaveLength(15)
|
|
160
|
+
expect(result.topology.nodes).toHaveLength(0)
|
|
161
|
+
expect(result.managedOnlySummary).toBe('10 PrometheusRules · 5 ConfigMaps')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('collapseStableReplicaSets', () => {
|
|
166
|
+
const replicaSetTopology: Topology = {
|
|
167
|
+
nodes: [
|
|
168
|
+
{ id: 'deployment/default/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'default' } },
|
|
169
|
+
{ id: 'replicaset/default/api-abc', kind: 'ReplicaSet', name: 'api-abc', status: 'healthy', data: { namespace: 'default', readyReplicas: 2, totalReplicas: 2 } },
|
|
170
|
+
{ id: 'pod/default/api-1', kind: 'Pod', name: 'api-1', status: 'healthy', data: { namespace: 'default' } },
|
|
171
|
+
],
|
|
172
|
+
edges: [
|
|
173
|
+
{ id: 'deployment-rs', source: 'deployment/default/api', target: 'replicaset/default/api-abc', type: 'manages' },
|
|
174
|
+
{ id: 'rs-pod', source: 'replicaset/default/api-abc', target: 'pod/default/api-1', type: 'manages' },
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('collapses a stable single ReplicaSet and rewires its pods', () => {
|
|
179
|
+
const result = collapseStableReplicaSets(replicaSetTopology, new Set())
|
|
180
|
+
expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'Pod'])
|
|
181
|
+
expect(result.edges).toContainEqual(expect.objectContaining({ source: 'deployment/default/api', target: 'pod/default/api-1' }))
|
|
182
|
+
expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: true })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('shows the ReplicaSet when the developer expands it', () => {
|
|
186
|
+
const result = collapseStableReplicaSets(replicaSetTopology, new Set(['deployment/default/api']))
|
|
187
|
+
expect(result.nodes.map((node) => node.kind)).toEqual(['Deployment', 'ReplicaSet', 'Pod'])
|
|
188
|
+
expect(result.nodes[0].data).toMatchObject({ replicaSetCount: 1, replicaSetsCollapsed: false })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps an unhealthy ReplicaSet visible automatically', () => {
|
|
192
|
+
const unhealthy = {
|
|
193
|
+
...replicaSetTopology,
|
|
194
|
+
nodes: replicaSetTopology.nodes.map((node) => node.kind === 'ReplicaSet' ? { ...node, status: 'degraded' as const } : node),
|
|
195
|
+
}
|
|
196
|
+
expect(collapseStableReplicaSets(unhealthy, new Set()).nodes.map((node) => node.kind)).toContain('ReplicaSet')
|
|
197
|
+
})
|
|
198
|
+
})
|