Files
portainer/app/react/components/LogViewer/LogLine.tsx
T
agent_coder 7f02d20e54 feat(logs): React log viewer for container logs
Replace the legacy AngularJS <log-viewer> on the container logs page with a
modern React log viewer, reusing the existing streaming (#6) and formatting/
coloring pipeline. Features: line-number gutter, zerolog level + key=value
coloring (from the existing formatter spans), from/to datetime range, Lines
limit, Line numbers / Timestamp / Wrap lines toggles, Auto refresh (live tail
on/off), Search + 'Filter search results', Copy, Download logs, and fullscreen.

The viewer is source-agnostic (StreamLogsFn), so service/task logs can adopt it
later; this PR wires container logs only. containerLogsController.js no longer
opens its own live stream (React owns fetching now), preventing a double stream.

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

85 lines
2.5 KiB
TypeScript

import { Fragment, ReactNode } from 'react';
import { FormattedLine } from '@/docker/helpers/logHelper/types';
interface Props {
line: FormattedLine;
/** 1-based ordinal shown in the left gutter (when line numbers are on). */
lineNumber?: number;
showLineNumber: boolean;
/** Case-insensitive substring to highlight (empty = no highlight). */
search: string;
}
// Split a span's text on the search term (case-insensitive) and wrap matches in
// a <mark>. Highlighting is done per span, so a match that straddles two spans
// (e.g. a coloured boundary) is not highlighted — an accepted edge case: the
// formatter's spans are the colouring unit and re-tokenising them to highlight
// across boundaries would fight the existing pipeline.
function highlight(text: string, search: string): ReactNode {
if (!search) {
return text;
}
const lower = text.toLowerCase();
const term = search.toLowerCase();
const parts: ReactNode[] = [];
let start = 0;
for (;;) {
const idx = lower.indexOf(term, start);
if (idx === -1) {
break;
}
if (idx > start) {
parts.push(text.substring(start, idx));
}
parts.push(
<mark key={idx} className="bg-warning-5 th-dark:bg-warning-9">
{text.substring(idx, idx + term.length)}
</mark>
);
start = idx + term.length;
}
if (parts.length === 0) {
return text;
}
if (start < text.length) {
parts.push(text.substring(start));
}
return parts.map((part, i) => <Fragment key={i}>{part}</Fragment>);
}
export function LogLine({ line, lineNumber, showLineNumber, search }: Props) {
return (
<div className="log-viewer-line flex">
{showLineNumber && (
<span
className="log-viewer-gutter shrink-0 select-none pr-3 text-right text-muted"
aria-hidden="true"
>
{lineNumber}
</span>
)}
<span className="log-viewer-line-content min-w-0 flex-1">
{line.spans.length === 0 ? (
// keep empty lines visible (they still occupy a row)
' '
) : (
line.spans.map((span, index) => (
<span
// spans have no stable id; index within a formatted line is stable
key={index}
style={{
color: span.fgColor,
backgroundColor: span.bgColor,
fontWeight: span.fontWeight,
}}
>
{highlight(span.text, search)}
</span>
))
)}
</span>
</div>
);
}