Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d190d6e
ColorStuff
adameska Nov 14, 2025
c54fac7
feat: detect and add simple coloring to json rows
adameska Nov 18, 2025
92a2f4c
should be ??
adameska Nov 18, 2025
ae41006
Fix Formatting
adameska Nov 19, 2025
faa05dc
Simplify Tests
adameska Nov 20, 2025
4bdeaf6
Merge branch 'main' into JsonColorize
adameska Nov 24, 2025
bdf43f8
Merge with master
adameska Nov 24, 2025
d292030
Some PR changes, still working on finishing it up
adameska Nov 25, 2025
7fbfb26
wrap null coalesce
adameska Nov 25, 2025
7c3292f
Not sure what format is off here
adameska Nov 25, 2025
d60cbcd
bad comment
adameska Nov 26, 2025
a5aba3f
Prettier fighting with pnpm:format and implement another rabbit comme…
adameska Nov 26, 2025
e157582
Bad copy/paste
adameska Nov 26, 2025
0c8b7ef
Merge branch 'main' into JsonColorize
adameska Nov 27, 2025
b919362
Clean up podlog tests
adameska Dec 1, 2025
bfbc053
Enhance multi-container testing
adameska Dec 2, 2025
f2ebbc9
Make padding an exact check
adameska Dec 2, 2025
1c1ac4c
Be a little more strict on JSON colorizing
adameska Dec 3, 2025
9e9f609
Is json shouldn't do the slicing, that's an implementation detail the…
adameska Dec 3, 2025
472ef9d
I don't think this ever worked when streaming, now only check the fir…
adameska Dec 4, 2025
9b77cb1
Still not processing the first x lines, now it will
adameska Dec 4, 2025
fdb4f92
Add a toolbar for coloring options
adameska Dec 5, 2025
273b6a3
Remove caching
adameska Dec 7, 2025
993b601
Fix tests after removing local storage
adameska Dec 10, 2025
b8beb37
Label change
adameska Dec 16, 2025
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
437 changes: 326 additions & 111 deletions packages/webview/src/component/pods/PodLogs.spec.ts

Large diffs are not rendered by default.

