@skyhook-io/k8s-ui 1.15.0 → 1.16.0
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 +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +14 -9
- package/src/components/ui/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- package/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +24 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +87 -52
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
associateContinuations,
|
|
4
|
+
detectLevel,
|
|
5
|
+
groupContinuations,
|
|
6
|
+
isContinuationLine,
|
|
7
|
+
normalizeLevel,
|
|
8
|
+
selectLevelField,
|
|
9
|
+
type LevelSource,
|
|
10
|
+
type LogLevel,
|
|
11
|
+
} from './log-level'
|
|
12
|
+
import { applySearchMode } from '../components/logs/useLogSearch'
|
|
13
|
+
|
|
14
|
+
// Lines taken from real clusters (lightly shortened), grouped by the library that wrote them.
|
|
15
|
+
const CASES: [string, string, LogLevel, LevelSource][] = [
|
|
16
|
+
// klog — most Kubernetes controllers
|
|
17
|
+
['klog error', 'E0929 22:27:48.228549 1 controller.go:157] "re-queuing item due to error processing" err="secrets \\"capi-serving-cert-\\" is forbidden"', 'error', 'header'],
|
|
18
|
+
['klog info', 'I0929 22:27:51.404929 1 reflector.go:376] Caches populated for *v1.Gateway from k8s.io/client-go@v0.32.0/tools/cache/reflector.go:251', 'info', 'header'],
|
|
19
|
+
['klog warning', 'W0930 10:00:00.000001 1 warnings.go:70] v1 Endpoints is deprecated in v1.33+', 'warn', 'header'],
|
|
20
|
+
['klog fatal', 'F0930 10:00:00.000001 1 main.go:12] cannot start', 'error', 'header'],
|
|
21
|
+
|
|
22
|
+
// logfmt — Argo CD, Grafana, slog text (Prometheus, Beyla), logrus
|
|
23
|
+
['logfmt level beats message words', 'time=2026-09-30T10:00:00Z level=info msg="retrying after error: connection reset"', 'info', 'structured'],
|
|
24
|
+
['two-pair logfmt', 'level=info msg="retrying after error"', 'info', 'structured'],
|
|
25
|
+
['argocd', 'time="2026-09-29T22:28:21Z" level=info msg="started call" grpc.component=server grpc.method=GetGitFiles', 'info', 'structured'],
|
|
26
|
+
['slog uppercase', 'time=2026-09-12T22:00:43.702Z level=WARN msg="can\'t fetch Kubernetes Cluster Name"', 'warn', 'structured'],
|
|
27
|
+
['prometheus warning text at INFO', 'time=2026-09-25T09:12:00.796Z level=INFO source=warnings.go:107 msg="Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice"', 'info', 'structured'],
|
|
28
|
+
['grafana', 'logger=dashboard-service t=2026-09-29T22:28:31.732506197Z level=info msg="No last resource version found, starting from scratch" orgID=1', 'info', 'structured'],
|
|
29
|
+
['header level before a level= pair in the message', '2026-09-30T10:00:00Z ERROR request failed level=debug', 'error', 'header'],
|
|
30
|
+
['level inside quoted message', 'msg="upstream said level=error" level=info', 'info', 'structured'],
|
|
31
|
+
['unrecognized structured level', 'level=custom_level msg="error happened"', 'unknown', 'structured'],
|
|
32
|
+
|
|
33
|
+
// JSON — zap, controller-runtime, pino, GCP, ECS
|
|
34
|
+
['zap json', '{"level":"error","ts":"2026-09-29T22:20:12.561715963Z","msg":"Reconciler error","controller":"cluster"}', 'error', 'structured'],
|
|
35
|
+
['json info with error key', '{"level":"info","ts":"2026-09-29T20:42:20.831Z","message":"RequestCompleted HTTP/1.1 GET / 404","error":null}', 'info', 'structured'],
|
|
36
|
+
['pino numeric wins over severity text', '{"severity":"ERROR","level":50,"time":1790720767281,"errmsg":"Authentication failed."}', 'error', 'structured'],
|
|
37
|
+
['gcp severity', '{"time":"2026-09-29T12:18:58.483192609Z","severity":"INFO","message":"[audit] retention sweep"}', 'info', 'structured'],
|
|
38
|
+
['logfmt dotted level key', 'time=2026-09-30T10:00:00Z log.level=info msg="retry after error"', 'info', 'structured'],
|
|
39
|
+
['ecs dotted key', '{"@timestamp":"2026-09-30T10:00:00Z","log.level":"warn","message":"slow"}', 'warn', 'structured'],
|
|
40
|
+
['ecs nested', '{"log":{"level":"debug"},"message":"cache miss"}', 'debug', 'structured'],
|
|
41
|
+
['empty level falls through to next field', '{"level":"","severity":"error","msg":"failed"}', 'error', 'structured'],
|
|
42
|
+
['json without level, null error', '{"msg":"ok","error":null}', 'unknown', 'none'],
|
|
43
|
+
|
|
44
|
+
// Console encoders and Java/Python loggers — level word in the line header
|
|
45
|
+
['zap console', '2026-09-29T22:19:30.112Z\tERROR\tprovider\tkubernetes/controller.go:639\tfailed to get Service\t{"runner": "provider"}', 'error', 'header'],
|
|
46
|
+
['zap console info', '2026-09-29T22:26:08.897Z\tINFO\tinfrastructure\trunner/runner.go:100\treceived an update', 'info', 'header'],
|
|
47
|
+
['zap verbosity', '2026-09-29T22:20:31Z\tLEVEL(-2)\tReconciling JobSet\t{"controller": "jobset"}', 'debug', 'header'],
|
|
48
|
+
['zerolog console', '2026-09-25T11:58:46Z WRN Controller is running', 'warn', 'header'],
|
|
49
|
+
['zerolog console info', '2026-09-25T11:58:46Z INF Starting controllers', 'info', 'header'],
|
|
50
|
+
['log4j kafka', '[2026-09-29 22:12:42,867] INFO [SnapshotEmitter id=0] Successfully wrote snapshot (org.apache.kafka.image.publisher.SnapshotEmitter)', 'info', 'header'],
|
|
51
|
+
['java agent prefix', '[otel.javaagent 2026-09-29 22:31:56:986 +0000] [EndpointMetricCollector] INFO software.amazon.opentelemetry.EndpointCollector - Error rate 0', 'info', 'header'],
|
|
52
|
+
['python logging', 'WARNING:root:disk almost full', 'warn', 'header'],
|
|
53
|
+
['nginx error log', '2026/09/30 10:00:00 [error] 29#29: *1 connect() failed (111: Connection refused)', 'error', 'header'],
|
|
54
|
+
['nestjs with ANSI colours', '\x1b[31m[Nest] 1 - \x1b[39m09/29/2026, 10:23:14 PM \x1b[31m ERROR\x1b[39m \x1b[38;5;3m[MongooseModule] \x1b[39m\x1b[31mUnable to connect to the database.\x1b[39m', 'error', 'header'],
|
|
55
|
+
['nestjs LOG', '[Nest] 1 - 09/29/2026, 10:23:14 PM LOG [RoutesResolver] AppController {/}:', 'info', 'header'],
|
|
56
|
+
|
|
57
|
+
['clickhouse', '2026.09.29 22:30:12.093808 [ 31 ] {} <Information> KeeperTCPHandler: Receiving request for session 36 took 4421 ms', 'info', 'header'],
|
|
58
|
+
['clickhouse error', '2026.09.29 22:30:12.093808 [ 31 ] {} <Error> TCPHandler: Code: 210. DB::NetException', 'error', 'header'],
|
|
59
|
+
['cloudwatch agent', '2026-09-29T20:38:42Z I! {"caller":"k8sapiserver/k8sapiserver.go:128","msg":"collect data from K8s API Server..."}', 'info', 'header'],
|
|
60
|
+
['cloudwatch agent error', '2026-09-29T20:38:42Z E! [outputs.cloudwatchlogs] Aws error received when sending logs', 'error', 'header'],
|
|
61
|
+
['supervisord', "2026-09-29 22:33:00,972 DEBG 'nginx' stdout output:", 'debug', 'header'],
|
|
62
|
+
['envoy bracketed', '[2026-09-29 22:43:46.517][1][info][main] [source/server/server.cc:493] request header map: 664 bytes', 'info', 'header'],
|
|
63
|
+
|
|
64
|
+
// Unstructured text — keyword fallback
|
|
65
|
+
['exception head', 'MongoServerError: Authentication failed.', 'error', 'keyword'],
|
|
66
|
+
['node error code', 'TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string', 'error', 'keyword'],
|
|
67
|
+
['logfmt empty level falls through', 'level= severity=error msg="failed"', 'error', 'structured'],
|
|
68
|
+
['java exception head', 'java.lang.IllegalStateException: Connection pool shut down', 'error', 'keyword'],
|
|
69
|
+
['python traceback', 'Traceback (most recent call last):', 'error', 'keyword'],
|
|
70
|
+
['go panic', 'panic: runtime error: invalid memory address or nil pointer dereference', 'error', 'keyword'],
|
|
71
|
+
['plain warning', 'deprecated flag used, warning: will be removed', 'warn', 'keyword'],
|
|
72
|
+
['key names are not severities', 'GET /healthz 200 errors=0 debug=false', 'unknown', 'none'],
|
|
73
|
+
['stack trace word is not debug', 'printing stack trace for request 42', 'unknown', 'none'],
|
|
74
|
+
['access log', '10.0.0.1 - - [30/Sep/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 612', 'unknown', 'none'],
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
describe('detectLevel', () => {
|
|
78
|
+
it.each(CASES)('%s', (_name, line, level, source) => {
|
|
79
|
+
expect(detectLevel(line)).toEqual({ level, source })
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('normalizeLevel', () => {
|
|
84
|
+
it('treats absent values as no level and unrecognized names as unknown', () => {
|
|
85
|
+
expect(normalizeLevel(undefined)).toBeNull()
|
|
86
|
+
expect(normalizeLevel('')).toBeNull()
|
|
87
|
+
expect(normalizeLevel({ name: 'error' })).toBeNull()
|
|
88
|
+
expect(normalizeLevel('custom')).toBe('unknown')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('maps pino numbers, including as strings', () => {
|
|
92
|
+
expect(normalizeLevel(60)).toBe('error')
|
|
93
|
+
expect(normalizeLevel('40')).toBe('warn')
|
|
94
|
+
expect(normalizeLevel(30)).toBe('info')
|
|
95
|
+
expect(normalizeLevel(10)).toBe('debug')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('selectLevelField', () => {
|
|
100
|
+
it('returns the raw value of the field the level came from', () => {
|
|
101
|
+
expect(selectLevelField({ level: '', severity: 'ERROR' })).toEqual({ raw: 'ERROR', level: 'error' })
|
|
102
|
+
expect(selectLevelField({ msg: 'x' })).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
interface TestEntry {
|
|
107
|
+
id: number
|
|
108
|
+
content: string
|
|
109
|
+
level: LogLevel
|
|
110
|
+
levelSource: LevelSource
|
|
111
|
+
pod?: string
|
|
112
|
+
container: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function entry(id: number, content: string, pod = 'a'): TestEntry {
|
|
116
|
+
const { level, source } = detectLevel(content)
|
|
117
|
+
return { id, content, level, levelSource: source, pod, container: 'app' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('isContinuationLine', () => {
|
|
121
|
+
it.each([
|
|
122
|
+
['\tat com.example.Pool.get(Pool.java:10)', true],
|
|
123
|
+
['Caused by: java.io.IOException: closed', true],
|
|
124
|
+
['\x1b[90m at process.processTicksAndRejections (node:internal/process/task_queues:104:5)', true],
|
|
125
|
+
['}', true],
|
|
126
|
+
['});', true],
|
|
127
|
+
['goroutine 1 [running]:', true],
|
|
128
|
+
['main.main()', true],
|
|
129
|
+
['net/http.(*conn).serve(0xc000112000, {0x1a2b3c, 0x4})', true],
|
|
130
|
+
['created by net/http.(*Server).Serve in goroutine 1', true],
|
|
131
|
+
['Starting server (version 1.2)', false],
|
|
132
|
+
['GET /api/v1/pods(list)', false],
|
|
133
|
+
['I0929 22:27:51.404929 1 reflector.go:376] Caches populated', false],
|
|
134
|
+
])('%s', (line, expected) => {
|
|
135
|
+
expect(isContinuationLine(line)).toBe(expected)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('keeps a Python traceback together, closing line included', () => {
|
|
139
|
+
const lines = [
|
|
140
|
+
entry(0, 'Traceback (most recent call last):'),
|
|
141
|
+
entry(1, ' File "/app/main.py", line 7, in <module>'),
|
|
142
|
+
entry(2, 'ValueError: bad config'),
|
|
143
|
+
entry(3, 'ValueError: a separate error'),
|
|
144
|
+
]
|
|
145
|
+
expect(associateContinuations(lines).headOf).toEqual([0, 0, 0, 3])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('closes a traceback with a generic exception name', () => {
|
|
149
|
+
const lines = [
|
|
150
|
+
entry(0, 'Traceback (most recent call last):'),
|
|
151
|
+
entry(1, ' File "/app/main.py", line 1, in <module>'),
|
|
152
|
+
entry(2, ' raise Exception("boom")'),
|
|
153
|
+
entry(3, 'Exception: boom'),
|
|
154
|
+
entry(4, 'Traceback (most recent call last):'),
|
|
155
|
+
entry(5, 'KeyboardInterrupt'),
|
|
156
|
+
]
|
|
157
|
+
expect(associateContinuations(lines).headOf).toEqual([0, 0, 0, 0, 4, 4])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('keeps an indented source line with a level word inside its traceback', () => {
|
|
161
|
+
const lines = [
|
|
162
|
+
entry(0, 'Traceback (most recent call last):'),
|
|
163
|
+
entry(1, ' File "/app/main.py", line 2, in <module>'),
|
|
164
|
+
entry(2, ' ERROR = "boom"'),
|
|
165
|
+
entry(3, 'ValueError: bad'),
|
|
166
|
+
]
|
|
167
|
+
expect(lines[2].levelSource).toBe('header')
|
|
168
|
+
expect(associateContinuations(lines).headOf).toEqual([0, 0, 0, 0])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('keeps a Go panic together as one error record', () => {
|
|
172
|
+
const lines = [
|
|
173
|
+
entry(0, 'panic: runtime error: invalid memory address or nil pointer dereference'),
|
|
174
|
+
entry(1, 'goroutine 1 [running]:'),
|
|
175
|
+
entry(2, 'main.main()'),
|
|
176
|
+
entry(3, '\t/app/main.go:12 +0x1d'),
|
|
177
|
+
]
|
|
178
|
+
expect(associateContinuations(lines).effectiveLevel).toEqual(['error', 'error', 'error', 'error'])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('associateContinuations', () => {
|
|
183
|
+
const nodeTrace = [
|
|
184
|
+
entry(0, '[Nest] 1 - 09/29/2026, 10:25:37 PM ERROR [ExceptionHandler] MongoServerError: Authentication failed.'),
|
|
185
|
+
entry(1, 'MongoServerError: Authentication failed.'),
|
|
186
|
+
entry(2, ' at Connection.sendCommand (/app/node_modules/mongodb/lib/cmap/connection.js:306:27)'),
|
|
187
|
+
entry(3, ' at process.processTicksAndRejections (node:internal/process/task_queues:104:5)'),
|
|
188
|
+
entry(4, '[Nest] 1 - 09/29/2026, 10:25:38 PM LOG [NestApplication] Nest application successfully started'),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
it('gives stack frames the level of the line that started their record', () => {
|
|
192
|
+
const { headOf, effectiveLevel } = associateContinuations(nodeTrace)
|
|
193
|
+
expect(headOf).toEqual([0, 1, 1, 1, 4])
|
|
194
|
+
expect(effectiveLevel).toEqual(['error', 'error', 'error', 'error', 'info'])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('keeps a frame whose text contains a level word inside its record', () => {
|
|
198
|
+
const lines = [
|
|
199
|
+
entry(0, '2026-09-30 10:00:00 ERROR [main] request failed'),
|
|
200
|
+
entry(1, ' at example.debug.Handler.run(Handler.java:42)'),
|
|
201
|
+
]
|
|
202
|
+
expect(lines[1].levelSource).toBe('keyword')
|
|
203
|
+
expect(associateContinuations(lines).effectiveLevel).toEqual(['error', 'error'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('does not splice interleaved pods into each other\'s traces', () => {
|
|
207
|
+
const lines = [
|
|
208
|
+
entry(0, 'java.lang.IllegalStateException: pool closed', 'a'),
|
|
209
|
+
entry(1, 'GET /healthz 200', 'b'),
|
|
210
|
+
entry(2, '\tat com.example.Pool.get(Pool.java:10)', 'a'),
|
|
211
|
+
entry(3, '\tat com.example.Pool.get(Pool.java:10)', 'c'),
|
|
212
|
+
]
|
|
213
|
+
const { headOf, effectiveLevel } = associateContinuations(lines)
|
|
214
|
+
expect(headOf).toEqual([0, 1, 0, 3])
|
|
215
|
+
expect(effectiveLevel[2]).toBe('error')
|
|
216
|
+
// A continuation with no earlier line from its own source starts its own record.
|
|
217
|
+
expect(effectiveLevel[3]).toBe('unknown')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('groupContinuations', () => {
|
|
222
|
+
const lines = [
|
|
223
|
+
entry(0, 'java.lang.IllegalStateException: pool closed', 'a'),
|
|
224
|
+
entry(1, 'GET /healthz 200', 'b'),
|
|
225
|
+
entry(2, '\tat com.example.Pool.get(Pool.java:10)', 'a'),
|
|
226
|
+
entry(3, '\tat com.example.Main.run(Main.java:5)', 'a'),
|
|
227
|
+
]
|
|
228
|
+
const { headOf } = associateContinuations(lines)
|
|
229
|
+
const headIdById = new Map<number, number>()
|
|
230
|
+
headOf.forEach((h, i) => { if (h !== i) headIdById.set(lines[i].id, lines[h].id) })
|
|
231
|
+
|
|
232
|
+
it('groups frames under their own pod\'s record, not the previous row', () => {
|
|
233
|
+
const groups = groupContinuations(lines, headIdById)
|
|
234
|
+
expect(groups.map(g => [g.head.id, g.continuations.map(c => c.id)])).toEqual([[0, [2, 3]], [1, []]])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('leaves frames on their own rows when their record start is filtered out', () => {
|
|
238
|
+
const groups = groupContinuations(lines.slice(1), headIdById)
|
|
239
|
+
expect(groups.map(g => g.head.id)).toEqual([1, 2, 3])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const recordIdOf = (e: TestEntry) => headIdById.get(e.id) ?? e.id
|
|
243
|
+
|
|
244
|
+
it('hides a whole record when its first line matches', () => {
|
|
245
|
+
expect(applySearchMode(lines, [0], 'hide', recordIdOf).map(e => e.id)).toEqual([1])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('hides a whole record when only a frame matches', () => {
|
|
249
|
+
expect(applySearchMode(lines, [3], 'hide', recordIdOf).map(e => e.id)).toEqual([1])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('shows a whole record, error line included, when only a frame matches', () => {
|
|
253
|
+
expect(applySearchMode(lines, [3], 'only', recordIdOf).map(e => e.id)).toEqual([0, 2, 3])
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('applySearchMode', () => {
|
|
258
|
+
const lines = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]
|
|
259
|
+
const ids = (xs: { id: number }[]) => xs.map(x => x.id)
|
|
260
|
+
it('shows everything when highlighting', () => {
|
|
261
|
+
expect(ids(applySearchMode(lines, [1, 3], 'highlight'))).toEqual([0, 1, 2, 3])
|
|
262
|
+
})
|
|
263
|
+
it('keeps only matches', () => {
|
|
264
|
+
expect(ids(applySearchMode(lines, [1, 3], 'only'))).toEqual([1, 3])
|
|
265
|
+
})
|
|
266
|
+
it('drops matches when hiding', () => {
|
|
267
|
+
expect(ids(applySearchMode(lines, [1, 3], 'hide'))).toEqual([0, 2])
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { stripAnsi } from './log-format'
|
|
2
|
+
import type { LogEntry } from '../components/logs/useLogBuffer'
|
|
3
|
+
|
|
4
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a line's level came from. `structured` and `header` are the line
|
|
8
|
+
* stating its own severity; `keyword` is a guess from words in the message, so
|
|
9
|
+
* it must not override a line's membership in a stack trace or a structured
|
|
10
|
+
* field that says otherwise.
|
|
11
|
+
*/
|
|
12
|
+
export type LevelSource = 'structured' | 'header' | 'keyword' | 'none'
|
|
13
|
+
|
|
14
|
+
export interface DetectedLevel {
|
|
15
|
+
level: LogLevel
|
|
16
|
+
source: LevelSource
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ERROR_NAMES = new Set(['error', 'err', 'eror', 'fatal', 'ftl', 'panic', 'pnc', 'dpanic', 'critical', 'crit', 'alert', 'emerg', 'emergency', 'severe'])
|
|
20
|
+
const WARN_NAMES = new Set(['warn', 'warning', 'wrn'])
|
|
21
|
+
const INFO_NAMES = new Set(['info', 'inf', 'information', 'notice', 'log'])
|
|
22
|
+
const DEBUG_NAMES = new Set(['debug', 'dbg', 'debg', 'trace', 'trc', 'verbose', 'fine', 'finer', 'finest'])
|
|
23
|
+
|
|
24
|
+
function levelFromNumber(n: number): LogLevel {
|
|
25
|
+
// Pino/bunyan: 10 trace, 20 debug, 30 info, 40 warn, 50 error, 60 fatal
|
|
26
|
+
if (n >= 50) return 'error'
|
|
27
|
+
if (n >= 40) return 'warn'
|
|
28
|
+
if (n >= 30) return 'info'
|
|
29
|
+
return 'debug'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map a raw level value to a LogLevel. Returns null when the value carries no
|
|
34
|
+
* level at all (missing, empty, non-scalar) so callers can fall through to the
|
|
35
|
+
* next source; a present but unrecognized name is `unknown`.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeLevel(raw: unknown): LogLevel | null {
|
|
38
|
+
if (typeof raw === 'number') return Number.isFinite(raw) ? levelFromNumber(raw) : null
|
|
39
|
+
if (typeof raw !== 'string') return null
|
|
40
|
+
const v = raw.trim().toLowerCase()
|
|
41
|
+
if (!v) return null
|
|
42
|
+
if (/^\d+$/.test(v)) return levelFromNumber(Number(v))
|
|
43
|
+
if (ERROR_NAMES.has(v)) return 'error'
|
|
44
|
+
if (WARN_NAMES.has(v)) return 'warn'
|
|
45
|
+
if (INFO_NAMES.has(v)) return 'info'
|
|
46
|
+
if (DEBUG_NAMES.has(v)) return 'debug'
|
|
47
|
+
// zap/logr verbosity levels render as LEVEL(-2)
|
|
48
|
+
if (/^level\(-\d+\)$/.test(v)) return 'debug'
|
|
49
|
+
return 'unknown'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pick the level field of a structured record. Shared by detection and the
|
|
56
|
+
* structured-line badge so the badge text and the line's resolved level always
|
|
57
|
+
* come from the same field.
|
|
58
|
+
*/
|
|
59
|
+
export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
|
|
60
|
+
for (const key of LEVEL_FIELD_KEYS) {
|
|
61
|
+
const level = normalizeLevel(obj[key])
|
|
62
|
+
if (level) return { raw: obj[key], level }
|
|
63
|
+
}
|
|
64
|
+
const log = obj.log
|
|
65
|
+
if (log && typeof log === 'object' && !Array.isArray(log)) {
|
|
66
|
+
const raw = (log as Record<string, unknown>).level
|
|
67
|
+
const level = normalizeLevel(raw)
|
|
68
|
+
if (level) return { raw, level }
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Same tokenization as parseLogfmt: a quoted value is consumed whole, so a
|
|
74
|
+
// `level=` inside a quoted message is never read as the line's own level.
|
|
75
|
+
const LOGFMT_PAIR_RE = /(?:^|\s)([a-zA-Z_][\w.]*)=((?:"(?:[^"\\]|\\.)*")|(?:[^\s]*))/g
|
|
76
|
+
const LOGFMT_LEVEL_KEYS = new Set(['level', 'lvl', 'severity', 'log.level'])
|
|
77
|
+
const LOGFMT_LEVEL_HINT = /(?:^|\s)(?:level|lvl|severity|log\.level)=/
|
|
78
|
+
|
|
79
|
+
interface PlacedLevel {
|
|
80
|
+
level: LogLevel
|
|
81
|
+
/** Where in the line the level marker starts */
|
|
82
|
+
at: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function logfmtLevel(line: string): PlacedLevel | null {
|
|
86
|
+
if (!LOGFMT_LEVEL_HINT.test(line)) return null
|
|
87
|
+
const re = LOGFMT_PAIR_RE
|
|
88
|
+
re.lastIndex = 0
|
|
89
|
+
let match: RegExpExecArray | null
|
|
90
|
+
while ((match = re.exec(line)) !== null) {
|
|
91
|
+
if (!LOGFMT_LEVEL_KEYS.has(match[1])) continue
|
|
92
|
+
let value = match[2]
|
|
93
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) value = value.slice(1, -1)
|
|
94
|
+
const level = normalizeLevel(value)
|
|
95
|
+
if (level) return { level, at: match.index }
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// klog: Lmmdd hh:mm:ss.uuuuuu threadid file:line]
|
|
101
|
+
const KLOG_RE = /^([IWEF])\d{4} \d{2}:\d{2}:\d{2}\.\d+\s/
|
|
102
|
+
const KLOG_LEVELS: Record<string, LogLevel> = { I: 'info', W: 'warn', E: 'error', F: 'error' }
|
|
103
|
+
|
|
104
|
+
// A standalone uppercase level word near the start of the line: zap and
|
|
105
|
+
// zerolog console output, logback/log4j, Python logging, NestJS. The window
|
|
106
|
+
// covers prefixes such as timestamps, thread names and agent tags.
|
|
107
|
+
const HEADER_WINDOW = 100
|
|
108
|
+
const HEADER_TOKEN_RE = /(?:^|[\s[<|(])(TRACE|TRC|DEBUG|DBG|DEBG|VERBOSE|INFO|INF|LOG|NOTICE|WARN|WARNING|WRN|ERROR|ERR|FATAL|FTL|PANIC|PNC|DPANIC|CRITICAL|CRIT|SEVERE|LEVEL\(-\d+\))(?=$|[\s\]>|:)])/
|
|
109
|
+
// `[error]` (nginx, envoy), `<Information>` (ClickHouse)
|
|
110
|
+
const BRACKETED_TOKEN_RE = /[[<](trace|debug|information|info|notice|warning|warn|error|critical|crit|alert|emerg|fatal)[\]>]/i
|
|
111
|
+
// telegraf and the CloudWatch agent: `2026-09-29T20:38:42Z E! message`
|
|
112
|
+
const BANG_PREFIX_RE = /^\S+\s([DIWE])!\s/
|
|
113
|
+
const BANG_LEVELS: Record<string, LogLevel> = { D: 'debug', I: 'info', W: 'warn', E: 'error' }
|
|
114
|
+
|
|
115
|
+
function headerLevel(line: string): PlacedLevel | null {
|
|
116
|
+
const klog = KLOG_RE.exec(line)
|
|
117
|
+
if (klog) return { level: KLOG_LEVELS[klog[1]], at: 0 }
|
|
118
|
+
const bang = BANG_PREFIX_RE.exec(line)
|
|
119
|
+
if (bang) return { level: BANG_LEVELS[bang[1]], at: 0 }
|
|
120
|
+
const head = line.slice(0, HEADER_WINDOW)
|
|
121
|
+
const token = HEADER_TOKEN_RE.exec(head)
|
|
122
|
+
const tokenLevel = token && normalizeLevel(token[1])
|
|
123
|
+
if (token && tokenLevel) return { level: tokenLevel, at: token.index }
|
|
124
|
+
const bracketed = BRACKETED_TOKEN_RE.exec(head)
|
|
125
|
+
const bracketedLevel = bracketed && normalizeLevel(bracketed[1])
|
|
126
|
+
if (bracketed && bracketedLevel) return { level: bracketedLevel, at: bracketed.index }
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A word followed by `=` is a key name (`errors=0`, `debug=false`), and a JSON
|
|
131
|
+
// key with an empty value says nothing happened.
|
|
132
|
+
const NOT_A_KEY = String.raw`(?!\s*=)(?!"\s*:\s*(?:null|false|0|""|\[\]|\{\})\s*[,}])`
|
|
133
|
+
const KEYWORD_ERROR_RE = new RegExp(String.raw`\b(?:error|fatal|panic|critical|crit|exception)\b` + NOT_A_KEY)
|
|
134
|
+
const KEYWORD_WARN_RE = new RegExp(String.raw`\b(?:warn|warning)\b` + NOT_A_KEY)
|
|
135
|
+
const KEYWORD_DEBUG_RE = new RegExp(String.raw`\bdebug\b` + NOT_A_KEY)
|
|
136
|
+
const KEYWORD_INFO_RE = new RegExp(String.raw`\binfo\b` + NOT_A_KEY)
|
|
137
|
+
const PYTHON_TRACEBACK_HEAD = 'Traceback (most recent call last):'
|
|
138
|
+
// The unindented line that ends a traceback: `ValueError: bad`, `Exception: boom`, `KeyboardInterrupt`
|
|
139
|
+
const PYTHON_EXCEPTION_LINE_RE = /^[A-Za-z_][\w.]*(?::|$)/
|
|
140
|
+
// The first line of an exception: `MongoServerError: ...`, `java.lang.IllegalStateException`,
|
|
141
|
+
// `Traceback (most recent call last):`.
|
|
142
|
+
const EXCEPTION_HEAD_RE = /^(?:(?:[\w$]+\.)*[A-Z][\w$]*(?:Error|Exception)(?: \[[\w-]+\])?(?::|\s*$)|Traceback \(most recent call last\):)/
|
|
143
|
+
|
|
144
|
+
function keywordLevel(line: string): LogLevel | null {
|
|
145
|
+
if (EXCEPTION_HEAD_RE.test(line)) return 'error'
|
|
146
|
+
const lower = line.toLowerCase()
|
|
147
|
+
if (KEYWORD_ERROR_RE.test(lower)) return 'error'
|
|
148
|
+
if (KEYWORD_WARN_RE.test(lower)) return 'warn'
|
|
149
|
+
if (KEYWORD_DEBUG_RE.test(lower)) return 'debug'
|
|
150
|
+
if (KEYWORD_INFO_RE.test(lower)) return 'info'
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a log line's severity. Sources are tried from most to least
|
|
156
|
+
* authoritative, and the first that yields a level wins: a structured field,
|
|
157
|
+
* then a header the logging library wrote, then words in the message.
|
|
158
|
+
*/
|
|
159
|
+
export function detectLevel(content: string): DetectedLevel {
|
|
160
|
+
const line = content.includes('\x1b') ? stripAnsi(content) : content
|
|
161
|
+
const trimmed = line.trim()
|
|
162
|
+
|
|
163
|
+
if (trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}') {
|
|
164
|
+
try {
|
|
165
|
+
const obj = JSON.parse(trimmed)
|
|
166
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
167
|
+
const selected = selectLevelField(obj as Record<string, unknown>)
|
|
168
|
+
if (selected) return { level: selected.level, source: 'structured' }
|
|
169
|
+
}
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A console line can carry key=value pairs in its message, so between a
|
|
174
|
+
// header level word and a `level=` pair the one written first is the
|
|
175
|
+
// line's own.
|
|
176
|
+
const fromHeader = headerLevel(line.trimEnd())
|
|
177
|
+
const fromLogfmt = trimmed[0] === '{' ? null : logfmtLevel(line.trimEnd())
|
|
178
|
+
if (fromLogfmt && (!fromHeader || fromLogfmt.at <= fromHeader.at)) return { level: fromLogfmt.level, source: 'structured' }
|
|
179
|
+
if (fromHeader) return { level: fromHeader.level, source: 'header' }
|
|
180
|
+
|
|
181
|
+
const fromKeyword = keywordLevel(trimmed)
|
|
182
|
+
if (fromKeyword) return { level: fromKeyword, source: 'keyword' }
|
|
183
|
+
|
|
184
|
+
return { level: 'unknown', source: 'none' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function detectLogLevel(content: string): LogLevel {
|
|
188
|
+
return detectLevel(content).level
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const LEADING_ANSI_RE = /^(?:\x1b\[[0-9;]*m)+/
|
|
192
|
+
|
|
193
|
+
// `goroutine 1 [running]:`, `main.main()`, `net/http.(*conn).serve(0xc000112000, {0x1a2b3c, 0x4})`,
|
|
194
|
+
// `created by net/http.(*Server).Serve in goroutine 1`
|
|
195
|
+
const GO_PANIC_LINE_RE = /^(?:goroutine \d+ \[[^\]]*\]:$|created by \S+|[\w./-]*[\w)\]]\.[\w*().[\]{}-]*\((?:[^()]|\([^()]*\))*\)$)/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Lines that continue the previous line's record rather than starting a new
|
|
199
|
+
* one: stack frames and wrapped detail, which almost always start indented.
|
|
200
|
+
*/
|
|
201
|
+
export function isContinuationLine(raw: string): boolean {
|
|
202
|
+
return isIndented(raw) || looksLikeUnindentedFrame(raw)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Java `\tat com.foo.Bar`, Go `\tpackage.func`, Node ` at func`, Python ` File "..."`.
|
|
206
|
+
function isIndented(raw: string): boolean {
|
|
207
|
+
// Colored output (Node's inspector) can put an escape code ahead of the indentation.
|
|
208
|
+
const content = raw.charCodeAt(0) === 0x1b ? raw.replace(LEADING_ANSI_RE, '') : raw
|
|
209
|
+
return /^\s/.test(content)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function looksLikeUnindentedFrame(content: string): boolean {
|
|
213
|
+
// Java's secondary chain markers that don't start with whitespace.
|
|
214
|
+
if (/^(Caused by:|Suppressed:|\.\.\. \d+ more)/.test(content)) return true
|
|
215
|
+
// The closing bracket of an object printed across lines (Node's util.inspect).
|
|
216
|
+
if (/^[}\])]+[;,]?$/.test(content)) return true
|
|
217
|
+
// A Go panic prints its goroutine header and function frames unindented;
|
|
218
|
+
// only the file:line under each frame is indented.
|
|
219
|
+
if (content.startsWith('goroutine ') || content.startsWith('created by ')) return GO_PANIC_LINE_RE.test(content)
|
|
220
|
+
const paren = content.indexOf('(')
|
|
221
|
+
if (paren <= 0 || !content.endsWith(')') || content.lastIndexOf(' ', paren) !== -1) return false
|
|
222
|
+
return GO_PANIC_LINE_RE.test(content)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface AssociableEntry {
|
|
226
|
+
content: string
|
|
227
|
+
level: LogLevel
|
|
228
|
+
levelSource?: LevelSource
|
|
229
|
+
pod?: string
|
|
230
|
+
container: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface ContinuationAssociation {
|
|
234
|
+
/** Index of the record's first line for each entry; equals the entry's own index for a head. */
|
|
235
|
+
headOf: number[]
|
|
236
|
+
/** The level filters should use: a continuation carries its record's level. */
|
|
237
|
+
effectiveLevel: LogLevel[]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Link each continuation line to the record it belongs to, per pod and
|
|
242
|
+
* container, so a filter keeps or drops a stack trace together with its
|
|
243
|
+
* first line and interleaved pods never splice into each other's traces.
|
|
244
|
+
*/
|
|
245
|
+
export function associateContinuations(entries: readonly AssociableEntry[]): ContinuationAssociation {
|
|
246
|
+
const headOf = new Array<number>(entries.length)
|
|
247
|
+
const effectiveLevel = new Array<LogLevel>(entries.length)
|
|
248
|
+
const lastHeadBySource = new Map<string, number>()
|
|
249
|
+
// Sources whose current record is a Python traceback still waiting for its
|
|
250
|
+
// closing `ValueError: ...` line, which Python prints unindented.
|
|
251
|
+
const openTraceback = new Set<string>()
|
|
252
|
+
for (let i = 0; i < entries.length; i++) {
|
|
253
|
+
const entry = entries[i]
|
|
254
|
+
const source = `${entry.pod ?? ''}\x00${entry.container}`
|
|
255
|
+
const lastHead = lastHeadBySource.get(source)
|
|
256
|
+
// Indentation always continues a record, even when a frame's source text
|
|
257
|
+
// holds a level word; the unindented patterns yield to a line's own level.
|
|
258
|
+
const explicit = entry.levelSource === 'structured' || entry.levelSource === 'header'
|
|
259
|
+
const closesTraceback = openTraceback.has(source) && PYTHON_EXCEPTION_LINE_RE.test(entry.content)
|
|
260
|
+
const continues = isIndented(entry.content) || (!explicit && (closesTraceback || looksLikeUnindentedFrame(entry.content)))
|
|
261
|
+
if (lastHead !== undefined && continues) {
|
|
262
|
+
headOf[i] = lastHead
|
|
263
|
+
effectiveLevel[i] = effectiveLevel[lastHead]
|
|
264
|
+
if (closesTraceback) openTraceback.delete(source)
|
|
265
|
+
} else {
|
|
266
|
+
headOf[i] = i
|
|
267
|
+
effectiveLevel[i] = entry.level
|
|
268
|
+
lastHeadBySource.set(source, i)
|
|
269
|
+
if (entry.content.startsWith(PYTHON_TRACEBACK_HEAD)) openTraceback.add(source)
|
|
270
|
+
else openTraceback.delete(source)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { headOf, effectiveLevel }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface LogGroup<T extends { id: number } = LogEntry> {
|
|
277
|
+
head: T
|
|
278
|
+
continuations: T[]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Fold visible lines into records using `headIdById` (continuation id → id of
|
|
283
|
+
* its record's first line). A line whose record start is not visible keeps
|
|
284
|
+
* its own row rather than attaching to an unrelated neighbour.
|
|
285
|
+
*/
|
|
286
|
+
export function groupContinuations<T extends { id: number }>(visible: readonly T[], headIdById: ReadonlyMap<number, number>): LogGroup<T>[] {
|
|
287
|
+
const groups: LogGroup<T>[] = []
|
|
288
|
+
const groupByHeadId = new Map<number, LogGroup<T>>()
|
|
289
|
+
for (const entry of visible) {
|
|
290
|
+
const headId = headIdById.get(entry.id)
|
|
291
|
+
const headGroup = headId !== undefined ? groupByHeadId.get(headId) : undefined
|
|
292
|
+
if (headGroup) {
|
|
293
|
+
headGroup.continuations.push(entry)
|
|
294
|
+
} else {
|
|
295
|
+
const group: LogGroup<T> = { head: entry, continuations: [] }
|
|
296
|
+
groups.push(group)
|
|
297
|
+
groupByHeadId.set(entry.id, group)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return groups
|
|
301
|
+
}
|
|
@@ -1039,6 +1039,75 @@ describe('group-qualified lane identity', () => {
|
|
|
1039
1039
|
expect(lanes.map((lane) => lane.id)).toEqual(['Cluster.postgresql.cnpg.io/prod/main'])
|
|
1040
1040
|
})
|
|
1041
1041
|
|
|
1042
|
+
it("builds a workload's detail lanes from its scoped history without its Service's other backends", () => {
|
|
1043
|
+
const events = [
|
|
1044
|
+
changeEvent('Deployment', 'prod', 'web', { apiVersion: 'apps/v1' }),
|
|
1045
|
+
changeEvent('ReplicaSet', 'prod', 'web-1', { apiVersion: 'apps/v1', owner: { kind: 'Deployment', name: 'web' } }),
|
|
1046
|
+
changeEvent('Pod', 'prod', 'web-1-a', { apiVersion: 'v1', owner: { kind: 'ReplicaSet', name: 'web-1' } }),
|
|
1047
|
+
changeEvent('Service', 'prod', 'web', { apiVersion: 'v1' }),
|
|
1048
|
+
]
|
|
1049
|
+
// The Service also fronts a sibling Deployment; with scoped history the
|
|
1050
|
+
// sibling has no events, so nothing of it may appear.
|
|
1051
|
+
const topology = {
|
|
1052
|
+
nodes: [
|
|
1053
|
+
{ id: 'service/prod/web', kind: 'Service', name: 'web', status: 'healthy', data: { namespace: 'prod', apiVersion: 'v1' } },
|
|
1054
|
+
{ id: 'deployment/prod/web', kind: 'Deployment', name: 'web', status: 'healthy', data: { namespace: 'prod', apiVersion: 'apps/v1' } },
|
|
1055
|
+
{ id: 'deployment/prod/api', kind: 'Deployment', name: 'api', status: 'healthy', data: { namespace: 'prod', apiVersion: 'apps/v1' } },
|
|
1056
|
+
],
|
|
1057
|
+
edges: [
|
|
1058
|
+
{ id: 'e1', source: 'service/prod/web', target: 'deployment/prod/web', type: 'exposes' },
|
|
1059
|
+
{ id: 'e2', source: 'service/prod/web', target: 'deployment/prod/api', type: 'exposes' },
|
|
1060
|
+
],
|
|
1061
|
+
} as unknown as Topology
|
|
1062
|
+
const lanes = buildResourceHierarchy({
|
|
1063
|
+
events,
|
|
1064
|
+
topology,
|
|
1065
|
+
rootResource: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' },
|
|
1066
|
+
groupByApp: true,
|
|
1067
|
+
})
|
|
1068
|
+
const ids = getAllEventsFromHierarchy(lanes).map((e) => e.id).sort()
|
|
1069
|
+
expect(ids).toEqual(['Deployment/prod/web', 'Pod/prod/web-1-a', 'ReplicaSet/prod/web-1', 'Service/prod/web'])
|
|
1070
|
+
const laneNames: string[] = []
|
|
1071
|
+
const walk = (list: ResourceLane[]) => list.forEach((l) => { laneNames.push(l.name); walk(l.children ?? []) })
|
|
1072
|
+
walk(lanes)
|
|
1073
|
+
expect(laneNames).not.toContain('api')
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
describe('history already scoped to the root', () => {
|
|
1077
|
+
const laneNames = (lanes: ResourceLane[]) => {
|
|
1078
|
+
const out: string[] = []
|
|
1079
|
+
const walk = (list: ResourceLane[]) => list.forEach((l) => { out.push(`${l.kind}/${l.name}`); walk(l.children ?? []) })
|
|
1080
|
+
walk(lanes)
|
|
1081
|
+
return out.sort()
|
|
1082
|
+
}
|
|
1083
|
+
const build = (root: { kind: string; group: string; name: string }, events: TimelineEvent[], eventsScopedToRoot: boolean) =>
|
|
1084
|
+
buildResourceHierarchy({ events, rootResource: { ...root, namespace: 'prod' }, eventsScopedToRoot, groupByApp: true })
|
|
1085
|
+
|
|
1086
|
+
it("keeps a StatefulSet's ownerless Pod", () => {
|
|
1087
|
+
const events = [
|
|
1088
|
+
changeEvent('StatefulSet', 'prod', 'db', { apiVersion: 'apps/v1' }),
|
|
1089
|
+
changeEvent('Pod', 'prod', 'db-2', { apiVersion: 'v1' }),
|
|
1090
|
+
]
|
|
1091
|
+
const root = { kind: 'StatefulSet', group: 'apps', name: 'db' }
|
|
1092
|
+
expect(laneNames(build(root, events, false))).toEqual(['StatefulSet/db'])
|
|
1093
|
+
const lanes = build(root, events, true)
|
|
1094
|
+
expect(laneNames(lanes)).toEqual(['Pod/db-2', 'StatefulSet/db'])
|
|
1095
|
+
expect(getAllEventsFromHierarchy(lanes).map((e) => e.id).sort()).toEqual(['Pod/prod/db-2', 'StatefulSet/prod/db'])
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it("keeps a CronJob's past runs when the CronJob and a run's Job left no events", () => {
|
|
1099
|
+
const events = [
|
|
1100
|
+
changeEvent('Job', 'prod', 'backup-29012345', { apiVersion: 'batch/v1' }),
|
|
1101
|
+
changeEvent('Pod', 'prod', 'backup-29012346-bcdfg', { apiVersion: 'v1' }),
|
|
1102
|
+
]
|
|
1103
|
+
const lanes = build({ kind: 'CronJob', group: 'batch', name: 'backup' }, events, true)
|
|
1104
|
+
expect(lanes).toHaveLength(1)
|
|
1105
|
+
expect(lanes[0].name).toBe('backup')
|
|
1106
|
+
expect(laneNames(lanes)).toEqual(['CronJob/backup', 'Job/backup-29012345', 'Pod/backup-29012346-bcdfg'])
|
|
1107
|
+
expect(lanes[0].allEventsSorted).toHaveLength(2)
|
|
1108
|
+
})
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1042
1111
|
it('keeps core/built-in resource ids bare (byte-stable)', () => {
|
|
1043
1112
|
const lanes = buildResourceHierarchy({
|
|
1044
1113
|
events: [
|