Files
portainer/app/react/components/LogViewer/LogLine.tsx
T
agent_coder f6752130ac feat(logs): re-skin viewer to maintainer's mockup design
Reproduce the maintainer-provided ContainerLogs mockup faithfully: dark card
(#0c0c0d), his header + single toolbar row styling, his custom search box /
'Filter search results' checkbox / Copy+Download buttons, his toggle-button
style (Line numbers / Timestamp / Wrap — no Auto refresh), and the dark log
area with a right-aligned line-number gutter. Palette carried inline in this one
component (deliberate dark log-viewer design).

Deviations from the mockup, by design: fonts/sizes use the project scale and
monospace (no Google Fonts / hardcoded Inter/JetBrains); real streaming data via
useLogViewer rendered as safe React span nodes (no dangerouslySetInnerHTML);
mock page chrome dropped (Portainer's page provides breadcrumb/title); the
datetime range keeps the functional react-datetimerange-picker. Live-tails by
default; selecting an upper bound in the range picker shows a bounded snapshot.

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

89 lines
2.7 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
// Left gutter styled to the maintainer's mockup (fixed width, right
// aligned, muted grey). Kept as its own class so the layout/tests can
// target it.
className="log-viewer-gutter shrink-0 select-none pr-4 text-right"
style={{ width: '3.25rem', color: '#8b939a' }}
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>
);
}