@skyhook-io/k8s-ui 1.14.5 → 1.14.7
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/package.json +5 -5
- package/src/components/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +41 -10
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +76 -28
- package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +237 -14
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +8 -12
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +20 -1
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/animation.ts +93 -13
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
describeFileDrag,
|
|
4
|
+
MAX_YAML_FILE_BYTES,
|
|
5
|
+
needsReplaceConfirmation,
|
|
6
|
+
readYamlFile,
|
|
7
|
+
} from './yaml-file-import'
|
|
8
|
+
|
|
9
|
+
function drag(items: Array<{ kind: string; type: string }>, types: string[] = []): DataTransfer {
|
|
10
|
+
return { items, types } as unknown as DataTransfer
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Safari keeps the dragged items protected until the drop, so `items` is empty
|
|
14
|
+
// mid-drag; only `types` reports that files are coming.
|
|
15
|
+
function safariDrag(types: string[]): DataTransfer {
|
|
16
|
+
return { items: [], types } as unknown as DataTransfer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MULTI_DOC = `apiVersion: v1
|
|
20
|
+
kind: ConfigMap
|
|
21
|
+
metadata:
|
|
22
|
+
name: first
|
|
23
|
+
---
|
|
24
|
+
apiVersion: v1
|
|
25
|
+
kind: Secret
|
|
26
|
+
metadata:
|
|
27
|
+
name: second
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
function yamlFile(name: string, content: string, type = ''): File {
|
|
31
|
+
return new File([content], name, { type })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('readYamlFile', () => {
|
|
35
|
+
it('loads a multi-document file as text the editor can hold unchanged', async () => {
|
|
36
|
+
const result = await readYamlFile([yamlFile('bundle.yaml', MULTI_DOC)])
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ ok: true, fileName: 'bundle.yaml', yaml: MULTI_DOC })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts .yml as readily as .yaml', async () => {
|
|
42
|
+
const result = await readYamlFile([yamlFile('bundle.yml', MULTI_DOC)])
|
|
43
|
+
|
|
44
|
+
expect(result.ok).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Browsers disagree on the MIME type for YAML — macOS Chrome commonly reports
|
|
48
|
+
// "", Windows reports assorted octet-stream variants — so the extension is the
|
|
49
|
+
// only signal that means the same thing everywhere.
|
|
50
|
+
it('accepts a YAML extension even when the browser reports no usable MIME type', async () => {
|
|
51
|
+
const result = await readYamlFile([
|
|
52
|
+
yamlFile('bundle.yaml', MULTI_DOC, 'application/octet-stream'),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
expect(result.ok).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects a non-YAML extension even when the browser claims it is YAML', async () => {
|
|
59
|
+
const result = await readYamlFile([yamlFile('notes.txt', MULTI_DOC, 'text/yaml')])
|
|
60
|
+
|
|
61
|
+
expect(result).toMatchObject({ ok: false, reason: 'extension' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects a file that holds only whitespace', async () => {
|
|
65
|
+
const result = await readYamlFile([yamlFile('empty.yaml', ' \n\n ')])
|
|
66
|
+
|
|
67
|
+
expect(result).toMatchObject({ ok: false, reason: 'empty' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects a file larger than the cap without reading it', async () => {
|
|
71
|
+
const oversize = yamlFile('huge.yaml', 'x'.repeat(MAX_YAML_FILE_BYTES + 1))
|
|
72
|
+
|
|
73
|
+
expect(await readYamlFile([oversize])).toMatchObject({ ok: false, reason: 'too-large' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects a drop carrying more than one file', async () => {
|
|
77
|
+
const result = await readYamlFile([
|
|
78
|
+
yamlFile('one.yaml', MULTI_DOC),
|
|
79
|
+
yamlFile('two.yaml', MULTI_DOC),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
expect(result).toMatchObject({ ok: false, reason: 'multiple-files' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('rejects an empty selection', async () => {
|
|
86
|
+
expect(await readYamlFile([])).toMatchObject({ ok: false, reason: 'no-file' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('reports a file the browser cannot read rather than throwing', async () => {
|
|
90
|
+
const unreadable = {
|
|
91
|
+
name: 'gone.yaml',
|
|
92
|
+
size: 128,
|
|
93
|
+
text: () => Promise.reject(new Error('NotReadableError')),
|
|
94
|
+
} as unknown as File
|
|
95
|
+
|
|
96
|
+
expect(await readYamlFile([unreadable])).toMatchObject({ ok: false, reason: 'unreadable' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('explains the cap in the message it hands the error banner', async () => {
|
|
100
|
+
const result = await readYamlFile([yamlFile('huge.yaml', 'x'.repeat(MAX_YAML_FILE_BYTES + 1))])
|
|
101
|
+
|
|
102
|
+
expect(result.ok).toBe(false)
|
|
103
|
+
if (!result.ok) expect(result.message).toContain('6 MiB')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// A drag exposes only `kind` and `type`; the file name is withheld until drop,
|
|
108
|
+
// and YAML has no MIME type worth trusting. So the overlay promises "a file is
|
|
109
|
+
// coming", never "an acceptable YAML file is coming".
|
|
110
|
+
describe('describeFileDrag', () => {
|
|
111
|
+
it('ignores a drag with nothing attached', () => {
|
|
112
|
+
expect(describeFileDrag(null)).toBe('none')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('leaves a text drag alone so the editor keeps its own drop behaviour', () => {
|
|
116
|
+
expect(describeFileDrag(drag([{ kind: 'string', type: 'text/plain' }]))).toBe('none')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('reports a single file even when the browser withholds its type', () => {
|
|
120
|
+
expect(describeFileDrag(drag([{ kind: 'file', type: '' }]))).toBe('file')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('counts only files when a drag carries companion text items', () => {
|
|
124
|
+
const dragged = drag([
|
|
125
|
+
{ kind: 'file', type: '' },
|
|
126
|
+
{ kind: 'string', type: 'text/uri-list' },
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
expect(describeFileDrag(dragged)).toBe('file')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('distinguishes a multi-file drag, which is the one rejection knowable up front', () => {
|
|
133
|
+
const dragged = drag([
|
|
134
|
+
{ kind: 'file', type: '' },
|
|
135
|
+
{ kind: 'file', type: '' },
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
expect(describeFileDrag(dragged)).toBe('multiple-files')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// The create dialog opens prefilled — ResourcesView with a kind skeleton,
|
|
143
|
+
// WorkloadView with a duplicate — so a non-empty editor is the normal state,
|
|
144
|
+
// not a sign the user has work worth protecting.
|
|
145
|
+
describe('needsReplaceConfirmation', () => {
|
|
146
|
+
const SKELETON = 'apiVersion: v1\nkind: ConfigMap\n'
|
|
147
|
+
|
|
148
|
+
it('loads straight over an untouched skeleton', () => {
|
|
149
|
+
expect(needsReplaceConfirmation(SKELETON, SKELETON)).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('loads straight into an empty editor', () => {
|
|
153
|
+
expect(needsReplaceConfirmation('', '')).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('asks before discarding edits the user actually made', () => {
|
|
157
|
+
expect(needsReplaceConfirmation(`${SKELETON} name: checkout\n`, SKELETON)).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('asks before discarding a manifest pasted into an editor that opened empty', () => {
|
|
161
|
+
expect(needsReplaceConfirmation(SKELETON, '')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('treats a whitespace-only difference as nothing worth protecting', () => {
|
|
165
|
+
expect(needsReplaceConfirmation(`${SKELETON}\n \n`, SKELETON)).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('describeFileDrag across browsers', () => {
|
|
170
|
+
it('sees a file drag in Safari, where items stay empty until the drop', () => {
|
|
171
|
+
expect(describeFileDrag(safariDrag(['Files']))).toBe('file')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('still ignores a Safari text drag, which advertises no Files type', () => {
|
|
175
|
+
expect(describeFileDrag(safariDrag(['text/plain']))).toBe('none')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('counts files when the browser does expose them alongside the Files type', () => {
|
|
179
|
+
const dragged = drag(
|
|
180
|
+
[
|
|
181
|
+
{ kind: 'file', type: '' },
|
|
182
|
+
{ kind: 'file', type: '' },
|
|
183
|
+
],
|
|
184
|
+
['Files'],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(describeFileDrag(dragged)).toBe('multiple-files')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The same limit /resources/apply enforces, so the number in this message is the
|
|
2
|
+
// number the server uses. Loading the file blocks the main thread while the
|
|
3
|
+
// editor parses the buffer — around 90ms at 128 KiB, 460ms at 1 MiB, 2.4s at
|
|
4
|
+
// 5.5 MiB — which the import covers with a loading state rather than by capping
|
|
5
|
+
// below what the server would accept.
|
|
6
|
+
export const MAX_YAML_FILE_BYTES = 6 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
const YAML_EXTENSIONS = ['.yaml', '.yml'] as const
|
|
9
|
+
|
|
10
|
+
export const YAML_FILE_ACCEPT = '.yaml,.yml'
|
|
11
|
+
|
|
12
|
+
export type YamlFileRejectionReason =
|
|
13
|
+
| 'no-file'
|
|
14
|
+
| 'multiple-files'
|
|
15
|
+
| 'extension'
|
|
16
|
+
| 'too-large'
|
|
17
|
+
| 'empty'
|
|
18
|
+
| 'unreadable'
|
|
19
|
+
|
|
20
|
+
export type YamlFileResult =
|
|
21
|
+
| { ok: true; fileName: string; yaml: string }
|
|
22
|
+
| { ok: false; reason: YamlFileRejectionReason; message: string }
|
|
23
|
+
|
|
24
|
+
function reject(reason: YamlFileRejectionReason, message: string): YamlFileResult {
|
|
25
|
+
return { ok: false, reason, message }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasYamlExtension(name: string): boolean {
|
|
29
|
+
const lower = name.toLowerCase()
|
|
30
|
+
return YAML_EXTENSIONS.some((extension) => lower.endsWith(extension))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readYamlFile(
|
|
34
|
+
files: ArrayLike<File> | null | undefined,
|
|
35
|
+
): Promise<YamlFileResult> {
|
|
36
|
+
if (!files || files.length === 0) {
|
|
37
|
+
return reject('no-file', 'No file selected.')
|
|
38
|
+
}
|
|
39
|
+
if (files.length > 1) {
|
|
40
|
+
return reject('multiple-files', 'Select a single YAML file — one file at a time.')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const file = files[0]
|
|
44
|
+
if (!hasYamlExtension(file.name)) {
|
|
45
|
+
return reject('extension', `${file.name} is not a YAML file. Choose a .yaml or .yml file.`)
|
|
46
|
+
}
|
|
47
|
+
if (file.size > MAX_YAML_FILE_BYTES) {
|
|
48
|
+
const limit = Math.round(MAX_YAML_FILE_BYTES / (1024 * 1024))
|
|
49
|
+
return reject('too-large', `${file.name} is larger than the ${limit} MiB limit.`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let content: string
|
|
53
|
+
try {
|
|
54
|
+
content = await file.text()
|
|
55
|
+
} catch {
|
|
56
|
+
return reject('unreadable', `${file.name} could not be read. Check the file and try again.`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!content.trim()) {
|
|
60
|
+
return reject('empty', `${file.name} is empty.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { ok: true, fileName: file.name, yaml: content }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type FileDragState = 'none' | 'file' | 'multiple-files'
|
|
67
|
+
|
|
68
|
+
export function describeFileDrag(transfer: DataTransfer | null | undefined): FileDragState {
|
|
69
|
+
if (!transfer) return 'none'
|
|
70
|
+
|
|
71
|
+
// Safari keeps the dragged items protected until the drop, so `items` is
|
|
72
|
+
// empty for the whole drag. Every browser advertises "Files" in `types`
|
|
73
|
+
// though, which makes that the signal for whether files are coming; `items`
|
|
74
|
+
// only adds the count, where the browser is willing to give one.
|
|
75
|
+
const carriesFiles = transfer.types ? Array.from(transfer.types).includes('Files') : false
|
|
76
|
+
|
|
77
|
+
let files = 0
|
|
78
|
+
const items = transfer.items
|
|
79
|
+
if (items) {
|
|
80
|
+
for (let index = 0; index < items.length; index++) {
|
|
81
|
+
if (items[index].kind === 'file') files++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!carriesFiles && files === 0) return 'none'
|
|
86
|
+
return files > 1 ? 'multiple-files' : 'file'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The dialog opens prefilled in both of its entry points, so "the editor holds
|
|
90
|
+
// YAML" does not mean the user has work to lose. Only a divergence from what
|
|
91
|
+
// the dialog put there does.
|
|
92
|
+
export function needsReplaceConfirmation(current: string, initial: string): boolean {
|
|
93
|
+
const edited = current.trim()
|
|
94
|
+
return edited !== '' && edited !== initial.trim()
|
|
95
|
+
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { useState, useMemo } from 'react'
|
|
2
|
-
import { ChevronRight, ChevronDown } from 'lucide-react'
|
|
3
|
-
import type { LogLevel } from './useLogBuffer'
|
|
4
|
-
import { getLogPalette, getLogLevelColor } from './log-palette'
|
|
5
|
-
|
|
6
|
-
interface JsonLogLineProps {
|
|
7
|
-
content: string
|
|
8
|
-
level: LogLevel
|
|
9
|
-
wordWrap: boolean
|
|
10
|
-
/** Dark/light palette selector. Defaults to `true` for the dark-by-default viewer. */
|
|
11
|
-
isDark?: boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function JsonLogLine({ content, level, wordWrap, isDark = true }: JsonLogLineProps) {
|
|
15
|
-
const [expanded, setExpanded] = useState(false)
|
|
16
|
-
const palette = useMemo(() => getLogPalette(isDark), [isDark])
|
|
17
|
-
const levelColor = getLogLevelColor(level, isDark)
|
|
18
|
-
|
|
19
|
-
const parsed = useMemo(() => {
|
|
20
|
-
try {
|
|
21
|
-
return JSON.parse(content.trim())
|
|
22
|
-
} catch {
|
|
23
|
-
return null
|
|
24
|
-
}
|
|
25
|
-
}, [content])
|
|
26
|
-
|
|
27
|
-
if (!parsed) {
|
|
28
|
-
return (
|
|
29
|
-
<span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
|
|
30
|
-
{content}
|
|
31
|
-
</span>
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const summary = buildSummary(parsed)
|
|
36
|
-
const fieldCount = Object.keys(parsed).length
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<span className={levelColor}>
|
|
40
|
-
<button
|
|
41
|
-
onClick={() => setExpanded(!expanded)}
|
|
42
|
-
className={`inline-flex items-center gap-0.5 ${palette.hoverSurface} rounded px-0.5 -ml-0.5 align-middle`}
|
|
43
|
-
>
|
|
44
|
-
{expanded
|
|
45
|
-
? <ChevronDown className={`w-3 h-3 shrink-0 ${palette.textTertiary}`} />
|
|
46
|
-
: <ChevronRight className={`w-3 h-3 shrink-0 ${palette.textTertiary}`} />
|
|
47
|
-
}
|
|
48
|
-
</button>
|
|
49
|
-
{!expanded ? (
|
|
50
|
-
<span className={wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}>
|
|
51
|
-
{summary}
|
|
52
|
-
<span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
|
|
53
|
-
</span>
|
|
54
|
-
) : (
|
|
55
|
-
<span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
|
|
56
|
-
{formatJsonExpanded(parsed)}
|
|
57
|
-
</span>
|
|
58
|
-
)}
|
|
59
|
-
</span>
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function buildSummary(obj: Record<string, unknown>): string {
|
|
64
|
-
const parts: string[] = []
|
|
65
|
-
|
|
66
|
-
// Level
|
|
67
|
-
const lvl = obj.level || obj.severity || obj.lvl
|
|
68
|
-
if (typeof lvl === 'string') {
|
|
69
|
-
parts.push(lvl.toUpperCase())
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Message
|
|
73
|
-
const msg = obj.msg || obj.message
|
|
74
|
-
if (typeof msg === 'string') {
|
|
75
|
-
parts.push(msg)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Error
|
|
79
|
-
const err = obj.error || obj.err
|
|
80
|
-
if (typeof err === 'string') {
|
|
81
|
-
parts.push(`error=${err}`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return parts.join(' ')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function formatJsonExpanded(obj: Record<string, unknown>): string {
|
|
88
|
-
try {
|
|
89
|
-
return JSON.stringify(obj, null, 2)
|
|
90
|
-
} catch {
|
|
91
|
-
return String(obj)
|
|
92
|
-
}
|
|
93
|
-
}
|