@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
@@ -93,6 +93,12 @@ export interface GitOpsInsightSummary {
93
93
  // The controller's API server was asked and didn't answer usefully — why,
94
94
  // in the user's words. Only set when the integration is configured.
95
95
  resourceHealthApiError?: string
96
+ // The host is still connecting to the controller's API server; ask again
97
+ // shortly.
98
+ resourceHealthApiPending?: boolean
99
+ // The host couldn't reach the controller's API server and is retrying;
100
+ // ask again now and then.
101
+ resourceHealthApiRetrying?: boolean
96
102
  }
97
103
 
98
104
  export interface GitOpsIgnoredDifferences {
@@ -177,6 +183,8 @@ export interface GitOpsChange {
177
183
  recentEvents?: GitOpsEventSummary[]
178
184
  partial: boolean
179
185
  partialNote?: string
186
+ // A Flux source or dependsOn target the root consumes but doesn't manage.
187
+ dependency?: boolean
180
188
  }
181
189
 
182
190
  export interface GitOpsDrift {
@@ -247,6 +255,12 @@ export interface GitOpsCapabilities {
247
255
  // tell "not set up" (offer Connect) from "set up but disconnected" (offer
248
256
  // Reconnect) — instead of advertising a diff that would fail.
249
257
  argoConfigured?: boolean
258
+ // Configured, and the host is still making the first connection; ask
259
+ // again shortly instead of reporting the diff as down.
260
+ argoConnecting?: boolean
261
+ // Configured, the connection failed in a way that may pass on its own, and
262
+ // the host is retrying; ask again now and then.
263
+ argoRetrying?: boolean
250
264
  // True on an Argo CD Application detail when the integration is connected,
251
265
  // meaning the revision-metadata endpoint can resolve Git commit details
252
266
  // (author, message, signature) for this app's deployed revisions.
@@ -269,13 +283,16 @@ export interface ArgoRevisionMetadata {
269
283
  // Response of GET /api/argo/applications/{ns}/{name}/resource-diff. `desired`
270
284
  // is the Git-rendered manifest, `live` the normalized cluster state — both YAML
271
285
  // for the line-by-line view. `fieldEntries` is the same diff structured per
272
- // path for a compact summary. `redacted` is set when Secret values were masked
286
+ // path for a compact summary; Radars before the list was made non-null send
287
+ // null when it is empty. `fieldEntriesTruncated` is set when the list was cut
288
+ // at the server's entry cap. `redacted` is set when Secret values were masked
273
289
  // server-side; `hook` marks Argo sync-hook resources.
274
290
  export interface GitOpsResourceDiff {
275
291
  source: string
276
292
  desired: string
277
293
  live: string
278
- fieldEntries: GitOpsResourceDiffFieldEntry[]
294
+ fieldEntries: GitOpsResourceDiffFieldEntry[] | null
295
+ fieldEntriesTruncated?: boolean
279
296
  redacted: boolean
280
297
  hook: boolean
281
298
  }
@@ -43,6 +43,9 @@ export interface GitOpsTreeNode {
43
43
  // Lives on the destination cluster of a remote tree. This cluster's API
44
44
  // would answer for a different, same-named object.
45
45
  remote?: boolean
46
+ // Reached only through a Flux source, chart or dependsOn declaration: the
47
+ // root consumes it but does not manage it. Not counted as a managed resource.
48
+ dependency?: boolean
46
49
  }
47
50
 
48
51
  // 'controllerApi' is the controller's own verdict read from its API server
@@ -69,6 +72,7 @@ export interface GitOpsTreeSummary {
69
72
  grouped: number
70
73
  degraded: number
71
74
  outOfSync: number
75
+ dependencies?: number
72
76
  }
73
77
 
74
78
  export interface GitOpsResourceTree {
@@ -83,6 +87,12 @@ export interface GitOpsResourceTree {
83
87
  healthFromApi?: boolean
84
88
  // The controller's API server was asked and didn't answer usefully.
85
89
  healthApiError?: string
90
+ // The host is still connecting to the controller's API server; ask again
91
+ // shortly.
92
+ healthApiPending?: boolean
93
+ // The host couldn't reach the controller's API server and is retrying;
94
+ // ask again now and then.
95
+ healthApiRetrying?: boolean
86
96
  // The Application (or Flux object with spec.kubeConfig) deploys to another
87
97
  // cluster; Radar derives nothing about its resources from here.
88
98
  remoteDestination?: boolean
@@ -99,6 +99,7 @@ export interface FluxCondition {
99
99
  reason?: string
100
100
  message?: string
101
101
  lastTransitionTime?: string
102
+ observedGeneration?: number
102
103
  }
103
104
 
104
105
  // ============================================================================
@@ -141,8 +142,11 @@ function hasGenerationDrift(gen?: FluxGeneration): boolean {
141
142
  * - suspend → Suspended (sync from Ready, unless drifted)
142
143
  * - Stalled=True → OutOfSync / Degraded
143
144
  * - Ready=True and no generation drift → Synced, health from Healthy
145
+ * - Ready=False with a failure reason about
146
+ * the current generation → OutOfSync / Degraded
144
147
  * - drift, or Reconciling with Ready not True → Reconciling / Progressing
145
- * - Ready=False → OutOfSync
148
+ * - Ready=False → OutOfSync / Degraded, or
149
+ * Progressing for a retry/wait reason
146
150
  */
147
151
  export function fluxConditionsToGitOpsStatus(
148
152
  conditions: FluxCondition[],
@@ -203,6 +207,25 @@ export function fluxConditionsToGitOpsStatus(
203
207
  }
204
208
  }
205
209
 
210
+ // A failure the controller reported outranks activity too: a source that
211
+ // can't clone keeps Reconciling=True and never records observedGeneration
212
+ // while it retries, and reading that as "in progress" hid it.
213
+ const readyReason = readyCondition?.reason || ''
214
+ const transientReason = readyReason.includes('Progress') || readyReason.includes('Retry') || readyReason.includes('Wait')
215
+ // Only a failure about the current spec counts: after a fix, the old
216
+ // Ready=False describes an older generation until the controller looks again.
217
+ const currentFailure = readyCondition?.observedGeneration !== undefined && readyCondition.observedGeneration === generation?.generation
218
+ if (readyCondition?.status === 'False' && !transientReason && currentFailure) {
219
+ return {
220
+ sync: 'OutOfSync',
221
+ health: 'Degraded',
222
+ message: readyCondition.message,
223
+ lastSyncTime: readyCondition.lastTransitionTime,
224
+ suspended: false,
225
+ ...activity,
226
+ }
227
+ }
228
+
206
229
  // Genuinely not applied: the controller has not observed the current spec, or
207
230
  // it is working and has not reached Ready.
208
231
  if (drifted || isReconciling) {
@@ -216,14 +239,11 @@ export function fluxConditionsToGitOpsStatus(
216
239
  }
217
240
  }
218
241
 
219
- // Handle not ready state
242
+ // A retry or remediation in flight is out of sync, but not a settled failure.
220
243
  if (readyCondition?.status === 'False') {
221
- const reason = readyCondition.reason || ''
222
- const isTransient = reason.includes('Progress') || reason.includes('Retry') || reason.includes('Wait')
223
-
224
244
  return {
225
245
  sync: 'OutOfSync',
226
- health: isTransient ? 'Progressing' : 'Degraded',
246
+ health: transientReason ? 'Progressing' : 'Degraded',
227
247
  message: readyCondition.message,
228
248
  lastSyncTime: readyCondition.lastTransitionTime,
229
249
  suspended: false,
@@ -63,6 +63,13 @@ const ADDITIONAL_BUILTIN_IDENTITIES: Record<string, string> = {
63
63
  deviceclass: 'resource.k8s.io', deviceclasses: 'resource.k8s.io',
64
64
  };
65
65
 
66
+ // Kind spellings only: a resource name such as `services` is not a Kind.
67
+ const ADDITIONAL_CORE_KINDS: ReadonlySet<string> = new Set(['Endpoints'])
68
+
69
+ export function isBuiltinCoreKind(kind: string): boolean {
70
+ return ADDITIONAL_CORE_KINDS.has(kind) || CORE_RESOURCES.some(r => r.group === '' && r.kind === kind)
71
+ }
72
+
66
73
  export function builtinGroupForKind(kindOrResource: string): string | undefined {
67
74
  const value = kindOrResource.toLowerCase()
68
75
  return CORE_RESOURCES.find(resource =>
@@ -38,6 +38,7 @@ const RELATED_RUNTIME_KINDS = new Set([
38
38
  'HTTPRoute',
39
39
  'GRPCRoute',
40
40
  'TCPRoute',
41
+ 'UDPRoute',
41
42
  'TLSRoute',
42
43
  'ConfigMap',
43
44
  'Secret',
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { cnpgBackupDeclaration, cnpgBackupDestinationBlocker, cnpgBarmanPlugin, cnpgDestinationPlugin, cnpgHasBackupDestination } from './cnpg-backup'
4
+
5
+ const cases: Array<{
6
+ name: string
7
+ cluster: Parameters<typeof cnpgBackupDeclaration>[0]
8
+ method: string
9
+ plugin: string
10
+ hasDestination: boolean
11
+ destinationPlugin: string
12
+ blockerCode: string
13
+ barman: { objectStore: string; serverName: string } | null
14
+ }> = JSON.parse(readFileSync(new URL('../../../../pkg/cnpg/testdata/backup-declarations.json', import.meta.url), 'utf8'))
15
+
16
+ describe('CNPG backup declarations shared with Go', () => {
17
+ it.each(cases)('$name', (test) => {
18
+ const declaration = cnpgBackupDeclaration(test.cluster)
19
+ expect(cnpgHasBackupDestination(declaration)).toBe(test.hasDestination)
20
+ expect(cnpgDestinationPlugin(declaration)?.name ?? '').toBe(test.destinationPlugin)
21
+ expect(cnpgBackupDestinationBlocker(declaration, test.method || undefined, test.plugin)?.code ?? '').toBe(test.blockerCode)
22
+ const barman = cnpgBarmanPlugin(declaration)
23
+ if (test.barman) expect(barman).toMatchObject({ barmanObjectName: test.barman.objectStore || undefined, serverName: test.barman.serverName })
24
+ else expect(barman).toBeUndefined()
25
+ })
26
+ })
@@ -0,0 +1,70 @@
1
+ export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
2
+
3
+ interface BackupCluster {
4
+ metadata?: { name?: string }
5
+ spec?: {
6
+ backup?: { barmanObjectStore?: { destinationPath?: string }; volumeSnapshot?: unknown }
7
+ plugins?: { name: string; enabled?: boolean; isWALArchiver?: boolean; parameters?: { barmanObjectName?: string; serverName?: string } }[]
8
+ }
9
+ }
10
+
11
+ export interface CNPGBackupPlugin {
12
+ name: string
13
+ enabled: boolean
14
+ isWALArchiver: boolean
15
+ barmanObjectName?: string
16
+ serverName?: string
17
+ }
18
+
19
+ export interface CNPGBackupDeclaration {
20
+ plugins: CNPGBackupPlugin[]
21
+ inTreeConfigured: boolean
22
+ inTreeDestination?: string
23
+ snapshotsConfigured: boolean
24
+ }
25
+
26
+ export function cnpgBackupDeclaration(cluster: BackupCluster): CNPGBackupDeclaration {
27
+ const backup = cluster.spec?.backup
28
+ return {
29
+ inTreeConfigured: !!backup?.barmanObjectStore,
30
+ inTreeDestination: backup?.barmanObjectStore?.destinationPath,
31
+ snapshotsConfigured: backup?.volumeSnapshot != null,
32
+ plugins: (cluster.spec?.plugins ?? []).filter((p) => p?.name).map((p) => ({
33
+ name: p.name,
34
+ enabled: p.enabled !== false,
35
+ isWALArchiver: p.isWALArchiver === true,
36
+ barmanObjectName: p.parameters?.barmanObjectName,
37
+ serverName: p.parameters?.serverName || cluster.metadata?.name,
38
+ })),
39
+ }
40
+ }
41
+
42
+ export function cnpgBarmanPlugin(declaration: CNPGBackupDeclaration): CNPGBackupPlugin | undefined {
43
+ return declaration.plugins.find((p) => p.enabled && p.name === CNPG_BARMAN_PLUGIN_NAME)
44
+ }
45
+
46
+ export function cnpgDestinationPlugin(declaration: CNPGBackupDeclaration): CNPGBackupPlugin | undefined {
47
+ return declaration.plugins.find((p) => p.enabled && (p.name !== CNPG_BARMAN_PLUGIN_NAME || !!p.barmanObjectName))
48
+ }
49
+
50
+ export function cnpgHasBackupDestination(declaration: CNPGBackupDeclaration): boolean {
51
+ return !!declaration.inTreeDestination || declaration.snapshotsConfigured || !!cnpgDestinationPlugin(declaration)
52
+ }
53
+
54
+ export interface CNPGBackupBlocker {
55
+ code: 'no_destination' | 'method_destination_missing'
56
+ method: string
57
+ plugin?: string
58
+ }
59
+
60
+ export function cnpgBackupDestinationBlocker(declaration: CNPGBackupDeclaration, method = 'barmanObjectStore', plugin?: string): CNPGBackupBlocker | null {
61
+ const blocked = method === 'barmanObjectStore' ? !declaration.inTreeDestination
62
+ : method === 'volumeSnapshot' ? !declaration.snapshotsConfigured
63
+ : method === 'plugin' ? !declaration.plugins.some((p) => p.name === plugin && p.enabled && (p.name !== CNPG_BARMAN_PLUGIN_NAME || !!p.barmanObjectName))
64
+ : false
65
+ return blocked ? { code: cnpgHasBackupDestination(declaration) ? 'method_destination_missing' : 'no_destination', method, plugin: cnpgDestinationPlugin(declaration)?.name } : null
66
+ }
67
+
68
+ export function cnpgBackupBlockerText(blocker: CNPGBackupBlocker): string {
69
+ return blocker.code === 'no_destination' ? 'No backup destination' : `No ${blocker.method} destination`
70
+ }
@@ -41,6 +41,15 @@ describe('parseMemoryToBytes', () => {
41
41
  expect(parseMemoryToBytes('1P')).toBe(1000 ** 5)
42
42
  expect(parseMemoryToBytes('1E')).toBe(1000 ** 6)
43
43
  })
44
+
45
+ // The API server keeps a quoted exponent quantity as written, and defaults a
46
+ // request from it the same way ("1e9" limit → "500e6" request).
47
+ it('parses decimal-exponent quantities', () => {
48
+ expect(parseMemoryToBytes('1e9')).toBe(1e9)
49
+ expect(parseMemoryToBytes('500e6')).toBe(5e8)
50
+ expect(parseMemoryToBytes('1E3')).toBe(1000)
51
+ expect(formatMemoryString('1e9')).toBe('954 MiB')
52
+ })
44
53
  })
45
54
 
46
55
  describe('parseQuantityToNumber', () => {
@@ -120,6 +120,10 @@ export function parseMemoryToBytes(memString: string): number {
120
120
  if (!memString) return 0
121
121
 
122
122
  const str = memString.trim()
123
+ // Decimal-exponent form ("1e9") is decided before the suffix table, as in
124
+ // parseQuantityToNumber, so "1E3" reads as 1000 and not as exa.
125
+ if (/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/.test(str)) return Number(str)
126
+
123
127
  const match = str.match(/^(-?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
124
128
  if (!match) return 0
125
129
 
@@ -38,10 +38,11 @@ export function gitOpsOwnerFromRelationships(
38
38
  ): GitOpsOwnerRef | null {
39
39
  const refs = rel?.managedBy
40
40
  if (!refs || refs.length === 0) return null
41
- return refToGitOpsOwner(refs[0])
41
+ return gitOpsOwnerFromRef(refs[0])
42
42
  }
43
43
 
44
- function refToGitOpsOwner(ref: ResourceRef): GitOpsOwnerRef | null {
44
+ /** A manager ref (relationships.managedBy, a workspace's managedBy) as a GitOps owner, or null when the manager is not a GitOps controller. */
45
+ export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
45
46
  switch (true) {
46
47
  case ref.kind === 'Application' && ref.group === ARGO_APPLICATION_GROUP:
47
48
  return { tool: 'argocd', kind: 'applications', namespace: ref.namespace, name: ref.name }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatGrant } from './grant'
3
+
4
+ describe('formatGrant', () => {
5
+ it('words a namespaced grant like the server', () => {
6
+ expect(formatGrant({ verb: 'patch', group: 'postgresql.cnpg.io', resource: 'clusters', subresource: 'status', namespace: 'pg' })).toBe(
7
+ 'patch clusters/status (postgresql.cnpg.io) in namespace pg',
8
+ )
9
+ expect(formatGrant({ verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'pg' })).toBe('get pods/proxy in namespace pg')
10
+ })
11
+ it('words a cluster-wide grant with the group as resource.group', () => {
12
+ expect(formatGrant({ verb: 'patch', resource: 'clusters', group: 'example.io', subresource: 'status' })).toBe('patch clusters.example.io/status cluster-wide')
13
+ expect(formatGrant({ verb: 'get', resource: 'nodes' })).toBe('get nodes cluster-wide')
14
+ expect(formatGrant({ verb: 'list', group: 'postgresql.cnpg.io', resource: 'clusters' })).toBe('list clusters.postgresql.cnpg.io cluster-wide')
15
+ })
16
+ it('is undefined without a grant', () => {
17
+ expect(formatGrant(undefined)).toBeUndefined()
18
+ })
19
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One RBAC permission a read or action needs, as the server sends it. An
3
+ * empty namespace means cluster-wide (a cluster-scoped resource, or a list
4
+ * across every namespace).
5
+ */
6
+ export interface Grant {
7
+ verb: string
8
+ group?: string
9
+ resource: string
10
+ subresource?: string
11
+ namespace?: string
12
+ }
13
+
14
+ function grantResource(g: Grant): string {
15
+ return g.subresource ? `${g.resource}/${g.subresource}` : g.resource
16
+ }
17
+
18
+ /**
19
+ * The grant's two halves for display: what ("patch clusters/status
20
+ * (postgresql.cnpg.io)") and where (" in namespace pg", " cluster-wide").
21
+ */
22
+ export function grantParts(g: Grant): { what: string; scope: string } {
23
+ if (g.namespace) {
24
+ return { what: `${g.verb} ${grantResource(g)}${g.group ? ` (${g.group})` : ''}`, scope: ` in namespace ${g.namespace}` }
25
+ }
26
+ return { what: `${g.verb} ${g.resource}${g.group ? `.${g.group}` : ''}${g.subresource ? `/${g.subresource}` : ''}`, scope: ' cluster-wide' }
27
+ }
28
+
29
+ export function formatGrant(g: Grant): string
30
+ export function formatGrant(g: Grant | undefined): string | undefined
31
+ export function formatGrant(g: Grant | undefined): string | undefined {
32
+ if (!g) return undefined
33
+ const { what, scope } = grantParts(g)
34
+ return what + scope
35
+ }
@@ -32,4 +32,8 @@ export * from './bulk-workload-actions'
32
32
  export * from './inClusterConsent'
33
33
  export * from './workflow-execution'
34
34
  export * from './network-policy'
35
+ export * from './cnpg-backup'
36
+ export * from './url-path'
35
37
  export * from './resource-refs'
38
+
39
+ export * from './grant'
@@ -6,7 +6,7 @@ interface FriendlyError {
6
6
  suggestion: string
7
7
  }
8
8
 
9
- const patterns: Array<{ test: (msg: string) => boolean; friendly: (msg: string) => FriendlyError }> = [
9
+ const patterns: Array<{ test: (msg: string) => boolean; friendly: (msg: string, allowApply: boolean) => FriendlyError }> = [
10
10
  // Admission webhook timeout
11
11
  {
12
12
  test: (msg) => /failed calling webhook.*context deadline exceeded/i.test(msg),
@@ -75,9 +75,9 @@ const patterns: Array<{ test: (msg: string) => boolean; friendly: (msg: string)
75
75
  // Already exists (create mode)
76
76
  {
77
77
  test: (msg) => /already exists/i.test(msg),
78
- friendly: () => ({
78
+ friendly: (_msg, allowApply) => ({
79
79
  summary: 'Resource already exists',
80
- suggestion: 'Switch to Apply mode to update the existing resource, or change the name to create a new one.',
80
+ suggestion: allowApply ? 'Switch to Apply mode to update the existing resource, or change the name to create a new one.' : 'Change the name to create a new resource, or remove this document if it was already created.',
81
81
  }),
82
82
  },
83
83
  // Quota exceeded
@@ -122,11 +122,11 @@ function stripPrefix(msg: string): string {
122
122
  return msg.replace(/^failed to (apply|create) resource:\s*/i, '')
123
123
  }
124
124
 
125
- export function getFriendlyError(rawError: string): { summary: string; suggestion: string; raw: string } | null {
125
+ export function getFriendlyError(rawError: string, allowApply = true): { summary: string; suggestion: string; raw: string } | null {
126
126
  const cleaned = stripPrefix(rawError)
127
127
  for (const pattern of patterns) {
128
128
  if (pattern.test(cleaned)) {
129
- const { summary, suggestion } = pattern.friendly(cleaned)
129
+ const { summary, suggestion } = pattern.friendly(cleaned, allowApply)
130
130
  return { summary, suggestion, raw: cleaned }
131
131
  }
132
132
  }
@@ -134,8 +134,8 @@ export function getFriendlyError(rawError: string): { summary: string; suggestio
134
134
  }
135
135
 
136
136
  // Always returns something displayable — friendly if matched, raw if not
137
- export function formatApplyError(rawError: string): { summary: string; suggestion?: string; raw: string } {
138
- const friendly = getFriendlyError(rawError)
137
+ export function formatApplyError(rawError: string, { allowApply = true }: { allowApply?: boolean } = {}): { summary: string; suggestion?: string; raw: string } {
138
+ const friendly = getFriendlyError(rawError, allowApply)
139
139
  if (friendly) return friendly
140
140
  const cleaned = stripPrefix(rawError)
141
141
  return { summary: cleaned, raw: rawError }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { highlightJson, tokenizeJson, unescapeJsonStrings } from './log-format'
3
+
4
+ describe('unescapeJsonStrings', () => {
5
+ it('expands whitespace inside strings while leaving escapes outside them untouched', () => {
6
+ expect(unescapeJsonStrings('{"msg":"first\\r\\nsecond\\nthird\\tend"}')).toBe('{"msg":"first\r\nsecond\nthird\tend"}')
7
+ expect(unescapeJsonStrings('outside\\n {"msg":"inside\\n"} outside\\t')).toBe('outside\\n {"msg":"inside\n"} outside\\t')
8
+ })
9
+
10
+ it('keeps escaped quotes and backslashes in their original string', () => {
11
+ expect(unescapeJsonStrings('{"msg":"say \\"hello\\"\\nnext","path":"C:\\\\docs"}')).toBe('{"msg":"say \\"hello\\"\nnext","path":"C:\\\\docs"}')
12
+ expect(unescapeJsonStrings(JSON.stringify({ empty: '', nested: ['a\nb', 'c\td'], number: 42, yes: true }))).toBe('{"empty":"","nested":["a\nb","c\td"],"number":42,"yes":true}')
13
+ })
14
+
15
+ it('stops once at an unterminated string with many escaped quotes', () => {
16
+ const text = '"' + '\\"'.repeat(100_000) + '\\n'
17
+ expect(unescapeJsonStrings(text)).toBe(text)
18
+ })
19
+ })
20
+
21
+ describe('JSON highlighting', () => {
22
+ it('keeps primitive-looking words inside strings and preserves JSON token colors', () => {
23
+ const tokens = tokenizeJson('{ "text" : "true null 123 \\"quoted\\"", "values": [-1.2e+3, true, false, null, ""] }')
24
+ expect(tokens.filter((token) => token.type === 'string').map((token) => token.value)).toEqual(['"true null 123 \\"quoted\\""', '""'])
25
+ expect(tokens.filter((token) => token.type === 'number').map((token) => token.value)).toEqual(['-1.2e+3'])
26
+ expect(highlightJson('{"a":true,"b":null,"n":12}')).toBe('{<span style="color:#7cacf8">"a"</span>:<span style="color:#c678dd">true</span>,<span style="color:#7cacf8">"b"</span>:<span style="color:#808080">null</span>,<span style="color:#7cacf8">"n"</span>:<span style="color:#e5c07b">12</span>}')
27
+ })
28
+
29
+ it('escapes HTML in log values before inserting highlighting markup', () => {
30
+ const html = highlightJson(JSON.stringify({ msg: '<script>alert("x")</script>&' }))
31
+ expect(html).toContain('&lt;script&gt;')
32
+ expect(html).toContain('&lt;/script&gt;&amp;')
33
+ expect(html).not.toContain('<script>')
34
+ })
35
+
36
+ it('leaves a long unterminated string as plain text without rescanning escaped quotes', () => {
37
+ const text = '"' + '\\"'.repeat(100_000)
38
+ expect(tokenizeJson(text)).toEqual([{ type: 'text', value: text }])
39
+ expect(highlightJson(text)).toBe(text)
40
+ })
41
+ })
@@ -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 }