Skip to content

Commit 97f0afd

Browse files
author
Hettinger, David
committed
feat: enhance log window to allow configuring options for viewing logs and support text copying.
Signed-off-by: David Hettinger <[email protected]> Signed-off-by: Hettinger, David <[email protected]>
1 parent 9902f50 commit 97f0afd

File tree

10 files changed

+333
-133
lines changed

10 files changed

+333
-133
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ dist
77
output
88
packages/extension/media
99

10+
/.vs

packages/channels/src/interface/pod-logs-api.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@
1818

1919
export const PodLogsApi = Symbol.for('PodLogsApi');
2020

21+
export type PodLogsOptions = {
22+
stream?: boolean;
23+
previous?: boolean;
24+
tailLines?: number;
25+
sinceSeconds?: number;
26+
timestamps?: boolean;
27+
};
28+
2129
export interface PodLogsApi {
22-
streamPodLogs(podName: string, namespace: string, containerName: string): Promise<void>;
23-
stopStreamPodLogs(podName: string, namespace: string, containerName: string): Promise<void>;
30+
streamPodLogs(podName: string, namespace: string, containerName: string, options?: PodLogsOptions): Promise<void>;
31+
stopStreamPodLogs(podName: string, namespace: string, containerName: string): Promise<void>;
2432
}

packages/channels/src/model/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export * from './kubernetes-resource-count';
3434
export * from './kubernetes-troubleshooting';
3535
export * from './openshift-types';
3636
export * from './pod-logs-chunk';
37+
export * from './pod-logs-options';
3738
export * from './pod-terminal-chunk';
3839
export * from './port-forward-info';
3940
export * from './port-forward';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
export interface PodLogsOptions {
20+
stream?: boolean;
21+
previous?: boolean;
22+
tailLines?: number;
23+
sinceSeconds?: number;
24+
timestamps?: boolean;
25+
}

packages/extension/src/manager/pod-logs-api-impl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { IDisposable, PodLogsApi } from '@kubernetes-dashboard/channels';
2121
import { PodLogsService } from '/@/pod-logs/pod-logs-service';
2222
import { ContextsManager } from './contexts-manager';
2323
import { RpcExtension } from '@kubernetes-dashboard/rpc';
24+
import type { PodLogsOptions } from '@kubernetes-dashboard/channels';
2425

2526
type PodLogsInstance = {
2627
counter: number;
@@ -35,7 +36,7 @@ export class PodLogsApiImpl implements PodLogsApi, IDisposable {
3536
@inject(RpcExtension) private rpcExtension: RpcExtension,
3637
) {}
3738

38-
async streamPodLogs(podName: string, namespace: string, containerName: string): Promise<void> {
39+
async streamPodLogs(podName: string, namespace: string, containerName: string, options?: PodLogsOptions): Promise<void> {
3940
if (!this.contextsManager.currentContext) {
4041
throw new Error('No current context found');
4142
}
@@ -45,7 +46,7 @@ export class PodLogsApiImpl implements PodLogsApi, IDisposable {
4546
};
4647
instance.counter++;
4748
if (instance.counter === 1) {
48-
await instance.service.startStream(podName, namespace, containerName);
49+
await instance.service.startStream(podName, namespace, containerName, options);
4950
}
5051
this.#instances.set(this.getKey(podName, namespace, containerName), instance);
5152
}

packages/extension/src/pod-logs/pod-logs-service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Log } from '@kubernetes/client-node';
2020
import { injectable } from 'inversify';
2121
import { PassThrough } from 'node:stream';
2222
import { RpcExtension } from '@kubernetes-dashboard/rpc';
23-
import { POD_LOGS } from '@kubernetes-dashboard/channels';
23+
import { POD_LOGS, type PodLogsOptions } from '@kubernetes-dashboard/channels';
2424
import { KubeConfigSingleContext } from '/@/types/kubeconfig-single-context';
2525

2626
@injectable()
@@ -33,7 +33,7 @@ export class PodLogsService {
3333
private readonly rpcExtension: RpcExtension,
3434
) {}
3535

36-
async startStream(podName: string, namespace: string, containerName: string): Promise<void> {
36+
async startStream(podName: string, namespace: string, containerName: string, options?: PodLogsOptions): Promise<void> {
3737
const log = new Log(this.context.getKubeConfig());
3838

3939
this.#logStream = new PassThrough();
@@ -52,7 +52,14 @@ export class PodLogsService {
5252
})
5353
.catch(console.error);
5454
});
55-
this.#abortController = await log.log(namespace, podName, containerName, this.#logStream, { follow: true });
55+
this.#abortController = await log.log(namespace, podName, containerName, this.#logStream,
56+
{
57+
follow: options?.stream ?? true,
58+
previous: options?.previous,
59+
tailLines: options?.tailLines,
60+
sinceSeconds: options?.sinceSeconds,
61+
timestamps: options?.timestamps,
62+
});
5663
}
5764

