Files
portainer/app/react/components/LogViewer/LogViewer.tsx
T
agent_coder 8829c8cfd2 fix(#6): theme-consistent search highlight + Copied ack on Copy button
- F3: give the <mark> search highlight the full theme-token tier set
  (light / th-dark / th-highcontrast, with a legible text colour) so it reads
  correctly in every theme now that the viewer is theme-aware.
- F6: restore the transient 'Copied' acknowledgement on the Copy button via
  useCopy's copiedSuccessfully (Check icon + 'Copied' label).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:46:55 +03:00

386 lines
13 KiB
TypeScript

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 (`<resourceName>_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<Date | null>(null);
const [until, setUntil] = useState<Date | null>(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<HTMLDivElement>(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.
<div
className="flex w-full flex-col"
style={{ height: 'calc(100vh - 150px)' }}
>
{/* 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. */}
<div
className={clsx(
'flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-solid',
'border-gray-5 bg-white',
'th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11',
'th-highcontrast:border-white th-highcontrast:bg-black'
)}
>
{/* Header row: file icon + "Logs" left, search / filter / copy / download right */}
<div
className={clsx(
'flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',
'border-0 border-b border-solid border-gray-5 bg-gray-iron-2',
'th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-10',
'th-highcontrast:border-white th-highcontrast:bg-gray-warm-10'
)}
>
<div className="flex items-center gap-2">
<Icon
icon={File}
size="md"
className="text-gray-7 th-dark:text-gray-4 th-highcontrast:text-white"
/>
<span className="text-sm font-semibold text-gray-9 th-dark:text-gray-2 th-highcontrast:text-white">
Logs
</span>
<Button
color="link"
size="xsmall"
icon={RefreshCw}
onClick={() => setReloadNonce((n) => n + 1)}
title="Reload logs"
aria-label="Reload logs"
className="!m-0 !p-0"
data-cy="log-viewer-reload"
/>
</div>
<div className="flex-1" />
{/* Search box */}
<div className="relative w-56 max-w-full">
<Search
size={14}
aria-hidden="true"
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted"
/>
<Input
id="log-viewer-search"
type="text"
value={search}
placeholder="Search..."
onChange={(e) => setSearch(e.target.value)}
data-cy="log-viewer-search"
className="!pl-8 !pr-8"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
aria-label="Clear search"
className="absolute right-2 top-1/2 flex -translate-y-1/2 border-0 bg-transparent p-0 text-muted"
>
<X size={14} />
</button>
)}
</div>
{/* Filter search results — project themed checkbox */}
<Checkbox
id="log-viewer-filter-results"
label="Filter search results"
checked={filterResults}
onChange={(e) => setFilterResults(e.target.checked)}
bold={false}
data-cy="log-viewer-filter-results"
/>
{/* Copy — shows a transient "Copied" acknowledgement */}
<Button
color="default"
size="small"
icon={copiedSuccessfully ? Check : Copy}
onClick={handleCopy}
className="!m-0"
data-cy="log-viewer-copy"
>
{copiedSuccessfully ? 'Copied' : 'Copy'}
</Button>
{/* Download logs */}
<Button
color="primary"
size="small"
icon={Download}
onClick={downloadLogs}
disabled={!hasLogs}
className="!m-0"
data-cy="log-viewer-download"
>
Download logs
</Button>
</div>
{/* Toolbar row: datetime range / lines / toggles */}
<div
className={clsx(
'flex flex-wrap items-center gap-2 px-4 py-2.5',
'border-0 border-b border-solid border-gray-5 bg-gray-iron-2',
'th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-10',
'th-highcontrast:border-white th-highcontrast:bg-gray-warm-10'
)}
>
{/* 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. */}
<div data-cy="log-viewer-datetime-range" className="text-xs">
<DateTimeRangePicker
// Wrap in a themed form-control and strip the picker's own inner
// border so only the project frame shows.
className="form-control h-9 [&>div]:border-0"
format="y-MM-dd HH:mm"
value={since || until ? [since, until] : null}
onChange={(value) => {
if (Array.isArray(value)) {
setSince(value[0]);
setUntil(value[1]);
return;
}
setSince(value);
setUntil(null);
}}
calendarIcon={<Calendar size={14} />}
clearIcon={<X size={14} />}
/>
</div>
{/* Lines (historical-backfill tail request) */}
<div className="flex items-center gap-2">
<label
htmlFor="log-viewer-lines"
className="!mb-0 text-xs text-muted"
>
Lines
</label>
<Input
id="log-viewer-lines"
type="number"
min={1}
value={lineCount}
placeholder="Lines..."
onChange={(e) => {
// 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"
/>
</div>
{/* Display toggles */}
<div className="flex flex-wrap items-center gap-2">
<ToggleButton
active={lineNumbers}
onChange={setLineNumbers}
label="Line numbers"
icon={List}
title="Toggle line numbers"
data-cy="log-viewer-line-numbers"
/>
<ToggleButton
active={timestamps}
onChange={setTimestamps}
label="Timestamp"
icon={Clock}
title="Toggle timestamps"
data-cy="log-viewer-timestamps"
/>
<ToggleButton
active={wrapLines}
onChange={setWrapLines}
label="Wrap lines"
icon={WrapText}
title="Toggle line wrapping"
data-cy="log-viewer-wrap"
/>
</div>
</div>
{error && (
<div className="border-0 border-b border-solid border-gray-5 px-4 py-2 text-xs text-error-8 th-dark:border-legacy-grey-3 th-dark:text-error-6">
{error}
</div>
)}
{/* 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. */}
<div
ref={scrollRef}
onScroll={onScroll}
className={clsx(
'log_viewer font-mono',
wrapLines ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
style={{
flex: '1 1 auto',
minHeight: 0,
height: 'auto',
overflowY: 'auto',
padding: '12px 4px 18px 0',
}}
data-cy="log-viewer-body"
>
{hasLogs ? (
displayedLogs.map((line, index) => (
<LogLine
key={line.id}
line={line}
lineNumber={index + 1}
showLineNumber={lineNumbers}
search={search}
/>
))
) : (
<div className="px-4 py-2 text-xs text-muted">
{search
? `No log line matching the '${search}' filter`
: 'No logs available'}
</div>
)}
</div>
</div>
</div>
);
}