@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.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. 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'
@@ -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: -16px 0 60px rgba(0,0,0,0.6), -6px 0 20px rgba(0,0,0,0.4);
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) — NOT for repo-side errors like a stale index or
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 group
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
+ })