Skip to content

Commit e64f8c0

Browse files
adameskaHettinger, David
authored andcommitted
feat: detect and add simple coloring to json rows
Signed-off-by: Hettinger, David <[email protected]>
1 parent 1af3e9f commit e64f8c0

File tree

3 files changed

+262
-29
lines changed

3 files changed

+262
-29
lines changed

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

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import type { Terminal } from '@xterm/xterm';
66
import { getContext, onDestroy, onMount, tick } from 'svelte';
77
import { SvelteMap } from 'svelte/reactivity';
88
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
9+
import { detectJsonLogs } from '/@/component/terminal/json-colorizer';
910
import {
10-
ansi256Colours,
11-
colorizeJSON,
12-
colorizeLogLevel,
13-
colourizedANSIContainerName,
11+
ansi256Colours,
12+
colorizeJSON,
13+
colorizeLogLevel,
14+
colourizedANSIContainerName,
1415
} from '/@/component/terminal/terminal-colors';
1516
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
1617
import { Streams } from '/@/stream/streams';
@@ -23,6 +24,12 @@ let { object }: Props = $props();
2324
// Logs has been initialized
2425
let noLogs = $state(true);
2526
27+
// Track if logs are JSON format (auto-detected from first 10 lines)
28+
let isJsonFormat = $state<boolean | undefined>(undefined);
29+
// TODO once we have a toolbar in logs we can add a checkbox/hamburger menu for this setting
30+
let shouldColorizeLogs = $state<boolean>(true);
31+
let logBuffer: string[] = [];
32+
2633
let logsTerminal = $state<Terminal>();
2734
2835
let disposables: IDisposable[] = [];
@@ -32,6 +39,50 @@ const streams = getContext<Streams>(Streams);
3239
// if we run out of colours, we'll start from the beginning.
3340
const colourizedContainerName = new SvelteMap<string, string>();
3441
42+
/**
43+
* Colorizes and formats log lines with optional container prefix.
44+
* Applies log level colorization and JSON colorization (if detected).
45+
*
46+
* @param data - Raw log data from stream
47+
* @param containerName - Name of the container (for multi-container pods)
48+
* @param maxNameLength - Maximum container name length for padding (0 for single container)
49+
* @returns Formatted and colorized log lines
50+
*/
51+
const colorizeAndFormatLogs = (data: string, containerName?: string, maxNameLength: number = 0): string => {
52+
let lines = data.split('\n');
53+
54+
if (shouldColorizeLogs) {
55+
// Auto-detect JSON format from first batch of logs
56+
if (isJsonFormat === undefined) {
57+
logBuffer.push(...lines.filter(l => l.trim()));
58+
if (logBuffer.length >= 10) {
59+
isJsonFormat = detectJsonLogs(logBuffer);
60+
logBuffer = []; // Clear buffer after detection
61+
}
62+
}
63+
64+
// Apply colorization: JSON first (if detected/only a few lines of logs), then log levels
65+
lines =
66+
isJsonFormat || true
67+
? lines.map(line => colorizeLogLevel(colorizeJSON(line)))
68+
: lines.map(line => colorizeLogLevel(line));
69+
}
70+
71+
// Add container prefix for multi-container pods
72+
if (containerName && maxNameLength > 0) {
73+
const padding = ' '.repeat(maxNameLength - containerName.length);
74+
const colouredName = colourizedContainerName.get(containerName);
75+
// All lines are prefixed, except the last one if it's empty
76+
return lines
77+
.map((line, index, arr) =>
78+
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
79+
)
80+
.join('\n');
81+
}
82+
83+
return lines.join('\n');
84+
};
85+
3586
onMount(async () => {
3687
logsTerminal?.clear();
3788
@@ -50,29 +101,14 @@ onMount(async () => {
50101
});
51102
}
52103
53-
const multiContainers =
54-
containerCount > 1
55-
? (name: string, data: string, callback: (data: string) => void): void => {
56-
const padding = ' '.repeat(maxNameLength - name.length);
57-
const colouredName = colourizedContainerName.get(name);
58-
59-
// All lines are prefixed, except the last one if it's empty.
60-
const lines = data
61-
.split('\n')
62-
.map(line => colorizeJSON(line))
63-
.map(line => colorizeLogLevel(line))
64-
.map((line, index, arr) =>
65-
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
66-
);
67-
callback(lines.join('\n'));
68-
}
69-
: (_name: string, data: string, callback: (data: string) => void): void => {
70-
const lines = data
71-
.split('\n')
72-
.map(line => colorizeJSON(line))
73-
.map(line => colorizeLogLevel(line));
74-
callback(lines.join('\n'));
75-
};
104+
const processLogData = (containerName: string, data: string, callback: (data: string) => void): void => {
105+
const formattedLogs = colorizeAndFormatLogs(
106+
data,
107+
containerCount > 1 ? containerName : undefined,
108+
containerCount > 1 ? maxNameLength : 0,
109+
);
110+
callback(formattedLogs);
111+
};
76112
77113
for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
78114
disposables.push(
@@ -81,7 +117,7 @@ onMount(async () => {
81117
object.metadata?.namespace ?? '',
82118
containerName,
83119
chunk => {
84-
multiContainers(containerName, chunk.data, data => {
120+
processLogData(containerName, chunk.data, data => {
85121
if (noLogs) {
86122
noLogs = false;
87123
}

packages/webview/src/component/terminal/json-colorizer.spec.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import { describe, expect, test } from 'vitest';
2020

2121
import type { JsonColorScheme } from './json-colorizer.js';
22-
import { JsonColorizer } from './json-colorizer.js';
22+
import { detectJsonLogs, JsonColorizer } from './json-colorizer.js';
2323

2424
describe('JsonColorizer', () => {
2525
const colorScheme: JsonColorScheme = {
@@ -283,3 +283,157 @@ describe('JsonColorizer', () => {
283283
expect(result).toContain('\u001b[32m0.5\u001b[0m');
284284
});
285285
});
286+
287+
describe('detectJsonLogs', () => {
288+
test('should detect JSON logs when 80% or more lines are valid JSON', () => {
289+
const logs = [
290+
'{"timestamp":"2025-11-18T10:00:00Z","level":"info","message":"Starting application"}',
291+
'{"timestamp":"2025-11-18T10:00:01Z","level":"info","message":"Connected to database"}',
292+
'{"timestamp":"2025-11-18T10:00:02Z","level":"info","message":"Server listening on port 8080"}',
293+
'{"timestamp":"2025-11-18T10:00:03Z","level":"debug","message":"Request received"}',
294+
'{"timestamp":"2025-11-18T10:00:04Z","level":"debug","message":"Processing request"}',
295+
'{"timestamp":"2025-11-18T10:00:05Z","level":"info","message":"Request completed"}',
296+
'{"timestamp":"2025-11-18T10:00:06Z","level":"info","message":"Cache updated"}',
297+
'{"timestamp":"2025-11-18T10:00:07Z","level":"info","message":"Background job started"}',
298+
'Not a JSON line',
299+
'{"timestamp":"2025-11-18T10:00:08Z","level":"info","message":"Job completed"}',
300+
];
301+
302+
expect(detectJsonLogs(logs)).toBe(true);
303+
});
304+
305+
test('should not detect JSON logs when less than 80% are valid JSON', () => {
306+
const logs = [
307+
'{"timestamp":"2025-11-18T10:00:00Z","level":"info","message":"Starting"}',
308+
'{"timestamp":"2025-11-18T10:00:01Z","level":"info","message":"Running"}',
309+
'Regular log line without JSON',
310+
'Another regular log line',
311+
'Yet another non-JSON line',
312+
'Still not JSON',
313+
'Nope, not JSON either',
314+
'{"timestamp":"2025-11-18T10:00:02Z","level":"info","message":"Done"}',
315+
'More regular logs',
316+
'Last regular log',
317+
];
318+
319+
expect(detectJsonLogs(logs)).toBe(false);
320+
});
321+
322+
test('should handle empty log array', () => {
323+
expect(detectJsonLogs([])).toBe(false);
324+
});
325+
326+
test('should ignore empty lines when calculating ratio', () => {
327+
const logs = [
328+
'{"timestamp":"2025-11-18T10:00:00Z","level":"info","message":"Line 1"}',
329+
'',
330+
'{"timestamp":"2025-11-18T10:00:01Z","level":"info","message":"Line 2"}',
331+
'',
332+
'{"timestamp":"2025-11-18T10:00:02Z","level":"info","message":"Line 3"}',
333+
'',
334+
'{"timestamp":"2025-11-18T10:00:03Z","level":"info","message":"Line 4"}',
335+
'',
336+
'{"timestamp":"2025-11-18T10:00:04Z","level":"info","message":"Line 5"}',
337+
'',
338+
'{"timestamp":"2025-11-18T10:00:05Z","level":"info","message":"Line 6"}',
339+
'',
340+
'{"timestamp":"2025-11-18T10:00:06Z","level":"info","message":"Line 7"}',
341+
'',
342+
'{"timestamp":"2025-11-18T10:00:07Z","level":"info","message":"Line 8"}',
343+
'',
344+
];
345+
346+
expect(detectJsonLogs(logs)).toBe(true);
347+
});
348+
349+
test('should only check first 10 non-empty lines', () => {
350+
const logs = [
351+
'{"timestamp":"2025-11-18T10:00:00Z","level":"info","message":"Line 1"}',
352+
'{"timestamp":"2025-11-18T10:00:01Z","level":"info","message":"Line 2"}',
353+
'{"timestamp":"2025-11-18T10:00:02Z","level":"info","message":"Line 3"}',
354+
'{"timestamp":"2025-11-18T10:00:03Z","level":"info","message":"Line 4"}',
355+
'{"timestamp":"2025-11-18T10:00:04Z","level":"info","message":"Line 5"}',
356+
'{"timestamp":"2025-11-18T10:00:05Z","level":"info","message":"Line 6"}',
357+
'{"timestamp":"2025-11-18T10:00:06Z","level":"info","message":"Line 7"}',
358+
'{"timestamp":"2025-11-18T10:00:07Z","level":"info","message":"Line 8"}',
359+
'{"timestamp":"2025-11-18T10:00:08Z","level":"info","message":"Line 9"}',
360+
'{"timestamp":"2025-11-18T10:00:09Z","level":"info","message":"Line 10"}',
361+
// These lines after the 10th should be ignored
362+
'Not JSON line 11',
363+
'Not JSON line 12',
364+
'Not JSON line 13',
365+
];
366+
367+
expect(detectJsonLogs(logs)).toBe(true);
368+
});
369+
370+
test('should detect JSON with nested objects', () => {
371+
const logs = [
372+
'{"user":{"id":123,"name":"John"},"action":"login"}',
373+
'{"user":{"id":124,"name":"Jane"},"action":"logout"}',
374+
'{"data":{"key":"value","nested":{"deep":"data"}},"timestamp":"2025-11-18"}',
375+
'{"array":[1,2,3],"object":{"a":"b"}}',
376+
'{"simple":"test","number":42}',
377+
'{"bool":true,"null":null}',
378+
'{"str":"value","num":123}',
379+
'{"x":1,"y":2}',
380+
'{"foo":"bar"}',
381+
'{"last":"one"}',
382+
];
383+
384+
expect(detectJsonLogs(logs)).toBe(true);
385+
});
386+
387+
test('should not detect lines with braces but no key-value pairs', () => {
388+
const logs = [
389+
'Processing {item}',
390+
'Found {value} in cache',
391+
'Error: {error message}',
392+
'Debug: {info}',
393+
'Status: {ok}',
394+
'Result: {success}',
395+
'Output: {data}',
396+
'Input: {params}',
397+
'Config: {settings}',
398+
'State: {ready}',
399+
];
400+
401+
expect(detectJsonLogs(logs)).toBe(false);
402+
});
403+
404+
test('should detect exactly 80% threshold', () => {
405+
const logs = [
406+
'{"valid":"json","line":1}',
407+
'{"valid":"json","line":2}',
408+
'{"valid":"json","line":3}',
409+
'{"valid":"json","line":4}',
410+
'{"valid":"json","line":5}',
411+
'{"valid":"json","line":6}',
412+
'{"valid":"json","line":7}',
413+
'{"valid":"json","line":8}',
414+
'Not JSON line 9',
415+
'Not JSON line 10',
416+
];
417+
418+
// 8 out of 10 = 80%
419+
expect(detectJsonLogs(logs)).toBe(true);
420+
});
421+
422+
test('should not detect just below 80% threshold', () => {
423+
const logs = [
424+
'{"valid":"json","line":1}',
425+
'{"valid":"json","line":2}',
426+
'{"valid":"json","line":3}',
427+
'{"valid":"json","line":4}',
428+
'{"valid":"json","line":5}',
429+
'{"valid":"json","line":6}',
430+
'{"valid":"json","line":7}',
431+
'Not JSON line 8',
432+
'Not JSON line 9',
433+
'Not JSON line 10',
434+
];
435+
436+
// 7 out of 10 = 70%
437+
expect(detectJsonLogs(logs)).toBe(false);
438+
});
439+
});

packages/webview/src/component/terminal/json-colorizer.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,46 @@ export class JsonColorizer {
209209
return undefined;
210210
}
211211
}
212+
213+
/**
214+
* Checks if a string appears to be valid JSON.
215+
* A valid JSON line should have both { and } and contain at least one key-value pair pattern.
216+
*
217+
* @param line - The line to check
218+
* @returns true if the line appears to be JSON
219+
*/
220+
export function isValidJSON(line: string): boolean {
221+
const trimmed = line.trim();
222+
if (!trimmed) return false;
223+
224+
// Check for basic JSON structure: has { and } and contains key-value patterns
225+
const hasBraces = trimmed.includes('{') && trimmed.includes('}');
226+
if (!hasBraces) return false;
227+
228+
// Look for key-value pair pattern: "key": value or "key":value
229+
const kvpPattern = /"[^"]+"\s*:\s*[^,}]+/;
230+
return kvpPattern.test(trimmed);
231+
}
232+
233+
/**
234+
* Detects if log lines are predominantly JSON format.
235+
* Checks the first 10 non-empty lines and returns true if at least 80% are valid JSON.
236+
*
237+
* @param lines - Array of log lines to analyze
238+
* @returns true if logs should be treated as JSON format
239+
*/
240+
export function detectJsonLogs(lines: string[]): boolean {
241+
const samplesToCheck = 10;
242+
const threshold = 0.8; // 80%
243+
244+
// Filter out empty lines and take first 10
245+
const nonEmptyLines = lines.filter(line => line.trim().length > 0).slice(0, samplesToCheck);
246+
247+
// Need at least a few lines to make a determination
248+
if (nonEmptyLines.length === 0) return false;
249+
250+
const jsonCount = nonEmptyLines.filter(line => isValidJSON(line)).length;
251+
const jsonRatio = jsonCount / nonEmptyLines.length;
252+
253+
return jsonRatio >= threshold;
254+
}

0 commit comments

Comments
 (0)