@skyhook-io/k8s-ui 1.16.1 → 1.16.3
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 +22 -0
- package/package.json +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/dock/BottomDock.tsx +8 -1
- package/src/components/dock/DockContext.tsx +8 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
- package/src/components/dock/LocalTerminalTab.tsx +56 -18
- package/src/components/dock/TerminalTab.tsx +2 -4
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/LogCore.theme.test.tsx +77 -0
- package/src/components/logs/LogCore.tsx +23 -2
- package/src/components/logs/LogsViewer.tsx +5 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/ui/SelectMenu.test.tsx +25 -0
- package/src/components/ui/SelectMenu.tsx +11 -9
- package/src/components/ui/YamlReview.test.tsx +40 -0
- package/src/components/ui/YamlReview.tsx +22 -1
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/utils/log-level.ts +14 -0
|
@@ -4,6 +4,7 @@ import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
|
|
|
4
4
|
import { Badge } from './Badge'
|
|
5
5
|
import { StatusDot } from './status-tone'
|
|
6
6
|
import { YamlDiffEditor } from './YamlEditor'
|
|
7
|
+
import { AlertBanner } from './drawer-components'
|
|
7
8
|
|
|
8
9
|
export interface YamlPreviewResult {
|
|
9
10
|
index: number
|
|
@@ -30,6 +31,9 @@ export interface YamlReviewProps {
|
|
|
30
31
|
force?: boolean
|
|
31
32
|
isApplying?: boolean
|
|
32
33
|
applyError?: string | null
|
|
34
|
+
/** Set when an apply failed and the refreshed review found the resource had
|
|
35
|
+
* changed since the user reviewed it: the diff now shows its latest version. */
|
|
36
|
+
changedSinceReview?: boolean
|
|
33
37
|
applyLabel?: string
|
|
34
38
|
onClose?: () => void
|
|
35
39
|
onBack: () => void
|
|
@@ -48,6 +52,13 @@ function previewSeverity(status: YamlPreviewResult['status']) {
|
|
|
48
52
|
return 'warning' as const
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
// The server's refusal when the resource moved past the reviewed version. The
|
|
56
|
+
// changed-after-review notice already says this, so only this error gives way
|
|
57
|
+
// to it; a timeout or an admission denial still shows beside the notice.
|
|
58
|
+
function isChangedAfterReviewError(error: string) {
|
|
59
|
+
return /resource changed after review/i.test(error)
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export function canApplyYamlPreview(
|
|
52
63
|
documents: YamlPreviewResult[],
|
|
53
64
|
unavailableAcknowledged: boolean,
|
|
@@ -80,6 +91,7 @@ export function YamlReview({
|
|
|
80
91
|
force = false,
|
|
81
92
|
isApplying = false,
|
|
82
93
|
applyError,
|
|
94
|
+
changedSinceReview = false,
|
|
83
95
|
applyLabel = 'Apply reviewed changes',
|
|
84
96
|
onClose,
|
|
85
97
|
onBack,
|
|
@@ -351,7 +363,16 @@ export function YamlReview({
|
|
|
351
363
|
</div>
|
|
352
364
|
|
|
353
365
|
<div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
|
|
354
|
-
{
|
|
366
|
+
{changedSinceReview && (
|
|
367
|
+
<AlertBanner
|
|
368
|
+
variant="warning"
|
|
369
|
+
title="This resource changed after your review"
|
|
370
|
+
message="The diff above now shows its latest version. Check it, then apply again."
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
{/* The notice above already says why the apply was refused; the
|
|
374
|
+
raw API message would repeat it (the error toast carries it). */}
|
|
375
|
+
{applyError && !(changedSinceReview && isChangedAfterReviewError(applyError)) && (
|
|
355
376
|
<div className="mb-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-600 dark:text-red-400">
|
|
356
377
|
{applyError}
|
|
357
378
|
</div>
|
|
@@ -77,9 +77,19 @@ import { rolloutMayAdvanceAutomatically, type WorkloadRolloutActivity } from '..
|
|
|
77
77
|
import { WorkloadRolloutNotice } from './WorkloadRolloutNotice'
|
|
78
78
|
import { isCoreBatchJob } from '../../utils/api-resources'
|
|
79
79
|
|
|
80
|
-
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
|
|
80
|
+
export type WorkloadTabType = 'overview' | 'spec' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
|
|
81
81
|
type TabType = WorkloadTabType
|
|
82
82
|
|
|
83
|
+
/** A host-provided tab. `after` places it behind a built-in tab; `replaces` hides that built-in tab. */
|
|
84
|
+
export interface WorkloadExtraTab {
|
|
85
|
+
id: string
|
|
86
|
+
label: string
|
|
87
|
+
icon?: ReactNode
|
|
88
|
+
after?: WorkloadTabType
|
|
89
|
+
replaces?: WorkloadTabType
|
|
90
|
+
render: () => ReactNode
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
export interface ResourceOwnershipContext {
|
|
84
94
|
application?: {
|
|
85
95
|
key: string
|
|
@@ -286,6 +296,24 @@ interface WorkloadViewProps {
|
|
|
286
296
|
initialContainer: string | null
|
|
287
297
|
onConsumeInitialContainer: () => void
|
|
288
298
|
}) => ReactNode
|
|
299
|
+
/**
|
|
300
|
+
* A composed summary for kinds that have one. When it returns content, the
|
|
301
|
+
* Overview tab shows the summary and the resource's own renderer moves to a
|
|
302
|
+
* "Spec & status" tab, so the same facts never render twice. Return null to
|
|
303
|
+
* keep the default Overview.
|
|
304
|
+
*/
|
|
305
|
+
renderSummary?: (props: {
|
|
306
|
+
kind: string
|
|
307
|
+
apiKind: string
|
|
308
|
+
namespace: string
|
|
309
|
+
name: string
|
|
310
|
+
group?: string
|
|
311
|
+
resource: any
|
|
312
|
+
context: 'drawer' | 'expanded'
|
|
313
|
+
onNavigate?: NavigateToResource
|
|
314
|
+
}) => ReactNode
|
|
315
|
+
/** Extra tabs for the expanded view (e.g. a domain's own sections). */
|
|
316
|
+
extraTabs?: WorkloadExtraTab[]
|
|
289
317
|
/** Render a full replacement for the expanded Overview tab. */
|
|
290
318
|
renderExpandedOverview?: (props: {
|
|
291
319
|
kind: string
|
|
@@ -434,6 +462,8 @@ export function WorkloadView({
|
|
|
434
462
|
renderDiagnoseTab,
|
|
435
463
|
reachableVia,
|
|
436
464
|
renderExpandedOverview,
|
|
465
|
+
renderSummary,
|
|
466
|
+
extraTabs,
|
|
437
467
|
renderRelatedYaml,
|
|
438
468
|
renderMetricsTab,
|
|
439
469
|
renderCostTab,
|
|
@@ -480,8 +510,10 @@ export function WorkloadView({
|
|
|
480
510
|
|
|
481
511
|
// Collapsed mode state (YAML toggle for drawer mode)
|
|
482
512
|
const [showYaml, setShowYaml] = useState(initialTab === 'yaml')
|
|
513
|
+
const [drawerSpec, setDrawerSpec] = useState(false)
|
|
483
514
|
useEffect(() => {
|
|
484
515
|
setShowYaml(initialTab === 'yaml')
|
|
516
|
+
setDrawerSpec(false)
|
|
485
517
|
}, [kindProp, namespace, name, initialTab])
|
|
486
518
|
|
|
487
519
|
const switchView = useCallback((yaml: boolean) => {
|
|
@@ -776,8 +808,12 @@ export function WorkloadView({
|
|
|
776
808
|
const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
|
|
777
809
|
const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading)
|
|
778
810
|
const requestedTab: TabType = activeTab
|
|
811
|
+
const summaryContext = { kind, apiKind, namespace, name, group, resource, onNavigate: onNavigateToResource }
|
|
812
|
+
const expandedSummary = expanded && resource ? renderSummary?.({ ...summaryContext, context: 'expanded' }) ?? null : null
|
|
813
|
+
const drawerSummary = !expanded && resource ? renderSummary?.({ ...summaryContext, context: 'drawer' }) ?? null : null
|
|
779
814
|
const tabs: DetailShellTab<TabType>[] = [
|
|
780
815
|
{ id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
|
|
816
|
+
{ id: 'spec', label: 'Spec & status', icon: <FileText className="w-4 h-4" />, hidden: !expandedSummary },
|
|
781
817
|
{ id: 'topology', label: 'Topology', icon: <Network className="w-4 h-4" />, hidden: topologyTabHidden },
|
|
782
818
|
{
|
|
783
819
|
id: 'timeline',
|
|
@@ -796,12 +832,15 @@ export function WorkloadView({
|
|
|
796
832
|
{ id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
|
|
797
833
|
{ id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
|
|
798
834
|
]
|
|
799
|
-
const
|
|
835
|
+
const allTabs = mergeExtraTabs(tabs, expanded ? extraTabs : undefined)
|
|
836
|
+
const requestedTabAvailable = allTabs.some((tab) => tab.id === requestedTab && !tab.hidden)
|
|
800
837
|
const effectiveTab: TabType = requestedTabAvailable ? requestedTab : 'overview'
|
|
838
|
+
const activeExtraTab = expanded ? extraTabs?.find((x) => x.id === effectiveTab) : undefined
|
|
801
839
|
const shouldCommitFallback =
|
|
802
840
|
requestedTab !== 'overview' &&
|
|
803
841
|
!requestedTabAvailable &&
|
|
804
842
|
(
|
|
843
|
+
(requestedTab === 'spec' && !!resource && !resourceLoading && !expandedSummary) ||
|
|
805
844
|
(requestedTab === 'topology' && topologyTabHidden) ||
|
|
806
845
|
(requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) ||
|
|
807
846
|
(requestedTab === 'cost' && (!renderCostTab || (!!resource && !resourceLoading && !showCostTab))) ||
|
|
@@ -941,6 +980,33 @@ export function WorkloadView({
|
|
|
941
980
|
/>
|
|
942
981
|
) : (
|
|
943
982
|
<OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
|
|
983
|
+
{drawerSummary && (
|
|
984
|
+
<div className="px-4 pt-3" role="tablist" aria-label="Detail view">
|
|
985
|
+
<div className="inline-flex rounded-lg bg-theme-elevated p-0.5 text-xs">
|
|
986
|
+
{([['overview', 'Overview'], ['spec', 'Spec & status']] as const).map(([id, label]) => {
|
|
987
|
+
const on = id === 'spec' ? drawerSpec : !drawerSpec
|
|
988
|
+
return (
|
|
989
|
+
<button
|
|
990
|
+
key={id}
|
|
991
|
+
role="tab"
|
|
992
|
+
aria-selected={on}
|
|
993
|
+
onClick={() => setDrawerSpec(id === 'spec')}
|
|
994
|
+
className={clsx(
|
|
995
|
+
'rounded-md px-2.5 py-1 font-medium transition-colors',
|
|
996
|
+
on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
|
|
997
|
+
)}
|
|
998
|
+
>
|
|
999
|
+
{label}
|
|
1000
|
+
</button>
|
|
1001
|
+
)
|
|
1002
|
+
})}
|
|
1003
|
+
</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
)}
|
|
1006
|
+
{drawerSummary && !drawerSpec ? (
|
|
1007
|
+
drawerSummary
|
|
1008
|
+
) : (
|
|
1009
|
+
<>
|
|
944
1010
|
{renderOverviewLead && hasOperationalIssues && (
|
|
945
1011
|
<div className="px-4 pt-4">
|
|
946
1012
|
{renderOverviewLead({ kind, namespace, name })}
|
|
@@ -969,6 +1035,8 @@ export function WorkloadView({
|
|
|
969
1035
|
updatesError={resourceFocusedUpdatesError}
|
|
970
1036
|
mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'drawer' })}
|
|
971
1037
|
/>
|
|
1038
|
+
</>
|
|
1039
|
+
)}
|
|
972
1040
|
</OperationalIssuesShownContext.Provider>
|
|
973
1041
|
)}
|
|
974
1042
|
</div>
|
|
@@ -1085,7 +1153,7 @@ export function WorkloadView({
|
|
|
1085
1153
|
)}
|
|
1086
1154
|
</>
|
|
1087
1155
|
}
|
|
1088
|
-
tabs={
|
|
1156
|
+
tabs={allTabs}
|
|
1089
1157
|
activeTab={effectiveTab}
|
|
1090
1158
|
onTabChange={handleSetTab}
|
|
1091
1159
|
scopeControls={scopeControls}
|
|
@@ -1100,7 +1168,10 @@ export function WorkloadView({
|
|
|
1100
1168
|
</div>
|
|
1101
1169
|
)}
|
|
1102
1170
|
<div className="min-h-0 flex-1">
|
|
1103
|
-
{
|
|
1171
|
+
{activeExtraTab && <div className="h-full min-h-0 overflow-y-auto">{activeExtraTab.render()}</div>}
|
|
1172
|
+
{effectiveTab === 'overview' && expandedSummary ? (
|
|
1173
|
+
<div className="h-full min-h-0 overflow-y-auto">{expandedSummary}</div>
|
|
1174
|
+
) : effectiveTab === 'overview' && expandedOverview ? (
|
|
1104
1175
|
<div className="h-full min-h-0">
|
|
1105
1176
|
{hasOperationalIssues && renderOverviewLead && (
|
|
1106
1177
|
<div className="px-4 pt-4">
|
|
@@ -1109,7 +1180,7 @@ export function WorkloadView({
|
|
|
1109
1180
|
)}
|
|
1110
1181
|
{expandedOverview}
|
|
1111
1182
|
</div>
|
|
1112
|
-
) : effectiveTab === 'overview' && (
|
|
1183
|
+
) : (effectiveTab === 'overview' || effectiveTab === 'spec') && (
|
|
1113
1184
|
<InfoTab
|
|
1114
1185
|
resource={resource}
|
|
1115
1186
|
selectedResource={selectedResource}
|
|
@@ -1846,6 +1917,7 @@ const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
|
|
|
1846
1917
|
'clusterworkflowtemplates',
|
|
1847
1918
|
'scaledjobs',
|
|
1848
1919
|
'jobsets',
|
|
1920
|
+
'clusters',
|
|
1849
1921
|
])
|
|
1850
1922
|
const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
|
|
1851
1923
|
const ROLLOUT_STATUS_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
|
|
@@ -1862,6 +1934,11 @@ export function supportsLogsWithoutPods(
|
|
|
1862
1934
|
if (normalizedKind === 'jobsets') {
|
|
1863
1935
|
return group === 'jobset.x-k8s.io' && apiVersion === 'jobset.x-k8s.io/v1alpha2'
|
|
1864
1936
|
}
|
|
1937
|
+
// A CloudNativePG Cluster's instance Pods are its children, not related
|
|
1938
|
+
// Pods, and its logs are resolved server-side from the Cluster itself.
|
|
1939
|
+
if (normalizedKind === 'clusters') {
|
|
1940
|
+
return group === 'postgresql.cnpg.io' || !!apiVersion?.startsWith('postgresql.cnpg.io/')
|
|
1941
|
+
}
|
|
1865
1942
|
return true
|
|
1866
1943
|
}
|
|
1867
1944
|
|
|
@@ -3835,3 +3912,17 @@ function mergeAndRankEvents(events: TimelineEvent[], updates: TimelineEvent[]):
|
|
|
3835
3912
|
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
3836
3913
|
})
|
|
3837
3914
|
}
|
|
3915
|
+
|
|
3916
|
+
function mergeExtraTabs(tabs: DetailShellTab<TabType>[], extra: WorkloadExtraTab[] | undefined): DetailShellTab<TabType>[] {
|
|
3917
|
+
if (!extra || extra.length === 0) return tabs
|
|
3918
|
+
const replaced = new Set(extra.map((x) => x.replaces).filter(Boolean))
|
|
3919
|
+
const out = tabs.map((t) => (replaced.has(t.id) ? { ...t, hidden: true } : t))
|
|
3920
|
+
for (const x of extra) {
|
|
3921
|
+
const tab: DetailShellTab<TabType> = { id: x.id as TabType, label: x.label, icon: x.icon }
|
|
3922
|
+
const anchor = x.after ?? x.replaces
|
|
3923
|
+
const idx = anchor ? out.findIndex((t) => t.id === anchor) : -1
|
|
3924
|
+
if (idx >= 0) out.splice(idx + 1, 0, tab)
|
|
3925
|
+
else out.push(tab)
|
|
3926
|
+
}
|
|
3927
|
+
return out
|
|
3928
|
+
}
|
|
@@ -17,4 +17,10 @@ describe('supportsLogsWithoutPods', () => {
|
|
|
17
17
|
expect(supportsLogsWithoutPods('jobs', 'Job', 'batch', 'batch/v1')).toBe(true)
|
|
18
18
|
expect(supportsLogsWithoutPods('jobs', 'Job', 'example.io', 'example.io/v1')).toBe(false)
|
|
19
19
|
})
|
|
20
|
+
|
|
21
|
+
it('offers a CloudNativePG Cluster its merged instance logs, but not other Cluster kinds', () => {
|
|
22
|
+
expect(supportsLogsWithoutPods('clusters', 'Cluster', 'postgresql.cnpg.io', 'postgresql.cnpg.io/v1')).toBe(true)
|
|
23
|
+
expect(supportsLogsWithoutPods('clusters', 'Cluster', undefined, 'postgresql.cnpg.io/v1')).toBe(true)
|
|
24
|
+
expect(supportsLogsWithoutPods('clusters', 'Cluster', 'cluster.x-k8s.io', 'cluster.x-k8s.io/v1beta1')).toBe(false)
|
|
25
|
+
})
|
|
20
26
|
})
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,9 @@ export * from './components/checks'
|
|
|
55
55
|
// queue)
|
|
56
56
|
export * from './components/issues'
|
|
57
57
|
|
|
58
|
+
// CloudNativePG workspace model (fleet derivation over /api/cnpg/workspace)
|
|
59
|
+
export * from './components/cnpg'
|
|
60
|
+
|
|
58
61
|
// Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
|
|
59
62
|
export * from './components/cluster-switcher'
|
|
60
63
|
|
package/src/utils/log-level.ts
CHANGED
|
@@ -49,6 +49,12 @@ 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.
|
|
54
|
+
const POSTGRES_SEVERITIES: Record<string, LogLevel> = {
|
|
55
|
+
DEBUG: 'debug', LOG: 'info', INFO: 'info', NOTICE: 'info', WARNING: 'warn', ERROR: 'error', FATAL: 'error', PANIC: 'error',
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
|
|
53
59
|
|
|
54
60
|
/**
|
|
@@ -57,6 +63,14 @@ const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level']
|
|
|
57
63
|
* come from the same field.
|
|
58
64
|
*/
|
|
59
65
|
export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
|
|
66
|
+
// CloudNativePG wraps each PostgreSQL line in an instance-manager record
|
|
67
|
+
// 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
|
+
}
|
|
60
74
|
for (const key of LEVEL_FIELD_KEYS) {
|
|
61
75
|
const level = normalizeLevel(obj[key])
|
|
62
76
|
if (level) return { raw: obj[key], level }
|