@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.
- package/README.md +41 -0
- package/package.json +3 -3
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- 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
|
|
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
|
}
|
package/src/types/gitops-tree.ts
CHANGED
|
@@ -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
|
package/src/types/gitops.ts
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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 =>
|
|
@@ -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
|
+
}
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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', () => {
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
|
41
|
+
return gitOpsOwnerFromRef(refs[0])
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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'
|
package/src/utils/k8s-errors.ts
CHANGED
|
@@ -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('<script>')
|
|
32
|
+
expect(html).toContain('</script>&')
|
|
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
|
+
})
|
package/src/utils/log-format.ts
CHANGED
|
@@ -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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
package/src/utils/log-level.ts
CHANGED
|
@@ -49,14 +49,22 @@ export function normalizeLevel(raw: unknown): LogLevel | null {
|
|
|
49
49
|
return 'unknown'
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// PostgreSQL
|
|
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
|
|
69
|
-
if (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 }
|