diff --git a/frontend/src/components/App/icons.ts b/frontend/src/components/App/icons.ts index cdfeb4aa5fe..987e67183cd 100644 --- a/frontend/src/components/App/icons.ts +++ b/frontend/src/components/App/icons.ts @@ -108,6 +108,9 @@ const mdiIcons = { 'chevron-right': { body: '\u003Cpath fill="currentColor" d="M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z"/\u003E', }, + 'chevron-up': { + body: '\u003Cpath fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6z"/\u003E', + }, 'shield-key': { body: '\u003Cpath fill="currentColor" d="M12 8a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m9 3c0 5.55-3.84 10.74-9 12c-5.16-1.26-9-6.45-9-12V5l9-4l9 4zm-9-5a3 3 0 0 0-3 3c0 1.31.83 2.42 2 2.83V18h2v-2h2v-2h-2v-2.17c1.17-.41 2-1.52 2-2.83a3 3 0 0 0-3-3"/\u003E', }, diff --git a/frontend/src/components/common/LogsViewer/AnsiText.css b/frontend/src/components/common/LogsViewer/AnsiText.css new file mode 100644 index 00000000000..b0ac9b3dc5c --- /dev/null +++ b/frontend/src/components/common/LogsViewer/AnsiText.css @@ -0,0 +1,114 @@ +.ansi-bold { + font-weight: bold; +} +.ansi-italic { + font-style: italic; +} +.ansi-underline { + text-decoration: underline; +} + +/* Foreground colors */ +.ansi-black-fg { + color: #000; +} +.ansi-red-fg { + color: #cc0000; +} +.ansi-green-fg { + color: #00aa00; +} +.ansi-yellow-fg { + color: #aa5500; +} +.ansi-blue-fg { + color: #3465a4; +} +.ansi-magenta-fg { + color: #aa00aa; +} +.ansi-cyan-fg { + color: #00aaaa; +} +.ansi-white-fg { + color: #aaaaaa; +} +.ansi-bright-black-fg { + color: #555555; +} +.ansi-bright-red-fg { + color: #ff5555; +} +.ansi-bright-green-fg { + color: #55ff55; +} +.ansi-bright-yellow-fg { + color: #ffff55; +} +.ansi-bright-blue-fg { + color: #5555ff; +} +.ansi-bright-magenta-fg { + color: #ff55ff; +} +.ansi-bright-cyan-fg { + color: #55ffff; +} +.ansi-bright-white-fg { + color: #ffffff; +} + +/* Background colors */ +.ansi-black-bg { + background-color: #000; +} +.ansi-red-bg { + background-color: #aa0000; +} +.ansi-green-bg { + background-color: #00aa00; +} +.ansi-yellow-bg { + background-color: #aa5500; +} +.ansi-blue-bg { + background-color: #0000aa; +} +.ansi-magenta-bg { + background-color: #aa00aa; +} +.ansi-cyan-bg { + background-color: #00aaaa; +} +.ansi-white-bg { + background-color: #aaaaaa; +} +.ansi-bright-black-bg { + background-color: #555555; +} +.ansi-bright-red-bg { + background-color: #ff5555; +} +.ansi-bright-green-bg { + background-color: #55ff55; +} +.ansi-bright-yellow-bg { + background-color: #ffff55; +} +.ansi-bright-blue-bg { + background-color: #5555ff; +} +.ansi-bright-magenta-bg { + background-color: #ff55ff; +} +.ansi-bright-cyan-bg { + background-color: #55ffff; +} +.ansi-bright-white-bg { + background-color: #ffffff; +} + +.search-highlight { + background-color: #fceb92; + color: #333; +} diff --git a/frontend/src/components/common/LogsViewer/AnsiText.tsx b/frontend/src/components/common/LogsViewer/AnsiText.tsx new file mode 100644 index 00000000000..e12ff944450 --- /dev/null +++ b/frontend/src/components/common/LogsViewer/AnsiText.tsx @@ -0,0 +1,167 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './AnsiText.css'; + +export const AnsiText = ({ text, searchQuery }: { text: string; searchQuery?: string }) => { + const ansiCodes: Record = { + '1': 'ansi-bold', + '3': 'ansi-italic', + '4': 'ansi-underline', + '30': 'ansi-black-fg', + '31': 'ansi-red-fg', + '32': 'ansi-green-fg', + '33': 'ansi-yellow-fg', + '34': 'ansi-blue-fg', + '35': 'ansi-magenta-fg', + '36': 'ansi-cyan-fg', + '37': 'ansi-white-fg', + '90': 'ansi-bright-black-fg', + '91': 'ansi-bright-red-fg', + '92': 'ansi-bright-green-fg', + '93': 'ansi-bright-yellow-fg', + '94': 'ansi-bright-blue-fg', + '95': 'ansi-bright-magenta-fg', + '96': 'ansi-bright-cyan-fg', + '97': 'ansi-bright-white-fg', + '40': 'ansi-black-bg', + '41': 'ansi-red-bg', + '42': 'ansi-green-bg', + '43': 'ansi-yellow-bg', + '44': 'ansi-blue-bg', + '45': 'ansi-magenta-bg', + '46': 'ansi-cyan-bg', + '47': 'ansi-white-bg', + '100': 'ansi-bright-black-bg', + '101': 'ansi-bright-red-bg', + '102': 'ansi-bright-green-bg', + '103': 'ansi-bright-yellow-bg', + '104': 'ansi-bright-blue-bg', + '105': 'ansi-bright-magenta-bg', + '106': 'ansi-bright-cyan-bg', + '107': 'ansi-bright-white-bg', + }; + + const parseAnsi = (inputText: string) => { + const ansiRegex = /\u001b\[([0-9;]*)m/g; + const parts = []; + let lastIndex = 0; + let match; + const currentClasses = new Set(); + + while ((match = ansiRegex.exec(inputText)) !== null) { + const textBefore = inputText.substring(lastIndex, match.index); + if (textBefore) { + parts.push({ text: textBefore, classes: Array.from(currentClasses) }); + } + + lastIndex = ansiRegex.lastIndex; + + const codes = match[1].split(';').filter(Boolean); + + // An empty code sequence is a reset. + if (codes.length === 0) { + currentClasses.clear(); + continue; + } + + codes.forEach(code => { + if (code === '0') { + // Full reset. + currentClasses.clear(); + } else if (code === '39') { + // Reset foreground color. + currentClasses.forEach(cls => { + if (cls.endsWith('-fg')) { + currentClasses.delete(cls); + } + }); + } else if (code === '49') { + // Reset background color. + currentClasses.forEach(cls => { + if (cls.endsWith('-bg')) { + currentClasses.delete(cls); + } + }); + } else { + const newClass = ansiCodes[code]; + if (newClass) { + // If setting a new color, remove any existing color of the same type. + const isFg = newClass.endsWith('-fg'); + const isBg = newClass.endsWith('-bg'); + + if (isFg || isBg) { + const typeSuffix = isFg ? '-fg' : '-bg'; + currentClasses.forEach(cls => { + if (cls.endsWith(typeSuffix)) { + currentClasses.delete(cls); + } + }); + } + currentClasses.add(newClass); + } + } + }); + } + + // Add any remaining text after the last ANSI code. + const remainingText = inputText.substring(lastIndex); + if (remainingText) { + parts.push({ text: remainingText, classes: Array.from(currentClasses) }); + } + + return parts; + }; + + /** + * Highlight matches of a search query in a text. + * @param text The text to search within. + * @param query The search query. + * @returns An array of strings and JSX elements with matches wrapped in tags. + */ + const highlightMatches = (text: string, query?: string) => { + if (!query) { + return text; + } + + // Escape special characters for regex + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ); + }; + + const textParts = parseAnsi(text); + + return ( + <> + {textParts.map((part, index) => ( + + {highlightMatches(part.text, searchQuery)} + + ))} + + ); +}; diff --git a/frontend/src/components/common/LogsViewer/LogDisplay.css b/frontend/src/components/common/LogsViewer/LogDisplay.css new file mode 100644 index 00000000000..b02dc55b29f --- /dev/null +++ b/frontend/src/components/common/LogsViewer/LogDisplay.css @@ -0,0 +1,9 @@ +.LogsDisplay__row:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.LogsDisplay__row { + word-break: break-all; + border-left: 5px solid; + padding-left: 5px; +} diff --git a/frontend/src/components/common/LogsViewer/LogDisplay.tsx b/frontend/src/components/common/LogsViewer/LogDisplay.tsx new file mode 100644 index 00000000000..78ddcfd7e7c --- /dev/null +++ b/frontend/src/components/common/LogsViewer/LogDisplay.tsx @@ -0,0 +1,368 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './LogDisplay.css'; +import { Icon } from '@iconify/react'; +import { alpha, Box, Button, InputAdornment, TextField, useTheme } from '@mui/material'; +import { blue } from '@mui/material/colors'; +import { memoize } from 'lodash'; +import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Trans } from 'react-i18next'; +import { VariableSizeList } from 'react-window'; +import { AnsiText } from './AnsiText'; +import { type ParsedLog } from './ParsedLog'; + +function stripAnsi(str: string) { + return str.replace(/\x1B[@-_][0-?]*[ -/]*[@-~]/g, ''); +} + +/** Find logs that match the query */ +const useSearchLogs = (logs: ParsedLog[], query: string) => { + const deferredQuery = useDeferredValue(query); + + const results = useMemo(() => { + if (!deferredQuery.trim()) return []; + + const results: number[] = []; + + logs.forEach((log, index) => { + if (log.content.toLowerCase().includes(deferredQuery.toLowerCase())) { + results.push(index); + } + }); + + return results; + }, [logs, deferredQuery]); + + return results; +}; + +/** Proper math modulo operation */ +function modulo(n: number, d: number) { + return ((n % d) + d) % d; +} + +/** Make a deterministic random color for a given string */ +const getColorForString = memoize( + (str: string, isDarkMode: boolean) => { + let total = 0; + for (let i = 0; i < str.length - 1; i++) { + total += (str.charCodeAt(i) * 13371337) % 360; + } + const hue = total % 360; + + return isDarkMode ? `hsl(${hue} 60% 82%)` : `hsl(${hue} 70% 20%)`; + }, + (str, isDarkmode) => str + (isDarkmode ? 'true' : 'false') +); + +function Row({ index, data, style }: any) { + const log = data.logs[index] as ParsedLog; + const query = data.searchQuery; + const showTimestamps = data.showTimestamps; + const showSeverity = data.showSeverity; + const textWrap = data.textWrap; + const showPodName = data.showPodName; + const theme = useTheme(); + + const getSeverityColor = (severity: string) => + ({ + error: theme.palette.error.dark, + warning: theme.palette.warning.dark, + info: theme.palette.text.secondary, + debug: blue[900], + trace: blue[900], + fatal: theme.palette.error.dark, + }[severity]); + + return ( +
+ {showPodName && ( + + [{log.pod}]{' '} + + )} + {showTimestamps && ( + {log.timestamp} + )} + {showSeverity && ( + {log.severity} + )} + + + + +
+ ); +} + +export function LogDisplay({ + logs: allLogs, + severityFilter, + showTimestamps, + textWrap, + showSeverity, +}: { + logs: ParsedLog[]; + severityFilter?: Set; + showTimestamps?: boolean; + textWrap?: boolean; + showSeverity?: boolean; +}) { + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [height, setHeight] = useState(0); + const [searchResultIndex, setSearchResultIndex] = useState(undefined); + const [autoScroll, setAutoScroll] = useState(true); + + const filtered = useMemo( + () => (severityFilter ? allLogs.filter(it => severityFilter.has(it.severity)) : allLogs), + [severityFilter, allLogs] + ); + const searchResults = useSearchLogs(filtered, searchQuery); + + const logs = filtered; + + const showPodName = !!logs[0]?.pod; + + const ignoreNextScrollEvent = useRef(false); + const boxRef = useRef(null); + const listRef = useRef>(null); + const fontSize = 14; + const lineHeightMultiplier = 1.4; + const lineHeight = fontSize * lineHeightMultiplier; + const charWidth = fontSize * 0.56; + + function getItemSize(index: number) { + if (!boxRef.current) return lineHeight; + if (!logs[index]) return lineHeight; + if (!textWrap) return lineHeight; + + const data = logs[index]; + + const width = boxRef.current.clientWidth; + const charsPerLine = width / charWidth; + const text = + (showSeverity ? data.severity + ' ' : '') + + (showTimestamps ? data.timestamp + ' ' : '') + + (showPodName ? data.pod + ' ' : '') + + stripAnsi(data.content); + const lines = Math.ceil(text.length / charsPerLine); + const divHeight = lines * lineHeight; + + return divHeight; + } + + // Scroll to the selected search result + useEffect(() => { + if (searchResults.length > 0 && searchResultIndex === 0) { + listRef.current?.scrollToItem(searchResults[0], 'start'); + } + }, [searchResults]); + + // Handle resizing, make sure height of the list is right + useEffect(() => { + if (!boxRef.current) return; + + const observer = new ResizeObserver(entries => { + setHeight(entries[0].contentRect.height); + if (!listRef.current) return; + listRef.current.resetAfterIndex(0, true); + }); + + observer.observe(boxRef.current); + + return () => observer.disconnect(); + }, [height]); + + // Recalculate row heights when row content changes + useEffect(() => { + if (!listRef.current) return; + listRef.current.resetAfterIndex(0, true); + }, [textWrap, showSeverity, showTimestamps, severityFilter]); + + // Scroll to bottom by default, unless user has scrolled + useLayoutEffect(() => { + if (!listRef.current || !autoScroll) return; + + ignoreNextScrollEvent.current = true; + listRef.current.scrollToItem(logs.length - 1); + }, [logs, height, autoScroll]); + + const handleChangeResultIndex = (diff: 1 | -1) => { + const newIndex = + searchResultIndex === undefined ? 0 : modulo(searchResultIndex + diff, searchResults.length); + setSearchResultIndex(newIndex); + listRef.current?.scrollToItem(searchResults[newIndex], 'start'); + }; + + return ( + { + if (ignoreNextScrollEvent.current) { + ignoreNextScrollEvent.current = false; + return; + } + + setAutoScroll(false); + }} + > + ({ + position: 'absolute', + top: 0, + right: 0, + mr: 4, + mt: 2, + p: 0, + zIndex: 1, + boxShadow: '2px 2px 8px rgba(0,0,0,0.1)', + borderRadius: theme.shape.borderRadius + 'px', + })} + > + {!showSearch ? ( + + ) : ( + ({ + background: alpha(theme.palette.background.muted, 0.95), + border: '1px solid', + borderColor: theme.palette.divider, + p: 1, + borderRadius: theme.shape.borderRadius + 'px', + display: 'flex', + alignItems: 'stretch', + })} + > +
{ + e.preventDefault(); + e.stopPropagation(); + + handleChangeResultIndex(+1); + }} + > + Search} + value={searchQuery} + InputProps={{ + endAdornment: ( + + {searchResults.length > 0 && ( + <> + {(searchResultIndex ?? 0) + 1}/{searchResults.length} + + )} + + ), + }} + onChange={e => { + setSearchQuery(e.target.value); + setSearchResultIndex(0); + }} + /> + + + + +
+ )} +
+ + {height > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/common/LogsViewer/LogsViewer.tsx b/frontend/src/components/common/LogsViewer/LogsViewer.tsx new file mode 100644 index 00000000000..461017f9287 --- /dev/null +++ b/frontend/src/components/common/LogsViewer/LogsViewer.tsx @@ -0,0 +1,177 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Box, Checkbox, FormControlLabel, MenuItem, TextField } from '@mui/material'; +import { useMemo, useState } from 'react'; +import { Trans } from 'react-i18next'; +import { usePodLogs, useWorkloadLogs } from '../../../lib/k8s/api/v2/fetchLogs'; +import { KubeContainer } from '../../../lib/k8s/cluster'; +import Deployment from '../../../lib/k8s/deployment'; +import Pod from '../../../lib/k8s/pod'; +import type ReplicaSet from '../../../lib/k8s/replicaSet'; +import { ClusterGroupErrorMessage } from '../../cluster/ClusterGroupErrorMessage'; +import { useLocalStorageState } from '../../globalSearch/useLocalStorageState'; +import { LogDisplay } from './LogDisplay'; +import { useParsedLogs } from './ParsedLog'; +import { SeveritySelector } from './SeveritySelector'; + +/** Display logs for a workload instance */ +export function LogsViewer({ + item, + defaultSeverities, +}: { + item: Pod | Deployment | ReplicaSet; + defaultSeverities?: string[]; +}) { + const containers: KubeContainer[] = + item.kind === 'Pod' ? item.spec.containers : item.spec.template.spec.containers; + const [severityFilter, setSeverityFilter] = useState | undefined>( + defaultSeverities ? new Set(defaultSeverities) : undefined + ); + const [showTimestamps, setShowtimestamps] = useLocalStorageState( + 'logs-viewer-show-timestamps', + true + ); + const [showSeverity, setShowSeverity] = useLocalStorageState('logs-viewer-show-severity', false); + const [textWrap, setTextWrap] = useLocalStorageState('logs-viewer-text-wrap', true); + const [showPrevious, setShowPrevious] = useState(false); + const [container, setContainer] = useState(containers[0].name); + const [lines, setLines] = useState(100); + const { logs: rawLogs, error: logsError } = (item.kind === 'Pod' ? usePodLogs : useWorkloadLogs)({ + item: item as Pod & Deployment, + container, + lines: lines === 'All' ? undefined : lines, + previous: showPrevious, + }); + + const parsed = useParsedLogs(rawLogs); + const filtered = useMemo( + () => (severityFilter ? parsed.filter(it => severityFilter.has(it.severity)) : parsed), + [parsed, severityFilter] + ); + + const logs = filtered; + + return ( + <> + ({ + display: 'flex', + alignItems: 'center', + gap: 1, + p: 1, + borderBottom: '1px solid', + borderColor: theme.palette.divider, + flexWrap: 'wrap', + })} + > + {containers.length > 1 && ( + setContainer(e.target.value)} + value={container} + label={Container} + > + {containers.map(c => ( + + {c.name} + + ))} + + )} + + setLines(e.target.value === 'All' ? 'All' : Number(e.target.value))} + value={lines} + label={Lines} + > + {[100, 1000, 2500, 'All'].map(l => ( + + {l} + + ))} + + + + + setShowSeverity(() => Boolean(e.target.checked))} + checked={showSeverity} + /> + } + label={Severity} + /> + + setShowPrevious(() => Boolean(e.target.checked))} + checked={showPrevious} + /> + } + label={Previous} + /> + + setShowtimestamps(() => Boolean(e.target.checked))} + checked={showTimestamps} + /> + } + label={Timestamps} + /> + + setTextWrap(() => Boolean(e.target.checked))} + checked={textWrap} + /> + } + label={Wrap lines} + /> + + {logsError && } + + + ); +} diff --git a/frontend/src/components/common/LogsViewer/ParsedLog.tsx b/frontend/src/components/common/LogsViewer/ParsedLog.tsx new file mode 100644 index 00000000000..82e35d4b552 --- /dev/null +++ b/frontend/src/components/common/LogsViewer/ParsedLog.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { sortBy } from 'lodash'; +import { useMemo } from 'react'; + +const severities = { + warning: ['warning', 'warn', 'wrn'], + error: ['error', 'err'], + info: ['info', 'inf'], + debug: ['debug', 'dbg'], + trace: ['trace'], + fatal: ['fatal'], +}; + +export interface ParsedLog { + timestamp: string; + severity: 'info' | 'error' | 'warning' | 'fatal' | 'trace' | 'debug'; + content: string; + pod?: string; +} + +const severityLookupMap: Record = {}; +for (const severityLevel in severities) { + const key = severityLevel as ParsedLog['severity']; + for (const alias of severities[key]) { + severityLookupMap[alias] = key; + } +} + +const allAliases = Object.values(severities).flat(); +const masterSeverityRegex = new RegExp(`\\b(${allAliases.join('|')})\\b`, 'i'); + +const ansiLikeRegex = /\[\s*\[?\s*\d+m/g; + +// Regex to identify an ISO 8601-like timestamp at the start of a string. +const secondTimestampRegex = + /^\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/; + +/** + * Parses a log line in a single pass (linear time complexity). + * + * @param line The log line string to parse. + * @returns A ParsedLog object. + */ +function parseLogLine(line: string, pod?: string): ParsedLog { + const timestampMatch = line.match(/^\S+/); + const timestamp = timestampMatch ? timestampMatch[0] : ''; + + let severity: ParsedLog['severity'] = 'info'; // Deault severity + const severityMatch = line.replace(ansiLikeRegex, '').match(masterSeverityRegex); + if (severityMatch) { + const matchedAlias = severityMatch[1].toLowerCase(); + severity = severityLookupMap[matchedAlias]; + } + + const content = line.substring(timestamp.length).trim().replace(secondTimestampRegex, ''); + + return { + timestamp: timestamp, + severity: severity, + content, + pod, + }; +} + +export const useParsedLogs = (logs: string[] | Record) => { + const parsed = useMemo(() => { + if (Array.isArray(logs)) { + return logs.map(log => parseLogLine(log)); + } + + const result: ParsedLog[] = []; + const addPod = Object.keys(logs).length > 1; + Object.entries(logs).forEach(([pod, logs]) => { + logs.forEach(log => { + result.push(parseLogLine(log, addPod ? pod : undefined)); + }); + }); + return sortBy(result, it => it.timestamp); + }, [logs]); + return parsed; +}; diff --git a/frontend/src/components/common/LogsViewer/SeveritySelector.tsx b/frontend/src/components/common/LogsViewer/SeveritySelector.tsx new file mode 100644 index 00000000000..7b1dd32d594 --- /dev/null +++ b/frontend/src/components/common/LogsViewer/SeveritySelector.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Icon } from '@iconify/react'; +import { Button, Divider, Menu, MenuItem } from '@mui/material'; +import { Dispatch, SetStateAction, useMemo, useState } from 'react'; +import { Trans } from 'react-i18next'; +import { type ParsedLog } from './ParsedLog'; + +/** Calculate counts for each severity */ +const useSeverityStats = (logs: ParsedLog[]) => { + return useMemo(() => { + const stats = new Map(); + + logs.forEach(({ severity }) => { + const current = stats.get(severity) ?? 0; + stats.set(severity, current + 1); + }); + + return stats; + }, [logs]); +}; + +const ALL_SEVERITIES = ['info', 'error', 'warning', 'fatal', 'trace', 'debug']; + +/** Show a dropdown picker with different severity levels and their counts */ +export function SeveritySelector({ + logs, + severityFilter, + setSeverityFilter, +}: { + logs: ParsedLog[]; + severityFilter?: Set; + setSeverityFilter: Dispatch | undefined>>; +}) { + const stats = useSeverityStats(logs); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSeverityClick = (severity: string) => { + setSeverityFilter(f => { + const newFilter = f ? new Set(f) : new Set(ALL_SEVERITIES); + if (newFilter.has(severity)) { + newFilter.delete(severity); + } else { + newFilter.add(severity); + } + return newFilter; + }); + }; + + const allLevels = severityFilter === undefined || severityFilter.size === ALL_SEVERITIES.length; + + return ( + <> + + + { + setSeverityFilter(undefined); + handleClose(); + }} + > + {(severityFilter === undefined || severityFilter.size === ALL_SEVERITIES.length) && ( + + )} + All levels + + + {['info', 'error', 'warning', 'fatal', 'trace', 'debug'].map(s => ( + handleSeverityClick(s)} + key={s} + sx={{ textTransform: 'capitalize' }} + > + {(severityFilter === undefined || severityFilter.has(s)) && ( + + )} + {s} ({stats.get(s) ?? 0}) + + ))} + + + ); +} diff --git a/frontend/src/components/common/LogsViewer/index.ts b/frontend/src/components/common/LogsViewer/index.ts new file mode 100644 index 00000000000..ca7a8c1c7d1 --- /dev/null +++ b/frontend/src/components/common/LogsViewer/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LogsViewer } from './LogsViewer'; + +export default LogsViewer; diff --git a/frontend/src/components/common/Resource/LogsButton.tsx b/frontend/src/components/common/Resource/LogsButton.tsx index 98cafa2442c..470bece3d43 100644 --- a/frontend/src/components/common/Resource/LogsButton.tsx +++ b/frontend/src/components/common/Resource/LogsButton.tsx @@ -36,6 +36,7 @@ import Pod from '../../../lib/k8s/pod'; import ReplicaSet from '../../../lib/k8s/replicaSet'; import { Activity } from '../../activity/Activity'; import ActionButton from '../ActionButton'; +import { LogsViewer } from '../LogsViewer/LogsViewer'; import { LogViewer } from '../LogViewer'; import { LightTooltip } from '../Tooltip'; @@ -51,6 +52,7 @@ const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ paddingRight: theme.spacing(2), })); +// eslint-disable-next-line no-unused-vars function LogsButtonContent({ item }: LogsButtonProps) { const [pods, setPods] = useState([]); const [selectedPodIndex, setSelectedPodIndex] = useState('all'); @@ -528,7 +530,7 @@ export function LogsButton({ item }: LogsButtonProps) { icon: , cluster: item.cluster, location: 'full', - content: , + content: , }); }; diff --git a/frontend/src/components/common/index.test.ts b/frontend/src/components/common/index.test.ts index 1d8b6e2a18e..62b0967cd76 100644 --- a/frontend/src/components/common/index.test.ts +++ b/frontend/src/components/common/index.test.ts @@ -46,6 +46,7 @@ const checkExports = [ 'Link', 'Loader', 'LogViewer', + 'LogsViewer', 'NamespacesAutocomplete', 'NameValueTable', 'Resource', diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 4366e948074..69a449bdc48 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -67,3 +67,4 @@ export * from './NamespacesAutocomplete'; export * from './Table/Table'; export { default as Table } from './Table'; export * from './CreateResourceButton'; +export { LogsViewer } from './LogsViewer/LogsViewer'; diff --git a/frontend/src/components/pod/Details.tsx b/frontend/src/components/pod/Details.tsx index aaebcac3325..b59f9c8fd0f 100644 --- a/frontend/src/components/pod/Details.tsx +++ b/frontend/src/components/pod/Details.tsx @@ -34,6 +34,7 @@ import { EventStatus, HeadlampEventType, useEventCallback } from '../../redux/he import { Activity } from '../activity/Activity'; import ActionButton from '../common/ActionButton'; import Link from '../common/Link'; +import { LogsViewer } from '../common/LogsViewer/LogsViewer'; import { LogViewer, LogViewerProps } from '../common/LogViewer'; import { ConditionsSection, @@ -607,7 +608,7 @@ export default function PodDetails(props: PodDetailsProps) { ), location: 'full', - content: {}} />, + content: , }); dispatchHeadlampEvent({ type: HeadlampEventType.LOGS, diff --git a/frontend/src/components/project/ProjectResourcesTab.tsx b/frontend/src/components/project/ProjectResourcesTab.tsx index c6d4ca5bdf4..705625e6fca 100644 --- a/frontend/src/components/project/ProjectResourcesTab.tsx +++ b/frontend/src/components/project/ProjectResourcesTab.tsx @@ -29,13 +29,13 @@ import { Activity } from '../activity/Activity'; import { StatusLabel } from '../common'; import ActionButton from '../common/ActionButton/ActionButton'; import Link from '../common/Link'; +import { LogsViewer } from '../common/LogsViewer/LogsViewer'; import AuthVisible from '../common/Resource/AuthVisible'; import DeleteButton from '../common/Resource/DeleteButton'; import ScaleButton from '../common/Resource/ScaleButton'; import { TableColumn } from '../common/Table'; import Table from '../common/Table'; import Terminal from '../common/Terminal'; -import { PodLogViewer } from '../pod/Details'; import { getStatus, KubeObjectStatus } from '../resourceMap/nodes/KubeObjectStatus'; import { getResourcesHealth } from './projectUtils'; import { ResourceCategoriesList } from './ResourceCategoriesList'; @@ -339,14 +339,7 @@ export function ProjectResourcesTab({ ), location: 'full', - content: ( - Activity.close(id)} - /> - ), + content: , }); }} /> diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index a2939c17feb..99247608308 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "", + "Lines": "Zeilen", + "Severity": "", + "Previous": "", + "Timestamps": "Zeitstempel anzeigen", + "Wrap lines": "", + "All levels": "", "Find": "Finden", "Download": "Herunterladen", "No results": "Keine Ergebnisse", @@ -302,13 +310,11 @@ "Logs are paused. Click the follow button to resume following them.": "Ereignisprotokolle wurden angehalten. Klicken Sie auf die Schaltfläche \"Verfolgen\", um die Protokollanzeige fortzusetzen.", "Select Pod": "", "All Pods": "", - "Container": "", "Restarted": "", "Show logs for previous instances of this container.": "Ereignisprotokolle für frühere Instanzen dieses Containers anzeigen.", "You can only select this option for containers that have been restarted.": "Sie können diese Option nur für Container wählen, die neu gestartet wurden.", "Show previous": "Vorherige anzeigen", "Show timestamps in the logs.": "", - "Timestamps": "Zeitstempel anzeigen", "Follow logs in real-time.": "", "Follow": "Verfolgen", "Show logs": "", @@ -507,8 +513,6 @@ "None": "", "Software": "Software", "Redirecting to main page…": "Weiterleiten zur Hauptseite…", - "Lines": "Zeilen", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 7dc2e1eac07..ebe79c7d4b6 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "Failed to copy to clipboard", "Copy": "Copy", "Open Issue on GitHub": "Open Issue on GitHub", + "Scroll to bottom": "Scroll to bottom", + "Container": "Container", + "Lines": "Lines", + "Severity": "Severity", + "Previous": "Previous", + "Timestamps": "Timestamps", + "Wrap lines": "Wrap lines", + "All levels": "All levels", "Find": "Find", "Download": "Download", "No results": "No results", @@ -302,13 +310,11 @@ "Logs are paused. Click the follow button to resume following them.": "Logs are paused. Click the follow button to resume following them.", "Select Pod": "Select Pod", "All Pods": "All Pods", - "Container": "Container", "Restarted": "Restarted", "Show logs for previous instances of this container.": "Show logs for previous instances of this container.", "You can only select this option for containers that have been restarted.": "You can only select this option for containers that have been restarted.", "Show previous": "Show previous", "Show timestamps in the logs.": "Show timestamps in the logs.", - "Timestamps": "Timestamps", "Follow logs in real-time.": "Follow logs in real-time.", "Follow": "Follow", "Show logs": "Show logs", @@ -507,8 +513,6 @@ "None": "None", "Software": "Software", "Redirecting to main page…": "Redirecting to main page…", - "Lines": "Lines", - "Previous": "Previous", "Prettify": "Prettify", "Show JSON values in plain text by removing escape characters.": "Show JSON values in plain text by removing escape characters.", "Format": "Format", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 716d6b73c62..339361a7449 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "Copiar", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "Contenedor", + "Lines": "Líneas", + "Severity": "", + "Previous": "", + "Timestamps": "Marcas de tiempo", + "Wrap lines": "", + "All levels": "", "Find": "Buscar", "Download": "Descargar", "No results": "Sin resultados", @@ -303,13 +311,11 @@ "Logs are paused. Click the follow button to resume following them.": "Los registros están en pausa. Haga clic en el botón siguiente para continuar a seguirlos.", "Select Pod": "Seleccionar Pod", "All Pods": "Todos los Pods", - "Container": "Contenedor", "Restarted": "Reiniciado", "Show logs for previous instances of this container.": "Mostrar registros para instancias anteriores de este contenedor.", "You can only select this option for containers that have been restarted.": "Sólo podrá seleccionar esta opción para los contenedores que se han reiniciado.", "Show previous": "Mostrar anteriores", "Show timestamps in the logs.": "", - "Timestamps": "Marcas de tiempo", "Follow logs in real-time.": "", "Follow": "Seguir", "Show logs": "Mostrar \"logs\"", @@ -510,8 +516,6 @@ "None": "Ninguno", "Software": "Software", "Redirecting to main page…": "Redireccionando a la página principal…", - "Lines": "Líneas", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index f9199bede4e..536ce503cd0 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "", + "Lines": "Lignes", + "Severity": "", + "Previous": "", + "Timestamps": "Horodatages", + "Wrap lines": "", + "All levels": "", "Find": "Trouver", "Download": "Télécharger", "No results": "Aucun résultat", @@ -303,13 +311,11 @@ "Logs are paused. Click the follow button to resume following them.": "Les journaux sont en pause. Cliquez sur le bouton suivre pour reprendre leur suivi.", "Select Pod": "", "All Pods": "", - "Container": "", "Restarted": "", "Show logs for previous instances of this container.": "Afficher les journaux des instances précédentes de ce conteneur.", "You can only select this option for containers that have been restarted.": "Vous ne pouvez sélectionner cette option que pour les conteneurs qui ont été redémarrés.", "Show previous": "Montrer le précédent", "Show timestamps in the logs.": "", - "Timestamps": "Horodatages", "Follow logs in real-time.": "", "Follow": "Suivez", "Show logs": "", @@ -510,8 +516,6 @@ "None": "", "Software": "Logiciel", "Redirecting to main page…": "Redirection vers la page principale…", - "Lines": "Lignes", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/hi/translation.json b/frontend/src/i18n/locales/hi/translation.json index 555ce69a1de..be3ca8221b1 100644 --- a/frontend/src/i18n/locales/hi/translation.json +++ b/frontend/src/i18n/locales/hi/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "कॉपी करें", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "कंटेनर", + "Lines": "", + "Severity": "", + "Previous": "", + "Timestamps": "समय-मुद्रांक", + "Wrap lines": "", + "All levels": "", "Find": "खोजें", "Download": "डाउनलोड करें", "No results": "कोई परिणाम नहीं", @@ -302,13 +310,11 @@ "Logs are paused. Click the follow button to resume following them.": "लॉग रोके गए हैं। उन्हें फिर से फॉलो करने के लिए फॉलो बटन पर क्लिक करें।", "Select Pod": "Pod चुनें", "All Pods": "सभी Pods", - "Container": "कंटेनर", "Restarted": "पुनरारंभ किया गया", "Show logs for previous instances of this container.": "इस कंटेनर के पिछले इंस्टेंस के लिए लॉग दिखाएँ।", "You can only select this option for containers that have been restarted.": "आप यह विकल्प केवल उन कंटेनरों के लिए चुन सकते हैं जिन्हें पुनरारंभ किया गया है।", "Show previous": "पिछला दिखाएँ", "Show timestamps in the logs.": "", - "Timestamps": "समय-मुद्रांक", "Follow logs in real-time.": "", "Follow": "फॉलो करें", "Show logs": "लॉग दिखाएँ", @@ -507,8 +513,6 @@ "None": "", "Software": "", "Redirecting to main page…": "मुख्य पृष्ठ पर पुनः निर्देशित किया जा रहा है…", - "Lines": "", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "एस्केप कैरेक्टर्स हटाकर JSON मानों को सादा पाठ में दिखाएँ।", "Format": "", diff --git a/frontend/src/i18n/locales/it/translation.json b/frontend/src/i18n/locales/it/translation.json index 159a2c6057d..839088298ed 100644 --- a/frontend/src/i18n/locales/it/translation.json +++ b/frontend/src/i18n/locales/it/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "Copia", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "Container", + "Lines": "Linee", + "Severity": "", + "Previous": "", + "Timestamps": "Timestamp", + "Wrap lines": "", + "All levels": "", "Find": "Trova", "Download": "Scarica", "No results": "Nessun risultato", @@ -303,13 +311,11 @@ "Logs are paused. Click the follow button to resume following them.": "I log sono in pausa. Fai clic sul pulsante 'segui' per riprendere il monitoraggio.", "Select Pod": "Seleziona Pod", "All Pods": "Tutti i Pod", - "Container": "Container", "Restarted": "Riavviato", "Show logs for previous instances of this container.": "Mostra i log delle istanze precedenti di questo container.", "You can only select this option for containers that have been restarted.": "Puoi selezionare questa opzione solo per i container che sono stati riavviati.", "Show previous": "Mostra precedenti", "Show timestamps in the logs.": "", - "Timestamps": "Timestamp", "Follow logs in real-time.": "", "Follow": "Segui", "Show logs": "Mostra log", @@ -510,8 +516,6 @@ "None": "Nessuno", "Software": "Software", "Redirecting to main page…": "Reindirizzamento alla pagina principale…", - "Lines": "Linee", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/ja/translation.json b/frontend/src/i18n/locales/ja/translation.json index fed43b2267f..8bb7547d777 100644 --- a/frontend/src/i18n/locales/ja/translation.json +++ b/frontend/src/i18n/locales/ja/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "コピー", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "コンテナー", + "Lines": "行", + "Severity": "", + "Previous": "", + "Timestamps": "タイムスタンプ", + "Wrap lines": "", + "All levels": "", "Find": "検索", "Download": "ダウンロード", "No results": "結果なし", @@ -301,13 +309,11 @@ "Logs are paused. Click the follow button to resume following them.": "ログは一時停止中です。追跡ボタンをクリックして再開します。", "Select Pod": "ポッドを選択", "All Pods": "すべてのポッド", - "Container": "コンテナー", "Restarted": "再起動済み", "Show logs for previous instances of this container.": "このコンテナーの以前のインスタンスのログを表示します。", "You can only select this option for containers that have been restarted.": "再起動されたコンテナーのみでこのオプションを選択できます。", "Show previous": "以前のログを表示", "Show timestamps in the logs.": "", - "Timestamps": "タイムスタンプ", "Follow logs in real-time.": "", "Follow": "追跡", "Show logs": "ログを表示", @@ -504,8 +510,6 @@ "None": "なし", "Software": "ソフトウェア", "Redirecting to main page…": "メインページへリダイレクト中…", - "Lines": "行", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/ko/translation.json b/frontend/src/i18n/locales/ko/translation.json index 6090430bbe9..c839452bce7 100644 --- a/frontend/src/i18n/locales/ko/translation.json +++ b/frontend/src/i18n/locales/ko/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "복사", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "컨테이너", + "Lines": "줄", + "Severity": "", + "Previous": "", + "Timestamps": "타임스탬프", + "Wrap lines": "", + "All levels": "", "Find": "찾기", "Download": "다운로드", "No results": "결과 없음", @@ -301,13 +309,11 @@ "Logs are paused. Click the follow button to resume following them.": "로그가 일시 중지되었습니다. 계속 보려면 '팔로우' 버튼을 클릭하세요.", "Select Pod": "파드 선택", "All Pods": "모든 파드", - "Container": "컨테이너", "Restarted": "재시작됨", "Show logs for previous instances of this container.": "이 컨테이너의 이전 인스턴스 로그 보기.", "You can only select this option for containers that have been restarted.": "이 옵션은 재시작된 컨테이너에서만 선택할 수 있습니다.", "Show previous": "이전 보기", "Show timestamps in the logs.": "", - "Timestamps": "타임스탬프", "Follow logs in real-time.": "", "Follow": "따르기", "Show logs": "로그 보기", @@ -504,8 +510,6 @@ "None": "없음", "Software": "소프트웨어", "Redirecting to main page…": "메인 페이지로 리디렉션 중…", - "Lines": "줄", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index 2ba2cc7c807..95ad5bd011d 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "Copiar", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "Container", + "Lines": "Linhas", + "Severity": "", + "Previous": "", + "Timestamps": "Marcações de tempo", + "Wrap lines": "", + "All levels": "", "Find": "Pesquisar", "Download": "Descarregar", "No results": "Sem resultados", @@ -303,13 +311,11 @@ "Logs are paused. Click the follow button to resume following them.": "Os \"logs\" estão pausados. Clique no seguinte botão para voltar a segui-los.", "Select Pod": "Selecionar Pod", "All Pods": "Todos os Pods", - "Container": "Container", "Restarted": "Reiniciado", "Show logs for previous instances of this container.": "Mostrar \"logs\" para instâncias anteriores deste \"container\".", "You can only select this option for containers that have been restarted.": "Só pode escolher esta opção para \"containers\" que tenham sido reiniciados.", "Show previous": "Mostrar anteriores", "Show timestamps in the logs.": "", - "Timestamps": "Marcações de tempo", "Follow logs in real-time.": "", "Follow": "Seguir", "Show logs": "Mostrar \"logs\"", @@ -510,8 +516,6 @@ "None": "Nenhum", "Software": "Software", "Redirecting to main page…": "A redireccionar para a página principal…", - "Lines": "Linhas", - "Previous": "", "Prettify": "", "Show JSON values in plain text by removing escape characters.": "", "Format": "", diff --git a/frontend/src/i18n/locales/ta/translation.json b/frontend/src/i18n/locales/ta/translation.json index fcc43f369c4..32dca31b576 100644 --- a/frontend/src/i18n/locales/ta/translation.json +++ b/frontend/src/i18n/locales/ta/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "", "Copy": "நகலெடு", "Open Issue on GitHub": "", + "Scroll to bottom": "", + "Container": "கண்டெய்னர்", + "Lines": "வரிகள்", + "Severity": "", + "Previous": "முந்தையது", + "Timestamps": "டைம்ஸ்டாம்ப்கள்", + "Wrap lines": "", + "All levels": "", "Find": "கண்டுபிடி", "Download": "பதிவிறக்கு", "No results": "முடிவுகள் இல்லை", @@ -302,13 +310,11 @@ "Logs are paused. Click the follow button to resume following them.": "லாக்குகள் இடைநிறுத்தப்பட்டுள்ளன. அவற்றைத் தொடர்ந்து பார்ப்பதை மீண்டும் தொடங்க ஃபாலோ பட்டனை கிளிக் செய்யவும்.", "Select Pod": "பாட்டைத் தேர்ந்தெடு", "All Pods": "அனைத்து பாட்கள்", - "Container": "கண்டெய்னர்", "Restarted": "மீண்டும் தொடங்கப்பட்டது", "Show logs for previous instances of this container.": "இந்த கண்டெய்னரின் முந்தைய இன்ஸ்டன்ஸ்களுக்கான லாக்குகளைக் காட்டு.", "You can only select this option for containers that have been restarted.": "மீண்டும் தொடங்கப்பட்ட கண்டெய்னர்களுக்கு மட்டுமே இந்த விருப்பத்தைத் தேர்ந்தெடுக்க முடியும்.", "Show previous": "முந்தையதைக் காட்டு", "Show timestamps in the logs.": "", - "Timestamps": "டைம்ஸ்டாம்ப்கள்", "Follow logs in real-time.": "", "Follow": "ஃபாலோ", "Show logs": "லாக்குகளைக் காட்டு", @@ -507,8 +513,6 @@ "None": "ஏதுமில்லை", "Software": "சாஃப்ட்வேர்", "Redirecting to main page…": "முக்கிய பக்கத்திற்கு திருப்பி விடுகிறது...", - "Lines": "வரிகள்", - "Previous": "முந்தையது", "Prettify": "அழகுபடுத்து", "Show JSON values in plain text by removing escape characters.": "தப்பிய எழுத்துகளை அகற்றி, JSON மதிப்புகளை எளிய உரையாகக் காண்பிக்கவும்.", "Format": "வடிவமை", diff --git a/frontend/src/i18n/locales/zh-tw/translation.json b/frontend/src/i18n/locales/zh-tw/translation.json index c5d0a36f037..1d862329c1d 100644 --- a/frontend/src/i18n/locales/zh-tw/translation.json +++ b/frontend/src/i18n/locales/zh-tw/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "複製到剪貼簿失敗", "Copy": "複製", "Open Issue on GitHub": "在 GitHub 上開啟 Issue", + "Scroll to bottom": "", + "Container": "容器", + "Lines": "行", + "Severity": "", + "Previous": "上一個", + "Timestamps": "時間戳", + "Wrap lines": "", + "All levels": "", "Find": "查詢", "Download": "下載", "No results": "無結果", @@ -301,13 +309,11 @@ "Logs are paused. Click the follow button to resume following them.": "日誌已暫停。點選跟隨按鈕以繼續跟隨它們。", "Select Pod": "選擇 Pod", "All Pods": "所有 Pod", - "Container": "容器", "Restarted": "重啟", "Show logs for previous instances of this container.": "顯示此容器先前實例的日誌。", "You can only select this option for containers that have been restarted.": "您只能為已重啟的容器選擇此選項。", "Show previous": "顯示先前", "Show timestamps in the logs.": "在日誌中顯示時間戳。", - "Timestamps": "時間戳", "Follow logs in real-time.": "", "Follow": "跟隨", "Show logs": "顯示日誌", @@ -504,8 +510,6 @@ "None": "無", "Software": "軟體", "Redirecting to main page…": "正在重定向到首頁…", - "Lines": "行", - "Previous": "上一個", "Prettify": "美化", "Show JSON values in plain text by removing escape characters.": "移除跳脫字元,以純文字顯示 JSON 值。", "Format": "格式化", diff --git a/frontend/src/i18n/locales/zh/translation.json b/frontend/src/i18n/locales/zh/translation.json index 6013e02ee5e..f784125db94 100644 --- a/frontend/src/i18n/locales/zh/translation.json +++ b/frontend/src/i18n/locales/zh/translation.json @@ -221,6 +221,14 @@ "Failed to copy to clipboard": "复制到剪贴板失败", "Copy": "复制", "Open Issue on GitHub": "在 GitHub 上创建 Issue", + "Scroll to bottom": "", + "Container": "容器", + "Lines": "行", + "Severity": "", + "Previous": "上一页", + "Timestamps": "时间戳", + "Wrap lines": "", + "All levels": "", "Find": "查找", "Download": "下载", "No results": "暂无结果", @@ -301,13 +309,11 @@ "Logs are paused. Click the follow button to resume following them.": "日志已暂停。点击跟随按钮以继续跟随他们。", "Select Pod": "选择 Pod", "All Pods": "所有 Pod", - "Container": "容器", "Restarted": "重启", "Show logs for previous instances of this container.": "显示此容器之前实例的日志。", "You can only select this option for containers that have been restarted.": "您只能为已重启的容器选择此选项。", "Show previous": "显示之前", "Show timestamps in the logs.": "在日志中显示时间戳。", - "Timestamps": "时间戳", "Follow logs in real-time.": "实时跟随日志输出。", "Follow": "跟随", "Show logs": "显示日志", @@ -504,8 +510,6 @@ "None": "无", "Software": "软件", "Redirecting to main page…": "正在重定向到首页…", - "Lines": "行", - "Previous": "上一页", "Prettify": "美化", "Show JSON values in plain text by removing escape characters.": "移除转义字符,以纯文本显示 JSON 值。", "Format": "格式化", diff --git a/frontend/src/lib/k8s/api/v2/fetchLogs.tsx b/frontend/src/lib/k8s/api/v2/fetchLogs.tsx new file mode 100644 index 00000000000..ed20138056a --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/fetchLogs.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { labelSelectorToQuery } from '../..'; +import { ApiError } from '../../apiProxy'; +import type DaemonSet from '../../daemonSet'; +import type Deployment from '../../deployment'; +import Pod from '../../pod'; +import type ReplicaSet from '../../replicaSet'; +import type StatefulSet from '../../statefulSet'; +import { clusterFetch } from './fetch'; +import { makeUrl } from './makeUrl'; +import { makeBatchingStream, makeLineSplitStream } from './transformStreams'; + +export interface LogParams { + /** Container name */ + container: string; + /** Number of lines to fetch. If none provided will fetch all of them */ + lines?: number; + /** Show the logs for the previous instance of the container in a pod if it exists */ + previous: boolean; +} + +/** Readable stream that emits buffered log lines for given Pod */ +export async function makePodLogsReadableStream({ + podName, + namespace, + cluster, + container, + lines, + previous, + signal, +}: { + /** Name of the pod */ + podName: string; + /** Namespace of the given pod */ + namespace: string; + /** Cluster of the given pod */ + cluster: string; + /** Signal to abort request */ + signal: AbortSignal; +} & LogParams) { + // Query params for the log request + // See more here: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#read-log-pod-v1-core + const params: Record = { + container, + follow: 'true', + timestamps: 'true', + }; + + if (lines) params.lines = String(lines); + if (previous) params.previous = 'true'; + + const url = makeUrl(`/api/v1/namespaces/${namespace}/pods/${podName}/log`, params); + const body = await clusterFetch(url, { cluster, signal }).then(it => it.body); + + if (!body) throw new Error('Body is missing from the logs request'); + + return ( + body + // bytes -> text + .pipeThrough(new TextDecoderStream()) + // text -> lines of text + .pipeThrough(makeLineSplitStream()) + // buffer into chunks so we don't spam too often and don't rerender as often + .pipeThrough(makeBatchingStream(signal)) + ); +} + +/** + * Fetch and watch logs for all pods in this workload + * + * @param params - Named params + * @returns logs and error (if any) + */ +export const useWorkloadLogs = ({ + item, + lines, + container, + previous, +}: { + /** Fetch logs from this workload */ + item: Deployment | ReplicaSet | DaemonSet | StatefulSet; +} & LogParams) => { + // Fetch all the logs from the given workload + const { items: pods, isLoading } = Pod.useList({ + cluster: item.cluster, + labelSelector: labelSelectorToQuery(item.jsonData.spec.selector!), + }); + + const [logs, setLogs] = useState>({}); + const [error, setError] = useState(); + + useEffect(() => { + if (isLoading || !pods) return; + + setLogs({}); + + // One abort controller for all streams + const controller = new AbortController(); + + pods.forEach(pod => + makePodLogsReadableStream({ + podName: pod.metadata.name, + namespace: pod.metadata.namespace!, + cluster: pod.cluster, + container, + lines, + previous, + signal: controller.signal, + }) + .then(stream => + stream.pipeTo( + // Put all new logs into state + new WritableStream({ + write(chunk) { + setLogs(oldLogs => ({ + ...oldLogs, + [pod.metadata.name]: chunk, + })); + }, + }) + ) + ) + .catch(e => { + // We aborted it so we don't care about this error + if (e.name === 'AbortError') return; + + // Cleanup and show error to user + controller.abort(); + setError(e as ApiError); + }) + ); + + return () => { + controller.abort(); + }; + }, [isLoading, pods, lines, container, previous]); + + return { logs, error }; +}; + +/** + * Fetch and watch logs for a given pod + * + * @param params - Named params + * @returns logs and error (if any) + */ +export const usePodLogs = ({ + item, + container, + lines, + previous, +}: { + /** Fetch logs from this Pod */ + item: Pod; +} & LogParams) => { + const [logs, setLogs] = useState([]); + const [error, setError] = useState(); + + useEffect(() => { + const controller = new AbortController(); + + makePodLogsReadableStream({ + podName: item.metadata.name, + namespace: item.metadata.namespace!, + cluster: item.cluster, + container, + lines, + previous, + signal: controller.signal, + }) + .then(stream => + stream.pipeTo( + // Write all new logs into state + new WritableStream({ + write(chunk) { + setLogs(l => [...l, ...chunk]); + }, + }) + ) + ) + .catch(e => { + // We aborted it so we don't care about this error + if (e.name === 'AbortError') return; + + // Cleanup and show error to user + controller.abort(); + setError(e as ApiError); + }); + + return () => { + controller.abort(); + }; + }, [item, container, lines, previous]); + + return { logs, error }; +}; diff --git a/frontend/src/lib/k8s/api/v2/transformStreams.test.ts b/frontend/src/lib/k8s/api/v2/transformStreams.test.ts new file mode 100644 index 00000000000..a0889871526 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/transformStreams.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import App from '../../../../App'; +import { makeBatchingStream, makeLineSplitStream } from './transformStreams'; + +// eslint-disable-next-line no-unused-vars +const _App = App; + +const collectStream = async (readable: ReadableStream): Promise => { + const results: T[] = []; + const reader = readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + results.push(value); + } + return results; +}; + +describe('makeLineSplitStream', () => { + it('splits a single chunk with multiple lines', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('line1\nline2\nline3'); + controller.close(); + }, + }); + + const result = await collectStream(stream.pipeThrough(makeLineSplitStream())); + expect(result).toEqual(['line1', 'line2', 'line3']); + }); + + it('handles chunks split mid-line', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('hello wo'); + controller.enqueue('rld\nfoo'); + controller.close(); + }, + }); + + const result = await collectStream(stream.pipeThrough(makeLineSplitStream())); + expect(result).toEqual(['hello world', 'foo']); + }); + + it('handles empty stream', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + const result = await collectStream(stream.pipeThrough(makeLineSplitStream())); + expect(result).toEqual([]); + }); + + it('handles chunk with trailing newline', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('line1\nline2\n'); + controller.close(); + }, + }); + + const result = await collectStream(stream.pipeThrough(makeLineSplitStream())); + expect(result).toEqual(['line1', 'line2']); + }); + + it('flushes leftover content on close', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('line1\npartial'); + controller.close(); + }, + }); + + const result = await collectStream(stream.pipeThrough(makeLineSplitStream())); + expect(result).toEqual(['line1', 'partial']); + }); +}); + +describe('makeBatchingStream', () => { + it('batches items within the buffer time', async () => { + const controller = new AbortController(); + const batchingStream = makeBatchingStream(controller.signal, 10); + + const results: string[][] = []; + + const writer = batchingStream.writable.getWriter(); + const readerPromise = (async () => { + const reader = batchingStream.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + results.push(value); + } + })(); + + writer.write('item1'); + writer.write('item2'); + + await new Promise(res => setTimeout(res, 5)); + expect(results).toEqual([]); + + await new Promise(res => setTimeout(res, 15)); + expect(results).toEqual([['item1', 'item2']]); + + writer.write('item3'); + await new Promise(res => setTimeout(res, 15)); + + expect(results).toEqual([['item1', 'item2'], ['item3']]); + + writer.close(); + await readerPromise; + }); + + it('flushes remaining items on close', async () => { + const controller = new AbortController(); + const batchingStream = makeBatchingStream(controller.signal, 100); + + const results: string[][] = []; + + const writer = batchingStream.writable.getWriter(); + const readerPromise = (async () => { + const reader = batchingStream.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + results.push(value); + } + })(); + + writer.write('item1'); + writer.write('item2'); + writer.close(); + + await readerPromise; + + expect(results).toEqual([['item1', 'item2']]); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/transformStreams.ts b/frontend/src/lib/k8s/api/v2/transformStreams.ts new file mode 100644 index 00000000000..e5fd839a68d --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/transformStreams.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Transform stream that splits text into lines */ +export const makeLineSplitStream = () => { + let leftover = ''; + return new TransformStream({ + transform(chunk, out) { + const parts = (leftover + chunk).split('\n'); + leftover = parts.pop() || ''; + for (const l of parts) out.enqueue(l); + }, + flush(out) { + if (leftover) out.enqueue(leftover); + }, + }); +}; + +/** Transform stream that will batch items for given amount of time */ +export const makeBatchingStream = (signal: AbortSignal, bufferTimeMs: number = 500) => { + const buffer: string[] = []; + let interval: number | undefined; + + signal.addEventListener('abort', () => clearInterval(interval), { once: true }); + + return new TransformStream({ + start(controller) { + interval = window.setInterval(() => { + if (!buffer.length) return; + + try { + controller.enqueue([...buffer]); + } catch (e) { + clearInterval(interval); + } + buffer.length = 0; + }, bufferTimeMs); + }, + transform(chunk) { + buffer.push(chunk); + }, + flush(out) { + clearInterval(interval); + if (buffer.length) out.enqueue([...buffer]); + }, + }); +}; diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index a746c05ffc1..ce588b0142c 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -66,6 +66,7 @@ "Loader": [Function], "LogViewer": [Function], "LogsButton": [Function], + "LogsViewer": [Function], "MainInfoSection": [Function], "MatchExpressions": [Function], "MetadataDictGrid": [Function],