8829c8cfd2
- 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>
386 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|