Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/webview/src/component/pods/PodLogs.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script lang="ts">
import type { V1Pod } from '@kubernetes/client-node';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { Streams } from '/@/stream/streams';
import type { IDisposable } from '@kubernetes-dashboard/channels';
import type { V1Pod } from '@kubernetes/client-node';
import { EmptyScreen } from '@podman-desktop/ui-svelte';
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
import type { Terminal } from '@xterm/xterm';
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ansi256Colours, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
import { ansi256Colours, colorizeLogLevel, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
import { Streams } from '/@/stream/streams';

interface Props {
object: V1Pod;
Expand Down Expand Up @@ -54,13 +54,15 @@ onMount(async () => {
// All lines are prefixed, except the last one if it's empty.
const lines = data
.split('\n')
.map(line => colorizeLogLevel(line))
.map((line, index, arr) =>
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
);
callback(lines.join('\n'));
}
: (_name: string, data: string, callback: (data: string) => void): void => {
callback(data);
const lines = data.split('\n').map(line => colorizeLogLevel(line));
callback(lines.join('\n'));
};

for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
Expand Down
202 changes: 202 additions & 0 deletions packages/webview/src/component/terminal/terminal-colors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { describe, expect, test } from 'vitest';

import { colorizeLogLevel } from './terminal-colors.js';

describe('colorizeLogLevel', () => {
test('should colorize INFO log level in cyan', () => {
const logLine = '[23:10:06 INF] Starting application';
const result = colorizeLogLevel(logLine);

// Should contain cyan ANSI code for INFO
expect(result).toContain('\u001b[36m');
// Should contain reset code
expect(result).toContain('\u001b[0m');
// Should preserve the rest of the message
expect(result).toContain('Starting application');
});

test('should colorize DEBUG log level in cyan', () => {
const logLine = '[DBG] Debug message';
const result = colorizeLogLevel(logLine);

// Should contain green ANSI code for DEBUG
expect(result).toContain('\u001b[32m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('Debug message');
});

test('should colorize ERROR log level in red', () => {
const logLine = '[ERROR] Something went wrong';
const result = colorizeLogLevel(logLine);

// Should contain bright red ANSI code for ERROR
expect(result).toContain('\u001b[31'); // Match both \u001b[31m and \u001b[31;1m
expect(result).toContain('\u001b[0m');
expect(result).toContain('Something went wrong');
});

test('should colorize WARN log level in yellow', () => {
const logLine = '[12:34:56 WARN] Warning message';
const result = colorizeLogLevel(logLine);

// Should contain yellow ANSI code for WARN
expect(result).toContain('\u001b[33m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('Warning message');
});

test('should colorize FATAL log level in bright red', () => {
const logLine = '[FATAL] Critical error';
const result = colorizeLogLevel(logLine);

// Should contain red ANSI code for FATAL
expect(result).toContain('\u001b[31m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('Critical error');
});

test('should colorize TRACE log level in magenta', () => {
const logLine = '[TRACE] Trace information';
const result = colorizeLogLevel(logLine);

// Should contain bright cyan ANSI code for TRACE
expect(result).toContain('\u001b[36;1m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('Trace information');
});

test('should handle log line with Kubernetes timestamp prefix', () => {
const logLine = '2025-10-29T23:10:10.688386132-05:00 [23:10:10 INF] Server started';
const result = colorizeLogLevel(logLine);

// Should preserve the K8s timestamp
expect(result).toContain('2025-10-29T23:10:10.688386132-05:00');
// Should colorize the INFO level
expect(result).toContain('\u001b[36m');
expect(result).toContain('Server started');
});

test('should handle case-insensitive log levels', () => {
const logLine1 = '[info] lowercase info';
const result1 = colorizeLogLevel(logLine1);
expect(result1).toContain('\u001b[36m');

const logLine2 = '[Info] Mixed case info';
const result2 = colorizeLogLevel(logLine2);
expect(result2).toContain('\u001b[36m');
});

test('should handle abbreviated log levels', () => {
const testCases = [
{ input: '[INF] Info message', color: '\u001b[36m' },
{ input: '[WRN] Warning message', color: '\u001b[33m' },
{ input: '[ERR] Error message', color: '\u001b[31' }, // Match both formats
];

testCases.forEach(({ input, color }) => {
const result = colorizeLogLevel(input);
expect(result).toContain(color);
});
});

test('should handle log level in middle of line', () => {
const logLine = 'prefix content [23:10:06 INFO] message after bracket';
const result = colorizeLogLevel(logLine);

// Should preserve prefix
expect(result).toContain('prefix content');
// Should colorize INFO
expect(result).toContain('\u001b[36m');
// Should preserve suffix
expect(result).toContain('message after bracket');
});

test('should not modify line without log level', () => {
const logLine = 'This is a regular log line without level';
const result = colorizeLogLevel(logLine);

// Should return the line unchanged
expect(result).toBe(logLine);
});

test('should not modify line with text in brackets that is not a log level', () => {
const logLine = '[NOTLEVEL] This is not a log level';
const result = colorizeLogLevel(logLine);

// Should return the line unchanged
expect(result).toBe(logLine);
});

test('should handle full word log levels', () => {
const testCases = [
{ input: '[DEBUG] Debug message', color: '\u001b[32m' },
{ input: '[INFO] Info message', color: '\u001b[36m' },
{ input: '[WARNING] Warning message', color: '\u001b[33m' },
{ input: '[ERROR] Error message', color: '\u001b[31' }, // Match both formats
];

testCases.forEach(({ input, color }) => {
const result = colorizeLogLevel(input);
expect(result).toContain(color);
});
});

test('should only colorize the first log level found', () => {
const logLine = '[INFO] Found another [ERROR] in message';
const result = colorizeLogLevel(logLine);

// Should colorize INFO (first match)
expect(result).toContain('\u001b[36m');
// Count how many times we see the reset code - should only be once
// eslint-disable-next-line sonarjs/no-control-regex, no-control-regex
const resetCount = (result.match(/\u001b\[0m/g) ?? []).length;
expect(resetCount).toBe(1);
});

test('should preserve exact format of brackets and timestamp', () => {
const logLine = '[23:10:06 INFO] Message';
const result = colorizeLogLevel(logLine);

// The timestamp and brackets should still be present
expect(result).toMatch(/23:10:06/);
expect(result).toMatch(/INFO/);
});

test('should colorize colon format log level', () => {
const logLine = 'info: mylog';
const result = colorizeLogLevel(logLine);

// Should contain cyan ANSI code for info
expect(result).toContain('\u001b[36m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('mylog');
});

test('should colorize JSON format with quoted level', () => {
const logLine = '{"timestamp":"123","level":"information","message":"test"}';
const result = colorizeLogLevel(logLine);

// Should contain cyan ANSI code for information
expect(result).toContain('\u001b[36m');
expect(result).toContain('\u001b[0m');
expect(result).toContain('"information"');
});
});
78 changes: 78 additions & 0 deletions packages/webview/src/component/terminal/terminal-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,86 @@ export const ansi256Colours = [
'\u001b[34;1m', // bright blue
];

// ANSI colors for log levels
const LOG_LEVEL_COLORS: Record<string, string> = {
TRACE: '\u001b[36;1m', // bright cyan
DBG: '\u001b[32m', // green
DEBUG: '\u001b[32m',
INF: '\u001b[36m', // cyan
INFO: '\u001b[36m',
INFORMATION: '\u001b[36m',
WRN: '\u001b[33m', // yellow
WARN: '\u001b[33m',
WARNING: '\u001b[33m',
ERR: '\u001b[31;1m', // bright red
ERROR: '\u001b[31;1m',
FATAL: '\u001b[31m', // red
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these the colors used, or are we loading them from elsewhere?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the ANSI colors supported by the terminal. If we want to change the color to be rendered at the end, we may want to change them in the theme of the terminal. I think it could be part of another PR

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 let's create a separate issue for that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, sure, let's work on it in another ticket.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@feloy I want to file the issue, but I am not sure which repo it should land in. Here or in PD, please?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Function that takes the container name and ANSI colour and encapsulates the name in the colour,
// making sure that we reset the colour back to white after the name.
export function colourizedANSIContainerName(name: string, colour: string): string {
return `${colour}${name}\u001b[0m`;
}

/**
* Colorizes log levels in brackets for better readability.
* Detects patterns like [timestamp LEVEL] or [LEVEL] or LEVEL: or (LEVEL) or "LEVEL" anywhere in the line.
*
* Examples:
* - [23:10:06 INF] -> cyan
* - [DBG] -> green
* - [ERROR] -> bright red
* - info: message -> cyan
* - (INFO) message -> cyan
* - "WARN" message -> yellow
* - "information" -> cyan
* - 2025-10-29T23:10:10.688386132-05:00 info: message -> cyan (with timestamp)
* - 2025-10-29T23:10:10.688386132-05:00 [23:10:10 INF] -> cyan (with K8s timestamp)
*/
export function colorizeLogLevel(logLine: string): string {
const levelNames = 'DBG|DEBUG|INF|INFO|INFORMATION|WRN|WARN|WARNING|ERR|ERROR|FATAL|TRACE';
// Combined pattern: Match [timestamp? LEVEL], LEVEL:, (LEVEL), or "LEVEL" anywhere in the line
const logLevelPattern = new RegExp(
`((\\[(?:[0-9:]+\\s+)?)(${levelNames})(\\])|(${levelNames})(:)|(\\()(${levelNames})(\\))|(")(${levelNames})("))`,
'i',
);

const match = logLevelPattern.exec(logLine);
if (match) {
const before = logLine.slice(0, match.index);
const rest = logLine.slice(match.index + match[0].length);

// Check which format matched
if (match[2]) {
// Bracket format: [timestamp? LEVEL]
const prefix = match[2]; // [timestamp or [
const level = match[3]; // Keep original case
const suffix = match[4]; // ]
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
return `${before}${color}${prefix}${level}${suffix}\u001b[0m${rest}`;
} else if (match[5]) {
// Colon format: LEVEL:
const level = match[5]; // Keep original case
const colon = match[6];
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
return `${before}${color}${level}${colon}\u001b[0m${rest}`;
} else if (match[7]) {
// Parenthesis format: (LEVEL)
const openParen = match[7];
const level = match[8]; // Keep original case
const closeParen = match[9];
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
return `${before}${color}${openParen}${level}${closeParen}\u001b[0m${rest}`;
} else if (match[10]) {
// Quote format: "LEVEL"
const openQuote = match[10];
const level = match[11]; // Keep original case
const closeQuote = match[12];
const color = LOG_LEVEL_COLORS[level.toUpperCase()] || '\u001b[37m';
return `${before}${color}${openQuote}${level}${closeQuote}\u001b[0m${rest}`;
}
}

return logLine;
}