@@ -111,7 +178,7 @@ export function LogViewer({
{/* Log Content */}
{logs.length === 0 ? (
@@ -120,43 +187,34 @@ export function LogViewer({
) : (
<>
-
-
- {visibleLogs.map((log, idx) => {
- const absoluteIdx = start + idx;
- return (
-
-
- [{log.pod}]
-
- {log.level && (
-
- {log.level.toUpperCase()}
-
- )}
-
- {log.message || log.text || ''}
-
-
- );
- })}
-
-
+
+ {visibleLogs.map((log, idx) => {
+ const absoluteIdx = visSlice.start + idx;
+ return (
+
measureRow(el, log.id ?? absoluteIdx)}
+ className="flex gap-2 hover:bg-gray-900/50 whitespace-pre-wrap break-words"
+ style={{ minHeight: ROW_HEIGHT, lineHeight: '20px' }}
+ >
+
+ [{log.pod}]
+
+ {log.level && (
+
+ {log.level.toUpperCase()}
+
+ )}
+
+ {log.message || log.text || ''}
+
+
+ );
+ })}
+
>
)}
diff --git a/frontend/src/components/logs/__tests__/LogViewer.test.jsx b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
new file mode 100644
index 0000000..1674c49
--- /dev/null
+++ b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
@@ -0,0 +1,119 @@
+// Generated by AI (UnitTest MCP)
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { LogViewer } from '../LogViewer';
+
+const sampleLogs = [
+ { pod: 'web-1', level: 'info', message: 'Server started on port 8080' },
+ { pod: 'web-1', level: 'error', message: 'Connection refused to database' },
+ { pod: 'api-1', level: 'warn', message: 'Slow query detected' }
+];
+
+describe('LogViewer', () => {
+ it('renders the empty state when there are no logs', () => {
+ render(
);
+ expect(screen.getByText('No logs to display yet.')).toBeInTheDocument();
+ });
+
+ it('renders log messages', () => {
+ render(
);
+ expect(screen.getByText('Server started on port 8080')).toBeInTheDocument();
+ expect(screen.getByText('Connection refused to database')).toBeInTheDocument();
+ expect(screen.getByText('Slow query detected')).toBeInTheDocument();
+ });
+
+ it('renders pod names in brackets', () => {
+ render(
);
+ expect(screen.getAllByText('[web-1]')).toHaveLength(2);
+ expect(screen.getByText('[api-1]')).toBeInTheDocument();
+ });
+
+ it('renders level badges in uppercase', () => {
+ render(
);
+ expect(screen.getByText('INFO')).toBeInTheDocument();
+ expect(screen.getByText('ERROR')).toBeInTheDocument();
+ expect(screen.getByText('WARN')).toBeInTheDocument();
+ });
+
+ it('applies wrapping classes to log rows', () => {
+ const { container } = render(
);
+ const row = container.querySelector('.whitespace-pre-wrap');
+ expect(row).not.toBeNull();
+ expect(row.className).toContain('break-words');
+ });
+
+ it('renders jump to top and jump to bottom buttons', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /jump to top/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /jump to bottom/i })).toBeInTheDocument();
+ });
+
+ it('scrolls to bottom when jump to bottom is clicked', () => {
+ const { container } = render(
);
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ const scrollTo = vi.fn();
+ scrollContainer.scrollTo = scrollTo;
+
+ fireEvent.click(screen.getByRole('button', { name: /jump to bottom/i }));
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
+ });
+
+ it('scrolls to top when jump to top is clicked', () => {
+ const { container } = render(
);
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ const scrollTo = vi.fn();
+ scrollContainer.scrollTo = scrollTo;
+
+ fireEvent.click(screen.getByRole('button', { name: /jump to top/i }));
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+
+ it('renders fallback text when only text is provided', () => {
+ const logs = [{ pod: 'db-1', text: 'raw log line without message' }];
+ render(
);
+ expect(screen.getByText('raw log line without message')).toBeInTheDocument();
+ });
+
+ it('accounts for measured row heights in the scroll geometry', () => {
+ const logs = Array.from({ length: 30 }, (_, i) => ({
+ id: i,
+ pod: 'web-1',
+ level: 'info',
+ message: `line ${i}`
+ }));
+
+ // jsdom reports no layout, so give rows a default height and simulate one
+ // wrapped row being taller than ROW_HEIGHT.
+ const proto = HTMLElement.prototype;
+ const original = Object.getOwnPropertyDescriptor(proto, 'offsetHeight');
+ Object.defineProperty(proto, 'offsetHeight', {
+ configurable: true,
+ get() { return 20; }
+ });
+
+ try {
+ const { container, rerender } = render(
);
+ const rows = container.querySelectorAll('.whitespace-pre-wrap');
+ Object.defineProperty(rows[0], 'offsetHeight', { value: 60, configurable: true });
+
+ rerender(
);
+
+ const spacers = container.querySelectorAll('[aria-hidden="true"]');
+ const topSpacer = parseFloat(spacers[0].style.height);
+ const bottomSpacer = parseFloat(spacers[1].style.height);
+ const renderedRows = container.querySelectorAll('.whitespace-pre-wrap');
+ const renderedHeight = Array.from(renderedRows).reduce(
+ (sum, row) => sum + row.offsetHeight,
+ 0
+ );
+
+ // 29 rows at 20px + 1 wrapped row at 60px = 640px total scroll height.
+ expect(topSpacer + renderedHeight + bottomSpacer).toBe(640);
+ } finally {
+ if (original) Object.defineProperty(proto, 'offsetHeight', original);
+ else delete proto.offsetHeight;
+ }
+ });
+});
diff --git a/frontend/src/components/stream/StreamConfig.jsx b/frontend/src/components/stream/StreamConfig.jsx
index e9862d1..ae6509a 100644
--- a/frontend/src/components/stream/StreamConfig.jsx
+++ b/frontend/src/components/stream/StreamConfig.jsx
@@ -17,10 +17,16 @@ const StreamConfigComponent = ({
const idPrefix = `stream-${streamId}`;
+ // config.container holds a comma-separated list of "pod/container" tokens,
+ // allowing multiple containers (across the same or different pods) to be
+ // tailed at once without reconnecting per selection. A single legacy
+ // whole-pod value (no "/") is read from config.query for older saved configs.
const selected = useMemo(() => {
- if (!config.query) return [];
- if (!config.container) return [config.query];
- return [`${config.query}/${config.container}`];
+ if (config.container) {
+ return config.container.split(',').map((s) => s.trim()).filter(Boolean);
+ }
+ if (config.query && config.query !== '.') return [config.query];
+ return [];
}, [config.query, config.container]);
return (
@@ -37,10 +43,17 @@ const StreamConfigComponent = ({
options={autocomplete.options}
selected={selected}
onChange={(newSelected) => {
- const pc = newSelected[0] || '';
- const [pod, container] = pc.split('/');
- updateConfig('query', pod || '');
- updateConfig('container', container || '');
+ if (newSelected.length === 0) {
+ onChange({ ...config, container: '', query: '.' });
+ return;
+ }
+ // A single whole-pod selection keeps using the plain "query" field
+ // for backward compatibility with saved configs and manual regex entry.
+ if (newSelected.length === 1 && !newSelected[0].includes('/')) {
+ onChange({ ...config, container: '', query: newSelected[0] });
+ return;
+ }
+ onChange({ ...config, container: newSelected.join(','), query: '.' });
}}
idPrefix={idPrefix}
/>
diff --git a/frontend/src/components/stream/StreamPanel.jsx b/frontend/src/components/stream/StreamPanel.jsx
index 2a58493..795f3d5 100644
--- a/frontend/src/components/stream/StreamPanel.jsx
+++ b/frontend/src/components/stream/StreamPanel.jsx
@@ -138,6 +138,7 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc
prev.node !== curr.node ||
prev.context !== curr.context ||
prev.query !== curr.query ||
+ prev.container !== curr.container ||
prev.selector !== curr.selector ||
prev.tail !== curr.tail ||
prev.initContainers !== curr.initContainers ||
@@ -183,14 +184,27 @@ export function StreamPanel({ streamId, initialConfig, onStreamStateChange, isAc
chips.push({ key: 'namespace', label: `Namespace: ${config.namespace}`, onRemove: () => setConfig(prev => ({ ...prev, namespace: '' })) });
}
- if (config.query && config.query !== '.') {
+ const containerTokens = config.container
+ ? config.container.split(',').map((s) => s.trim()).filter(Boolean)
+ : [];
+
+ if (containerTokens.length > 0) {
+ containerTokens.forEach((token) => {
+ chips.push({
+ key: `container-${token}`,
+ label: token.includes('/') ? `Container: ${token}` : `Pod: ${token}`,
+ onRemove: () => setConfig(prev => {
+ const remaining = containerTokens.filter((t) => t !== token);
+ return remaining.length > 0
+ ? { ...prev, container: remaining.join(',') }
+ : { ...prev, container: '', query: '.' };
+ })
+ });
+ });
+ } else if (config.query && config.query !== '.') {
chips.push({ key: 'query', label: `Pod: ${config.query}`, onRemove: () => setConfig(prev => ({ ...prev, query: '.' })) });
}
- if (config.container) {
- chips.push({ key: 'container', label: `Container: ${config.container}`, onRemove: () => setConfig(prev => ({ ...prev, container: '' })) });
- }
-
return chips;
}, [config]);
diff --git a/frontend/src/components/stream/__tests__/StreamConfig.test.jsx b/frontend/src/components/stream/__tests__/StreamConfig.test.jsx
new file mode 100644
index 0000000..96f188e
--- /dev/null
+++ b/frontend/src/components/stream/__tests__/StreamConfig.test.jsx
@@ -0,0 +1,69 @@
+// Generated by AI (UnitTest MCP)
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { StreamConfig } from '../StreamConfig';
+
+const autocomplete = {
+ namespaces: ['default'],
+ options: [
+ { pod: 'web-1', containers: ['main', 'sidecar'] },
+ { pod: 'api-1', containers: ['api'] },
+ ],
+};
+
+const baseConfig = {
+ namespace: 'default',
+ query: '.',
+ container: '',
+};
+
+describe('StreamConfig', () => {
+ it('clears container and resets query when the selection is emptied', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Remove web-1/main'));
+
+ expect(onChange).toHaveBeenCalledWith({ ...baseConfig, container: '', query: '.' });
+ });
+
+ it('stores multiple selected containers as a comma-separated container value', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByLabelText('Select all containers of web-1'));
+
+ expect(onChange).toHaveBeenCalledWith({
+ ...baseConfig,
+ container: 'web-1/main,web-1/sidecar',
+ query: '.',
+ });
+ });
+
+ it('stores a single container selection in the container field', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByText('api-1'));
+ fireEvent.mouseDown(screen.getByText('api'));
+
+ expect(onChange).toHaveBeenCalledWith({
+ ...baseConfig,
+ container: 'api-1/api',
+ query: '.',
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/logUtils.test.js b/frontend/src/utils/__tests__/logUtils.test.js
index 9dbd2b2..02a0240 100644
--- a/frontend/src/utils/__tests__/logUtils.test.js
+++ b/frontend/src/utils/__tests__/logUtils.test.js
@@ -95,6 +95,30 @@ describe('compileLogFilters', () => {
const filters = compileLogFilters({ query: '.', container: 'api-pod/api' });
expect(filterLogs(sampleLogs, filters)).toHaveLength(2);
});
+
+ it('matches any of multiple comma-separated pod/container tokens', () => {
+ const logs = [
+ { message: 'a', pod: 'api-pod', container: 'api' },
+ { message: 'b', pod: 'web-pod', container: 'nginx' },
+ { message: 'c', pod: 'db-pod', container: 'postgres' },
+ ];
+ const filters = compileLogFilters({ query: '.', container: 'api-pod/api,web-pod/nginx' });
+ const result = filterLogs(logs, filters);
+ expect(result).toHaveLength(2);
+ expect(result.map(log => log.container).sort()).toEqual(['api', 'nginx']);
+ });
+
+ it('handles mixed pod/container and plain container tokens', () => {
+ const logs = [
+ { message: 'a', pod: 'api-pod', container: 'api' },
+ { message: 'b', pod: 'web-pod', container: 'nginx' },
+ { message: 'c', pod: 'db-pod', container: 'postgres' },
+ ];
+ const filters = compileLogFilters({ query: '.', container: 'api-pod/api,postgres' });
+ const result = filterLogs(logs, filters);
+ expect(result).toHaveLength(2);
+ expect(result.map(log => log.container).sort()).toEqual(['api', 'postgres']);
+ });
});
describe('buildPodColorMap', () => {
diff --git a/frontend/src/utils/logUtils.js b/frontend/src/utils/logUtils.js
index f1b256f..e8ee604 100644
--- a/frontend/src/utils/logUtils.js
+++ b/frontend/src/utils/logUtils.js
@@ -61,15 +61,27 @@ function compilePattern(pattern) {
/**
* Precompile log filters so they aren't rebuilt for every log line.
+ * `container` may be a single token or a comma-separated list of
+ * "container" / "pod/container" tokens (matches any of them).
*/
export function compileLogFilters({ query, container } = {}) {
+ let containerPattern = null;
+ if (container instanceof RegExp) {
+ containerPattern = container;
+ } else if (typeof container === 'string' && container) {
+ const containerNames = container
+ .split(',')
+ .map((token) => token.trim())
+ .filter(Boolean)
+ .map((token) => (token.includes('/') ? token.split('/').pop() : token));
+ if (containerNames.length > 0) {
+ containerPattern = containerNames.map((name) => `^${name}$`).join('|');
+ }
+ }
+
return {
query: compilePattern(query && query !== '.' ? query : null),
- container: compilePattern(container
- ? (typeof container === 'string' && container.includes('/')
- ? container.split('/').pop()
- : container)
- : null)
+ container: compilePattern(containerPattern)
};
}
diff --git a/main.go b/main.go
index b6c93c7..8ac5118 100644
--- a/main.go
+++ b/main.go
@@ -431,6 +431,37 @@ func extractContainerName(input string) string {
return input
}
+// splitContainerTokens splits a comma-separated container filter into trimmed,
+// non-empty tokens. Each token is either "container" or "pod/container".
+func splitContainerTokens(containerStr string) []string {
+ var tokens []string
+ for _, t := range strings.Split(containerStr, ",") {
+ if t = strings.TrimSpace(t); t != "" {
+ tokens = append(tokens, t)
+ }
+ }
+ return tokens
+}
+
+// extractUniquePodNames returns the escaped, de-duplicated pod names found in
+// "pod/container" tokens, preserving first-seen order.
+func extractUniquePodNames(tokens []string) []string {
+ var podNames []string
+ seen := make(map[string]bool)
+ for _, t := range tokens {
+ if !strings.Contains(t, "/") {
+ continue
+ }
+ pod := strings.SplitN(t, "/", 2)[0]
+ if pod == "" || seen[pod] {
+ continue
+ }
+ seen[pod] = true
+ podNames = append(podNames, regexp.QuoteMeta(pod))
+ }
+ return podNames
+}
+
func compileContainerRegexList(filterStr string) ([]*regexp.Regexp, error) {
// Like compileRegexList but extracts container names from "pod/container" format
var regexes []*regexp.Regexp
@@ -476,18 +507,19 @@ func parseRegexFilters(params streamParams) (*regexp.Regexp, *regexp.Regexp, []*
debugLog(" allNamespaces: %q", params.allNamespaces)
debugLog("========================")
- // Handle query regex - if container has pod/container format, extract pod name for query
+ // container is a comma-separated list of tokens, each "container" or "pod/container",
+ // allowing multiple containers (optionally across multiple pods) to be tailed at once.
+ containerTokens := splitContainerTokens(params.container)
+
+ // Handle query regex - if any token has pod/container format, restrict query to those pod(s)
queryPattern := params.query
- if params.container != "" && strings.Contains(params.container, "/") {
- // Extract pod name from "pod/container" format
- parts := strings.Split(params.container, "/")
- if len(parts) >= 2 {
- podName := parts[0]
- debugLog("Container field contains pod/container format. Extracted pod name: %q", podName)
- // Override query to match only this specific pod
- queryPattern = "^" + regexp.QuoteMeta(podName) + "$"
- debugLog("Overriding query pattern to match specific pod: %q", queryPattern)
+ if podNames := extractUniquePodNames(containerTokens); len(podNames) > 0 {
+ if len(podNames) == 1 {
+ queryPattern = "^" + podNames[0] + "$"
+ } else {
+ queryPattern = "^(" + strings.Join(podNames, "|") + ")$"
}
+ debugLog("Container tokens specify pod(s). Overriding query pattern to match: %q", queryPattern)
}
queryRegex, err := regexp.Compile(queryPattern)
@@ -497,20 +529,20 @@ func parseRegexFilters(params streamParams) (*regexp.Regexp, *regexp.Regexp, []*
debugLog("Query regex compiled: %s", queryRegex.String())
containerRegex := regexp.MustCompile(".*")
- if params.container != "" {
- // Extract container name from "pod/container" format if needed
- containerName := extractContainerName(params.container)
- debugLog("Extracted container name: %q from input: %q", containerName, params.container)
-
- // Check if it's already a regex pattern (contains regex special chars)
- // If not, make it an exact match by escaping and anchoring
- pattern := containerName
- if !strings.ContainsAny(pattern, ".*+?[]{}()^$|\\") {
- pattern = "^" + regexp.QuoteMeta(pattern) + "$"
+ if len(containerTokens) > 0 {
+ var patterns []string
+ for _, t := range containerTokens {
+ containerName := extractContainerName(t)
+ pattern := containerName
+ if !strings.ContainsAny(pattern, ".*+?[]{}()^$|\\") {
+ pattern = "^" + regexp.QuoteMeta(pattern) + "$"
+ }
+ patterns = append(patterns, pattern)
}
- debugLog("Container regex pattern: %q -> compiled regex: %s", pattern, pattern)
+ combined := strings.Join(patterns, "|")
+ debugLog("Container regex pattern: %q -> compiled regex: %s", params.container, combined)
- containerRegex, err = regexp.Compile(pattern)
+ containerRegex, err = regexp.Compile(combined)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("invalid container regex: %w", err)
}