@skyhook-io/k8s-ui 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  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 +7 -3
  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 +142 -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 +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -363,24 +363,71 @@ export const SYNTAX_COLOR_NUMBER = '#e5c07b'
363
363
  export const SYNTAX_COLOR_BOOLEAN = '#c678dd'
364
364
  export const SYNTAX_COLOR_NULL = '#808080'
365
365
 
366
+ export interface JsonToken {
367
+ type: 'text' | 'key' | 'string' | 'number' | 'boolean' | 'null'
368
+ value: string
369
+ }
370
+
371
+ function jsonStringEnd(text: string, start: number): number {
372
+ let offset = start + 1
373
+ while (offset < text.length) {
374
+ const char = text[offset++]
375
+ if (char === '\\') offset++
376
+ else if (char === '"') return offset
377
+ }
378
+ return -1
379
+ }
380
+
381
+ export function tokenizeJson(text: string): JsonToken[] {
382
+ const tokens: JsonToken[] = []
383
+ const primitiveRe = /(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/y
384
+ let copied = 0
385
+ let offset = 0
386
+ while (offset < text.length) {
387
+ const start = offset
388
+ let type: JsonToken['type']
389
+ let value: string
390
+ if (text[offset] === '"') {
391
+ const end = jsonStringEnd(text, offset)
392
+ if (end < 0) break
393
+ value = text.slice(start, end)
394
+ let colon = end
395
+ while (colon < text.length && /\s/.test(text[colon])) colon++
396
+ type = text[colon] === ':' ? 'key' : 'string'
397
+ offset = type === 'key' ? colon + 1 : end
398
+ } else {
399
+ primitiveRe.lastIndex = offset
400
+ const match = primitiveRe.exec(text)
401
+ if (!match) {
402
+ offset++
403
+ continue
404
+ }
405
+ type = match[1] !== undefined ? 'number' : match[2] !== undefined ? 'boolean' : 'null'
406
+ value = match[0]
407
+ offset = primitiveRe.lastIndex
408
+ }
409
+ if (start > copied) tokens.push({ type: 'text', value: text.slice(copied, start) })
410
+ tokens.push({ type, value })
411
+ copied = offset
412
+ }
413
+ if (copied < text.length) tokens.push({ type: 'text', value: text.slice(copied) })
414
+ return tokens
415
+ }
416
+
366
417
  /**
367
418
  * Syntax-highlight a pretty-printed JSON string for HTML display.
368
419
  * Wraps keys, string values, numbers, booleans, and null in colored spans.
369
420
  * Applies HTML escaping internally — do not pre-escape the input.
370
421
  */
371
422
  export function highlightJson(json: string): string {
372
- const escaped = escapeHtml(json)
373
- // Single-pass tokenizer: matches all JSON tokens at once so content inside strings
374
- // is consumed and can't be re-matched by number/boolean/null patterns.
375
- const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
376
- return escaped.replace(tokenRe, (match, key, str, num, bool, nil) => {
377
- if (key) return `<span style="color:${SYNTAX_COLOR_KEY}">${key}</span>:`
378
- if (str) return `<span style="color:${SYNTAX_COLOR_STRING}">${str}</span>`
379
- if (num) return `<span style="color:${SYNTAX_COLOR_NUMBER}">${num}</span>`
380
- if (bool) return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${bool}</span>`
381
- if (nil) return `<span style="color:${SYNTAX_COLOR_NULL}">${nil}</span>`
382
- return match
383
- })
423
+ return tokenizeJson(escapeHtml(json)).map(({ type, value }) => {
424
+ if (type === 'key') return `<span style="color:${SYNTAX_COLOR_KEY}">${value}</span>:`
425
+ if (type === 'string') return `<span style="color:${SYNTAX_COLOR_STRING}">${value}</span>`
426
+ if (type === 'number') return `<span style="color:${SYNTAX_COLOR_NUMBER}">${value}</span>`
427
+ if (type === 'boolean') return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${value}</span>`
428
+ if (type === 'null') return `<span style="color:${SYNTAX_COLOR_NULL}">${value}</span>`
429
+ return value
430
+ }).join('')
384
431
  }
385
432
 
386
433
  /**
@@ -389,12 +436,21 @@ export function highlightJson(json: string): string {
389
436
  * making stack traces and multi-line messages readable in the expanded view.
390
437
  */
391
438
  export function unescapeJsonStrings(text: string): string {
392
- return text.replace(/"(?:[^"\\]|\\.)*"/g, (match) => {
393
- return match
439
+ let result = ''
440
+ let copied = 0
441
+ let offset = 0
442
+ while (offset < text.length) {
443
+ if (text[offset++] !== '"') continue
444
+ const start = offset - 1
445
+ offset = jsonStringEnd(text, start)
446
+ if (offset < 0) break
447
+ result += text.slice(copied, start) + text.slice(start, offset)
394
448
  .replace(/\\r\\n/g, '\r\n')
395
449
  .replace(/\\n/g, '\n')
396
450
  .replace(/\\t/g, '\t')
397
- })
451
+ copied = offset
452
+ }
453
+ return result + text.slice(copied)
398
454
  }
399
455
 
400
456
  // logfmt detection and parsing
@@ -49,14 +49,22 @@ export function normalizeLevel(raw: unknown): LogLevel | null {
49
49
  return 'unknown'
50
50
  }
51
51
 
52
- // PostgreSQL's error_severity values (every DEBUGn is written as DEBUG);
53
- // anything else under `record` isn't PostgreSQL's.
52
+ // PostgreSQL writes every DEBUGn severity as DEBUG.
54
53
  const POSTGRES_SEVERITIES: Record<string, LogLevel> = {
55
54
  DEBUG: 'debug', LOG: 'info', INFO: 'info', NOTICE: 'info', WARNING: 'warn', ERROR: 'error', FATAL: 'error', PANIC: 'error',
56
55
  }
57
56
 
58
57
  const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
59
58
 
59
+ export function selectPostgresRecord(obj: Record<string, unknown>): { record: Record<string, unknown>; raw: string; level: LogLevel } | null {
60
+ const record = obj.record
61
+ if (obj.logger !== 'postgres' || obj.msg !== 'record' || !record || typeof record !== 'object' || Array.isArray(record)) return null
62
+ const fields = record as Record<string, unknown>
63
+ const raw = fields.error_severity
64
+ const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
65
+ return typeof raw === 'string' && level ? { record: fields, raw, level } : null
66
+ }
67
+
60
68
  /**
61
69
  * Pick the level field of a structured record. Shared by detection and the
62
70
  * structured-line badge so the badge text and the line's resolved level always
@@ -65,12 +73,8 @@ const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level']
65
73
  export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
66
74
  // CloudNativePG wraps each PostgreSQL line in an instance-manager record
67
75
  // whose own level is usually info; the database's severity is nested.
68
- const pgRecord = obj.record
69
- if (pgRecord && typeof pgRecord === 'object' && !Array.isArray(pgRecord)) {
70
- const raw = (pgRecord as Record<string, unknown>).error_severity
71
- const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
72
- if (level) return { raw, level }
73
- }
76
+ const pgRecord = selectPostgresRecord(obj)
77
+ if (pgRecord) return { raw: pgRecord.raw, level: pgRecord.level }
74
78
  for (const key of LEVEL_FIELD_KEYS) {
75
79
  const level = normalizeLevel(obj[key])
76
80
  if (level) return { raw: obj[key], level }
@@ -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
+ })