@skyhook-io/k8s-ui 1.14.3 → 1.14.4
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 +2 -2
- package/src/components/logs/LogCore.tsx +182 -16
- package/src/components/logs/LogsViewer.tsx +4 -21
- package/src/components/logs/WorkloadLogsViewer.tsx +5 -23
- package/src/components/resources/ResourcesView.tsx +17 -3
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/AlertRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +5 -5
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +5 -5
- package/src/components/resources/renderers/ChallengeRenderer.tsx +8 -8
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +3 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +2 -2
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +3 -3
- package/src/components/resources/renderers/GatewayRenderer.tsx +2 -2
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +3 -3
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +23 -20
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +3 -3
- package/src/components/resources/renderers/OrderRenderer.tsx +6 -6
- package/src/components/resources/renderers/RoleRenderer.tsx +3 -3
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -3
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +8 -8
- package/src/components/resources/renderers/VPARenderer.tsx +1 -1
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -1
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -9
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +6 -12
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -1
- package/src/components/resources/renderers/eso-cells.tsx +17 -1
- package/src/components/resources/renderers/knative-cells.tsx +1 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +7 -3
- package/src/components/resources/resource-utils-certmanager.ts +2 -2
- package/src/components/resources/resource-utils-eso.test.ts +26 -1
- package/src/components/resources/resource-utils-eso.ts +13 -0
- package/src/components/resources/resource-utils-knative.ts +3 -3
- package/src/components/topology/TopologyGraph.tsx +18 -1
- package/src/components/topology/pod-phase-fallback.test.ts +24 -0
- package/src/theme/components.css +29 -0
- package/src/utils/badge-colors.ts +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/log-export.test.ts +225 -0
- package/src/utils/log-export.ts +149 -0
- package/src/utils/navigation.test.ts +10 -0
- package/src/utils/navigation.ts +4 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { getSecretStoreProviderType, getSecretStoreProviderKey } from './resource-utils-eso'
|
|
2
|
+
import { getSecretStoreProviderType, getSecretStoreProviderKey, getExternalSecretStoreKey } from './resource-utils-eso'
|
|
3
3
|
|
|
4
4
|
// The provider label answers "which backend holds these secrets?", so it has to
|
|
5
5
|
// track the upstream `spec.provider` field names rather than plausible ones.
|
|
@@ -54,3 +54,28 @@ describe('getSecretStoreProviderKey', () => {
|
|
|
54
54
|
expect(key('ParameterStore')).toBe('aws')
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
|
+
|
|
58
|
+
describe('getExternalSecretStoreKey', () => {
|
|
59
|
+
// A ClusterSecretStore is referenceable from any namespace, so keying it by
|
|
60
|
+
// namespace would miss every ExternalSecret outside the store's own.
|
|
61
|
+
it('keys a ClusterSecretStore by name alone', () => {
|
|
62
|
+
expect(getExternalSecretStoreKey({
|
|
63
|
+
metadata: { namespace: 'prod' },
|
|
64
|
+
spec: { secretStoreRef: { name: 'vault-global', kind: 'ClusterSecretStore' } },
|
|
65
|
+
})).toBe('vault-global')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('scopes a SecretStore to the ExternalSecret namespace', () => {
|
|
69
|
+
expect(getExternalSecretStoreKey({
|
|
70
|
+
metadata: { namespace: 'prod' },
|
|
71
|
+
spec: { secretStoreRef: { name: 'aws', kind: 'SecretStore' } },
|
|
72
|
+
})).toBe('prod/aws')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('treats an omitted kind as a SecretStore, as the CRD defaults it', () => {
|
|
76
|
+
expect(getExternalSecretStoreKey({
|
|
77
|
+
metadata: { namespace: 'prod' },
|
|
78
|
+
spec: { secretStoreRef: { name: 'aws' } },
|
|
79
|
+
})).toBe('prod/aws')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -235,6 +235,19 @@ export function getExternalSecretStore(resource: any): { name: string; kind: str
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Key for looking this ExternalSecret's store up in a provider map.
|
|
240
|
+
*
|
|
241
|
+
* A ClusterSecretStore is cluster-scoped and referenceable from any namespace,
|
|
242
|
+
* so it keys by name alone; a SecretStore only resolves within the
|
|
243
|
+
* ExternalSecret's own namespace.
|
|
244
|
+
*/
|
|
245
|
+
export function getExternalSecretStoreKey(resource: any): string {
|
|
246
|
+
const store = getExternalSecretStore(resource)
|
|
247
|
+
if (store.kind === 'ClusterSecretStore') return store.name
|
|
248
|
+
return `${resource?.metadata?.namespace ?? ''}/${store.name}`
|
|
249
|
+
}
|
|
250
|
+
|
|
238
251
|
export function getExternalSecretRefreshInterval(resource: any): string {
|
|
239
252
|
return resource.spec?.refreshInterval || '-'
|
|
240
253
|
}
|
|
@@ -62,14 +62,14 @@ export function getRevisionStatus(resource: any): StatusBadge {
|
|
|
62
62
|
if (ready?.status === 'True' && active?.status === 'False') {
|
|
63
63
|
const reason = active.reason || ''
|
|
64
64
|
if (reason === 'NoTraffic') {
|
|
65
|
-
return { text: 'Scaled to Zero', color: '
|
|
65
|
+
return { text: 'Scaled to Zero', color: 'status-blue', level: 'healthy' }
|
|
66
66
|
}
|
|
67
|
-
return { text: reason || 'Inactive', color: '
|
|
67
|
+
return { text: reason || 'Inactive', color: 'status-amber', level: 'degraded' }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// Check for activating: Active condition is Unknown (scaling up)
|
|
71
71
|
if (ready?.status === 'True' && active?.status === 'Unknown') {
|
|
72
|
-
return { text: 'Activating', color: '
|
|
72
|
+
return { text: 'Activating', color: 'status-blue', level: 'healthy' }
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
return getKnativeConditionStatus(resource)
|
|
@@ -245,6 +245,23 @@ interface TopologyGraphProps {
|
|
|
245
245
|
children?: ReactNode
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// A pod group's children normally carry the health the server computed. Without
|
|
249
|
+
// it, the phase is honest for every state except Running: a crash-looping pod
|
|
250
|
+
// sits at Phase=Running with its container restarting, so that one case says
|
|
251
|
+
// unknown rather than repeating the bug this fallback exists behind.
|
|
252
|
+
export function phaseOnlyHealth(phase: string | undefined): HealthStatus {
|
|
253
|
+
switch (phase) {
|
|
254
|
+
case 'Failed':
|
|
255
|
+
return 'unhealthy'
|
|
256
|
+
case 'Pending':
|
|
257
|
+
return 'degraded'
|
|
258
|
+
case 'Succeeded':
|
|
259
|
+
return 'neutral'
|
|
260
|
+
default:
|
|
261
|
+
return 'unknown'
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
248
265
|
export function TopologyGraph({
|
|
249
266
|
topology,
|
|
250
267
|
viewMode,
|
|
@@ -430,7 +447,7 @@ export function TopologyGraph({
|
|
|
430
447
|
// the kubelet's Waiting->Running oscillation, which the phase alone
|
|
431
448
|
// hides — a crash-looping pod sits at Phase=Running. Deriving it here
|
|
432
449
|
// again would rebuild that logic in a second place and get it wrong.
|
|
433
|
-
status: pod.status ??
|
|
450
|
+
status: pod.status ?? phaseOnlyHealth(pod.phase),
|
|
434
451
|
data: {
|
|
435
452
|
...podGroupNode.data,
|
|
436
453
|
namespace: pod.namespace,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { phaseOnlyHealth } from './TopologyGraph'
|
|
3
|
+
|
|
4
|
+
// Only reached when the server sent no per-pod status — an older Radar behind a
|
|
5
|
+
// newer embedding host. The point of the fallback is that it must not repeat the
|
|
6
|
+
// bug it stands in for.
|
|
7
|
+
describe('phaseOnlyHealth', () => {
|
|
8
|
+
it('does not call a Running pod healthy', () => {
|
|
9
|
+
// A crash-looping pod sits at Phase=Running with its container restarting,
|
|
10
|
+
// so Running is the one phase that hides a fault.
|
|
11
|
+
expect(phaseOnlyHealth('Running')).toBe('unknown')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('reads the phases that are honest on their own', () => {
|
|
15
|
+
expect(phaseOnlyHealth('Failed')).toBe('unhealthy')
|
|
16
|
+
expect(phaseOnlyHealth('Pending')).toBe('degraded')
|
|
17
|
+
expect(phaseOnlyHealth('Succeeded')).toBe('neutral')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('says nothing for a phase it does not recognise', () => {
|
|
21
|
+
expect(phaseOnlyHealth(undefined)).toBe('unknown')
|
|
22
|
+
expect(phaseOnlyHealth('SomeFuturePhase')).toBe('unknown')
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/theme/components.css
CHANGED
|
@@ -228,6 +228,35 @@
|
|
|
228
228
|
border-color: transparent;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/* Colour-named accents carry no health meaning — they exist so an
|
|
232
|
+
informational badge can pick a hue without borrowing a status semantic.
|
|
233
|
+
A categorical badge (an admission verb, a policy action, a configured
|
|
234
|
+
mode) belongs here; only evidence about a resource's condition belongs on
|
|
235
|
+
.status-healthy / -degraded / -unhealthy. */
|
|
236
|
+
.status-green {
|
|
237
|
+
background-color: light-dark(rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.2));
|
|
238
|
+
color: light-dark(#15803d, #4ade80);
|
|
239
|
+
border-color: transparent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.status-red {
|
|
243
|
+
background-color: light-dark(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.2));
|
|
244
|
+
color: light-dark(#b91c1c, #f87171);
|
|
245
|
+
border-color: transparent;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.status-amber {
|
|
249
|
+
background-color: light-dark(rgba(234, 179, 8, 0.15), rgba(234, 179, 8, 0.2));
|
|
250
|
+
color: light-dark(#a16207, #facc15);
|
|
251
|
+
border-color: transparent;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.status-blue {
|
|
255
|
+
background-color: light-dark(rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.2));
|
|
256
|
+
color: light-dark(#2563eb, #60a5fa);
|
|
257
|
+
border-color: transparent;
|
|
258
|
+
}
|
|
259
|
+
|
|
231
260
|
.status-cyan {
|
|
232
261
|
background-color: light-dark(rgba(6, 182, 212, 0.15), rgba(6, 182, 212, 0.2));
|
|
233
262
|
color: light-dark(#0891b2, #22d3ee);
|
|
@@ -74,7 +74,7 @@ export const NODEPOOL_MODE_BADGE: Record<string, string> = {
|
|
|
74
74
|
|
|
75
75
|
// Translucent gray badge for "inactive / unknown / pending / unset / disabled" states.
|
|
76
76
|
// The de-facto fallback in renderers when no severity or category applies.
|
|
77
|
-
export const BADGE_INACTIVE = '
|
|
77
|
+
export const BADGE_INACTIVE = 'status-unknown'
|
|
78
78
|
|
|
79
79
|
// Best practices category colors
|
|
80
80
|
export const BP_CATEGORY_BADGE: Record<string, string> = {
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LOG_EXPORT_FORMATS,
|
|
5
|
+
previewLogExport,
|
|
6
|
+
serializeLogEntries,
|
|
7
|
+
type ExportableLogEntry,
|
|
8
|
+
type LogExportFormat,
|
|
9
|
+
} from './log-export'
|
|
10
|
+
|
|
11
|
+
const ESC = '\u001b'
|
|
12
|
+
|
|
13
|
+
const entry = (over: Partial<ExportableLogEntry> = {}): ExportableLogEntry => ({
|
|
14
|
+
timestamp: '2024-01-20T10:30:00.123456789Z',
|
|
15
|
+
content: 'connection refused',
|
|
16
|
+
container: 'app',
|
|
17
|
+
pod: 'api-7d9f',
|
|
18
|
+
...over,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const opts = (over: Partial<{ format: LogExportFormat; showTimestamps: boolean; showPodName: boolean }> = {}) => ({
|
|
22
|
+
format: 'txt' as LogExportFormat,
|
|
23
|
+
showTimestamps: true,
|
|
24
|
+
showPodName: true,
|
|
25
|
+
...over,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('serializeLogEntries — text', () => {
|
|
29
|
+
it('emits the raw RFC3339 timestamp rather than any display format', () => {
|
|
30
|
+
const { content } = serializeLogEntries([entry()], opts({ showPodName: false }))
|
|
31
|
+
expect(content).toBe('2024-01-20T10:30:00.123456789Z connection refused')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('omits timestamps when the viewer has them hidden', () => {
|
|
35
|
+
const { content } = serializeLogEntries([entry()], opts({ showTimestamps: false, showPodName: false }))
|
|
36
|
+
expect(content).toBe('connection refused')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('attributes pod and container when pod names are shown', () => {
|
|
40
|
+
const { content } = serializeLogEntries([entry()], opts({ showTimestamps: false }))
|
|
41
|
+
expect(content).toBe('[api-7d9f/app] connection refused')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('falls back to the pod alone when no container is known', () => {
|
|
45
|
+
const { content } = serializeLogEntries([entry({ container: undefined })], opts({ showTimestamps: false }))
|
|
46
|
+
expect(content).toBe('[api-7d9f] connection refused')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('drops pod attribution for entries with no pod, even when the toggle is on', () => {
|
|
50
|
+
const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ showTimestamps: false }))
|
|
51
|
+
expect(content).toBe('connection refused')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('skips an empty timestamp instead of emitting a leading space', () => {
|
|
55
|
+
const { content } = serializeLogEntries([entry({ timestamp: '' })], opts({ showPodName: false }))
|
|
56
|
+
expect(content).toBe('connection refused')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('strips ANSI escapes', () => {
|
|
60
|
+
const { content } = serializeLogEntries(
|
|
61
|
+
[entry({ content: `${ESC}[31mconnection refused${ESC}[0m` })],
|
|
62
|
+
opts({ showTimestamps: false, showPodName: false }),
|
|
63
|
+
)
|
|
64
|
+
expect(content).toBe('connection refused')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('preserves raw JSON content rather than any collapsed rendering', () => {
|
|
68
|
+
const json = '{"level":"error","msg":"connection refused","stack":"a\\nb"}'
|
|
69
|
+
const { content } = serializeLogEntries([entry({ content: json })], opts({ showTimestamps: false, showPodName: false }))
|
|
70
|
+
expect(content).toBe(json)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('joins entries with newlines and returns empty for no entries', () => {
|
|
74
|
+
const { content } = serializeLogEntries(
|
|
75
|
+
[entry({ content: 'first' }), entry({ content: 'second' })],
|
|
76
|
+
opts({ showTimestamps: false, showPodName: false }),
|
|
77
|
+
)
|
|
78
|
+
expect(content).toBe('first\nsecond')
|
|
79
|
+
expect(serializeLogEntries([], opts()).content).toBe('')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('serializeLogEntries — structured formats', () => {
|
|
84
|
+
it('keeps the timestamp field in JSON even when the display toggle is off', () => {
|
|
85
|
+
const { content, mime, extension } = serializeLogEntries(
|
|
86
|
+
[entry()],
|
|
87
|
+
opts({ format: 'json', showTimestamps: false }),
|
|
88
|
+
)
|
|
89
|
+
expect(JSON.parse(content)).toEqual([{
|
|
90
|
+
timestamp: '2024-01-20T10:30:00.123456789Z',
|
|
91
|
+
pod: 'api-7d9f',
|
|
92
|
+
container: 'app',
|
|
93
|
+
content: 'connection refused',
|
|
94
|
+
}])
|
|
95
|
+
expect(mime).toBe('application/json;charset=utf-8')
|
|
96
|
+
expect(extension).toBe('json')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps the pod key in JSON even when pod attribution is off, since the display toggle is not a schema', () => {
|
|
100
|
+
const { content } = serializeLogEntries([entry()], opts({ format: 'json', showPodName: false }))
|
|
101
|
+
expect(Object.keys(JSON.parse(content)[0])).toEqual(['timestamp', 'pod', 'container', 'content'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('omits the pod key only for entries that genuinely have no pod', () => {
|
|
105
|
+
const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ format: 'json' }))
|
|
106
|
+
expect(Object.keys(JSON.parse(content)[0])).toEqual(['timestamp', 'container', 'content'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('strips ANSI from structured content too', () => {
|
|
110
|
+
const { content } = serializeLogEntries(
|
|
111
|
+
[entry({ content: `${ESC}[31mboom${ESC}[0m` })],
|
|
112
|
+
opts({ format: 'json' }),
|
|
113
|
+
)
|
|
114
|
+
expect(JSON.parse(content)[0].content).toBe('boom')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('writes a CSV header matching the columns it emits', () => {
|
|
118
|
+
const { content, mime } = serializeLogEntries([entry()], opts({ format: 'csv' }))
|
|
119
|
+
expect(content.split('\n')).toEqual([
|
|
120
|
+
'timestamp,pod,container,content',
|
|
121
|
+
'"2024-01-20T10:30:00.123456789Z","api-7d9f","app","connection refused"',
|
|
122
|
+
])
|
|
123
|
+
expect(mime).toBe('text/csv;charset=utf-8')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('keeps the CSV pod column when pod attribution is off but the data has pods', () => {
|
|
127
|
+
const { content } = serializeLogEntries([entry()], opts({ format: 'csv', showPodName: false }))
|
|
128
|
+
expect(content.split('\n')[0]).toBe('timestamp,pod,container,content')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('drops the CSV pod column only when no entry has a pod', () => {
|
|
132
|
+
const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ format: 'csv' }))
|
|
133
|
+
expect(content.split('\n')).toEqual([
|
|
134
|
+
'timestamp,container,content',
|
|
135
|
+
'"2024-01-20T10:30:00.123456789Z","app","connection refused"',
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('keeps the column count aligned when only some entries carry a pod', () => {
|
|
140
|
+
const { content } = serializeLogEntries(
|
|
141
|
+
[entry({ pod: undefined }), entry()],
|
|
142
|
+
opts({ format: 'csv' }),
|
|
143
|
+
)
|
|
144
|
+
const [header, ...rows] = content.split('\n')
|
|
145
|
+
expect(header.split(',').length).toBe(4)
|
|
146
|
+
for (const row of rows) expect(row.split('","').length).toBe(4)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('neutralizes cells a spreadsheet would evaluate as a formula', () => {
|
|
150
|
+
const { content } = serializeLogEntries(
|
|
151
|
+
[entry({ content: '=cmd|\' /c calc\'!A1' }), entry({ content: '+1' }), entry({ content: '-rf /' }), entry({ content: '@SUM(A1)' })],
|
|
152
|
+
opts({ format: 'csv', showPodName: false }),
|
|
153
|
+
)
|
|
154
|
+
const cells = content.split('\n').slice(1).map(r => r.split(',').pop())
|
|
155
|
+
expect(cells).toEqual([
|
|
156
|
+
'"\'=cmd|\' /c calc\'!A1"',
|
|
157
|
+
'"\'+1"',
|
|
158
|
+
'"\'-rf /"',
|
|
159
|
+
'"\'@SUM(A1)"',
|
|
160
|
+
])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('leaves ordinary content unprefixed', () => {
|
|
164
|
+
const { content } = serializeLogEntries([entry()], opts({ format: 'csv', showPodName: false }))
|
|
165
|
+
expect(content).toContain('"connection refused"')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('doubles quotes inside a CSV cell', () => {
|
|
169
|
+
const { content } = serializeLogEntries(
|
|
170
|
+
[entry({ content: 'said "hello"', pod: undefined })],
|
|
171
|
+
opts({ format: 'csv' }),
|
|
172
|
+
)
|
|
173
|
+
expect(content.split('\n')[1]).toBe('"2024-01-20T10:30:00.123456789Z","app","said ""hello"""')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('emits a header-only CSV and an empty JSON array for no entries', () => {
|
|
177
|
+
expect(serializeLogEntries([], opts({ format: 'csv' })).content).toBe('timestamp,container,content')
|
|
178
|
+
expect(serializeLogEntries([], opts({ format: 'json' })).content).toBe('[]')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('declares a charset so non-ASCII content survives a spreadsheet import', () => {
|
|
182
|
+
for (const format of LOG_EXPORT_FORMATS) {
|
|
183
|
+
expect(serializeLogEntries([], opts({ format })).mime).toContain('charset=utf-8')
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('previewLogExport', () => {
|
|
189
|
+
it('shows the first two lines of a text export', () => {
|
|
190
|
+
expect(previewLogExport(
|
|
191
|
+
[entry({ content: 'first' }), entry({ content: 'second' }), entry({ content: 'third' })],
|
|
192
|
+
opts({ showTimestamps: false, showPodName: false }),
|
|
193
|
+
)).toEqual(['first', 'second'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('shows one compact object for JSON, not the pretty-printed array', () => {
|
|
197
|
+
const [line, ...rest] = previewLogExport([entry()], opts({ format: 'json' }))
|
|
198
|
+
expect(rest).toEqual([])
|
|
199
|
+
expect(line).toBe('{"timestamp":"2024-01-20T10:30:00.123456789Z","pod":"api-7d9f","container":"app","content":"connection refused"}')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('shows the CSV header alongside a row so the columns are legible', () => {
|
|
203
|
+
expect(previewLogExport([entry()], opts({ format: 'csv' }))).toEqual([
|
|
204
|
+
'timestamp,pod,container,content',
|
|
205
|
+
'"2024-01-20T10:30:00.123456789Z","api-7d9f","app","connection refused"',
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('previews exactly what the payload contains', () => {
|
|
210
|
+
for (const format of ['txt', 'json', 'csv'] as LogExportFormat[]) {
|
|
211
|
+
const o = opts({ format })
|
|
212
|
+
const preview = previewLogExport([entry()], o)
|
|
213
|
+
const { content } = serializeLogEntries([entry()], o)
|
|
214
|
+
for (const line of preview) {
|
|
215
|
+
// JSON pretty-prints across lines, so compare on the field values instead.
|
|
216
|
+
if (format === 'json') expect(JSON.parse(content)[0]).toEqual(JSON.parse(line))
|
|
217
|
+
else expect(content.split('\n')).toContain(line)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns nothing when there is nothing to export', () => {
|
|
223
|
+
expect(previewLogExport([], opts())).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization for the log viewer's export popover.
|
|
3
|
+
*
|
|
4
|
+
* Copy and download share this module so the two can never disagree about what
|
|
5
|
+
* a line looks like — the popover's preview is rendered from the same functions
|
|
6
|
+
* that produce the payload.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stripAnsi } from './log-format'
|
|
10
|
+
|
|
11
|
+
export type LogExportFormat = 'txt' | 'json' | 'csv'
|
|
12
|
+
|
|
13
|
+
export interface ExportableLogEntry {
|
|
14
|
+
timestamp: string
|
|
15
|
+
content: string
|
|
16
|
+
container?: string
|
|
17
|
+
pod?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LogExportOptions {
|
|
21
|
+
format: LogExportFormat
|
|
22
|
+
/**
|
|
23
|
+
* Mirrors the viewer's timestamp toggle, and applies to `txt` only. JSON and
|
|
24
|
+
* CSV always carry the timestamp: there it is a named field a reader can
|
|
25
|
+
* ignore, not the visual noise the toggle exists to suppress.
|
|
26
|
+
*/
|
|
27
|
+
showTimestamps: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Mirrors the viewer's pod attribution, and — like `showTimestamps` — applies
|
|
30
|
+
* to `txt` only. JSON and CSV carry `pod` whenever the entry has one: a
|
|
31
|
+
* display preference must not silently strip the column that makes a
|
|
32
|
+
* multi-pod export attributable.
|
|
33
|
+
*/
|
|
34
|
+
showPodName: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LogExportPayload {
|
|
38
|
+
content: string
|
|
39
|
+
mime: string
|
|
40
|
+
extension: LogExportFormat
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Charset is explicit: log content is arbitrary UTF-8, and Excel on Windows
|
|
44
|
+
// falls back to a legacy code page without it.
|
|
45
|
+
const MIME: Record<LogExportFormat, string> = {
|
|
46
|
+
txt: 'text/plain;charset=utf-8',
|
|
47
|
+
json: 'application/json;charset=utf-8',
|
|
48
|
+
csv: 'text/csv;charset=utf-8',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Display order for the format picker. */
|
|
52
|
+
export const LOG_EXPORT_FORMATS: readonly LogExportFormat[] = ['txt', 'json', 'csv']
|
|
53
|
+
|
|
54
|
+
export const LOG_EXPORT_FORMAT_LABELS: Record<LogExportFormat, string> = {
|
|
55
|
+
txt: 'Text',
|
|
56
|
+
json: 'JSON',
|
|
57
|
+
csv: 'CSV',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One entry as the viewer would read it back: timestamps raw rather than in the
|
|
62
|
+
* display format, since an exported line is correlated against other systems
|
|
63
|
+
* long after the screen that produced it is gone.
|
|
64
|
+
*/
|
|
65
|
+
function textLine(entry: ExportableLogEntry, { showTimestamps, showPodName }: LogExportOptions): string {
|
|
66
|
+
const parts: string[] = []
|
|
67
|
+
if (showTimestamps && entry.timestamp) parts.push(entry.timestamp)
|
|
68
|
+
if (showPodName && entry.pod) parts.push(entry.container ? `[${entry.pod}/${entry.container}]` : `[${entry.pod}]`)
|
|
69
|
+
parts.push(stripAnsi(entry.content))
|
|
70
|
+
return parts.join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function jsonEntry(entry: ExportableLogEntry): Record<string, string> {
|
|
74
|
+
return {
|
|
75
|
+
timestamp: entry.timestamp,
|
|
76
|
+
...(entry.pod ? { pod: entry.pod } : {}),
|
|
77
|
+
...(entry.container ? { container: entry.container } : {}),
|
|
78
|
+
content: stripAnsi(entry.content),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Columns follow the data, not a display toggle, so a row can never carry a field the header doesn't name. */
|
|
83
|
+
function csvColumns(entries: readonly ExportableLogEntry[]): string[] {
|
|
84
|
+
return entries.some(e => e.pod)
|
|
85
|
+
? ['timestamp', 'pod', 'container', 'content']
|
|
86
|
+
: ['timestamp', 'container', 'content']
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A cell opening with one of these is a formula to Excel and Sheets, and quoting
|
|
90
|
+
// does not stop it — the quotes are stripped before evaluation. Log content is
|
|
91
|
+
// whatever a pod chose to print, so the leading apostrophe (which spreadsheets
|
|
92
|
+
// consume on import) is the difference between a CSV and an execution vector.
|
|
93
|
+
const SPREADSHEET_FORMULA_START = /^[=+\-@\t\r]/
|
|
94
|
+
|
|
95
|
+
function csvCell(value: string): string {
|
|
96
|
+
const escaped = (SPREADSHEET_FORMULA_START.test(value) ? `'${value}` : value).replace(/"/g, '""')
|
|
97
|
+
return `"${escaped}"`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function csvRow(entry: ExportableLogEntry, columns: readonly string[]): string {
|
|
101
|
+
const cells = [
|
|
102
|
+
entry.timestamp,
|
|
103
|
+
...(columns.includes('pod') ? [entry.pod ?? ''] : []),
|
|
104
|
+
entry.container ?? '',
|
|
105
|
+
stripAnsi(entry.content),
|
|
106
|
+
]
|
|
107
|
+
return cells.map(csvCell).join(',')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function serializeLogEntries(
|
|
111
|
+
entries: readonly ExportableLogEntry[],
|
|
112
|
+
opts: LogExportOptions,
|
|
113
|
+
): LogExportPayload {
|
|
114
|
+
const base = { mime: MIME[opts.format], extension: opts.format }
|
|
115
|
+
switch (opts.format) {
|
|
116
|
+
case 'json':
|
|
117
|
+
return { ...base, content: JSON.stringify(entries.map(jsonEntry), null, 2) }
|
|
118
|
+
case 'csv': {
|
|
119
|
+
const columns = csvColumns(entries)
|
|
120
|
+
return { ...base, content: [columns.join(','), ...entries.map(e => csvRow(e, columns))].join('\n') }
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return { ...base, content: entries.map(e => textLine(e, opts)).join('\n') }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Up to two representative lines for the popover's preview box. This is the only
|
|
129
|
+
* thing telling the user which fields their export carries, so it renders the
|
|
130
|
+
* shape of the real output rather than a description of it: for CSV that means
|
|
131
|
+
* the header alongside a row, and for JSON a single compact entry rather than
|
|
132
|
+
* the pretty-printed array the payload actually uses.
|
|
133
|
+
*/
|
|
134
|
+
export function previewLogExport(
|
|
135
|
+
entries: readonly ExportableLogEntry[],
|
|
136
|
+
opts: LogExportOptions,
|
|
137
|
+
): string[] {
|
|
138
|
+
if (entries.length === 0) return []
|
|
139
|
+
switch (opts.format) {
|
|
140
|
+
case 'json':
|
|
141
|
+
return [JSON.stringify(jsonEntry(entries[0]))]
|
|
142
|
+
case 'csv': {
|
|
143
|
+
const columns = csvColumns(entries)
|
|
144
|
+
return [columns.join(','), csvRow(entries[0], columns)]
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return entries.slice(0, 2).map(e => textLine(e, opts))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -326,3 +326,13 @@ describe('lane identity helpers', () => {
|
|
|
326
326
|
expect(parseLaneId('bogus')).toBeNull()
|
|
327
327
|
})
|
|
328
328
|
})
|
|
329
|
+
|
|
330
|
+
describe('pluralToKind on absent input', () => {
|
|
331
|
+
// A saved investigation can carry an empty kind. This runs over every row of
|
|
332
|
+
// the run list, so throwing here took the whole investigations panel down and
|
|
333
|
+
// made every other run unopenable — one bad row, no panel.
|
|
334
|
+
test('returns the empty string instead of throwing', () => {
|
|
335
|
+
expect(() => pluralToKind('')).not.toThrow()
|
|
336
|
+
expect(pluralToKind('')).toBe('')
|
|
337
|
+
})
|
|
338
|
+
})
|
package/src/utils/navigation.ts
CHANGED
|
@@ -147,6 +147,10 @@ export function kindToPluralWithGroup(kind: string, group: string): string {
|
|
|
147
147
|
* singular PascalCase form for internal logic (health checks, badge colors, hierarchy matching).
|
|
148
148
|
*/
|
|
149
149
|
export function pluralToKind(plural: string): string {
|
|
150
|
+
// A saved investigation can carry an empty kind, and this runs over every
|
|
151
|
+
// row of the run list — indexing [0] of "" threw and took the whole panel
|
|
152
|
+
// down with it, making every other run unopenable.
|
|
153
|
+
if (!plural) return plural
|
|
150
154
|
const lower = plural.toLowerCase()
|
|
151
155
|
const pluralToKindMap = getPluralToKind()
|
|
152
156
|
|