From 091c9c02dc3d605480c7a0fc6c2f28167031a521 Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Wed, 9 Sep 2026 08:38:36 +0200 Subject: [PATCH 1/9] feat: support multiple container selection without reconnect Allow selecting multiple containers (across same or different pods) in the Pod/Container picker without disconnecting and reconnecting. - PodContainerSelect: multi-select checkboxes replacing single-select dropdown - StreamConfig: stores multiple selections as comma-separated 'pod/container' tokens - StreamPanel: auto-reconnects on container change (was missing), per-token removable chips - logUtils: compileLogFilters handles comma-separated container tokens for client-side filtering - main.go: parseRegexFilters builds alternation regex from comma-separated container tokens --- .../components/common/PodContainerSelect.jsx | 120 ++++++++++-------- .../__tests__/PodContainerSelect.test.jsx | 13 +- .../src/components/stream/StreamConfig.jsx | 29 ++++- .../src/components/stream/StreamPanel.jsx | 24 +++- frontend/src/utils/logUtils.js | 22 +++- main.go | 76 +++++++---- 6 files changed, 188 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/common/PodContainerSelect.jsx b/frontend/src/components/common/PodContainerSelect.jsx index f5fddd8..292072c 100644 --- a/frontend/src/components/common/PodContainerSelect.jsx +++ b/frontend/src/components/common/PodContainerSelect.jsx @@ -1,10 +1,12 @@ /** - * Pod/container selector. Single-select: - * click a pod to expand its containers, click a container to select it. + * Pod/container selector. Multi-select: + * click a pod to expand its containers, toggle a container's checkbox to + * include/exclude it. Multiple containers, across the same or different + * pods, can be selected at once. * * Props: * options - [{ pod, containers: ['c1', ...] }] - * selected - ['pod/container'] (or [] / ['pod']) + * selected - ['pod/container', ...] (or ['pod'] for a legacy whole-pod value) * onChange - (nextSelected: string[]) => void */ import PropTypes from 'prop-types'; @@ -13,34 +15,36 @@ import { memo, useState, useMemo, useRef } from 'react'; function PodContainerSelectComponent({ options = [], selected = [], onChange, idPrefix }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(''); - const [expandedPod, setExpandedPod] = useState(null); + const [expandedPods, setExpandedPods] = useState(() => new Set()); const containerRef = useRef(null); - const selectedValue = selected[0] || ''; - const [selectedPod, selectedContainer] = selectedValue.split('/'); - const selectedPodFull = selectedPod && !selectedContainer ? selectedPod : selectedValue; - const filteredPods = useMemo(() => { const term = filter.toLowerCase(); return options.filter((o) => o.pod.toLowerCase().includes(term)); }, [options, filter]); - const togglePod = (pod) => { - setExpandedPod(expandedPod === pod ? null : pod); - setFilter(''); + const togglePodExpand = (pod) => { + setExpandedPods((prev) => { + const next = new Set(prev); + if (next.has(pod)) next.delete(pod); + else next.add(pod); + return next; + }); }; - const selectContainer = (pod, container) => { - onChange([`${pod}/${container}`]); - setOpen(false); - setExpandedPod(null); + const toggleValue = (value) => { + if (selected.includes(value)) { + onChange(selected.filter((v) => v !== value)); + } else { + onChange([...selected, value]); + } }; - const clear = (e) => { + const clearAll = (e) => { e.stopPropagation(); onChange([]); setOpen(false); - setExpandedPod(null); + setExpandedPods(new Set()); }; const id = idPrefix ? `${idPrefix}-pod-container` : 'pod-container'; @@ -53,30 +57,35 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id
{ setOpen(!open); setFilter(''); }} role="combobox" aria-expanded={open} aria-haspopup="listbox" tabIndex={0} > - {selectedValue ? ( - - {selectedPod} - {selectedContainer && <>/{selectedContainer}} - - + {selected.length > 0 ? ( + selected.map((value) => { + const [pod, container] = value.split('/'); + return ( + + {pod} + {container && <>/{container}} + + + ); + }) ) : ( - Select a pod + Select pods/containers )} - +
@@ -92,11 +101,11 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id placeholder="Filter pods..." autoFocus /> - {selectedValue && ( + {selected.length > 0 && ( @@ -106,24 +115,21 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id
No pods
)} {filteredPods.map(({ pod, containers }) => { - const isExpanded = expandedPod === pod; - const isSelected = selectedPodFull === pod; + const isExpanded = expandedPods.has(pod); + const podContainerValues = containers.map((c) => `${pod}/${c}`); + const selectedCount = podContainerValues.filter((v) => selected.includes(v)).length; return (
togglePod(pod)} + className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-gray-600 ${selectedCount > 0 ? 'bg-green-900/30' : ''}`} + onClick={() => togglePodExpand(pod)} role="option" - aria-selected={isSelected} + aria-selected={selectedCount > 0} > - {pod} - {isSelected ? ( - - - - ) : ( - {containers.length} ctr - )} + {pod} + + {selectedCount > 0 ? `${selectedCount}/${containers.length} selected` : `${containers.length} ctr`} +
{isExpanded && (
@@ -132,19 +138,21 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id )} {containers.map((c) => { const value = `${pod}/${c}`; - const isContainerSelected = selectedValue === value; + const isContainerSelected = selected.includes(value); return (
{ e.preventDefault(); e.stopPropagation(); selectContainer(pod, c); }} + className={`flex items-center gap-2 px-6 py-1 cursor-pointer hover:bg-gray-600 ${isContainerSelected ? 'bg-green-900/30' : ''}`} + onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); toggleValue(value); }} > + {}} + className="shrink-0" + aria-label={`Select ${value}`} + /> {c} - {isContainerSelected && ( - - - - )}
); })} diff --git a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx index 38d6167..f0f1073 100644 --- a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx +++ b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx @@ -10,7 +10,7 @@ const options = [ describe('PodContainerSelect', () => { it('shows placeholder when nothing selected', () => { render( {}} idPrefix="t" />); - expect(screen.getByText(/Select a pod/i)).toBeInTheDocument(); + expect(screen.getByText(/Select pods\/containers/i)).toBeInTheDocument(); }); it('shows selected pod/container as a chip', () => { @@ -30,6 +30,17 @@ describe('PodContainerSelect', () => { expect(onChange).toHaveBeenCalledWith(['web-1/sidecar']); }); + it('adds to the selection instead of replacing it when picking another container', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('api-1')); + fireEvent.mouseDown(screen.getByText('api')); + + expect(onChange).toHaveBeenCalledWith(['web-1/main', 'api-1/api']); + }); + it('removes selection when the x is clicked', () => { const onChange = vi.fn(); render(); diff --git a/frontend/src/components/stream/StreamConfig.jsx b/frontend/src/components/stream/StreamConfig.jsx index e9862d1..4571b0f 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,19 @@ 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) { + updateConfig('container', ''); + updateConfig('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('/')) { + updateConfig('container', ''); + updateConfig('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/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) } From b9d425c844e01d6ff7c1385e80d4cf58f271b30e Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Wed, 9 Sep 2026 08:52:16 +0200 Subject: [PATCH 2/9] feat: allow selecting an entire pod (all containers) in the picker Add a checkbox on each pod row that selects/deselects all of that pod's containers at once, complementing the per-container checkboxes. Shows an 'indeterminate'/partial-count indicator when only some containers are picked. --- .../components/common/PodContainerSelect.jsx | 33 ++++++++++++++++--- .../__tests__/PodContainerSelect.test.jsx | 20 +++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/common/PodContainerSelect.jsx b/frontend/src/components/common/PodContainerSelect.jsx index 292072c..0eab0c8 100644 --- a/frontend/src/components/common/PodContainerSelect.jsx +++ b/frontend/src/components/common/PodContainerSelect.jsx @@ -1,12 +1,12 @@ /** * Pod/container selector. Multi-select: - * click a pod to expand its containers, toggle a container's checkbox to - * include/exclude it. Multiple containers, across the same or different - * pods, can be selected at once. + * click a pod to expand its containers, toggle a pod checkbox to select + * ALL containers in that pod, or toggle individual container checkboxes. + * Multiple containers across different pods can be selected at once. * * Props: * options - [{ pod, containers: ['c1', ...] }] - * selected - ['pod/container', ...] (or ['pod'] for a legacy whole-pod value) + * selected - ['pod', 'pod/container', ...] * onChange - (nextSelected: string[]) => void */ import PropTypes from 'prop-types'; @@ -40,6 +40,18 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id } }; + // Toggle all containers of a pod on/off + const togglePod = (pod, containers) => { + const allValues = containers.map((c) => `${pod}/${c}`); + const allSelected = allValues.every((v) => selected.includes(v)); + if (allSelected) { + onChange(selected.filter((v) => !allValues.includes(v))); + } else { + const missing = allValues.filter((v) => !selected.includes(v)); + onChange([...selected, ...missing]); + } + }; + const clearAll = (e) => { e.stopPropagation(); onChange([]); @@ -118,6 +130,8 @@ function PodContainerSelectComponent({ options = [], selected = [], onChange, id const isExpanded = expandedPods.has(pod); const podContainerValues = containers.map((c) => `${pod}/${c}`); const selectedCount = podContainerValues.filter((v) => selected.includes(v)).length; + const allPodSelected = containers.length > 0 && selectedCount === containers.length; + const somePodSelected = selectedCount > 0 && !allPodSelected; return (
0} > + { if (el) el.indeterminate = somePodSelected; }} + onChange={() => {}} + onClick={(e) => { e.stopPropagation(); togglePod(pod, containers); }} + className="shrink-0" + aria-label={`Select all containers of ${pod}`} + /> {pod} - {selectedCount > 0 ? `${selectedCount}/${containers.length} selected` : `${containers.length} ctr`} + {allPodSelected ? 'all' : somePodSelected ? `${selectedCount}/${containers.length}` : `${containers.length} ctr`}
{isExpanded && ( diff --git a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx index f0f1073..1a6c2c9 100644 --- a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx +++ b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx @@ -48,4 +48,24 @@ describe('PodContainerSelect', () => { fireEvent.click(screen.getByLabelText('Remove api-1/api')); expect(onChange).toHaveBeenCalledWith([]); }); + + it('lets the user select all containers of a pod via the pod checkbox', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByLabelText('Select all containers of web-1')); + + expect(onChange).toHaveBeenCalledWith(['web-1/main', 'web-1/sidecar']); + }); + + it('clears all containers of a pod when deselecting the pod checkbox', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByLabelText('Select all containers of web-1')); + + expect(onChange).toHaveBeenCalledWith([]); + }); }); From 8b722ffa9cff146904e9d7ef55088a8d31d5bbc4 Mon Sep 17 00:00:00 2001 From: Esysc <> Date: Wed, 9 Sep 2026 09:35:11 +0200 Subject: [PATCH 3/9] feat: wrap long log lines in LogViewer - Switch log rows from whitespace-nowrap to whitespace-pre-wrap break-words so long lines wrap vertically instead of overflowing horizontally - Hide horizontal overflow on the scroll container - Let rows size naturally (min-height instead of fixed height) so wrapped lines are fully visible - Replace absolute-position virtualization with inline spacers so variable height rows don't overlap - Add LogViewer component tests --- frontend/src/components/logs/LogViewer.jsx | 67 +++++++--------- .../logs/__tests__/LogViewer.test.jsx | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/logs/__tests__/LogViewer.test.jsx diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx index d4a0664..ec5d8ca 100644 --- a/frontend/src/components/logs/LogViewer.jsx +++ b/frontend/src/components/logs/LogViewer.jsx @@ -79,7 +79,6 @@ export function LogViewer({ }; // Compute the visible slice from the current scroll position. - const totalHeight = logs.length * ROW_HEIGHT; const firstVisible = Math.floor(viewport.scrollTop / ROW_HEIGHT); const visibleCount = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2; const start = Math.max(0, firstVisible - OVERSCAN); @@ -111,7 +110,7 @@ export function LogViewer({ {/* Log Content */}
{logs.length === 0 ? (
@@ -120,43 +119,33 @@ export function LogViewer({
) : ( <> -
-
- {visibleLogs.map((log, idx) => { - const absoluteIdx = start + idx; - return ( -
- - [{log.pod}] - - {log.level && ( - - {log.level.toUpperCase()} - - )} - - {log.message || log.text || ''} - -
- ); - })} -
-
+