5865
stopStream(): void {
Lines changed: 174 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,190 @@
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';
5-
import type { IDisposable } from '@kubernetes-dashboard/channels';
6-
import { EmptyScreen } from '@podman-desktop/ui-svelte';
7-
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
8-
import type { Terminal } from '@xterm/xterm';
9-
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
10-
import { SvelteMap } from 'svelte/reactivity';
11-
import { ansi256Colours, colourizedANSIContainerName } from '/@/component/terminal/terminal-colors';
12-
13-
interface Props {
2+
import type { V1Pod } from '@kubernetes/client-node';
3+
import { getContext, onDestroy, onMount, tick } from 'svelte';
4+
import { Streams } from '/@/stream/streams';
5+
import type { IDisposable, PodLogsOptions } from '@kubernetes-dashboard/channels';
6+
import { EmptyScreen, Button, Input } from '@podman-desktop/ui-svelte';
7+
import NoLogIcon from '/@/component/icons/NoLogIcon.svelte';
8+
import type { Terminal } from '@xterm/xterm';
9+
import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte';
10+
import { SvelteMap } from 'svelte/reactivity';
11+
import { ansi256Colours, colourizedANSIContainerName, colorizeLogLevel } from '/@/component/terminal/terminal-colors';
12+
13+
interface Props {
1414
object: V1Pod;
15-
}
16-
let { object }: Props = $props();
15+
}
16+
let { object }: Props = $props();
1717
18-
// Logs has been initialized
19-
let noLogs = $state(true);
18+
// Logs has been initialized
19+
let noLogs = $state(true);
2020
21-
let logsTerminal = $state<Terminal>();
21+
let logsTerminal = $state<Terminal>();
2222
23-
let disposables: IDisposable[] = [];
24-
const streams = getContext<Streams>(Streams);
23+
// Log retrieval mode and options
24+
let isStreaming = $state(true);
25+
let previous = $state(false);
26+
let tailLines = $state<number | undefined>(undefined);
27+
let sinceSeconds = $state<number | undefined>(undefined);
28+
let timestamps = $state(false);
29+
let fontSize = $state(10);
2530
26-
// Create a map that will store the ANSI 256 colour for each container name
27-
// if we run out of colours, we'll start from the beginning.
28-
const colourizedContainerName = new SvelteMap<string, string>();
31+
let disposables: IDisposable[] = [];
32+
const streams = getContext<Streams>(Streams);
2933
30-
onMount(async () => {
31-
logsTerminal?.clear();
34+
// Create a map that will store the ANSI 256 colour for each container name
35+
// if we run out of colours, we'll start from the beginning.
36+
const colourizedContainerName = new SvelteMap<string, string>();
3237
33-
const containerCount = object.spec?.containers.length ?? 0;
38+
async function loadLogs() {
39+
logsTerminal?.clear();
40+
noLogs = true;
41+
42+
disposables.forEach(disposable => disposable.dispose());
43+
disposables = [];
3444
35-
// Go through each name of pod.containers array and determine
36-
// how much spacing is required for each name to be printed.
37-
let maxNameLength = 0;
38-
if (containerCount > 1) {
39-
object.spec?.containers.forEach((container, index) => {
40-
if (container.name.length > maxNameLength) {
41-
maxNameLength = container.name.length;
42-
}
43-
const colour = ansi256Colours[index % ansi256Colours.length];
44-
colourizedContainerName.set(container.name, colourizedANSIContainerName(container.name, colour));
45-
});
46-
}
45+
const containerCount = object.spec?.containers.length ?? 0;
4746
48-
const multiContainers =
49-
containerCount > 1
50-
? (name: string, data: string, callback: (data: string) => void): void => {
51-
const padding = ' '.repeat(maxNameLength - name.length);
52-
const colouredName = colourizedContainerName.get(name);
53-
54-
// All lines are prefixed, except the last one if it's empty.
55-
const lines = data
56-
.split('\n')
57-
.map((line, index, arr) =>
58-
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
59-
);
60-
callback(lines.join('\n'));
47+
// Go through each name of pod.containers array and determine
48+
// how much spacing is required for each name to be printed.
49+
let maxNameLength = 0;
50+
if (containerCount > 1) {
51+
object.spec?.containers.forEach((container, index) => {
52+
if (container.name.length > maxNameLength) {
53+
maxNameLength = container.name.length;
6154
}
62-
: (_name: string, data: string, callback: (data: string) => void): void => {
63-
callback(data);
64-
};
65-
66-
for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
67-
disposables.push(
68-
await streams.streamPodLogs.subscribe(
69-
object.metadata?.name ?? '',
70-
object.metadata?.namespace ?? '',
71-
containerName,
72-
chunk => {
73-
multiContainers(containerName, chunk.data, data => {
74-
if (noLogs) {
75-
noLogs = false;
76-
}
77-
logsTerminal?.write(data + '\r');
78-
tick()
79-
.then(() => {
80-
window.dispatchEvent(new Event('resize'));
81-
})
82-
.catch(console.error);
83-
});
84-
},
85-
),
86-
);
55+
const colour = ansi256Colours[index % ansi256Colours.length];
56+
colourizedContainerName.set(container.name, colourizedANSIContainerName(container.name, colour));
57+
});
58+
}
59+
60+
const multiContainers =
61+
containerCount > 1
62+
? (name: string, data: string, callback: (data: string) => void): void => {
63+
const padding = ' '.repeat(maxNameLength - name.length);
64+
const colouredName = colourizedContainerName.get(name);
65+
66+
// All lines are prefixed, except the last one if it's empty.
67+
const lines = data
68+
.split('\n')
69+
.map(line => colorizeLogLevel(line))
70+
.map((line, index, arr) =>
71+
index < arr.length - 1 || line.length > 0 ? `${padding}${colouredName}|${line}` : line,
72+
);
73+
callback(lines.join('\n'));
74+
}
75+
: (_name: string, data: string, callback: (data: string) => void): void => {
76+
const lines = data
77+
.split('\n')
78+
.map(line => colorizeLogLevel(line));
79+
callback(lines.join('\n'));
80+
};
81+
82+
const options: PodLogsOptions = {
83+
stream: isStreaming,
84+
previous,
85+
tailLines,
86+
sinceSeconds,
87+
timestamps,
88+
};
89+
90+
for (const containerName of object.spec?.containers.map(c => c.name) ?? []) {
91+
disposables.push(
92+
await streams.streamPodLogs.subscribe(
93+
object.metadata?.name ?? '',
94+
object.metadata?.namespace ?? '',
95+
containerName,
96+
options,
97+
chunk => {
98+
multiContainers(containerName, chunk.data, data => {
99+
if (noLogs) {
100+
noLogs = false;
101+
}
102+
logsTerminal?.write(data + '\r');
103+
tick().then(() => {
104+
window.dispatchEvent(new Event('resize'));
105+
}).catch(console.error);
106+
});
107+
}),
108+
);
109+
}
87110
}
88-
});
89111
90-
onDestroy(() => {
91-
disposables.forEach(disposable => disposable.dispose());
92-
disposables = [];
93-
});
112+
onMount(async () => {
113+
await loadLogs();
114+
});
115+
116+
onDestroy(() => {
117+
disposables.forEach(disposable => disposable.dispose());
118+
disposables = [];
119+
});
94120
</script>
95121

96-
<EmptyScreen
97-
icon={NoLogIcon}
98-
title="No Log"
99-
message="Log output of Pod {object.metadata?.name}"
100-
hidden={noLogs === false} />
101-
102-
<div
103-
class="min-w-full flex flex-col"
104-
class:invisible={noLogs === true}
105-
class:h-0={noLogs === true}
106-
class:h-full={noLogs === false}>
107-
<TerminalWindow class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn />
122+
<div class="flex flex-col h-full">
123+
<div class="flex items-center gap-4 p-4 bg-[var(--pd-content-header-bg)] border-b border-[var(--pd-content-divider)]">
124+
<div class="flex items-center gap-2">
125+
<label class="flex items-center gap-2 cursor-pointer">
126+
<input type="radio" bind:group={isStreaming} value={true} class="cursor-pointer" />
127+
<span class="text-sm">Stream</span>
128+
</label>
129+
<label class="flex items-center gap-2 cursor-pointer">
130+
<input type="radio" bind:group={isStreaming} value={false} class="cursor-pointer" />
131+
<span class="text-sm">Retrieve</span>
132+
</label>
133+
</div>
134+
135+
<label class="flex items-center gap-2 cursor-pointer">
136+
<input type="checkbox" bind:checked={previous} class="cursor-pointer" />
137+
<span class="text-sm">Previous</span>
138+
</label>
139+
140+
<label class="flex items-center gap-2">
141+
<span class="text-sm">Tail:</span>
142+
<Input
143+
type="number"
144+
bind:value={tailLines}
145+
placeholder="All"
146+
class="w-24"
147+
min="1"
148+
/>
149+
</label>
150+
151+
<label class="flex items-center gap-2">
152+
<span class="text-sm">Since (seconds):</span>
153+
<Input
154+
type="number"
155+
bind:value={sinceSeconds}
156+
placeholder="All"
157+
class="w-24"
158+
min="1"
159+
/>
160+
</label>
161+
162+
<label class="flex items-center gap-2 cursor-pointer">
163+
<input type="checkbox" bind:checked={timestamps} class="cursor-pointer" />
164+
<span class="text-sm">Timestamps</span>
165+
</label>
166+
167+
<label class="flex items-center gap-2">
168+
<span class="text-sm">Font Size:</span>
169+
<Input type="number" bind:value={fontSize} class="w-20" min="8" max="24" />
170+
</label>
171+
172+
<Button on:click={loadLogs} class="ml-auto">
173+
{isStreaming ? 'Restart Stream' : 'Retrieve Logs'}
174+
</Button>
175+
</div>
176+
177+
<EmptyScreen
178+
icon={NoLogIcon}
179+
title="No Log"
180+
message="Log output of Pod {object.metadata?.name}"
181+
hidden={noLogs === false} />
182+
183+
<div
184+
class="min-w-full flex flex-col"
185+
class:invisible={noLogs === true}
186+
class:h-0={noLogs === true}
187+
class:flex-1={noLogs === false}>
188+
<TerminalWindow class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn fontSize={fontSize} />
189+
</div>
108190
</div>

0 commit comments

Comments
 (0)