@skyhook-io/k8s-ui 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -193,6 +193,63 @@ describe('pluralToKind', () => {
193
193
  })
194
194
 
195
195
  describe('initNavigationMap', () => {
196
+ const coreNode = { group: '', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: false, verbs: ['get'] }
197
+ const customNode = { group: 'collision.example.io', version: 'v1', kind: 'Node', name: 'workerhosts', namespaced: false, isCrd: true, verbs: ['get'] }
198
+
199
+ test.each([
200
+ ['core listed first', [coreNode, customNode]],
201
+ ['custom listed first', [customNode, coreNode]],
202
+ ])('keeps the core plural for a Kind a CRD reuses under another plural (%s)', (_, resources) => {
203
+ initNavigationMap(resources)
204
+ expect(kindToPlural('Node')).toBe('nodes')
205
+ expect(kindToPluralWithGroup('Node', '')).toBe('nodes')
206
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
207
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
208
+ })
209
+
210
+ test('keeps the core plural when partial discovery omits the core group', () => {
211
+ initNavigationMap([customNode])
212
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
213
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
214
+ })
215
+
216
+ test.each([
217
+ ['partial discovery', [{ ...customNode, name: 'node' }]],
218
+ ['full discovery', [coreNode, { ...customNode, name: 'node' }]],
219
+ ])('keeps the core plural when a CRD registers the core Kind word as its plural (%s)', (_, resources) => {
220
+ initNavigationMap(resources)
221
+ expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
222
+ expect(kindToPlural('node')).toBe('node')
223
+ expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('node')
224
+ })
225
+
226
+ test('resolves a group-qualified Kind spelled like a core plural to its own resource', () => {
227
+ initNavigationMap([
228
+ { group: '', version: 'v1', kind: 'Endpoints', name: 'endpoints', namespaced: true, isCrd: false, verbs: ['get'] },
229
+ { group: 'custom.example.io', version: 'v1', kind: 'Endpoints', name: 'virtualendpoints', namespaced: true, isCrd: true, verbs: ['get'] },
230
+ ])
231
+ expect(kindToPluralWithGroup('Endpoints', 'custom.example.io')).toBe('virtualendpoints')
232
+ expect(kindToPluralWithGroup('Endpoints', '')).toBe('endpoints')
233
+ })
234
+
235
+ test('keeps a lowercase group plural rather than reading it as another Kind', () => {
236
+ initNavigationMap([
237
+ { group: 'shapes.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
238
+ { group: 'shapes.example.io', version: 'v1', kind: 'Widgets', name: 'widgetsets', namespaced: true, isCrd: true, verbs: ['get'] },
239
+ ])
240
+ expect(kindToPluralWithGroup('widgets', 'shapes.example.io')).toBe('widgets')
241
+ expect(kindToPluralWithGroup('Widgets', 'shapes.example.io')).toBe('widgetsets')
242
+ expect(kindToPluralWithGroup('Widget', 'shapes.example.io')).toBe('widgets')
243
+ })
244
+
245
+ test('keeps last-wins for Kinds that only CRDs declare', () => {
246
+ initNavigationMap([
247
+ { group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: [] },
248
+ { group: 'b.example.io', version: 'v1', kind: 'Widget', name: 'gadgets', namespaced: true, isCrd: true, verbs: [] },
249
+ ])
250
+ expect(kindToPlural('Widget')).toBe('gadgets')
251
+ })
252
+
196
253
  test('discovered API resources override heuristic pluralization', () => {
197
254
  // Before init, an unknown CRD would hit the heuristic fallback
198
255
  // After init, it uses the discovered plural name
@@ -1,6 +1,6 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
2
  import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3
- import { CORE_RESOURCES } from './api-resources'
3
+ import { CORE_RESOURCES, isBuiltinCoreKind } from './api-resources'
4
4
 
5
5
  /**
6
6
  * Canonical callback type for navigating to a resource.
@@ -67,6 +67,18 @@ let discoveredPluralToKind: Record<string, string> | null = null
67
67
  let discoveredKindToPlural: Record<string, string> | null = null
68
68
  let discoveredGroupKindToPlural: Record<string, string> | null = null
69
69
  let discoveredGroupPluralToKind: Record<string, string> | null = null
70
+ let discoveredGroupKindNamespaced: Record<string, boolean> | null = null
71
+ let discoveredCRDNames: Record<string, string> | null = null
72
+ const crdDefinitionListeners = new Set<() => void>()
73
+
74
+ export function subscribeCRDDefinitions(listener: () => void): () => void {
75
+ crdDefinitionListeners.add(listener)
76
+ return () => { crdDefinitionListeners.delete(listener) }
77
+ }
78
+
79
+ export function getCRDDefinitionsSnapshot(): Readonly<Record<string, string>> | null {
80
+ return discoveredCRDNames
81
+ }
70
82
 
71
83
  /**
72
84
  * Initialize navigation maps from discovered API resources.
@@ -75,17 +87,35 @@ let discoveredGroupPluralToKind: Record<string, string> | null = null
75
87
  */
76
88
  export function initNavigationMap(resources: APIResource[]) {
77
89
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
78
- const k2p: Record<string, string> = {}
90
+ // Seeded from the built-in table so core protection survives partial
91
+ // discovery (the server forwards whatever groups it could list).
92
+ const k2p: Record<string, string> = Object.fromEntries(
93
+ CORE_RESOURCES.filter(r => r.group === '').map(r => [r.kind.toLowerCase(), r.name]),
94
+ )
79
95
  const gk2p: Record<string, string> = {}
80
96
  const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
97
+ const scopes: Record<string, boolean> = {}
98
+ const crdNames: Record<string, string> = {}
99
+ const coreKinds = new Set(Object.keys(k2p))
81
100
  for (const r of resources) {
82
101
  const plural = r.name.toLowerCase()
102
+ const kindLower = r.kind.toLowerCase()
103
+ if (r.definitionName) crdNames[`${r.group}/${r.kind}`] = r.definitionName
83
104
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
84
105
  // (e.g. "pods" → "Pod") so a colliding API resource (metrics.k8s.io exposes
85
106
  // "pods" with kind "PodMetrics") cannot hijack the core mapping.
86
107
  if (!(plural in p2k)) p2k[plural] = r.kind
87
- k2p[r.kind.toLowerCase()] = plural
88
- gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
108
+ // Kind-only lookups (core links carry an empty group) likewise keep the core
109
+ // plural: a CRD that reuses a core Kind under another plural must not win
110
+ // just because discovery happened to list it later.
111
+ if (r.group === '') {
112
+ k2p[kindLower] = plural
113
+ coreKinds.add(kindLower)
114
+ } else if (!coreKinds.has(kindLower)) {
115
+ k2p[kindLower] = plural
116
+ }
117
+ gk2p[`${r.group}/${kindLower}`] = plural
118
+ scopes[`${r.group}/${kindLower}`] = r.namespaced
89
119
  const groupPlural = `${r.group}/${plural}`
90
120
  if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
91
121
  }
@@ -93,14 +123,20 @@ export function initNavigationMap(resources: APIResource[]) {
93
123
  discoveredKindToPlural = k2p
94
124
  discoveredGroupKindToPlural = gk2p
95
125
  discoveredGroupPluralToKind = gp2k
126
+ discoveredGroupKindNamespaced = scopes
127
+ discoveredCRDNames = crdNames
128
+ crdDefinitionListeners.forEach(listener => listener())
96
129
  }
97
130
 
98
- /** Reset navigation maps to builtin-only state. For testing. */
131
+ /** Reset navigation maps to builtin-only state on cluster changes. */
99
132
  export function resetNavigationMap() {
100
133
  discoveredPluralToKind = null
101
134
  discoveredKindToPlural = null
102
135
  discoveredGroupKindToPlural = null
103
136
  discoveredGroupPluralToKind = null
137
+ discoveredGroupKindNamespaced = null
138
+ discoveredCRDNames = null
139
+ crdDefinitionListeners.forEach(listener => listener())
104
140
  }
105
141
 
106
142
  function getPluralToKind(): Record<string, string> {
@@ -117,6 +153,11 @@ export function kindToPlural(kind: string): string {
117
153
  const kindLower = kind.toLowerCase()
118
154
  const pluralToKindMap = getPluralToKind()
119
155
 
156
+ // An exact core Kind (PascalCase, unlike plural slugs) keeps its core plural
157
+ // even when a CRD registered that word as its own plural.
158
+ const core = kind !== kindLower ? CORE_RESOURCES.find(r => r.group === '' && r.kind === kind) : undefined
159
+ if (core) return core.name
160
+
120
161
  // Already a known plural — return as-is to prevent double-pluralization
121
162
  if (kindLower in pluralToKindMap) return kindLower
122
163
 
@@ -156,10 +197,15 @@ export function kindToPlural(kind: string): string {
156
197
  export function kindToPluralWithGroup(kind: string, group: string): string {
157
198
  if (!group) return kindToPlural(kind)
158
199
  const kindLower = kind.toLowerCase()
159
- const pluralToKindMap = getPluralToKind()
160
- if (kindLower in pluralToKindMap) return kindLower
200
+ // The group's own plural, then its exact Kind, before the group-blind plural
201
+ // shortcut: a custom Kind spelled like a core plural (Endpoints) has its own
202
+ // resource, while a lowercase plural must not be read as another Kind.
203
+ if (kind === kindLower && discoveredGroupPluralToKind?.[`${group}/${kindLower}`]) return kindLower
161
204
  const groupKind = `${group}/${kindLower}`
162
- return discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind] ?? kindToPlural(kind)
205
+ const exact = discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind]
206
+ if (exact) return exact
207
+ if (kindLower in getPluralToKind()) return kindLower
208
+ return kindToPlural(kind)
163
209
  }
164
210
 
165
211
  /**
@@ -196,6 +242,13 @@ export function knownKindForPluralWithGroup(plural: string, group: string): stri
196
242
  ?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
197
243
  }
198
244
 
245
+ /** Return a CRD definition only when exact group/kind discovery identifies a CRD. */
246
+ export function customResourceDefinitionRef(apiVersion: string | undefined, kind: string | undefined): ResourceRef | null {
247
+ if (!apiVersion || !kind) return null
248
+ const name = discoveredCRDNames?.[`${apiVersionToGroup(apiVersion)}/${kind}`]
249
+ return name ? { kind: 'CustomResourceDefinition', group: 'apiextensions.k8s.io', namespace: '', name } : null
250
+ }
251
+
199
252
  /**
200
253
  * Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
201
254
  * Handles kind singular→plural conversion.
@@ -225,6 +278,31 @@ export function apiVersionToGroup(apiVersion?: string | null): string {
225
278
  return i === -1 ? '' : apiVersion.slice(0, i)
226
279
  }
227
280
 
281
+ /** A Kubernetes ObjectReference retains its own API identity and namespace.
282
+ * Cluster scope comes from exact group/kind discovery (or known built-ins).
283
+ * Core controllers omit apiVersion for core objects (the EndpointSlice
284
+ * controller writes Pod targets as kind/namespace/name/uid), so an omitted
285
+ * apiVersion means the core group only for a built-in core Kind. Any other
286
+ * missing identity or scope stays non-navigable rather than guessing an API
287
+ * or borrowing the referring object's namespace. UID is not a drawer locator.
288
+ */
289
+ export function objectReferenceToResourceRef(ref: {
290
+ apiVersion?: string
291
+ kind?: string
292
+ namespace?: string
293
+ name?: string
294
+ } | null | undefined): ResourceRef | null {
295
+ if (!ref?.kind || !ref.name) return null
296
+ const kindLower = ref.kind.toLowerCase()
297
+ if (!ref.apiVersion && !isBuiltinCoreKind(ref.kind)) return null
298
+ const group = apiVersionToGroup(ref.apiVersion)
299
+ const key = `${group}/${kindLower}`
300
+ const namespaced = discoveredGroupKindNamespaced?.[key]
301
+ ?? CORE_RESOURCES.find(r => r.group === group && r.kind.toLowerCase() === kindLower)?.namespaced
302
+ if (namespaced !== false && !ref.namespace) return null
303
+ return { kind: ref.kind, group, namespace: namespaced === false ? '' : ref.namespace!, name: ref.name }
304
+ }
305
+
228
306
  // -----------------------------------------------------------------------------
229
307
  // Timeline lane identity. A lane's id includes the API group so two CRDs that
230
308
  // share a kind name across vendors (CAPI `Cluster` in cluster.x-k8s.io vs CNPG
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { initNavigationMap, objectReferenceToResourceRef, resetNavigationMap } from './navigation'
3
+
4
+ afterEach(resetNavigationMap)
5
+
6
+ describe('objectReferenceToResourceRef', () => {
7
+ it('keeps the reference namespace and drops it for cluster-scoped core kinds', () => {
8
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', namespace: 'other', name: 'backend' }))
9
+ .toEqual({ kind: 'Pod', group: '', namespace: 'other', name: 'backend' })
10
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Node', namespace: 'irrelevant', name: 'worker' }))
11
+ .toEqual({ kind: 'Node', group: '', namespace: '', name: 'worker' })
12
+ })
13
+
14
+ it('resolves a core Kind without apiVersion, the shape the EndpointSlice controller writes', () => {
15
+ expect(objectReferenceToResourceRef({ kind: 'Pod', namespace: 'team', name: 'backend' }))
16
+ .toEqual({ kind: 'Pod', group: '', namespace: 'team', name: 'backend' })
17
+ expect(objectReferenceToResourceRef({ kind: 'Endpoints', namespace: 'team', name: 'backend' }))
18
+ .toEqual({ kind: 'Endpoints', group: '', namespace: 'team', name: 'backend' })
19
+ expect(objectReferenceToResourceRef({ kind: 'Deployment', namespace: 'team', name: 'backend' })).toBeNull()
20
+ expect(objectReferenceToResourceRef({ kind: 'Services', namespace: 'team', name: 'backend' })).toBeNull()
21
+ expect(objectReferenceToResourceRef({ kind: 'Nodes', namespace: 'team', name: 'worker' })).toBeNull()
22
+ })
23
+
24
+ it('uses exact discovered scope for colliding API kinds', () => {
25
+ initNavigationMap([
26
+ { group: 'cluster.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: true, verbs: ['get'] },
27
+ { group: 'team.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: true, isCrd: true, verbs: ['get'] },
28
+ ])
29
+ expect(objectReferenceToResourceRef({ apiVersion: 'cluster.example.io/v1', kind: 'Node', name: 'worker' }))
30
+ .toEqual({ kind: 'Node', group: 'cluster.example.io', namespace: '', name: 'worker' })
31
+ expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', namespace: 'team', name: 'worker' }))
32
+ .toEqual({ kind: 'Node', group: 'team.example.io', namespace: 'team', name: 'worker' })
33
+ expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
34
+ })
35
+
36
+ it('does not invent a custom API group, a namespace, or an unknown cluster scope', () => {
37
+ expect(objectReferenceToResourceRef({ kind: 'Widget', namespace: 'team', name: 'backend' })).toBeNull()
38
+ expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', name: 'backend' })).toBeNull()
39
+ expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
40
+ expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Widget', namespace: 'team', name: 'backend' }))
41
+ .toEqual({ kind: 'Widget', group: 'unknown.example.io', namespace: 'team', name: 'backend' })
42
+ })
43
+ })
@@ -1560,3 +1560,40 @@ describe('uses edges in the timeline hierarchy', () => {
1560
1560
  expect(deployment.children?.map(child => child.kind)).toEqual(['HorizontalPodAutoscaler'])
1561
1561
  })
1562
1562
  })
1563
+
1564
+ describe('buildResourceHierarchy Flux sources', () => {
1565
+ it('keeps a shared Flux source as its own lane instead of nesting it under one consumer', () => {
1566
+ const event = (kind: string, name: string, apiVersion: string): TimelineEvent => ({
1567
+ id: `${kind}/flux-system/${name}`,
1568
+ timestamp: '2024-01-01T00:00:00.000Z',
1569
+ source: 'informer',
1570
+ kind,
1571
+ apiVersion,
1572
+ namespace: 'flux-system',
1573
+ name,
1574
+ eventType: 'update',
1575
+ } as TimelineEvent)
1576
+ const events = [
1577
+ event('GitRepository', 'podinfo', 'source.toolkit.fluxcd.io/v1'),
1578
+ event('Kustomization', 'base', 'kustomize.toolkit.fluxcd.io/v1'),
1579
+ event('Kustomization', 'overlay', 'kustomize.toolkit.fluxcd.io/v1'),
1580
+ ]
1581
+ const topology = {
1582
+ nodes: [
1583
+ { id: 'gitrepository/flux-system/podinfo', kind: 'GitRepository', name: 'podinfo', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'source.toolkit.fluxcd.io/v1' } },
1584
+ { id: 'kustomization/flux-system/base', kind: 'Kustomization', name: 'base', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'kustomize.toolkit.fluxcd.io/v1' } },
1585
+ { id: 'kustomization/flux-system/overlay', kind: 'Kustomization', name: 'overlay', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'kustomize.toolkit.fluxcd.io/v1' } },
1586
+ ],
1587
+ edges: [
1588
+ { id: 'e1', source: 'gitrepository/flux-system/podinfo', target: 'kustomization/flux-system/base', type: 'configures' },
1589
+ { id: 'e2', source: 'gitrepository/flux-system/podinfo', target: 'kustomization/flux-system/overlay', type: 'configures' },
1590
+ ],
1591
+ } as unknown as Topology
1592
+ const lanes = buildResourceHierarchy({ events, topology, grouping: 'owner' })
1593
+ expect(lanes.map((lane) => `${lane.kind}/${lane.name}`).sort()).toEqual([
1594
+ 'GitRepository/podinfo',
1595
+ 'Kustomization/base',
1596
+ 'Kustomization/overlay',
1597
+ ])
1598
+ })
1599
+ })
@@ -466,7 +466,7 @@ function sortEventsForRendering(events: TimelineEvent[]): TimelineEvent[] {
466
466
  // then workloads, then their generated children (ReplicaSet/Pod), config last.
467
467
  // Applied at EVERY depth so a Job's Pods sort the same way a root's children do.
468
468
  const CHILD_SORT_KIND_PRIORITY: Record<string, number> = {
469
- Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
469
+ Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2, UDPRoute: 2,
470
470
  Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
471
471
  Job: 3, CronJob: 3,
472
472
  ReplicaSet: 3, Pod: 4, ConfigMap: 5, Secret: 5,
@@ -761,11 +761,11 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
761
761
  const targetKind = parseLaneId(targetId)!.kind
762
762
 
763
763
  // Gateway→Route: Gateway is parent of Route
764
- if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute')) {
764
+ if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute' || targetKind === 'UDPRoute')) {
765
765
  link(targetId, sourceId, targetExists)
766
766
  }
767
767
  // Route→Service: reverse (Service is representative, like Ingress)
768
- else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute') && targetKind === 'Service') {
768
+ else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute' || sourceKind === 'UDPRoute') && targetKind === 'Service') {
769
769
  link(sourceId, targetId, sourceExists)
770
770
  }
771
771
  // Ingress→Service: reverse relationship (Service is representative)
@@ -778,8 +778,10 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
778
778
  }
779
779
  }
780
780
 
781
- // configures/protects: ConfigMap→Deployment, PDB→Deployment (target is parent)
782
- if (edge.type === 'configures' || edge.type === 'protects') {
781
+ // configures/protects: ConfigMap→Deployment, PDB→Deployment (target is parent).
782
+ // A Flux source feeds many reconcilers and its timeline is its own, so it
783
+ // never nests under one of them.
784
+ if ((edge.type === 'configures' && !isFluxSource(topologyNodeById.get(edge.source))) || edge.type === 'protects') {
783
785
  link(sourceId, targetId, sourceExists)
784
786
  }
785
787
  // uses: only an autoscaler or a mounted claim belongs under the workload it
@@ -813,7 +815,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
813
815
  const appLabelEligibleKinds = new Set([
814
816
  'Service', 'Deployment', 'Rollout', 'StatefulSet', 'DaemonSet',
815
817
  'Job', 'CronJob', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute',
816
- 'TCPRoute', 'TLSRoute', 'ConfigMap', 'Secret',
818
+ 'TCPRoute', 'TLSRoute', 'UDPRoute', 'ConfigMap', 'Secret',
817
819
  'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
818
820
  'Workflow', 'CronWorkflow',
819
821
  'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
@@ -844,7 +846,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
844
846
 
845
847
  const kindPriority: Record<string, number> = {
846
848
  Service: 1, Ingress: 2, Gateway: 2,
847
- HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
849
+ HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2, UDPRoute: 2,
848
850
  Deployment: 3, Rollout: 3, StatefulSet: 3, DaemonSet: 3,
849
851
  Job: 4, CronJob: 4, Workflow: 4, CronWorkflow: 4,
850
852
  ConfigMap: 5, Secret: 5,
@@ -1487,6 +1489,12 @@ const ATTACHED_USES_SOURCES = new Set([
1487
1489
  '/PersistentVolumeClaim',
1488
1490
  ])
1489
1491
 
1492
+ function isFluxSource(node: Topology['nodes'][number] | undefined): boolean {
1493
+ if (!node) return false
1494
+ const apiVersion = typeof node.data?.apiVersion === 'string' ? node.data.apiVersion : ''
1495
+ return apiVersionToGroup(apiVersion) === 'source.toolkit.fluxcd.io'
1496
+ }
1497
+
1490
1498
  function attachesToTarget(node: Topology['nodes'][number] | undefined): boolean {
1491
1499
  if (!node) return false
1492
1500
  const kind = topologyNodeResourceKind(node)
@@ -111,6 +111,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
111
111
  httproute: Globe,
112
112
  grpcroute: Globe,
113
113
  tcproute: Globe,
114
+ udproute: Globe,
114
115
  tlsroute: Globe,
115
116
 
116
117
  // Config
@@ -219,6 +220,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
219
220
  // pseudo-kinds (cnpgcluster/…) belong here only once pkg/topology's
220
221
  // KindForGVK emits them — it has no CNPG case today.
221
222
  pooler: Waypoints,
223
+ databaserole: UserCog,
222
224
 
223
225
  // Cluster API
224
226
  capicluster: Server,
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getSkeletonYaml } from './skeleton-yaml'
3
+
4
+ describe('getSkeletonYaml', () => {
5
+ it('starts a CloudNativePG Cluster from a spec the operator accepts', () => {
6
+ const yaml = getSkeletonYaml('Cluster', 'postgresql.cnpg.io')
7
+ expect(yaml).toContain('apiVersion: postgresql.cnpg.io/v1')
8
+ expect(yaml).toContain('instances: 3')
9
+ expect(yaml).toContain('size: 1Gi')
10
+ })
11
+
12
+ it('leaves a Cluster API Cluster on the generic template', () => {
13
+ const yaml = getSkeletonYaml('Cluster', 'cluster.x-k8s.io')
14
+ expect(yaml).toContain('apiVersion: cluster.x-k8s.io/v1')
15
+ expect(yaml).not.toContain('instances')
16
+ })
17
+
18
+ it('keeps core kinds on their own skeleton', () => {
19
+ expect(getSkeletonYaml('Pod')).toContain('containers:')
20
+ })
21
+ })
@@ -198,8 +198,24 @@ spec:
198
198
  image: nginx:latest`,
199
199
  }
200
200
 
201
+ // Keyed by group as well as kind: CloudNativePG and Cluster API both name a
202
+ // kind Cluster.
203
+ const groupSkeletons: Record<string, string> = {
204
+ 'postgresql.cnpg.io/Cluster': `apiVersion: postgresql.cnpg.io/v1
205
+ kind: Cluster
206
+ metadata:
207
+ name: my-cluster
208
+ namespace: default
209
+ spec:
210
+ instances: 3
211
+ storage:
212
+ size: 1Gi`,
213
+ }
214
+
201
215
  // Returns skeleton YAML for a known kind, or a generic template for unknown kinds.
202
216
  export function getSkeletonYaml(kind: string, group?: string): string {
217
+ const grouped = group ? groupSkeletons[`${group}/${kind}`] : undefined
218
+ if (grouped) return grouped
203
219
  const skeleton = skeletons[kind]
204
220
  if (skeleton) return skeleton
205
221
 
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { normalizeURLForComparison, stripTrailingSlashes } from './url-path'
3
+
4
+ describe('stripTrailingSlashes', () => {
5
+ it.each([
6
+ ['', ''],
7
+ ['/', ''],
8
+ ['////', ''],
9
+ ['/api///', '/api'],
10
+ ['https://store.example//bucket///', 'https://store.example//bucket'],
11
+ ['https://store.example//bucket?prefix=/', 'https://store.example//bucket?prefix='],
12
+ ['/api//nested', '/api//nested'],
13
+ ])('preserves the existing path normalization for %s', (value, expected) => {
14
+ expect(stripTrailingSlashes(value)).toBe(expected)
15
+ })
16
+
17
+ it('handles long slash runs without scanning each possible start', () => {
18
+ const slashes = '/'.repeat(100_000)
19
+ expect(stripTrailingSlashes(`prefix${slashes}`)).toBe('prefix')
20
+ expect(stripTrailingSlashes(`prefix${slashes}suffix`)).toBe(`prefix${slashes}suffix`)
21
+ })
22
+ })
23
+
24
+ it('compares equivalent origins while retaining declared path semantics', () => {
25
+ expect(normalizeURLForComparison('https://STORE.example:443/prefix/')).toBe(normalizeURLForComparison('https://store.example/prefix'))
26
+ expect(normalizeURLForComparison('http://STORE.example:080/')).toBe(normalizeURLForComparison('http://store.example'))
27
+ expect(normalizeURLForComparison('s3://BUCKET/path/')).toBe(normalizeURLForComparison('s3://bucket/path'))
28
+ expect(normalizeURLForComparison('https://store.example/Prefix')).not.toBe(normalizeURLForComparison('https://store.example/prefix'))
29
+ expect(normalizeURLForComparison('https://store.example/a/../b')).not.toBe(normalizeURLForComparison('https://store.example/b'))
30
+ expect(normalizeURLForComparison('https://store.example:8443/path')).not.toBe(normalizeURLForComparison('https://store.example/path'))
31
+ expect(normalizeURLForComparison('')).toBe('')
32
+ expect(normalizeURLForComparison('invalid')).toBeNull()
33
+ expect(normalizeURLForComparison('https://host\\prefix')).toBeNull()
34
+ expect(normalizeURLForComparison('https://user:password@store.example')).toBeNull()
35
+ })
@@ -0,0 +1,21 @@
1
+ export function stripTrailingSlashes(value: string): string {
2
+ let end = value.length
3
+ while (end > 0 && value[end - 1] === '/') end--
4
+ return value.slice(0, end)
5
+ }
6
+
7
+ export function normalizeURLForComparison(value: string): string | null {
8
+ if (!value) return ''
9
+ if (value.includes('\\')) return null
10
+ try {
11
+ const url = new URL(value)
12
+ const authorityStart = value.indexOf('://')
13
+ if (authorityStart < 0 || !url.hostname || url.username || url.password || value.includes('?') || value.includes('#')) return null
14
+ const pathStart = value.indexOf('/', authorityStart + 3)
15
+ const path = pathStart < 0 ? '' : value.slice(pathStart)
16
+ const port = url.port || (url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '')
17
+ return `${url.protocol}//${url.hostname.toLowerCase()}:${port}${stripTrailingSlashes(path)}`
18
+ } catch {
19
+ return null
20
+ }
21
+ }
@@ -82,25 +82,16 @@ describe('validateRFC1123Subdomain', () => {
82
82
  if (!r.valid) expect(r.error).toMatch(/253/)
83
83
  })
84
84
 
85
- it('rejects single labels longer than 63 chars even when total is under 253', () => {
86
- // K8s' IsDNS1123Subdomain enforces ≤ 63 chars per dot-
87
- // separated label on top of the 253-char total. Without the
88
- // per-label check, a single 200-char label slipped through
89
- // client-side and was rejected only server-side.
90
- const huge = 'a'.repeat(200)
91
- const r = validateRFC1123Subdomain(huge)
92
- expect(r.valid).toBe(false)
93
- if (!r.valid) expect(r.error).toMatch(/63/)
85
+ it('accepts long undotted names under the Kubernetes total limit', () => {
86
+ expect(validateRFC1123Subdomain('a'.repeat(200)).valid).toBe(true)
94
87
  })
95
88
 
96
89
  it('accepts a 63-char label at the boundary', () => {
97
90
  expect(validateRFC1123Subdomain('a'.repeat(63)).valid).toBe(true)
98
91
  })
99
92
 
100
- it('rejects only the offending label when other labels are fine', () => {
101
- const r = validateRFC1123Subdomain(`ok.${'a'.repeat(64)}.also-ok`)
102
- expect(r.valid).toBe(false)
103
- if (!r.valid) expect(r.error).toMatch(/63/)
93
+ it('accepts long dot-separated parts under the Kubernetes total limit', () => {
94
+ expect(validateRFC1123Subdomain(`ok.${'a'.repeat(64)}.also-ok`).valid).toBe(true)
104
95
  })
105
96
  })
106
97
 
@@ -17,7 +17,7 @@ export type ValidationResult =
17
17
  | { valid: false; error: string }
18
18
 
19
19
  const RFC1123_LABEL_MAX = 63
20
- // Kubernetes namespace / pod / service names are RFC 1123 labels:
20
+ // Kubernetes namespace names are RFC 1123 labels:
21
21
  // lowercase alphanumeric or '-', start and end alphanumeric, ≤63 chars.
22
22
  // Source: k8s.io/apimachinery/pkg/util/validation.IsDNS1123Label.
23
23
  const RFC1123_LABEL_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
@@ -34,9 +34,7 @@ const HELM_RELEASE_NAME_MAX = 53
34
34
  // https://helm.sh/docs/chart_best_practices/conventions/#chart-names
35
35
 
36
36
  /**
37
- * Validates a Kubernetes RFC 1123 label. Used for namespace names,
38
- * pod / service / configmap / secret names — anything Kubernetes
39
- * accepts as a single-segment identifier.
37
+ * Matches Kubernetes IsDNS1123Label, including namespace names.
40
38
  */
41
39
  export function validateRFC1123Label(input: string): ValidationResult {
42
40
  if (input.length === 0) {
@@ -64,9 +62,9 @@ export function validateRFC1123Label(input: string): ValidationResult {
64
62
  }
65
63
 
66
64
  /**
67
- * Validates a Kubernetes RFC 1123 subdomain (labels joined by dots).
68
- * Used for Helm chart names, image registries — anything that allows
69
- * dotted segments. NOT for pod/namespace names; use the label form.
65
+ * Matches Kubernetes IsDNS1123Subdomain: the whole name is capped at 253
66
+ * characters, without a separate 63-character cap on dot-separated parts.
67
+ * Namespace names use validateRFC1123Label instead.
70
68
  */
71
69
  export function validateRFC1123Subdomain(input: string): ValidationResult {
72
70
  if (input.length === 0) {
@@ -90,18 +88,6 @@ export function validateRFC1123Subdomain(input: string): ValidationResult {
90
88
  error: 'must contain only lowercase letters, numbers, hyphens, and dots, and each segment must start/end with a letter or number',
91
89
  }
92
90
  }
93
- // Per-label cap: K8s' IsDNS1123Subdomain enforces ≤ 63 chars
94
- // PER dot-separated label on top of the 253-char total. The
95
- // regex above doesn't catch that; without this check a single
96
- // 200-char label passes here and only fails server-side.
97
- for (const label of input.split('.')) {
98
- if (label.length > RFC1123_LABEL_MAX) {
99
- return {
100
- valid: false,
101
- error: `each dot-separated label must be at most ${RFC1123_LABEL_MAX} characters (got ${label.length} for "${label}")`,
102
- }
103
- }
104
- }
105
91
  return { valid: true }
106
92
  }
107
93
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
- import { isInvalidStateError, startViewTransitionSafe } from './view-transition'
2
+ import { isInvalidStateError, isWebKitGTK, startViewTransitionSafe } from './view-transition'
3
3
 
4
4
  // SKY-833 bug 49: rapid tab / route switches surfaced
5
5
  // "InvalidStateError: Transition was aborted because of invalid state"
@@ -79,3 +79,37 @@ describe('startViewTransitionSafe', () => {
79
79
  expect(isInvalidStateError(new TypeError('y'))).toBe(false)
80
80
  })
81
81
  })
82
+
83
+ const WEBKITGTK_UA =
84
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'
85
+
86
+ describe('isWebKitGTK', () => {
87
+ it('matches WebKitGTK user agents', () => {
88
+ expect(isWebKitGTK(WEBKITGTK_UA)).toBe(true)
89
+ expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)')).toBe(true)
90
+ })
91
+
92
+ it('does not match other engines on Linux, or Safari', () => {
93
+ expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36')).toBe(false)
94
+ expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0')).toBe(false)
95
+ expect(isWebKitGTK('Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36')).toBe(false)
96
+ expect(isWebKitGTK('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15')).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe('startViewTransitionSafe under WebKitGTK', () => {
101
+ it('applies the update directly instead of starting a transition', () => {
102
+ const originalDoc = (globalThis as { document?: unknown }).document
103
+ const startSpy = vi.fn()
104
+ ;(globalThis as { document: object }).document = { startViewTransition: startSpy }
105
+ const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(WEBKITGTK_UA)
106
+
107
+ const update = vi.fn()
108
+ startViewTransitionSafe(update)
109
+ expect(update).toHaveBeenCalledTimes(1)
110
+ expect(startSpy).not.toHaveBeenCalled()
111
+
112
+ uaSpy.mockRestore()
113
+ ;(globalThis as { document?: unknown }).document = originalDoc
114
+ })
115
+ })