@skyhook-io/k8s-ui 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  5. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  8. package/src/components/issues/IssuesView.tsx +2 -2
  9. package/src/components/logs/LogCore.tsx +131 -97
  10. package/src/components/logs/StructuredLogLine.tsx +18 -28
  11. package/src/components/logs/log-palette.ts +4 -0
  12. package/src/components/logs/useLogBuffer.ts +8 -39
  13. package/src/components/logs/useLogSearch.ts +52 -24
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  16. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  17. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  18. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  19. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  20. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  21. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  22. package/src/components/shared/ResourceActionsBar.tsx +14 -9
  23. package/src/components/ui/FilterPill.tsx +1 -1
  24. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  25. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  26. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  27. package/src/components/ui/ResourceBar.tsx +12 -6
  28. package/src/components/ui/SelectMenu.tsx +62 -19
  29. package/src/components/ui/index.ts +8 -0
  30. package/src/components/workload/WorkloadView.tsx +109 -3
  31. package/src/types/core.ts +25 -4
  32. package/src/types/fetch-error.ts +33 -0
  33. package/src/utils/log-format.ts +1 -1
  34. package/src/utils/log-level.test.ts +269 -0
  35. package/src/utils/log-level.ts +301 -0
  36. package/src/utils/resource-hierarchy.test.ts +69 -0
  37. 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: [