Files
portainer/app/docker/helpers/logHelper/formatJSONLogs.ts
T
claude code agent 8ca0608b21 fix(logs): stable line ids, JSON string guard, streaming demuxer helper
Foundation for append-only log rendering and HTTP log streaming.

- FormattedLine gains a stable, monotonically increasing `id` (new
  lineId.ts sequence), assigned centrally in formatLogs. Internal
  formatters now return id-less FormattedLineContent. This lets the
  viewer use `track by log.id` so already-rendered rows are never
  re-bound (fixes the text-selection collapse).
- formatJSONLine: runtime guard so a bare JSON string/array log line
  falls back to plain text instead of rendering Object.keys as
  `0=h 1=e ...`.
- createLogStreamProcessor: stateful demuxer that buffers streamed text,
  emits only complete lines (carrying the partial remainder), and reuses
  formatLogs/stripHeadersFunc for Docker 8-byte frame demux.
- Unit tests for the demuxer, stable-id assignment and the JSON guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:03:10 +03:00

70 lines
1.9 KiB
TypeScript

import { without } from 'lodash';
import {
FormattedLineContent,
Span,
JSONLogs,
TIMESTAMP_LENGTH,
} from './types';
import {
formatCaller,
formatKeyValuePair,
formatLevel,
formatMessage,
formatStackTrace,
formatTime,
} from './formatters';
function removeKnownKeys(keys: string[]) {
return without(keys, 'time', 'level', 'caller', 'message', 'stack_trace');
}
export function formatJSONLine(
rawText: string,
withTimestamps?: boolean
): FormattedLineContent[] {
const spans: Span[] = [];
const lines: FormattedLineContent[] = [];
let line = '';
const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText;
const parsed: unknown = JSON.parse(text);
// Only treat the line as a structured JSON log when it actually parses to a
// plain object. A line serialized as a bare JSON string (e.g. `"hello"`)
// parses to a string, whose `Object.keys` is `['0','1',...]` — which used to
// render as `0=h 1=e ...`. Fall back to the plain-text path in that case.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
const plain = withTimestamps ? rawText : text;
return [{ line: plain, spans: [{ text: plain }] }];
}
const json = parsed as JSONLogs;
const { time, level, caller, message, stack_trace: stackTrace } = json;
const keys = removeKnownKeys(Object.keys(json));
if (withTimestamps) {
const timestamp = rawText.substring(0, TIMESTAMP_LENGTH);
spans.push({ text: timestamp });
line += `${timestamp} `;
}
line += formatTime(time, spans, line);
line += formatLevel(level, spans, line);
line += formatCaller(caller, spans, line);
line += formatMessage(message, spans, line, !!keys.length);
keys.forEach((key, idx) => {
line += formatKeyValuePair(
key,
json[key],
spans,
line,
idx === keys.length - 1
);
});
lines.push({ line, spans });
formatStackTrace(stackTrace, lines);
return lines;
}