Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>-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

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---
Expand Down
60 changes: 60 additions & 0 deletions server/components/LogCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ import {
type HighlightColorId,
type HighlightRule,
} from "@/lib/highlight-rules";
import {
buildTimelineIndex,
formatTimelineTime,
nearestTimelinePoint,
scrubToTimeMs,
} from "@/lib/timestamps";
import {
persistWrapMode,
resolveInitialWrapMode,
Expand Down Expand Up @@ -91,6 +97,8 @@ export function LogCanvas({
const [draftPattern, setDraftPattern] = useState("");
const [draftColor, setDraftColor] = useState<HighlightColorId>("accent");
const [draftError, setDraftError] = useState<string | null>(null);
const [scrubRatio, setScrubRatio] = useState(0);
const [scrubLabel, setScrubLabel] = useState<string | null>(null);

useEffect(() => {
setWrapMode(resolveInitialWrapMode());
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -538,6 +567,37 @@ export function LogCanvas({
{!wrapMounted ? "Wrap" : wrapLabel}
</button>
</div>
{timeline ? (
<div className="flex shrink-0 flex-col gap-1 border-b border-vscode-border bg-vscode-sidebar px-3 py-2">
<div className="flex items-center justify-between gap-2 font-mono text-[10px] text-vscode-muted">
<span title="Earliest timestamp in visible lines">
{formatTimelineTime(timeline.minMs)}
</span>
<span className="truncate text-vscode-accent">
{scrubLabel ??
`${timeline.points.length} timestamps · scrub to jump`}
</span>
<span title="Latest timestamp in visible lines">
{formatTimelineTime(timeline.maxMs)}
</span>
</div>
<label className="sr-only" htmlFor="timeline-scrub">
Timeline scrubber
</label>
<input
id="timeline-scrub"
type="range"
min={0}
max={1000}
step={1}
value={Math.round(scrubRatio * 1000)}
onChange={(e) =>
onTimelineScrub(Number.parseInt(e.target.value, 10) / 1000)
}
className="w-full accent-vscode-accent"
/>
</div>
) : null}
<div className="min-h-0 flex-1">
<VirtualLogList
lines={visibleLines}
Expand Down
80 changes: 80 additions & 0 deletions server/lib/timestamps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { parseLogLines } from "./log-lines";
import {
buildTimelineIndex,
extractTimestampMs,
formatTimelineTime,
nearestTimelinePoint,
scrubToTimeMs,
} from "./timestamps";

describe("extractTimestampMs", () => {
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",
);
});
});
182 changes: 182 additions & 0 deletions server/lib/timestamps.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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);
}
}
Loading