f6752130ac
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>
89 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
}
|