import { useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { saveAs } from 'file-saver'; import DateTimeRangePicker from '@wojtekmaj/react-datetimerange-picker'; import { Calendar, Check, Clock, Copy, Download, File, List, RefreshCw, Search, WrapText, X, } from 'lucide-react'; import '@wojtekmaj/react-datetimerange-picker/dist/DateTimeRangePicker.css'; import 'react-calendar/dist/Calendar.css'; import 'react-datetime-picker/dist/DateTimePicker.css'; import { concatLogsToString } from '@/docker/helpers/logHelper'; import { FormattedLine } from '@/docker/helpers/logHelper/types'; import { useCopy } from '@@/buttons/CopyButton/useCopy'; import { Button } from '@@/buttons'; import { Icon } from '@@/Icon'; import { Input } from '@@/form-components/Input'; import { Checkbox } from '@@/form-components/Checkbox'; import { StreamLogsFn } from './types'; import { useLogViewer } from './useLogViewer'; import { LogLine } from './LogLine'; import { ToggleButton } from './ToggleButton'; interface Props { /** Opens the (optionally following) log stream — see StreamLogsFn. */ streamLogs: StreamLogsFn; /** * Identifies the streamed resource (e.g. container id). `streamLogs` closes * over it, so it is forwarded to the hook to restart the stream on a switch. */ resourceId: string; /** Non-TTY container: Docker multiplexes stdout/stderr with frame headers. */ hasFrameHeaders: boolean; /** Used for the download filename (`_logs.txt`). */ resourceName: string; } function filterLogs(logs: FormattedLine[], search: string): FormattedLine[] { const term = search.toLowerCase(); return logs.filter((log) => log.line.toLowerCase().includes(term)); } export function LogViewer({ streamLogs, resourceId, hasFrameHeaders, resourceName, }: Props) { // Presentation state (the hook owns only the line buffer + network lifecycle). const [search, setSearch] = useState(''); const [filterResults, setFilterResults] = useState(false); const [lineNumbers, setLineNumbers] = useState(true); const [timestamps, setTimestamps] = useState(false); const [wrapLines, setWrapLines] = useState(true); const [lineCount, setLineCount] = useState(100); const [since, setSince] = useState(null); const [until, setUntil] = useState(null); const [reloadNonce, setReloadNonce] = useState(0); // The viewer live-follows by default and only switches to a bounded, static // snapshot when the datetime-range picker sets an upper bound. Deriving this // (rather than exposing a follow/pause toggle) keeps the toolbar to the three // toggles in the reference design: Line numbers / Timestamp / Wrap lines. const autoRefresh = !until; const scrollRef = useRef(null); // Whether the view is pinned to the bottom (so live lines keep it tailing). const stickToBottomRef = useRef(true); const { logs, error } = useLogViewer({ streamLogs, resourceId, stripHeaders: hasFrameHeaders, autoRefresh, withTimestamps: timestamps, lineCount, since, until, reloadNonce, }); const displayedLogs = useMemo( () => (filterResults && search ? filterLogs(logs, search) : logs), [logs, filterResults, search] ); const logsAsString = useMemo( () => concatLogsToString(displayedLogs), [displayedLogs] ); // Copy uses the project's secure-context-safe clipboard wrapper. const { handleCopy, copiedSuccessfully } = useCopy(logsAsString); // Keep the newest line in view while tailing, but only if the user has not // scrolled up to read history (so an active read is never yanked to the bottom). useEffect(() => { const el = scrollRef.current; if (el && stickToBottomRef.current) { el.scrollTop = el.scrollHeight; } }, [displayedLogs]); function onScroll() { const el = scrollRef.current; if (!el) { return; } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; stickToBottomRef.current = distanceFromBottom < 24; } function downloadLogs() { saveAs(new Blob([logsAsString]), `${resourceName}_logs.txt`); } const hasLogs = displayedLogs.length > 0; return ( // Fill the available page height so the viewer reaches down toward the // viewport bottom instead of leaving a dead white void below a fixed card. // The 150px offset accounts for Portainer's top nav + the page // header/breadcrumb rendered above this component.
{/* Themed card surface — mirrors the project's Card/Widget tokens so it adapts to light / dark / high-contrast like every other Portainer view. It is a flex column whose log body grows to fill the height. */}
{/* Header row: file icon + "Logs" left, search / filter / copy / download right */}
Logs
{/* Search box */}
{/* Filter search results — project themed checkbox */} setFilterResults(e.target.checked)} bold={false} data-cy="log-viewer-filter-results" /> {/* Copy — shows a transient "Copied" acknowledgement */} {/* Download logs */}
{/* Toolbar row: datetime range / lines / toggles */}
{/* One combined "from – to" datetime range control (with time). The functional @wojtekmaj picker is dropped into the project's themed form-control frame — the same approach as the app's DateRangePicker — so it reads correctly in both themes; clearing it (null) drops the time window. */}
{ if (Array.isArray(value)) { setSince(value[0]); setUntil(value[1]); return; } setSince(value); setUntil(null); }} calendarIcon={} clearIcon={} />
{/* Lines (historical-backfill tail request) */}
{ // Clearing a number input yields NaN (which React rejects as a // value); fall back to 0, which the hook treats as "use the // default tail". const n = e.target.valueAsNumber; setLineCount(Number.isNaN(n) ? 0 : n); }} data-cy="log-viewer-lines" className="!w-24" />
{/* Display toggles */}
{error && (
{error}
)} {/* Log body — project monospace + themed `.log_viewer` background/text (from app.css, which follows the theme). Grows to fill the card so there is no dead space below; overrides `.log_viewer`'s height:100% with a flex fill. */}
{hasLogs ? ( displayedLogs.map((line, index) => ( )) ) : (
{search ? `No log line matching the '${search}' filter` : 'No logs available'}
)}
); }