From 31387ace87d4b35400ec79ecd4b4496210977535 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 01:02:26 +0300 Subject: [PATCH] feat(ui): timeline scrubber for timestamped logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect ISO, date-time, syslog, and epoch timestamps in visible lines. When ≥2 timestamps exist, show a scrubber that jumps to the nearest line. Closes #31 --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- server/components/LogCanvas.tsx | 60 +++++++++++ server/lib/timestamps.test.ts | 80 ++++++++++++++ server/lib/timestamps.ts | 182 ++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 server/lib/timestamps.test.ts create mode 100644 server/lib/timestamps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0cfa2..3a4a7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log canvas **line pins / bookmarks** (per-paste `localStorage`, gutter star + sidebar jump list) - Log canvas **download filtered / visible lines** (`paste--filtered.log`) - Log canvas **custom highlight rules** (regex → color, per-browser `localStorage`) +- Log canvas **timeline scrubber** for timestamped logs (ISO / date-time / syslog / epoch) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 07b7789..beaa614 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,7 +80,7 @@ Focus: log analysis depth. | 1.2.2 | **Bookmark / pin lines** (local only) | ui | ✅ Done (per-paste `localStorage`, sidebar pins) | | 1.2.3 | **Multi-file or multi-paste compare** (side-by-side) | ui, api | Optional second paste id | | 1.2.4 | **Custom highlight rules** (user regex → color) | ui | ✅ Done (localStorage, sidebar regex → color) | -| 1.2.5 | **Timeline scrubber** for timestamped logs | ui | Detect common timestamp formats | +| 1.2.5 | **Timeline scrubber** for timestamped logs | ui | ✅ Done (ISO/syslog/epoch, scrub to jump) | | 1.2.6 | **Download filtered view** (not only raw) | ui | ✅ Done (export visible lines) | --- diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx index cdfb0f9..4424f71 100644 --- a/server/components/LogCanvas.tsx +++ b/server/components/LogCanvas.tsx @@ -31,6 +31,12 @@ import { type HighlightColorId, type HighlightRule, } from "@/lib/highlight-rules"; +import { + buildTimelineIndex, + formatTimelineTime, + nearestTimelinePoint, + scrubToTimeMs, +} from "@/lib/timestamps"; import { persistWrapMode, resolveInitialWrapMode, @@ -91,6 +97,8 @@ export function LogCanvas({ const [draftPattern, setDraftPattern] = useState(""); const [draftColor, setDraftColor] = useState("accent"); const [draftError, setDraftError] = useState(null); + const [scrubRatio, setScrubRatio] = useState(0); + const [scrubLabel, setScrubLabel] = useState(null); useEffect(() => { setWrapMode(resolveInitialWrapMode()); @@ -192,6 +200,27 @@ export function LogCanvas({ [allLines, levels, query], ); + // Timeline from currently visible lines so jumps always land in the list + const timeline = useMemo( + () => buildTimelineIndex(visibleLines), + [visibleLines], + ); + + function onTimelineScrub(ratio: number) { + setScrubRatio(ratio); + if (!timeline) { + setScrubLabel(null); + return; + } + const t = scrubToTimeMs(timeline, ratio); + const point = nearestTimelinePoint(timeline, t); + setScrubLabel( + `${formatTimelineTime(point.timeMs)} · L${point.lineNumber}`, + ); + setScrollToLine(null); + requestAnimationFrame(() => setScrollToLine(point.lineNumber)); + } + // Restore selection + scroll from URL hash useEffect(() => { const applyHash = () => { @@ -538,6 +567,37 @@ export function LogCanvas({ {!wrapMounted ? "Wrap" : wrapLabel} + {timeline ? ( +
+
+ + {formatTimelineTime(timeline.minMs)} + + + {scrubLabel ?? + `${timeline.points.length} timestamps · scrub to jump`} + + + {formatTimelineTime(timeline.maxMs)} + +
+ + + onTimelineScrub(Number.parseInt(e.target.value, 10) / 1000) + } + className="w-full accent-vscode-accent" + /> +
+ ) : null}
{ + it("parses ISO 8601", () => { + const ms = extractTimestampMs("2024-06-01T12:00:00.000Z start"); + expect(ms).toBe(Date.parse("2024-06-01T12:00:00.000Z")); + }); + + it("parses date space time as UTC", () => { + const ms = extractTimestampMs("2024-06-01 12:00:00 hello"); + expect(ms).toBe(Date.parse("2024-06-01T12:00:00.000Z")); + }); + + it("parses unix epoch seconds and ms", () => { + expect(extractTimestampMs("[1717243200] event")).toBe(1717243200 * 1000); + expect(extractTimestampMs(" 1717243200123 ")).toBe(1717243200123); + }); + + it("parses syslog with fallback year", () => { + const ms = extractTimestampMs("Jun 1 12:00:00 host app:", 2024); + expect(ms).toBe(Date.UTC(2024, 5, 1, 12, 0, 0)); + }); + + it("returns null when none found", () => { + expect(extractTimestampMs("no time here")).toBeNull(); + }); +}); + +describe("buildTimelineIndex / nearest / scrub", () => { + const lines = parseLogLines( + [ + "2024-01-01T00:00:00Z first", + "noise", + "2024-01-01T00:10:00Z middle", + "2024-01-01T00:20:00Z last", + ].join("\n"), + ); + + it("builds index when >= 2 timestamps", () => { + const index = buildTimelineIndex(lines); + expect(index).not.toBeNull(); + expect(index!.points).toHaveLength(3); + expect(index!.minMs).toBe(Date.parse("2024-01-01T00:00:00Z")); + expect(index!.maxMs).toBe(Date.parse("2024-01-01T00:20:00Z")); + }); + + it("returns null with fewer than 2 timestamps", () => { + const one = parseLogLines("2024-01-01T00:00:00Z only\nplain"); + expect(buildTimelineIndex(one)).toBeNull(); + }); + + it("finds nearest point and scrub mapping", () => { + const index = buildTimelineIndex(lines)!; + const mid = nearestTimelinePoint( + index, + Date.parse("2024-01-01T00:09:00Z"), + ); + expect(mid.lineNumber).toBe(3); + + expect(scrubToTimeMs(index, 0)).toBe(index.minMs); + expect(scrubToTimeMs(index, 1)).toBe(index.maxMs); + expect(scrubToTimeMs(index, 0.5)).toBe( + index.minMs + 0.5 * (index.maxMs - index.minMs), + ); + }); + + it("formats labels", () => { + expect(formatTimelineTime(Date.parse("2024-01-01T00:00:00.000Z"))).toContain( + "2024-01-01", + ); + }); +}); diff --git a/server/lib/timestamps.ts b/server/lib/timestamps.ts new file mode 100644 index 0000000..ac42157 --- /dev/null +++ b/server/lib/timestamps.ts @@ -0,0 +1,182 @@ +/** + * Extract timestamps from log lines for the canvas timeline scrubber. + */ + +import type { ParsedLogLine } from "./log-lines"; + +export interface TimelinePoint { + lineNumber: number; + /** Epoch ms */ + timeMs: number; +} + +export interface TimelineIndex { + points: TimelinePoint[]; + minMs: number; + maxMs: number; +} + +// ISO-like with optional fractional seconds and timezone +const ISO_RE = + /\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?)\b/; + +// Date + time without T: 2024-01-15 12:34:56 +const DATE_SPACE_RE = + /\b(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)\b/; + +// Syslog-style: Jan 2 15:04:05 (no year) +const SYSLOG_RE = + /\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\b/i; + +// Unix epoch seconds or ms (10 or 13 digits) near start +const EPOCH_RE = /(?:^|[\s\[])(\d{10}|\d{13})(?:\b|[\s\].])/; + +const MONTHS: Record = { + jan: 0, + feb: 1, + mar: 2, + apr: 3, + may: 4, + jun: 5, + jul: 6, + aug: 7, + sep: 8, + oct: 9, + nov: 10, + dec: 11, +}; + +function parseIsoLike(raw: string): number | null { + // Normalize space to T for Date.parse when no timezone + let s = raw.includes("T") ? raw : raw.replace(" ", "T"); + // If no timezone, treat as UTC for stability across locales + if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) { + s = `${s}Z`; + } + const ms = Date.parse(s); + return Number.isFinite(ms) ? ms : null; +} + +function parseSyslog(raw: string, year: number): number | null { + const m = + /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})$/i.exec( + raw.trim(), + ); + if (!m) return null; + const month = MONTHS[m[1]!.toLowerCase()]; + if (month === undefined) return null; + const day = Number.parseInt(m[2]!, 10); + const hh = Number.parseInt(m[3]!, 10); + const mm = Number.parseInt(m[4]!, 10); + const ss = Number.parseInt(m[5]!, 10); + const ms = Date.UTC(year, month, day, hh, mm, ss); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Best-effort timestamp extraction from a single plain log line. + * `fallbackYear` is used for syslog lines without a year. + */ +export function extractTimestampMs( + plain: string, + fallbackYear = 2024, +): number | null { + if (!plain) return null; + + const iso = ISO_RE.exec(plain); + if (iso?.[1]) { + const ms = parseIsoLike(iso[1]); + if (ms != null) return ms; + } + + const spaced = DATE_SPACE_RE.exec(plain); + if (spaced?.[1]) { + const ms = parseIsoLike(spaced[1]); + if (ms != null) return ms; + } + + const epoch = EPOCH_RE.exec(plain); + if (epoch?.[1]) { + const n = Number.parseInt(epoch[1], 10); + if (epoch[1].length === 13) return n; + if (epoch[1].length === 10) return n * 1000; + } + + const syslog = SYSLOG_RE.exec(plain); + if (syslog?.[1]) { + return parseSyslog(syslog[1], fallbackYear); + } + + return null; +} + +/** + * Build a sorted timeline from lines that carry parseable timestamps. + * Requires at least 2 points for a useful scrubber. + */ +export function buildTimelineIndex( + lines: readonly ParsedLogLine[], + options?: { fallbackYear?: number }, +): TimelineIndex | null { + const year = options?.fallbackYear ?? new Date().getUTCFullYear(); + const points: TimelinePoint[] = []; + + for (const line of lines) { + const timeMs = extractTimestampMs(line.plain, year); + if (timeMs == null) continue; + points.push({ lineNumber: line.lineNumber, timeMs }); + } + + if (points.length < 2) return null; + + points.sort((a, b) => a.timeMs - b.timeMs || a.lineNumber - b.lineNumber); + + let minMs = points[0]!.timeMs; + let maxMs = points[0]!.timeMs; + for (const p of points) { + if (p.timeMs < minMs) minMs = p.timeMs; + if (p.timeMs > maxMs) maxMs = p.timeMs; + } + + if (minMs === maxMs) return null; + + return { points, minMs, maxMs }; +} + +/** Nearest timeline point to `timeMs` (binary search). */ +export function nearestTimelinePoint( + index: TimelineIndex, + timeMs: number, +): TimelinePoint { + const { points } = index; + let lo = 0; + let hi = points.length - 1; + if (timeMs <= points[0]!.timeMs) return points[0]!; + if (timeMs >= points[hi]!.timeMs) return points[hi]!; + + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const t = points[mid]!.timeMs; + if (t === timeMs) return points[mid]!; + if (t < timeMs) lo = mid + 1; + else hi = mid - 1; + } + // lo is first > timeMs; hi is last < timeMs + const a = points[Math.max(0, hi)]!; + const b = points[Math.min(points.length - 1, lo)]!; + return Math.abs(a.timeMs - timeMs) <= Math.abs(b.timeMs - timeMs) ? a : b; +} + +/** Map 0..1 scrub position to time within the index range. */ +export function scrubToTimeMs(index: TimelineIndex, ratio: number): number { + const r = Math.min(1, Math.max(0, ratio)); + return index.minMs + r * (index.maxMs - index.minMs); +} + +export function formatTimelineTime(ms: number): string { + try { + return new Date(ms).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); + } catch { + return String(ms); + } +}