274 changes: 205 additions & 69 deletions packages/webview/src/component/pods/PodLogs.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
<script lang="ts">
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons';
import type { IDisposable } from '@kubernetes-dashboard/channels';
import type { V1Pod } from '@kubernetes/client-node';
import { EmptyScreen } from '@podman-desktop/ui-svelte';
import type { Terminal } from '@xterm/xterm';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import Fa from 'svelte-fa';
import { SvelteMap } from 'svelte/reactivity';
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
import { ansi256Colours, colorizeLogLevel, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
import { ColorOutputType } from '/@/component/terminal/color-output-types';
import { detectJsonLogs } from '/@/component/terminal/json-colorizer';
import {
ansi256Colours,
colorizeJSON,
colorizeLogLevel,
colourizedANSIContainerName,
} from '/@/component/terminal/terminal-colors';
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
import { Streams } from '/@/stream/streams';

Expand All @@ -15,78 +24,171 @@ interface Props {
}
let { object }: Props = $props();

// Logs has been initialized
let noLogs = $state(true);
const jsonColorizeSampleSize = 20;

let noLogs = $state(true);
let colorfulOutputType = $state(undefined);
let jsonDetected = $state(false);
let settingsMenuOpen = $state(false);
let logsTerminal = $state<Terminal>();

let logBuffer: string[] = [];
Comment on lines +31 to +35
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reset JSON detection state when viewing different pods.

jsonDetected and logBuffer are module-level variables that persist across different pods. When switching from one pod to another (e.g., viewing Pod A with JSON logs, then Pod B with plain logs), the cached detection result from Pod A incorrectly applies to Pod B.

Wrap these in a reactive statement that resets when object changes:

-let jsonDetected = $state(false);
+let jsonDetected = $state<boolean>(false);
 let settingsMenuOpen = $state(false);
 let logsTerminal = $state<Terminal>();
 
-let logBuffer: string[] = [];
+let logBuffer = $state<string[]>([]);
 let disposables: IDisposable[] = [];
 let settingsMenuRef: HTMLDivElement | undefined;
+
+// Reset detection state when viewing a different pod
+$effect(() => {
+  // Access object to create dependency
+  void object.metadata?.uid;
+  logBuffer = [];
+  jsonDetected = false;
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let jsonDetected = $state(false);
let settingsMenuOpen = $state(false);
let logsTerminal = $state<Terminal>();
let logBuffer: string[] = [];
let jsonDetected = $state<boolean>(false);
let settingsMenuOpen = $state(false);
let logsTerminal = $state<Terminal>();
let logBuffer = $state<string[]>([]);
let disposables: IDisposable[] = [];
let settingsMenuRef: HTMLDivElement | undefined;
// Reset detection state when viewing a different pod
$effect(() => {
// Access object to create dependency
void object.metadata?.uid;
logBuffer = [];
jsonDetected = false;
});
🤖 Prompt for AI Agents
In packages/webview/src/component/pods/PodLogs.svelte around lines 31–35,
jsonDetected and logBuffer are module-level and retain state across pod
switches; add a Svelte reactive statement that runs whenever the viewed pod
object changes and resets jsonDetected to its initial false state and clears
logBuffer (preserving existing settingsMenuOpen/logsTerminal behavior and
types). Ensure the reactive block only triggers on the pod-identifying variable
(e.g., object or its id) so each pod view starts with fresh JSON detection and
an empty buffer.

let disposables: IDisposable[] = [];
let settingsMenuRef: HTMLDivElement | undefined;

const streams = getContext<Streams>(Streams);

// Create a map that will store the ANSI 256 colour for each container name
// Map that will store the ANSI 256 colour for each container name
// if we run out of colours, we'll start from the beginning.
const colourizedContainerName = new SvelteMap<string, string>();
const prefixColourMap = new SvelteMap<string, string>();

onMount(async () => {
logsTerminal?.clear();
// Trigger resize when logs appear so terminal can recalculate its size
$effect(() => {
if (!noLogs) {
triggerResize();
}
});

function handleSettingChange(): void {
loadLogs().catch(console.error);
}

const addLogPrefix = (lines: string[], prefix: string, prefixLength: number): void => {
const safePadding = Math.max(0, prefixLength - prefix.length);
const padding = safePadding > 0 ? ' '.repeat(safePadding) : '';
const mappedPrefix = prefixColourMap.get(prefix) ?? prefix;

const containerCount = object.spec?.containers.length ?? 0;
lines.forEach((line, index) => {
if (index < lines.length - 1 || line.length > 0) {
lines[index] = `${padding}${mappedPrefix}|${line}`;
}
});
};

const isJsonDetected = (lines: string[]): boolean => {
if (logBuffer.length < jsonColorizeSampleSize) {
logBuffer.push(...lines.filter(l => l.trim()).slice(0, jsonColorizeSampleSize));
if (logBuffer.length > 0) {
jsonDetected = detectJsonLogs(logBuffer);
}
}
return jsonDetected;
};

/**
* Colorizes and formats log lines with optional container prefix.
* Applies log level colorization and JSON colorization (if detected).
*
* @param data - Raw log data from stream
* @param prefix - Log line prefix (for multi-container pods)
* @param maxPrefixLength - Length to normalize prefix with (0 for single container)
* @returns Formatted and colorized log lines
*/
const colorizeAndFormatLogs = (data: string, prefix?: string, maxPrefixLength: number = 0): string => {
if (colorfulOutputType === ColorOutputType.NONE || (data?.length ?? 0) === 0) {
return data;
}

let lines: string[] = data.split('\n');
//format json if the user asked for that or if they didn't set a preference and we auto-detected json logs
let isJsonFormat = colorfulOutputType === ColorOutputType.FULL || (!colorfulOutputType && isJsonDetected(lines));

lines = isJsonFormat
? lines.map(line => colorizeLogLevel(colorizeJSON(line)))
: lines.map(line => colorizeLogLevel(line));

if (prefix) {
addLogPrefix(lines, prefix, maxPrefixLength);
}

return lines.join('\n');
};

/**
* Calculates the maximum container name length for padding prefixes in multi-container
* pods so that log lines align correctly.
*
* @returns Max container name length for prefix padding
*/
const calculatePrefixLength = (): number => {
let maxNameLength = 0;
object.spec?.containers.forEach(container => {
if (container.name.length > maxNameLength) {
maxNameLength = container.name.length;
}
});
return maxNameLength;
};

/**
* Sets up ANSI color mappings for container name prefixes in multi-container pods.
* Cycles through available colors using modulo when there are more containers than colors.
*/
const setupPrefixColours = (): void => {
object.spec?.containers.forEach((container, index) => {
const colour = ansi256Colours[index % ansi256Colours.length];
prefixColourMap.set(container.name, colourizedANSIContainerName(container.name, colour));
});
};

function triggerResize(): void {
tick()
.then(() => {
window.dispatchEvent(new Event('resize'));
})
.catch(console.error);
}

async function loadLogs(): Promise<void> {
disposables.forEach(disposable => disposable.dispose());
disposables = [];

logsTerminal?.clear();
noLogs = true;

// Go through each name of pod.containers array and determine
// how much spacing is required for each name to be printed.
let maxNameLength = 0;
if (containerCount > 1) {
object.spec?.containers.forEach((container, index) => {
if (container.name.length > maxNameLength) {
maxNameLength = container.name.length;
}
const colour = ansi256Colours[index % ansi256Colours.length];
colourizedContainerName.set(container.name, colourizedANSIContainerName(container.name, colour));
});
if ((object.spec?.containers.length ?? 0) > 1) {
maxNameLength = calculatePrefixLength();
setupPrefixColours();
}

const multiContainers =
containerCount > 1
? (name: string, data: string, callback: (data: string) => void): void => {
const padding = ' '.repeat(maxNameLength - name.length);
const colouredName = colourizedContainerName.get(name);

// 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'));
const subscriptionPromises = (object.spec?.containers.map(c => c.name) ?? []).map(async containerName => {
const logLinePrefix = maxNameLength > 0 ? containerName : undefined;
return await streams.streamPodLogs.subscribe(
object.metadata?.name ?? '',
object.metadata?.namespace ?? '',
containerName,
chunk => {
const formattedLogs = colorizeAndFormatLogs(chunk.data, logLinePrefix, maxNameLength);
if (formattedLogs.length > 0) {
logsTerminal?.write(formattedLogs + '\r');
noLogs = false;
}
: (_name: string, data: string, callback: (data: string) => void): void => {
const lines = data.split('\n').map(line => colorizeLogLevel(line));
callback(lines.join('\n'));
};

for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
disposables.push(
await streams.streamPodLogs.subscribe(
object.metadata?.name ?? '',
object.metadata?.namespace ?? '',
containerName,
chunk => {
multiContainers(containerName, chunk.data, data => {
if (noLogs) {
noLogs = false;
}
logsTerminal?.write(data + '\r');
tick()
.then(() => {
window.dispatchEvent(new Event('resize'));
})
.catch(console.error);
});
},
),
},
);
});

const results = await Promise.allSettled(subscriptionPromises);
for (const result of results) {
if (result.status === 'fulfilled') {
disposables.push(result.value);
} else {
console.error('Failed to subscribe to container logs:', result.reason);
}
}
}

onMount(() => {
loadLogs().catch(console.error);
const handleClickOutside = (event: MouseEvent): void => {
if (settingsMenuOpen && settingsMenuRef && !settingsMenuRef.contains(event.target as Node)) {
settingsMenuOpen = false;
}
};
window.addEventListener('click', handleClickOutside);
return (): void => {
window.removeEventListener('click', handleClickOutside);
};
});

onDestroy(() => {
Expand All @@ -95,16 +197,50 @@ onDestroy(() => {
});
</script>

<EmptyScreen
icon={NoLogIcon}
title="No Log"
message="Log output of Pod {object.metadata?.name}"
hidden={noLogs === false} />

<div
class="min-w-full flex flex-col"
class:invisible={noLogs === true}
class:h-0={noLogs === true}
class:h-full={noLogs === false}>
<TerminalWindow class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn search />
<div class="flex flex-col h-full">
<div class="flex items-center gap-4 p-4 bg-(--pd-content-header-bg) border-b border-(--pd-content-divider)">
<div class="ml-auto flex items-center gap-2">
<div class="relative" bind:this={settingsMenuRef}>
<button
class="p-2 hover:bg-(--pd-content-card-hover-bg) rounded"
onclick={(): boolean => (settingsMenuOpen = !settingsMenuOpen)}
name="terminal-settings-button"
aria-label="Terminal settings">
<Fa icon={faEllipsisV} />
</button>
{#if settingsMenuOpen}
<div
class="absolute right-0 mt-1 w-64 bg-(--pd-content-card-bg) border border-(--pd-content-divider) rounded shadow-lg z-10">
<div class="p-3 space-y-3">
<label class="flex items-center justify-between gap-2">
<span class="text-sm">Colourize Logs</span>
<select
bind:value={colorfulOutputType}
onchange={handleSettingChange}
name="colorful-output"
class="bg-(--pd-content-card-bg) border border-(--pd-input-field-stroke) rounded px-2 py-1 text-sm cursor-pointer text-(--pd-content-text)">
<option value={undefined} class="bg-(--pd-content-card-bg)">Auto</option>
<option value={ColorOutputType.NONE} class="bg-(--pd-content-card-bg)">None</option>
<option value={ColorOutputType.BASIC} class="bg-(--pd-content-card-bg)">Basic</option>
<option value={ColorOutputType.FULL} class="bg-(--pd-content-card-bg)">Full</option>
</select>
</label>
</div>
</div>
{/if}
</div>
</div>
</div>

{#if noLogs}
<EmptyScreen icon={NoLogIcon} title="No Log" message="Log output of Pod {object.metadata?.name}" />
{/if}

<div
class="min-w-full flex flex-col"
class:invisible={noLogs === true}
class:h-0={noLogs === true}
class:flex-1={noLogs === false}>
<TerminalWindow class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn search />
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ onDestroy(() => {
{/if}

<div
class="{className} overflow-hidden p-[5px] pr-0 bg-(--pd-terminal-background)"
class="{className} overflow-hidden p-[5px] pr-0 bg-(--pd-terminal-background) h-full w-full"
role="term"
bind:this={logsXtermDiv}>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ColorOutputType {
NONE = 'none',
BASIC = 'basic', // log level and prefix coloring
FULL = 'full', // includes all available color matching
}
Loading