Skip to content

Commit 43c1684

Browse files
adameskaHettinger, David
andauthored
feat: Colorize terminal logs based on log level (#428)
* feat: Add logging colors to terminal color definitions Signed-off-by: David Hettinger <[email protected]> * feat: Add colorizeLogLevel to log processing Signed-off-by: David Hettinger <[email protected]> * Run P Signed-off-by: David Hettinger <[email protected]> * Add unit tests for colorizeLogLevel function Signed-off-by: David Hettinger <[email protected]> * Add more color tests and fix formatting/linting issues Signed-off-by: David Hettinger <[email protected]> * Add JsonColorizer class for colorizing JSON strings This file contains a class for colorizing JSON strings with customizable ANSI color schemes for different JSON elements. Signed-off-by: David Hettinger <[email protected]> * Add tests for JsonColorizer colorization functionality Add unit tests for JsonColorizer to validate colorization of various JSON structures, including braces, brackets, numbers, booleans, null values, and strings with custom color schemes. Signed-off-by: David Hettinger <[email protected]> * Fix linting issues and add json colorizer Updated log level color mappings and enhanced the log level detection regex to support additional formats. Signed-off-by: David Hettinger <[email protected]> * Remove JSON colorization test from terminal colors Removed tests for JSON colorization function. Signed-off-by: David Hettinger <[email protected]> * Remove default JSON colorizer and color scheme Removed unused JSON colorizer and related color scheme. Signed-off-by: David Hettinger <[email protected]> * Cleanup format Signed-off-by: David Hettinger <[email protected]> * Fix type with error Signed-off-by: Adameska <[email protected]> --------- Signed-off-by: David Hettinger <[email protected]> Signed-off-by: Adameska <[email protected]> Co-authored-by: Hettinger, David <[email protected]>
1 parent c0cd4c2 commit 43c1684

File tree

3 files changed

+289
-7
lines changed

3 files changed

+289
-7
lines changed

packages/webview/src/component/pods/PodLogs.svelte

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
<script lang="ts">
2-
import type { V1Pod } from '@kubernetes/client-node';
3-
import { getContext, onDestroy, onMount, tick } from 'svelte';
4-
import { Streams } from '/@/stream/streams';
52
import type { IDisposable } from '@kubernetes-dashboard/channels';
3+
import type { V1Pod } from '@kubernetes/client-node';
64
import { EmptyScreen } from '@podman-desktop/ui-svelte';
7-
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
85
import type { Terminal } from '@xterm/xterm';
9-
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
6+
import { getContext, onDestroy, onMount, tick } from 'svelte';
107
import { SvelteMap } from 'svelte/reactivity';
11-
import { ansi256Colours, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
8+
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
9+
import { ansi256Colours, colorizeLogLevel, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
10+
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
11+
import { Streams } from '/@/stream/streams';
1212
1313
interface Props {
1414
object: V1Pod;
@@ -54,13 +54,15 @@ onMount(async () => {
5454
// All lines are prefixed, except the last one if it's empty.
5555
const lines = data
5656
.split('\n')
57+
.map(line => colorizeLogLevel(line))
5758
.map((line, index, arr) =>
5859
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
5960
);
6061
callback(lines.join('\n'));
6162
}
6263
: (_name: string, data: string, callback: (data: string) => void): void => {
63-
callback(data);
64+
const lines = data.split('\n').map(line => colorizeLogLevel(line));
65+
callback(lines.join('\n'));
6466
};
6567
6668
for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { describe, expect, test } from 'vitest';
20+
21+
import { colorizeLogLevel } from './terminal-colors.js';
22+
23+
describe('colorizeLogLevel', () => {
24+
test('should colorize INFO log level in cyan', () => {
25+
const logLine = '[23:10:06 INF] Starting application';
26+
const result = colorizeLogLevel(logLine);
27+
28+
// Should contain cyan ANSI code for INFO
29+
expect(result).toContain('\u001b[36m');
30+
// Should contain reset code
31+
expect(result).toContain('\u001b[0m');
32+
// Should preserve the rest of the message
33+
expect(result).toContain('Starting application');
34+
});
35+
36+
test('should colorize DEBUG log level in cyan', () => {
37+
const logLine = '[DBG] Debug message';
38+
const result = colorizeLogLevel(logLine);
39+
40+
// Should contain green ANSI code for DEBUG
41+
expect(result).toContain('\u001b[32m');
42+
expect(result).toContain('\u001b[0m');
43+
expect(result).toContain('Debug message');
44+
});
45+
46+
test('should colorize ERROR log level in red', () => {
47+
const logLine = '[ERROR] Something went wrong';
48+
const result = colorizeLogLevel(logLine);
49+
50+
// Should contain bright red ANSI code for ERROR
51+
expect(result).toContain('\u001b[31'); // Match both \u001b[31m and \u001b[31;1m
52+
expect(result).toContain('\u001b[0m');
53+
expect(result).toContain('Something went wrong');
54+
});
55+
56+
test('should colorize WARN log level in yellow', () => {
57+
const logLine = '[12:34:56 WARN] Warning message';
58+
const result = colorizeLogLevel(logLine);
59+
60+
// Should contain yellow ANSI code for WARN
61+
expect(result).toContain('\u001b[33m');
62+
expect(result).toContain('\u001b[0m');
63+
expect(result).toContain('Warning message');
64+
});
65+
66+
test('should colorize FATAL log level in bright red', () => {
67+
const logLine = '[FATAL] Critical error';
68+
const result = colorizeLogLevel(logLine);
69+
70+
// Should contain red ANSI code for FATAL
71+
expect(result).toContain('\u001b[31m');
72+
expect(result).toContain('\u001b[0m');
73+
expect(result).toContain('Critical error');
74+
});
75+
76+
test('should colorize TRACE log level in magenta', () => {
77+
const logLine = '[TRACE] Trace information';
78+
const result = colorizeLogLevel(logLine);
79+
80+
// Should contain bright cyan ANSI code for TRACE
81+
expect(result).toContain('\u001b[36;1m');
82+
expect(result).toContain('\u001b[0m');
83+
expect(result).toContain('Trace information');
84+
});
85+
86+
test('should handle log line with Kubernetes timestamp prefix', () => {
87+
const logLine = '2025-10-29T23:10:10.688386132-05:00 [23:10:10 INF] Server started';
88+
const result = colorizeLogLevel(logLine);
89+
90+
// Should preserve the K8s timestamp
91+
expect(result).toContain('2025-10-29T23:10:10.688386132-05:00');
92+
// Should colorize the INFO level
93+
expect(result).toContain('\u001b[36m');
94+
expect(result).toContain('Server started');
95+
});
96+
97+
test('should handle case-insensitive log levels', () => {
98+
const logLine1 = '[info] lowercase info';
99+
const result1 = colorizeLogLevel(logLine1);
100+
expect(result1).toContain('\u001b[36m');
101+
102+
const logLine2 = '[Info] Mixed case info';
103+
const result2 = colorizeLogLevel(logLine2);
104+
expect(result2).toContain('\u001b[36m');
105+
});
106+
107+
test('should handle abbreviated log levels', () => {
108+
const testCases = [
109+
{ input: '[INF] Info message', color: '\u001b[36m' },
110+
{ input: '[WRN] Warning message', color: '\u001b[33m' },
111+
{ input: '[ERR] Error message', color: '\u001b[31' }, // Match both formats
112+
];
113+
114+
testCases.forEach(({ input, color }) => {
115+
const result = colorizeLogLevel(input);
116+
expect(result).toContain(color);
117+
});
118+
});
119+
120+
test('should handle log level in middle of line', () => {
121+
const logLine = 'prefix content [23:10:06 INFO] message after bracket';
122+
const result = colorizeLogLevel(logLine);
123+
124+
// Should preserve prefix
125+
expect(result).toContain('prefix content');
126+
// Should colorize INFO
127+
expect(result).toContain('\u001b[36m');
128+
// Should preserve suffix
129+
expect(result).toContain('message after bracket');
130+
});
131+
132+
test('should not modify line without log level', () => {
133+
const logLine = 'This is a regular log line without level';
134+
const result = colorizeLogLevel(logLine);
135+
136+
// Should return the line unchanged
137+
expect(result).toBe(logLine);
138+
});
139+
140+
test('should not modify line with text in brackets that is not a log level', () => {
141+
const logLine = '[NOTLEVEL] This is not a log level';
142+
const result = colorizeLogLevel(logLine);
143+
144+
// Should return the line unchanged
145+
expect(result).toBe(logLine);
146+
});
147+
148+
test('should handle full word log levels', () => {
149+
const testCases = [
150+
{ input: '[DEBUG] Debug message', color: '\u001b[32m' },
151+
{ input: '[INFO] Info message', color: '\u001b[36m' },
152+
{ input: '[WARNING] Warning message', color: '\u001b[33m' },
153+
{ input: '[ERROR] Error message', color: '\u001b[31' }, // Match both formats
154+
];
155+
156+
testCases.forEach(({ input, color }) => {
157+
const result = colorizeLogLevel(input);
158+
expect(result).toContain(color);
159+
});
160+
});
161+
162+
test('should only colorize the first log level found', () => {
163+
const logLine = '[INFO] Found another [ERROR] in message';
164+
const result = colorizeLogLevel(logLine);
165+
166+
// Should colorize INFO (first match)
167+
expect(result).toContain('\u001b[36m');
168+
// Count how many times we see the reset code - should only be once
169+
// eslint-disable-next-line sonarjs/no-control-regex, no-control-regex
170+
const resetCount = (result.match(/\u001b\[0m/g) ?? []).length;
171+
expect(resetCount).toBe(1);
172+
});
173+
174+
test('should preserve exact format of brackets and timestamp', () => {
175+
const logLine = '[23:10:06 INFO] Message';
176+
const result = colorizeLogLevel(logLine);
177+
178+
// The timestamp and brackets should still be present
179+
expect(result).toMatch(/23:10:06/);
180+
expect(result).toMatch(/INFO/);
181+
});
182+
183+
test('should colorize colon format log level', () => {
184+
const logLine = 'info: mylog';
185+
const result = colorizeLogLevel(logLine);
186+
187+
// Should contain cyan ANSI code for info
188+
expect(result).toContain('\u001b[36m');
189+
expect(result).toContain('\u001b[0m');
190+
expect(result).toContain('mylog');
191+
});
192+
193+
test('should colorize JSON format with quoted level', () => {
194+
const logLine = '{"timestamp":"123","level":"information","message":"test"}';
195+
const result = colorizeLogLevel(logLine);
196+
197+
// Should contain cyan ANSI code for information
198+
expect(result).toContain('\u001b[36m');
199+
expect(result).toContain('\u001b[0m');
200+
expect(result).toContain('"information"');
201+
});
202+
});

packages/webview/src/component/terminal/terminal-colors.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,86 @@ export const ansi256Colours = [
3232
'\u001b[34;1m', // bright blue
3333
];
3434

35+
// ANSI colors for log levels
36+
const LOG_LEVEL_COLORS: Record<string, string> = {
37+
TRACE: '\u001b[36;1m', // bright cyan
38+
DBG: '\u001b[32m', // green
39+
DEBUG: '\u001b[32m',
40+
INF: '\u001b[36m', // cyan
41+
INFO: '\u001b[36m',
42+
INFORMATION: '\u001b[36m',
43+
WRN: '\u001b[33m', // yellow
44+
WARN: '\u001b[33m',
45+
WARNING: '\u001b[33m',
46+
ERR: '\u001b[31;1m', // bright red
47+
ERROR: '\u001b[31;1m',
48+
FATAL: '\u001b[31m', // red
49+
};
50+
3551
// Function that takes the container name and ANSI colour and encapsulates the name in the colour,
3652
// making sure that we reset the colour back to white after the name.
3753
export function colourizedANSIContainerName(name: string, colour: string): string {
3854
return `${colour}${name}\u001b[0m`;
3955
}
56+
57+
/**
58+
* Colorizes log levels in brackets for better readability.
59+
* Detects patterns like [timestamp LEVEL] or [LEVEL] or LEVEL: or (LEVEL) or "LEVEL" anywhere in the line.
60+
*
61+
* Examples:
62+
* - [23:10:06 INF] -> cyan
63+
* - [DBG] -> green
64+
* - [ERROR] -> bright red
65+
* - info: message -> cyan
66+
* - (INFO) message -> cyan
67+
* - "WARN" message -> yellow
68+
* - "information" -> cyan
69+
* - 2025-10-29T23:10:10.688386132-05:00 info: message -> cyan (with timestamp)
70+
* - 2025-10-29T23:10:10.688386132-05:00 [23:10:10 INF] -> cyan (with K8s timestamp)
71+
*/
72+
export function colorizeLogLevel(logLine: string): string {
73+
const levelNames = 'DBG|DEBUG|INF|INFO|INFORMATION|WRN|WARN|WARNING|ERR|ERROR|FATAL|TRACE';
74+
// Combined pattern: Match [timestamp? LEVEL], LEVEL:, (LEVEL), or "LEVEL" anywhere in the line
75+
const logLevelPattern = new RegExp(
76+
`((\\[(?:[0-9:]+\\s+)?)(${levelNames})(\\])|(${levelNames})(:)|(\\()(${levelNames})(\\))|(")(${levelNames})("))`,
77+
'i',
78+
);
79+
80+
const match = logLevelPattern.exec(logLine);
81+
if (match) {
82+
const before = logLine.slice(0, match.index);
83+
const rest = logLine.slice(match.index + match[0].length);
84+
85+
// Check which format matched
86+
if (match[2]) {
87+
// Bracket format: [timestamp? LEVEL]
88+
const prefix = match[2]; // [timestamp or [
89+
const level = match[3]; // Keep original case
90+
const suffix = match[4]; // ]
91+
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
92+
return `${before}${color}${prefix}${level}${suffix}\u001b[0m${rest}`;
93+
} else if (match[5]) {
94+
// Colon format: LEVEL:
95+
const level = match[5]; // Keep original case
96+
const colon = match[6];
97+
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
98+
return `${before}${color}${level}${colon}\u001b[0m${rest}`;
99+
} else if (match[7]) {
100+
// Parenthesis format: (LEVEL)
101+
const openParen = match[7];
102+
const level = match[8]; // Keep original case
103+
const closeParen = match[9];
104+
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
105+
return `${before}${color}${openParen}${level}${closeParen}\u001b[0m${rest}`;
106+
} else if (match[10]) {
107+
// Quote format: "LEVEL"
108+
const openQuote = match[10];
109+
const level = match[11]; // Keep original case
110+
const closeQuote = match[12];
111+
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
112+
return `${before}${color}${openQuote}${level}${closeQuote}\u001b[0m${rest}`;
113+
}
114+
}
115+
116+
return logLine;
117+
}

0 commit comments

Comments
 (0)