diff --git a/src/lib/utils/log-entry.ts b/src/lib/utils/log-entry.ts index 241679a..fea4002 100644 --- a/src/lib/utils/log-entry.ts +++ b/src/lib/utils/log-entry.ts @@ -52,6 +52,35 @@ export function parseLines( return { entries, carryover: tail }; } +// Merge grouped-mode entries into one timeline ordered by Docker's ISO +// timestamp (oldest first, so the newest lands at the bottom). Used for the +// initial tail + stopped-container backfill, NOT for live appends (live lines +// already arrive in order and appending keeps them at the bottom). +// +// A continuation line (multi-line log, no timestamp of its own) must stay glued +// to its parent line from the SAME container, so it inherits the last seen +// timestamp for that container before sorting. ISO timestamps sort correctly as +// strings; ties (or missing timestamps) fall back to `id`, which is monotonic +// with arrival order, giving a stable, deterministic result on every click. +export function sortByTimestampStable(entries: LogEntry[]): LogEntry[] { + const lastTsByContainer = new Map(); + const keyed = entries.map((e) => { + const cid = e.containerId ?? ''; + let ts = e.timestamp; + if (ts) { + lastTsByContainer.set(cid, ts); + } else { + ts = lastTsByContainer.get(cid); + } + return { e, sortTs: ts ?? '' }; + }); + keyed.sort((a, b) => { + if (a.sortTs !== b.sortTs) return a.sortTs < b.sortTs ? -1 : 1; + return a.e.id - b.e.id; + }); + return keyed.map((k) => k.e); +} + // Per-entry ANSI HTML cache. WeakMap so entries evicted by buffer compaction // can be GC'd along with their cached HTML. const ansiCache = new WeakMap(); diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index 4be92c6..76fed1e 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -21,7 +21,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment'; import { appSettings, formatLogTimestamps } from '$lib/stores/settings'; import { NoEnvironment } from '$lib/components/ui/empty-state'; - import { parseLines, renderLineHtml, type LogEntry } from '$lib/utils/log-entry'; + import { parseLines, renderLineHtml, sortByTimestampStable, type LogEntry } from '$lib/utils/log-entry'; function renderTimestamp(ts: string | undefined): string { if (!ts) return ''; @@ -165,14 +165,19 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; queueMicrotask(flushGroupedLogs); } - function flushGroupedLogs() { + // initial=true is the tail flush: sort the whole buffer by timestamp so the + // history reads as one timeline regardless of per-container arrival order + // (#1406). Live flushes append in arrival order (already chronological) to + // avoid new lines jumping into the middle of the view. + function flushGroupedLogs(initial = false) { groupedFlushScheduled = false; if (pendingGroupedEntries.length === 0) return; const maxLines = getMaxLines(); const incoming = pendingGroupedEntries.length > maxLines ? pendingGroupedEntries.slice(pendingGroupedEntries.length - maxLines) : pendingGroupedEntries; - mergedLogs = compact([...mergedLogs, ...incoming], maxLines); + const combined = [...mergedLogs, ...incoming]; + mergedLogs = compact(initial ? sortByTimestampStable(combined) : combined, maxLines); pendingGroupedEntries = []; scrollToBottom(); } @@ -999,7 +1004,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; if (data.logs) { const { entries } = parseLines(data.logs, '', { containerId, containerName, color }); - mergedLogs = compact([...mergedLogs, ...entries], getMaxLines()); + // Stopped logs are historical: sort the merged buffer by timestamp so + // each container's block interleaves into one timeline instead of being + // appended as a separate per-container chunk (#1406). + mergedLogs = compact(sortByTimestampStable([...mergedLogs, ...entries]), getMaxLines()); } } catch (error) { console.error(`Failed to fetch logs for stopped container ${containerId}:`, error); @@ -1097,7 +1105,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; initialBuffering = false; initialBufferTimeout = null; loading = false; - flushGroupedLogs(); + flushGroupedLogs(true); }, INITIAL_BUFFER_DELAY); }); @@ -1153,7 +1161,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; initialBufferTimeout = null; } loading = false; - flushGroupedLogs(); + flushGroupedLogs(true); } });