Skip to content

Log Viewer

render_log_viewer.py

Standalone script that reads a structured_log.parquet file and generates a self-contained HTML page for interactively scrubbing through the run timeline.

Usage

python render_log_viewer.py /path/to/structured_log.parquet

The SLO configuration is read from config.yml or runner_config.yml in the same directory as the log file. The HTML file is written next to the input log file.

Supports both runner and simulator logs. Both log kinds share the same event vocabulary defined in :class:~autoslo.utils.structured_events.EventType.

HTML_TEMPLATE = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Structured Log Viewer</title>\n<style>\n* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #1a1a2e; color: #e0e0e0; overflow: hidden; }\n\n/* Layout */\n.container { display: flex; flex-direction: column; height: 100vh; }\n.header { padding: 8px 16px; background: #16213e; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }\n.header h1 { font-size: 14px; font-weight: 600; color: #e94560; }\n.header .meta { font-size: 11px; color: #888; margin-left: 16px; }\n.header .stats { font-size: 12px; color: #a0a0a0; }\n.header .stats span { margin-left: 16px; }\n.header .stats .violation { color: #e94560; }\n.header .stats .met { color: #4ecca3; }\n\n.main { display: flex; flex: 1; overflow: hidden; }\n\n/* Gantt panel */\n.gantt-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n.gantt-controls { padding: 6px 16px; background: #16213e; border-bottom: 1px solid #0f3460; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }\n/* Scrubber row: sits between controls and viewport; spacer aligns thumb with canvas timeline */\n.scrubber-row { display: flex; align-items: center; background: #16213e; border-bottom: 2px solid #0f3460; padding: 3px 0 3px 0; flex-shrink: 0; }\n.scrubber-spacer { flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 10px; } /* width set by JS to match CLUSTER_LABEL_WIDTH */\n.scrubber-tail { flex-shrink: 0; } /* width set by JS to match viewport scrollbar gutter */\n.scrubber-row input[type=range] { flex: 1; accent-color: #e94560; margin: 0; }\n.scrubber-row .time-display { font-size: 12px; color: #e94560; font-weight: 600; min-width: 72px; text-align: right; }\n.gantt-viewport { flex: 1; overflow: auto; position: relative; }\n.gantt-canvas-wrap { position: relative; min-height: 100%; }\ncanvas#gantt { display: block; }\n\n/* Event log panel */\n.event-panel { width: 360px; border-left: 1px solid #0f3460; display: flex; flex-direction: column; background: #16213e; flex-shrink: 0; }\n.event-panel h2 { font-size: 12px; padding: 8px 12px; border-bottom: 1px solid #0f3460; color: #a0a0a0; text-transform: uppercase; letter-spacing: 1px; }\n.event-list { flex: 1; overflow-y: auto; font-size: 11px; }\n.event-item { padding: 4px 12px; border-bottom: 1px solid #0f3460; }\n.event-item.highlight { background: #0f3460; }\n.event-item .event-time { color: #e94560; font-weight: 600; }\n.event-item .event-type { color: #4ecca3; margin-left: 6px; }\n.event-item .event-detail { color: #888; margin-left: 4px; }\n.event-item .score-toggle { cursor: pointer; color: #4ecca3; margin-left: 4px; text-decoration: underline; }\n.event-item .score-details { display: none; margin-top: 2px; padding-left: 12px; color: #888; }\n.event-item .score-details.open { display: block; }\n\n/* Tooltip */\n.tooltip { position: fixed; background: #16213e; border: 1px solid #0f3460; padding: 8px 12px; font-size: 11px; pointer-events: none; z-index: 100; border-radius: 4px; max-width: 400px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); display: none; }\n.tooltip .tt-row { margin: 2px 0; }\n.tooltip .tt-label { color: #a0a0a0; }\n.tooltip .tt-value { color: #e0e0e0; font-weight: 600; }\n.tooltip .tt-violation { color: #e94560; }\n.tooltip .tt-met { color: #4ecca3; }\n.tooltip .tt-failed { color: #f0a500; }\n\n/* Zoom controls */\n.zoom-controls { display: flex; gap: 4px; }\n.zoom-controls button { background: #0f3460; border: 1px solid #0f3460; color: #e0e0e0; padding: 2px 10px; cursor: pointer; font-size: 12px; border-radius: 3px; }\n.zoom-controls button:hover { background: #e94560; }\n\n/* Playback */\n.playback-controls { display: flex; gap: 4px; align-items: center; }\n.playback-controls button { background: #0f3460; border: 1px solid #0f3460; color: #e0e0e0; padding: 2px 8px; cursor: pointer; font-size: 12px; border-radius: 3px; }\n.playback-controls button:hover { background: #e94560; }\n.playback-controls button.active { background: #e94560; }\n</style>\n</head>\n<body>\n<div class="container">\n <div class="header">\n <h1>Structured Log Viewer</h1>\n <span class="meta" id="run-meta"></span>\n <div class="stats">\n <span>Queries: <b id="stat-total">0</b></span>\n <span>Completed: <b id="stat-completed">0</b></span>\n <span class="violation">Violations: <b id="stat-violations">0</b></span>\n <span class="violation">Rate: <b id="stat-viol-rate">0%</b></span>\n <span>Clusters: <b id="stat-clusters">0</b></span>\n </div>\n </div>\n <div class="main">\n <div class="gantt-panel">\n <div class="gantt-controls">\n <div class="playback-controls">\n <button id="btn-play" title="Play/Pause">&#9654;</button>\n <button id="btn-reset" title="Reset">&#9632;</button>\n </div>\n <div class="zoom-controls">\n <button id="btn-zoom-in" title="Zoom in (=)">+</button>\n <button id="btn-zoom-out" title="Zoom out (-)">-</button>\n <button id="btn-zoom-fit" title="Zoom to fit (0)">Fit</button>\n </div>\n </div>\n <div class="scrubber-row" id="scrubber-row">\n <div class="scrubber-spacer" id="scrubber-spacer">\n <div class="time-display" id="time-display">0.0s</div>\n </div>\n <input type="range" id="time-slider" min="0" max="1000" value="1000" step="1">\n <div class="scrubber-tail" id="scrubber-tail"></div>\n </div>\n <div class="gantt-viewport" id="gantt-viewport">\n <div class="gantt-canvas-wrap">\n <canvas id="gantt"></canvas>\n </div>\n </div>\n </div>\n <div class="event-panel">\n <h2>Events (<span id="event-count">0</span>)</h2>\n <div class="event-list" id="event-list"></div>\n </div>\n </div>\n</div>\n<div class="tooltip" id="tooltip"></div>\n\n<script>\n// ===========================================================================\n// DATA (injected by Python)\n// ===========================================================================\nconst DATA = __DATA_PLACEHOLDER__;\n\n// ===========================================================================\n// HELPERS\n// ===========================================================================\n\nfunction escapeHtml(s) {\n if (s == null) return "";\n return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")\n .replace(/>/g,"&gt;").replace(/"/g,"&quot;");\n}\n\nfunction formatMaybeNumber(v, digits = 2) {\n if (v == null || v === "") return "?";\n const n = Number(v);\n if (!Number.isFinite(n)) return escapeHtml(v);\n return n.toFixed(digits);\n}\n\n// ===========================================================================\n// STATE\n// ===========================================================================\nconst state = {\n currentTime: DATA.time_range[1],\n zoom: 1.0,\n panX: 0,\n playing: false,\n playTimer: null,\n playSpeed: 50,\n arrivalIdx: DATA.arrival_times.length,\n hoveredQuery: null,\n};\n\n// ===========================================================================\n// CONSTANTS\n// ===========================================================================\nconst COLORS = {\n met: "#4ecca3",\n metLight: "rgba(78,204,163,0.35)",\n violated: "#e94560",\n violatedLight: "rgba(233,69,96,0.35)",\n failed: "#f0a500",\n failedLight: "rgba(240,165,0,0.35)",\n running: "#555577",\n clusterBg: "#1e1e3a",\n clusterLine: "#0f3460",\n pending: "#ffcc00",\n pendingBg: "rgba(255,204,0,0.08)",\n text: "#a0a0a0",\n timeline: "#e94560",\n};\n\nconst ROW_HEIGHT = 18;\nconst LANE_GAP = 2;\nconst CLUSTER_PADDING = 6;\n// CLUSTER_LABEL_WIDTH is computed dynamically after realClusters is built.\nconst HEADER_HEIGHT = 56; // two 28px sub-rows: top = minutes, bottom = seconds\nconst HEADER_MID = HEADER_HEIGHT / 2; // y of divider between the two sub-rows\nconst MARKER_STRIP_HEIGHT = 10; // reserved px above query lanes for lifecycle markers\n// Minimum row height to ensure all label lines (name + RPU + queries + active) always fit.\nconst CLUSTER_LABEL_MIN_HEIGHT = CLUSTER_PADDING + MARKER_STRIP_HEIGHT + 42 + CLUSTER_PADDING; // 42px covers 3 label lines at 11px line-height\n\n// Off-screen canvas for the diagonal hatch pattern (dead-zone fill)\nconst _hatchCanvas = document.createElement("canvas");\n_hatchCanvas.width = 8;\n_hatchCanvas.height = 8;\n(function() {\n const hctx = _hatchCanvas.getContext("2d");\n hctx.clearRect(0, 0, 8, 8);\n hctx.strokeStyle = "rgba(255,255,255,0.06)";\n hctx.lineWidth = 1;\n // two diagonal stripes per tile (top-left to bottom-right)\n hctx.beginPath();\n hctx.moveTo(0, 0); hctx.lineTo(8, 8);\n hctx.moveTo(-4, 4); hctx.lineTo(4, -4);\n hctx.moveTo(4, 12); hctx.lineTo(12, 4);\n hctx.stroke();\n}());\nlet _hatchPattern = null; // lazily created per canvas context\n\n// ===========================================================================\n// DERIVED DATA\n// ===========================================================================\n\n// Build cluster lifecycle from cluster events and queries.\nconst clusterLifecycle = {};\n\nDATA.queries.forEach(q => {\n const name = q.cluster_name;\n if (!name) return;\n if (!(name in clusterLifecycle)) {\n clusterLifecycle[name] = { firstSeen: q.start_s, lastSeen: q.end_s, rpu: q.rpu, spinUpStarted: null, readyTime: null };\n }\n clusterLifecycle[name].firstSeen = Math.min(clusterLifecycle[name].firstSeen, q.start_s);\n clusterLifecycle[name].lastSeen = Math.max(clusterLifecycle[name].lastSeen, q.end_s);\n if (q.rpu != null) clusterLifecycle[name].rpu = q.rpu;\n});\n\nDATA.cluster_events.forEach(e => {\n const name = e.cluster_name;\n if (!name) return;\n if (!(name in clusterLifecycle)) {\n clusterLifecycle[name] = { firstSeen: e.rel_time_s, lastSeen: e.rel_time_s, rpu: e.rpu, spinUpStarted: null, readyTime: null };\n }\n clusterLifecycle[name].firstSeen = Math.min(clusterLifecycle[name].firstSeen, e.rel_time_s);\n clusterLifecycle[name].lastSeen = Math.max(clusterLifecycle[name].lastSeen, e.rel_time_s);\n if (e.rpu != null) clusterLifecycle[name].rpu = e.rpu;\n if (e.event_type === "spin_up_started") {\n clusterLifecycle[name].spinUpStarted = e.rel_time_s;\n }\n if (e.event_type === "cluster_ready") {\n clusterLifecycle[name].readyTime = e.rel_time_s;\n }\n if (e.event_type === "cluster_removed") {\n // Keep earliest removal time in case of multiple events\n if (clusterLifecycle[name].removedTime == null ||\n e.rel_time_s < clusterLifecycle[name].removedTime) {\n clusterLifecycle[name].removedTime = e.rel_time_s;\n }\n }\n});\n\n// Filter out hypothetical clusters; sort by ready time\nconst realClusters = Object.keys(clusterLifecycle)\n .filter(n => !n.includes("hypothetical"))\n .sort((a, b) => {\n const readyA = clusterLifecycle[a].readyTime ?? clusterLifecycle[a].firstSeen;\n const readyB = clusterLifecycle[b].readyTime ?? clusterLifecycle[b].firstSeen;\n return readyA - readyB;\n });\n\nconst realQueries = DATA.queries.filter(q => !q.cluster_name.includes("hypothetical"));\n\n// Pack queries into lanes per cluster\nfunction packLanes(queries) {\n const sorted = [...queries].sort((a, b) => a.start_s - b.start_s || a.end_s - b.end_s);\n const lanes = [];\n sorted.forEach(q => {\n let placed = false;\n for (let i = 0; i < lanes.length; i++) {\n if (q.start_s >= lanes[i].endTime) {\n lanes[i].endTime = q.end_s;\n q._lane = i;\n placed = true;\n break;\n }\n }\n if (!placed) {\n q._lane = lanes.length;\n lanes.push({ endTime: q.end_s });\n }\n });\n return lanes.length;\n}\n\nconst queriesByCluster = {};\nconst lanesPerCluster = {};\nrealClusters.forEach(name => { queriesByCluster[name] = []; });\nrealQueries.forEach(q => {\n if (q.cluster_name in queriesByCluster) {\n queriesByCluster[q.cluster_name].push(q);\n }\n});\nrealClusters.forEach(name => {\n lanesPerCluster[name] = Math.max(1, packLanes(queriesByCluster[name]));\n});\n\n// Compute CLUSTER_LABEL_WIDTH dynamically so the widest cluster name always fits.\n// We use an off-screen canvas to measure the actual rendered pixel width of the\n// bold 11px monospace font used for the cluster name label.\n(function() {\n const _mc = document.createElement("canvas").getContext("2d");\n _mc.font = "bold 11px monospace";\n let maxW = 100;\n realClusters.forEach(name => {\n const w = Math.ceil(_mc.measureText(name).width);\n if (w > maxW) maxW = w;\n });\n // 16px horizontal padding (8px each side)\n window.CLUSTER_LABEL_WIDTH = maxW + 16;\n}());\n\n// Align scrubber spacer width with the cluster label column so the\n// slider thumb position corresponds to the time position on the canvas.\nfunction syncScrubberLayout() {\n document.getElementById("scrubber-spacer").style.width = CLUSTER_LABEL_WIDTH + "px";\n const scrollbarWidth = Math.max(0, viewport.offsetWidth - viewport.clientWidth);\n document.getElementById("scrubber-tail").style.width = scrollbarWidth + "px";\n}\n\n// Compute cluster Y positions\n// Each row: CLUSTER_PADDING + MARKER_STRIP_HEIGHT + max(lanes, min_label_lines) + CLUSTER_PADDING\nconst clusterYPositions = {};\nlet currentY = HEADER_HEIGHT;\nrealClusters.forEach(name => {\n const numLanes = lanesPerCluster[name];\n const lanesHeight = numLanes * (ROW_HEIGHT + LANE_GAP);\n const height = CLUSTER_PADDING + MARKER_STRIP_HEIGHT + Math.max(lanesHeight, CLUSTER_LABEL_MIN_HEIGHT - CLUSTER_PADDING - MARKER_STRIP_HEIGHT - CLUSTER_PADDING) + CLUSTER_PADDING;\n clusterYPositions[name] = { y: currentY, height };\n currentY += height + 1;\n});\nconst totalHeight = currentY + 20;\n\n// Build unified event list for the event panel\nconst allEvents = [];\n\n// RUN_START\nif (DATA.run_meta && DATA.run_meta.workload_name) {\n allEvents.push({ timestamp: DATA.time_range[0], type: "run_start", detail: escapeHtml(DATA.run_meta.workload_name) + (DATA.run_meta.routing_policy ? " / " + escapeHtml(DATA.run_meta.routing_policy) : "") });\n}\n\nDATA.cluster_events.forEach(e => {\n allEvents.push({ timestamp: e.rel_time_s, type: e.event_type, detail: escapeHtml(e.cluster_name) + (e.rpu != null ? " (" + e.rpu + " RPU)" : "") + (e.reason ? " \\u2014 " + escapeHtml(e.reason) : "") });\n});\n\nDATA.autoscaler_events.forEach(e => {\n let detail = "";\n if (e.rpu != null) detail = e.rpu + " RPU";\n if (e.slo_violation != null) detail += " viol=" + e.slo_violation;\n if (e.cost != null) detail += " cost=" + e.cost;\n allEvents.push({ timestamp: e.rel_time_s, type: e.event_type, detail: escapeHtml(detail) });\n});\n\n(DATA.latency_update_events || []).forEach(e => {\n allEvents.push({ timestamp: e.rel_time_s, type: "latency_update", detail: escapeHtml(e.query_id) + " on " + escapeHtml(e.cluster_name) + " " + formatMaybeNumber(e.old_latency_s, 2) + "s\\u2192" + formatMaybeNumber(e.latency_s, 2) + "s" });\n});\n\nDATA.arrival_times.forEach((t, i) => {\n allEvents.push({ timestamp: t, type: "arrival", detail: "query #" + (i + 1) });\n});\n\nrealQueries.filter(q => q.state === "completed").forEach(q => {\n allEvents.push({ timestamp: q.end_s, type: "completion", detail: escapeHtml(q.query_id) + " on " + escapeHtml(q.cluster_name) + " (" + q.latency_s.toFixed(1) + "s)" });\n});\n\nrealQueries.forEach(q => {\n const scores = DATA.routing_scores[q.query_id];\n allEvents.push({\n timestamp: q.exec_start_s,\n type: "query_routed",\n detail: escapeHtml(q.query_id) + " \\u2192 " + escapeHtml(q.cluster_name),\n routingScores: scores || null,\n });\n});\n\n(DATA.run_finish_events || []).forEach(e => {\n allEvents.push({ timestamp: e.rel_time_s, type: "run_finish", detail: "" });\n});\n\nallEvents.sort((a, b) => a.timestamp - b.timestamp);\n\n// Populate run metadata in header\nif (DATA.run_meta && DATA.run_meta.workload_name) {\n const m = DATA.run_meta;\n let parts = [escapeHtml(m.workload_name)];\n if (m.routing_policy) parts.push(escapeHtml(m.routing_policy));\n if (m.closed_loop != null) parts.push(m.closed_loop ? "closed-loop" : "open-loop");\n if (m.num_queries != null) parts.push(m.num_queries + " queries");\n document.getElementById("run-meta").innerHTML = parts.join(" \\u00b7 ");\n}\n\n// ===========================================================================\n// CANVAS RENDERING\n// ===========================================================================\n\nconst canvas = document.getElementById("gantt");\nconst ctx = canvas.getContext("2d");\nconst viewport = document.getElementById("gantt-viewport");\n\nfunction timeToX(t) {\n return CLUSTER_LABEL_WIDTH + (t - DATA.time_range[0]) * state.zoom + state.panX;\n}\n\nfunction xToTime(x) {\n return (x - CLUSTER_LABEL_WIDTH - state.panX) / state.zoom + DATA.time_range[0];\n}\n\nfunction resizeCanvas() {\n const dpr = window.devicePixelRatio || 1;\n const viewWidth = viewport.clientWidth;\n const contentWidth = Math.max(viewWidth, CLUSTER_LABEL_WIDTH + (DATA.time_range[1] - DATA.time_range[0]) * state.zoom + 40);\n canvas.width = contentWidth * dpr;\n canvas.height = totalHeight * dpr;\n canvas.style.width = contentWidth + "px";\n canvas.style.height = totalHeight + "px";\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n}\n\nfunction getQueryColor(q) {\n if (q.success === false) return { full: COLORS.failed, light: COLORS.failedLight };\n if (q.violates_slo) return { full: COLORS.violated, light: COLORS.violatedLight };\n return { full: COLORS.met, light: COLORS.metLight };\n}\n\nfunction drawTimeline() {\n resizeCanvas();\n const W = parseFloat(canvas.style.width);\n const H = totalHeight;\n\n // Ensure hatch pattern is created for this canvas context\n if (!_hatchPattern) {\n _hatchPattern = ctx.createPattern(_hatchCanvas, "repeat");\n }\n\n ctx.fillStyle = "#1a1a2e";\n ctx.fillRect(0, 0, W, H);\n\n // Time axis header — two sub-rows\n ctx.fillStyle = "#16213e";\n ctx.fillRect(0, 0, W, HEADER_HEIGHT);\n // Divider between top (minutes) and bottom (seconds) rows\n ctx.strokeStyle = "#1e2d50";\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(0, HEADER_MID - 0.5);\n ctx.lineTo(W, HEADER_MID - 0.5);\n ctx.stroke();\n // Bottom border of header\n ctx.strokeStyle = COLORS.clusterLine;\n ctx.beginPath();\n ctx.moveTo(0, HEADER_HEIGHT - 0.5);\n ctx.lineTo(W, HEADER_HEIGHT - 0.5);\n ctx.stroke();\n\n // --- Interval selection ---\n const niceIntervals = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];\n // Major ticks: target ~80px spacing — shown in top row with minute labels\n let majorIdx = niceIntervals.findIndex(n => n >= 80 / state.zoom);\n if (majorIdx < 0) majorIdx = niceIntervals.length - 1;\n const majorInterval = niceIntervals[majorIdx];\n // Minor ticks: target ~20px spacing — shown in bottom row with second labels\n let minorIdx = niceIntervals.findIndex(n => n >= 20 / state.zoom);\n if (minorIdx < 0) minorIdx = niceIntervals.length - 1;\n const minorInterval = niceIntervals[Math.min(minorIdx, majorIdx)]; // never coarser than major\n\n ctx.textAlign = "center";\n\n // --- Major ticks (top row: minutes) ---\n const firstMajor = Math.ceil(DATA.time_range[0] / majorInterval) * majorInterval;\n for (let t = firstMajor; t <= DATA.time_range[1]; t += majorInterval) {\n const x = timeToX(t);\n if (x < CLUSTER_LABEL_WIDTH || x > W) continue;\n // Full-height grid line into cluster area\n ctx.strokeStyle = "#0f3460";\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(x, HEADER_HEIGHT);\n ctx.lineTo(x, H);\n ctx.stroke();\n // Tick mark in top row\n ctx.strokeStyle = "#445";\n ctx.beginPath();\n ctx.moveTo(x, HEADER_MID - 1);\n ctx.lineTo(x, HEADER_MID - 6);\n ctx.stroke();\n // Label in top row\n const minLabel = majorInterval >= 60\n ? (t / 60).toFixed(0) + "m"\n : t.toFixed(0) + "s";\n ctx.fillStyle = "#c0c0c0";\n ctx.font = "bold 10px monospace";\n ctx.fillText(minLabel, x, HEADER_MID - 9);\n }\n\n // --- Minor ticks (bottom row: seconds) ---\n const firstMinor = Math.ceil(DATA.time_range[0] / minorInterval) * minorInterval;\n const showMinorLabels = minorInterval * state.zoom >= 25; // only label if ticks are ≥25px apart\n for (let t = firstMinor; t <= DATA.time_range[1]; t += minorInterval) {\n const x = timeToX(t);\n if (x < CLUSTER_LABEL_WIDTH || x > W) continue;\n // Tick mark in bottom row\n ctx.strokeStyle = "#334";\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(x, HEADER_MID + 1);\n ctx.lineTo(x, HEADER_MID + 5);\n ctx.stroke();\n // Label in bottom row\n if (showMinorLabels) {\n const secLabel = t.toFixed(0) + "s";\n ctx.fillStyle = COLORS.text;\n ctx.font = "10px monospace";\n ctx.fillText(secLabel, x, HEADER_HEIGHT - 5);\n }\n }\n\n // Current time line\n const curX = timeToX(state.currentTime);\n if (curX >= CLUSTER_LABEL_WIDTH) {\n ctx.strokeStyle = COLORS.timeline;\n ctx.lineWidth = 1.5;\n ctx.setLineDash([4, 3]);\n ctx.beginPath();\n ctx.moveTo(curX, HEADER_HEIGHT);\n ctx.lineTo(curX, H);\n ctx.stroke();\n ctx.setLineDash([]);\n ctx.lineWidth = 1;\n }\n\n // Draw cluster rows\n realClusters.forEach(name => {\n const cl = clusterLifecycle[name];\n const pos = clusterYPositions[name];\n\n ctx.fillStyle = COLORS.clusterBg;\n ctx.fillRect(0, pos.y, W, pos.height);\n\n // Dead zone BEFORE spin_up_started (cluster does not yet exist)\n const deadStart = DATA.time_range[0];\n const aliveStart = cl.spinUpStarted ?? cl.readyTime ?? cl.firstSeen;\n if (aliveStart > deadStart) {\n const dx0 = Math.max(CLUSTER_LABEL_WIDTH, timeToX(deadStart));\n const dx1 = timeToX(aliveStart);\n if (dx1 > CLUSTER_LABEL_WIDTH) {\n ctx.fillStyle = "rgba(0,0,0,0.55)";\n ctx.fillRect(dx0, pos.y, dx1 - dx0, pos.height);\n if (_hatchPattern) {\n ctx.fillStyle = _hatchPattern;\n ctx.fillRect(dx0, pos.y, dx1 - dx0, pos.height);\n }\n // Right edge boundary line\n ctx.strokeStyle = "rgba(255,255,255,0.18)";\n ctx.lineWidth = 1;\n ctx.setLineDash([3, 3]);\n ctx.beginPath();\n ctx.moveTo(dx1, pos.y);\n ctx.lineTo(dx1, pos.y + pos.height);\n ctx.stroke();\n ctx.setLineDash([]);\n }\n }\n\n // Pending period: spin_up_started -> cluster_ready\n if (cl.readyTime != null && cl.spinUpStarted != null && cl.spinUpStarted < cl.readyTime) {\n const px0 = Math.max(CLUSTER_LABEL_WIDTH, timeToX(cl.spinUpStarted));\n const px1 = timeToX(cl.readyTime);\n if (px1 > CLUSTER_LABEL_WIDTH) {\n ctx.fillStyle = COLORS.pendingBg;\n ctx.fillRect(px0, pos.y, px1 - px0, pos.height);\n ctx.strokeStyle = COLORS.pending;\n ctx.lineWidth = 1;\n ctx.setLineDash([2, 2]);\n ctx.beginPath();\n ctx.moveTo(px1, pos.y);\n ctx.lineTo(px1, pos.y + pos.height);\n ctx.stroke();\n ctx.setLineDash([]);\n }\n }\n\n // Dead zone AFTER cluster_removed (cluster no longer exists)\n if (cl.removedTime != null) {\n const rx0 = Math.max(CLUSTER_LABEL_WIDTH, timeToX(cl.removedTime));\n const rx1 = timeToX(DATA.time_range[1]);\n if (rx1 > CLUSTER_LABEL_WIDTH && rx0 < W) {\n ctx.fillStyle = "rgba(0,0,0,0.55)";\n ctx.fillRect(rx0, pos.y, rx1 - rx0, pos.height);\n if (_hatchPattern) {\n ctx.fillStyle = _hatchPattern;\n ctx.fillRect(rx0, pos.y, rx1 - rx0, pos.height);\n }\n // Left edge boundary line\n ctx.strokeStyle = "rgba(255,255,255,0.18)";\n ctx.lineWidth = 1;\n ctx.setLineDash([3, 3]);\n ctx.beginPath();\n ctx.moveTo(rx0, pos.y);\n ctx.lineTo(rx0, pos.y + pos.height);\n ctx.stroke();\n ctx.setLineDash([]);\n }\n }\n\n // Separator line\n ctx.strokeStyle = COLORS.clusterLine;\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(0, pos.y + pos.height + 0.5);\n ctx.lineTo(W, pos.y + pos.height + 0.5);\n ctx.stroke();\n\n // Cluster label\n ctx.fillStyle = "#16213e";\n ctx.fillRect(0, pos.y, CLUSTER_LABEL_WIDTH, pos.height);\n ctx.strokeStyle = COLORS.clusterLine;\n ctx.beginPath();\n ctx.moveTo(CLUSTER_LABEL_WIDTH - 0.5, pos.y);\n ctx.lineTo(CLUSTER_LABEL_WIDTH - 0.5, pos.y + pos.height);\n ctx.stroke();\n\n ctx.save();\n ctx.rect(0, pos.y, CLUSTER_LABEL_WIDTH - 2, pos.height);\n ctx.clip();\n\n ctx.fillStyle = "#e0e0e0";\n ctx.font = "bold 11px monospace";\n ctx.textAlign = "left";\n ctx.fillText(name, 6, pos.y + 13);\n\n ctx.fillStyle = COLORS.text;\n ctx.font = "10px monospace";\n const numQs = queriesByCluster[name].filter(q => q.start_s <= state.currentTime).length;\n const activeQs = queriesByCluster[name].filter(q => q.start_s <= state.currentTime && q.end_s > state.currentTime).length;\n if (cl.rpu != null) ctx.fillText(cl.rpu + " RPU", 6, pos.y + 25);\n ctx.fillText(numQs + " queries", 6, pos.y + 36);\n ctx.fillText(activeQs + " active", 6, pos.y + 47);\n\n ctx.restore();\n });\n\n // Draw query bars (multi-segment)\n realClusters.forEach(clusterName => {\n const pos = clusterYPositions[clusterName];\n const queries = queriesByCluster[clusterName];\n\n queries.forEach(q => {\n if (q.start_s > state.currentTime) return;\n\n const laneY = pos.y + CLUSTER_PADDING + MARKER_STRIP_HEIGHT + q._lane * (ROW_HEIGHT + LANE_GAP);\n const barHeight = ROW_HEIGHT;\n const colors = getQueryColor(q);\n const isCompleted = q.state === "completed" && q.end_s <= state.currentTime;\n\n // Segment 1: arrival_s -> exec_start_s (queue time)\n if (q.arrival_s < q.exec_start_s) {\n const x0 = timeToX(q.arrival_s);\n const segEnd = Math.min(q.exec_start_s, state.currentTime);\n const x1 = timeToX(segEnd);\n if (x1 > CLUSTER_LABEL_WIDTH && x0 < parseFloat(canvas.style.width)) {\n ctx.fillStyle = isCompleted ? colors.light : COLORS.running;\n ctx.fillRect(Math.max(CLUSTER_LABEL_WIDTH, x0), laneY, Math.max(1, x1 - Math.max(CLUSTER_LABEL_WIDTH, x0)), barHeight);\n }\n }\n\n // Segment 2: exec_start_s -> exec_finish_s (execution time)\n if (state.currentTime > q.exec_start_s) {\n const x0 = timeToX(q.exec_start_s);\n const segEnd = Math.min(q.exec_finish_s, state.currentTime);\n const x1 = timeToX(segEnd);\n if (x1 > CLUSTER_LABEL_WIDTH && x0 < parseFloat(canvas.style.width)) {\n ctx.fillStyle = isCompleted ? colors.full : COLORS.running;\n ctx.fillRect(Math.max(CLUSTER_LABEL_WIDTH, x0), laneY, Math.max(1, x1 - Math.max(CLUSTER_LABEL_WIDTH, x0)), barHeight);\n }\n }\n\n // Segment 3: exec_finish_s -> completion_s (post-exec)\n if (q.completion_s != null && q.completion_s > q.exec_finish_s && state.currentTime > q.exec_finish_s) {\n const x0 = timeToX(q.exec_finish_s);\n const segEnd = Math.min(q.completion_s, state.currentTime);\n const x1 = timeToX(segEnd);\n if (x1 > CLUSTER_LABEL_WIDTH && x0 < parseFloat(canvas.style.width)) {\n ctx.fillStyle = isCompleted ? colors.light : COLORS.running;\n ctx.fillRect(Math.max(CLUSTER_LABEL_WIDTH, x0), laneY, Math.max(1, x1 - Math.max(CLUSTER_LABEL_WIDTH, x0)), barHeight);\n }\n }\n });\n });\n\n // Draw cluster lifecycle markers\n DATA.cluster_events.forEach(e => {\n if (e.rel_time_s > state.currentTime) return;\n const x = timeToX(e.rel_time_s);\n if (x < CLUSTER_LABEL_WIDTH) return;\n const clName = e.cluster_name;\n if (!(clName in clusterYPositions)) return;\n const pos = clusterYPositions[clName];\n // Draw markers in the dedicated strip at top of the cluster row\n const my = pos.y + CLUSTER_PADDING + Math.floor(MARKER_STRIP_HEIGHT / 2);\n\n switch (e.event_type) {\n case "spin_up_decision":\n ctx.fillStyle = COLORS.met;\n ctx.beginPath(); ctx.moveTo(x, my-4); ctx.lineTo(x+4, my); ctx.lineTo(x, my+4); ctx.lineTo(x-4, my); ctx.fill();\n break;\n case "spin_up_requested":\n ctx.fillStyle = COLORS.met;\n ctx.beginPath(); ctx.moveTo(x, my-4); ctx.lineTo(x+4, my+2); ctx.lineTo(x-4, my+2); ctx.fill();\n break;\n case "spin_up_started":\n ctx.fillStyle = COLORS.met;\n ctx.beginPath(); ctx.moveTo(x-3, my-4); ctx.lineTo(x+3, my); ctx.lineTo(x-3, my+4); ctx.fill();\n break;\n case "cluster_ready":\n ctx.fillStyle = COLORS.met;\n ctx.beginPath(); ctx.arc(x, my, 3, 0, Math.PI*2); ctx.fill();\n break;\n case "tear_down_decision":\n ctx.fillStyle = COLORS.violated;\n ctx.beginPath(); ctx.moveTo(x, my-4); ctx.lineTo(x+4, my); ctx.lineTo(x, my+4); ctx.lineTo(x-4, my); ctx.fill();\n break;\n case "tear_down_requested":\n ctx.fillStyle = COLORS.violated;\n ctx.beginPath(); ctx.moveTo(x, my+4); ctx.lineTo(x+4, my-2); ctx.lineTo(x-4, my-2); ctx.fill();\n break;\n case "tear_down_blocked":\n ctx.strokeStyle = COLORS.violated; ctx.lineWidth = 2;\n ctx.beginPath(); ctx.moveTo(x-3, my-3); ctx.lineTo(x+3, my+3); ctx.moveTo(x+3, my-3); ctx.lineTo(x-3, my+3); ctx.stroke();\n ctx.lineWidth = 1;\n break;\n case "tear_down_started":\n ctx.fillStyle = COLORS.violated;\n ctx.beginPath(); ctx.moveTo(x-3, my-4); ctx.lineTo(x+3, my); ctx.lineTo(x-3, my+4); ctx.fill();\n break;\n case "stats_collected":\n ctx.fillStyle = "#888";\n ctx.beginPath(); ctx.arc(x, my, 2.5, 0, Math.PI*2); ctx.fill();\n break;\n case "cluster_removed":\n ctx.fillStyle = COLORS.violated;\n ctx.beginPath(); ctx.arc(x, my, 3, 0, Math.PI*2); ctx.fill();\n break;\n }\n });\n\n}\n\n// ===========================================================================\n// STATS\n// ===========================================================================\n\nfunction updateStats() {\n const visible = realQueries.filter(q => q.start_s <= state.currentTime);\n const completed = visible.filter(q => q.state === "completed" && q.end_s <= state.currentTime);\n const violations = completed.filter(q => q.violates_slo);\n const activeClusters = new Set(visible.map(q => q.cluster_name));\n\n document.getElementById("stat-total").textContent = visible.length;\n document.getElementById("stat-completed").textContent = completed.length;\n document.getElementById("stat-violations").textContent = violations.length;\n document.getElementById("stat-viol-rate").textContent = completed.length > 0\n ? (100 * violations.length / completed.length).toFixed(1) + "%"\n : "0%";\n document.getElementById("stat-clusters").textContent = activeClusters.size;\n}\n\n// ===========================================================================\n// EVENT PANEL\n// ===========================================================================\n\nfunction updateEventPanel() {\n const container = document.getElementById("event-list");\n const visible = allEvents.filter(e => e.timestamp <= state.currentTime);\n const toShow = visible.slice(-200);\n\n document.getElementById("event-count").textContent = visible.length;\n\n let html = "";\n toShow.forEach((e, idx) => {\n const tStr = e.timestamp.toFixed(1);\n html += \'<div class="event-item"><span class="event-time">\' + escapeHtml(tStr) + \'s</span><span class="event-type">\' + escapeHtml(e.type) + \'</span><span class="event-detail">\' + (e.detail || "") + \'</span>\';\n if (e.routingScores && e.routingScores.length > 0) {\n const detailId = "score-detail-" + idx;\n html += \' <span class="score-toggle" onclick="document.getElementById(\\\'\' + detailId + \'\\\').classList.toggle(\\\'open\\\')">[scores]</span>\';\n html += \'<div class="score-details" id="\' + detailId + \'">\';\n e.routingScores.forEach(s => {\n html += \'<div>\' + escapeHtml(s.cluster_name) + (s.rpu != null ? \' (\' + s.rpu + \' RPU)\' : \'\') + \' lat=\' + formatMaybeNumber(s.latency_s, 2) + \'s cost=\' + (s.cost != null ? s.cost : \'?\') + \'</div>\';\n });\n html += \'</div>\';\n }\n html += \'</div>\';\n });\n container.innerHTML = html;\n container.scrollTop = container.scrollHeight;\n}\n\n// ===========================================================================\n// TOOLTIP\n// ===========================================================================\n\nconst tooltipEl = document.getElementById("tooltip");\n\nfunction showTooltip(x, y, q) {\n const sloLabel = q.success === false ? "tt-failed" : (q.violates_slo ? "tt-violation" : "tt-met");\n const sloText = q.success === false ? "FAILED" : (q.violates_slo ? "VIOLATED" : "MET");\n tooltipEl.innerHTML =\n \'<div class="tt-row"><span class="tt-label">Query:</span> <span class="tt-value">\' + escapeHtml(q.query_id) + \'</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Template:</span> <span class="tt-value">\' + escapeHtml(q.query_text_id) + \'</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Cluster:</span> <span class="tt-value">\' + escapeHtml(q.cluster_name) + (q.rpu != null ? " (" + q.rpu + " RPU)" : "") + \'</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Arrival:</span> <span class="tt-value">\' + q.arrival_s.toFixed(1) + \'s</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Exec start:</span> <span class="tt-value">\' + q.exec_start_s.toFixed(1) + \'s</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Exec finish:</span> <span class="tt-value">\' + q.exec_finish_s.toFixed(1) + \'s</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">Latency:</span> <span class="tt-value">\' + q.latency_s.toFixed(2) + \'s</span></div>\' +\n \'<div class="tt-row"><span class="tt-label">SLO:</span> <span class="tt-value">\' + q.slo_s.toFixed(1) + \'s</span> <span class="\' + sloLabel + \'">(\' + sloText + \')</span></div>\' +\n (q.completion_s != null ? \'<div class="tt-row"><span class="tt-label">Completion:</span> <span class="tt-value">\' + q.completion_s.toFixed(1) + \'s</span></div>\' : \'\') +\n \'<div class="tt-row"><span class="tt-label">State:</span> <span class="tt-value">\' + escapeHtml(q.state) + \'</span></div>\';\n tooltipEl.style.display = "block";\n tooltipEl.style.left = Math.min(x + 10, window.innerWidth - 420) + "px";\n tooltipEl.style.top = Math.min(y + 10, window.innerHeight - 200) + "px";\n}\n\nfunction hideTooltip() {\n tooltipEl.style.display = "none";\n state.hoveredQuery = null;\n}\n\n// ===========================================================================\n// HIT TESTING\n// ===========================================================================\n\nfunction hitTest(canvasX, canvasY) {\n for (const clusterName of realClusters) {\n const pos = clusterYPositions[clusterName];\n if (canvasY < pos.y || canvasY > pos.y + pos.height) continue;\n\n for (const q of queriesByCluster[clusterName]) {\n if (q.start_s > state.currentTime) continue;\n const x0 = timeToX(q.start_s);\n const effectiveEnd = Math.min(q.end_s, state.currentTime);\n const x1 = timeToX(effectiveEnd);\n const laneY = pos.y + CLUSTER_PADDING + MARKER_STRIP_HEIGHT + q._lane * (ROW_HEIGHT + LANE_GAP);\n\n if (canvasX >= x0 && canvasX <= x1 && canvasY >= laneY && canvasY <= laneY + ROW_HEIGHT) {\n return q;\n }\n }\n }\n return null;\n}\n\n// ===========================================================================\n// INTERACTIONS\n// ===========================================================================\n\nconst slider = document.getElementById("time-slider");\nconst timeDisplay = document.getElementById("time-display");\n\nfunction setTime(t) {\n state.currentTime = Math.max(DATA.time_range[0], Math.min(DATA.time_range[1], t));\n slider.value = Math.round(1000 * (state.currentTime - DATA.time_range[0]) / (DATA.time_range[1] - DATA.time_range[0]));\n timeDisplay.textContent = state.currentTime.toFixed(1) + "s";\n drawTimeline();\n updateStats();\n updateEventPanel();\n}\n\nslider.addEventListener("input", () => {\n const frac = parseInt(slider.value) / 1000;\n setTime(DATA.time_range[0] + frac * (DATA.time_range[1] - DATA.time_range[0]));\n});\n\n// Zoom helpers\nfunction zoomBy(factor, centerX) {\n if (centerX == null) centerX = viewport.clientWidth / 2;\n const timeBefore = xToTime(centerX);\n state.zoom = Math.max(0.01, state.zoom * factor);\n const timeAfter = xToTime(centerX);\n state.panX += (timeAfter - timeBefore) * state.zoom;\n drawTimeline();\n}\n\ndocument.getElementById("btn-zoom-in").addEventListener("click", () => { zoomBy(1.5); });\ndocument.getElementById("btn-zoom-out").addEventListener("click", () => { zoomBy(1 / 1.5); });\ndocument.getElementById("btn-zoom-fit").addEventListener("click", () => {\n const viewW = viewport.clientWidth - CLUSTER_LABEL_WIDTH - 40;\n const timeSpan = DATA.time_range[1] - DATA.time_range[0];\n state.zoom = timeSpan > 0 ? viewW / timeSpan : 1;\n state.panX = 0;\n drawTimeline();\n});\n\n// Ctrl+scroll / Shift+scroll zoom\nviewport.addEventListener("wheel", (e) => {\n if (e.ctrlKey || e.shiftKey) {\n e.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const cx = e.clientX - rect.left;\n const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;\n zoomBy(factor, cx);\n }\n}, { passive: false });\n\n// Playback\ndocument.getElementById("btn-play").addEventListener("click", () => {\n if (state.playing) {\n clearInterval(state.playTimer);\n state.playing = false;\n document.getElementById("btn-play").classList.remove("active");\n } else {\n if (state.arrivalIdx >= DATA.arrival_times.length) {\n state.arrivalIdx = 0;\n }\n state.playing = true;\n document.getElementById("btn-play").classList.add("active");\n state.playTimer = setInterval(() => {\n if (state.arrivalIdx < DATA.arrival_times.length) {\n setTime(DATA.arrival_times[state.arrivalIdx]);\n state.arrivalIdx++;\n } else {\n setTime(DATA.time_range[1]);\n clearInterval(state.playTimer);\n state.playing = false;\n document.getElementById("btn-play").classList.remove("active");\n }\n }, 1000 / state.playSpeed);\n }\n});\n\ndocument.getElementById("btn-reset").addEventListener("click", () => {\n if (state.playing) {\n clearInterval(state.playTimer);\n state.playing = false;\n document.getElementById("btn-play").classList.remove("active");\n }\n state.arrivalIdx = 0;\n setTime(DATA.time_range[0]);\n});\n\n// Mouse hover for tooltip\ncanvas.addEventListener("mousemove", (e) => {\n const rect = canvas.getBoundingClientRect();\n const cx = e.clientX - rect.left;\n const cy = e.clientY - rect.top;\n const hit = hitTest(cx, cy);\n if (hit) {\n state.hoveredQuery = hit;\n showTooltip(e.clientX, e.clientY, hit);\n canvas.style.cursor = "pointer";\n drawTimeline();\n } else {\n if (state.hoveredQuery) {\n state.hoveredQuery = null;\n drawTimeline();\n }\n hideTooltip();\n canvas.style.cursor = "default";\n }\n});\ncanvas.addEventListener("mouseleave", () => {\n hideTooltip();\n if (state.hoveredQuery) {\n state.hoveredQuery = null;\n drawTimeline();\n }\n});\n\n// Keyboard\ndocument.addEventListener("keydown", (e) => {\n if (e.key === "ArrowLeft") {\n const prevIdx = DATA.arrival_times.findIndex(t => t >= state.currentTime) - 1;\n if (prevIdx >= 0) setTime(DATA.arrival_times[prevIdx]);\n else if (DATA.arrival_times.length > 0) setTime(DATA.arrival_times[0]);\n } else if (e.key === "ArrowRight") {\n const nextIdx = DATA.arrival_times.findIndex(t => t > state.currentTime);\n if (nextIdx >= 0) setTime(DATA.arrival_times[nextIdx]);\n else setTime(DATA.time_range[1]);\n } else if (e.key === " ") {\n e.preventDefault();\n document.getElementById("btn-play").click();\n } else if (e.key === "=" || e.key === "+") {\n zoomBy(1.5);\n } else if (e.key === "-") {\n zoomBy(1 / 1.5);\n } else if (e.key === "0") {\n document.getElementById("btn-zoom-fit").click();\n }\n});\n\n// ===========================================================================\n// INIT\n// ===========================================================================\n\nwindow.addEventListener("resize", () => {\n syncScrubberLayout();\n drawTimeline();\n});\n\n{\n const viewW = viewport.clientWidth - CLUSTER_LABEL_WIDTH - 40;\n const timeSpan = DATA.time_range[1] - DATA.time_range[0];\n state.zoom = timeSpan > 0 ? viewW / timeSpan : 1;\n}\nsetTime(DATA.time_range[1]);\nsyncScrubberLayout(); // measure scrollbar after canvas has been sized\n\n</script>\n</body>\n</html>' module-attribute

parser = argparse.ArgumentParser(description='Render a structured log as an interactive HTML timeline viewer.') module-attribute

args = parser.parse_args() module-attribute

_detect_log_kind(df)

Return 'simulator' or 'runner' based on source column.

Source code in src/autoslo/visualizations/render_log_viewer.py
def _detect_log_kind(df: pd.DataFrame) -> str:
    """Return ``'simulator'`` or ``'runner'`` based on ``source`` column."""
    sources = set(df["source"].unique())
    if "WorkloadRunner" in sources:
        return "runner"
    if "WorkloadSimulator" in sources:
        return "simulator"
    raise ValueError(
        f"Cannot determine log kind from sources: {sources}. "
        f"Expected 'WorkloadRunner' or 'WorkloadSimulator'."
    )

_load_slo_resolver(log_dir)

Read the config file next to the log and build a SloResolver.

Source code in src/autoslo/visualizations/render_log_viewer.py
def _load_slo_resolver(log_dir: Path) -> SloResolver:
    """Read the config file next to the log and build a SloResolver."""
    for name in ("config.yml", "runner_config.yml", "execution_config.yml"):
        cfg_path = log_dir / name
        if cfg_path.exists():
            break
    else:
        raise FileNotFoundError(
            f"No config.yml, runner_config.yml, or execution_config.yml found "
            f"in {log_dir}"
        )

    with open(cfg_path) as f:
        cfg = yaml.safe_load(f) or {}

    return SloResolver(SloResolverConfig.from_config(cfg))

_validate_rel_time(df)

Assert rel_time_s is present and contains relative timestamps.

Source code in src/autoslo/visualizations/render_log_viewer.py
def _validate_rel_time(df: pd.DataFrame) -> None:
    """Assert ``rel_time_s`` is present and contains relative timestamps."""
    if "rel_time_s" not in df.columns:
        raise ValueError(
            "Column 'rel_time_s' not found in log. "
            f"Available columns: {list(df.columns)}"
        )
    bad = df[df["rel_time_s"] > 1_000_000]
    if not bad.empty:
        counts = bad.groupby("event_type").size().to_dict()
        raise ValueError(
            "rel_time_s values appear to be absolute epoch timestamps, "
            f"not relative. Offending event types: {counts}"
        )

_safe_rpu(cluster_name)

Extract RPU from cluster name, returning None on failure.

Source code in src/autoslo/visualizations/render_log_viewer.py
def _safe_rpu(cluster_name: str) -> int | None:
    """Extract RPU from cluster name, returning None on failure."""
    if not cluster_name:
        return None
    try:
        return Cluster.rpu_for_cluster_name(cluster_name)
    except ValueError:
        return None

_parse_log(structured_log, slo_resolver, log_kind)

Parse a structured log DataFrame into the JS data payload.

Source code in src/autoslo/visualizations/render_log_viewer.py
def _parse_log(
    structured_log: StructuredLog,
    slo_resolver: SloResolver,
    log_kind: str,
) -> dict:
    """Parse a structured log DataFrame into the JS data payload."""

    df = structured_log.df
    success_by_qid = structured_log.query_success()

    events = df.sort_values("rel_time_s")

    # --- Event type value sets (strings) for filtering ---
    query_lifecycle_values = {
        e.value for e in EventType.query_lifecycle_types()
    }
    routing_values = {e.value for e in EventType.routing_types()}
    cluster_lifecycle_values = {
        e.value for e in EventType.cluster_lifecycle_types()
    }
    autoscaler_values = {e.value for e in EventType.autoscaler_types()}

    # --- Build per-query event timeline ---
    query_events: dict[str, list[dict]] = defaultdict(list)
    for _, row in events.iterrows():
        qid = row.get("query_id")
        if pd.isna(qid) or not qid:
            continue
        et = row["event_type"]
        if et not in query_lifecycle_values and et not in routing_values:
            continue
        # Skip events emitted by the autoscaler's internal counterfactual-replay
        # router — those synthetic queries have no execution lifecycle events.
        if row.get("source") == "Autoscaler.QueryRouter":
            continue
        query_events[qid].append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "event_type": et,
                "cluster_name": row.get("cluster_name", ""),
                "query_text_id": str(row.get("query_text_id", "")),
                "details": row.get("details", {}),
            }
        )

    # --- Reconstruct queries ---
    queries = []
    for qid, evts in query_events.items():
        evts.sort(key=lambda e: e["rel_time_s"])

        by_type: dict[str, list[dict]] = defaultdict(list)
        for e in evts:
            by_type[e["event_type"]].append(e)

        # Arrival
        arrival_evts = by_type.get(EventType.ARRIVAL.value, [])
        arrival_s = arrival_evts[0]["rel_time_s"] if arrival_evts else None

        # Execution start (required)
        exec_start_evts = by_type.get(EventType.QUERY_EXECUTION_START.value, [])
        if not exec_start_evts:
            raise ValueError(
                f"Query {qid!r} is missing a QUERY_EXECUTION_START event. "
                "Check emission sites."
            )
        exec_start_s = exec_start_evts[0]["rel_time_s"]

        # Execution finish (optional — query may have been interrupted)
        exec_finish_evts = by_type.get(
            EventType.QUERY_EXECUTION_FINISH.value, []
        )
        exec_finish_s: float | None = (
            exec_finish_evts[0]["rel_time_s"] if exec_finish_evts else None
        )

        # Completion (required)
        completion_evts = by_type.get(EventType.COMPLETION.value, [])
        if not completion_evts:
            raise ValueError(
                f"Query {qid!r} is missing a COMPLETION event. "
                "Check emission sites."
            )
        completion_s: float = completion_evts[0]["rel_time_s"]
        success: bool | None = success_by_qid.get(qid)

        # Cluster name from QUERY_ROUTED or execution events
        routed_evts = by_type.get(EventType.QUERY_ROUTED.value, [])
        if routed_evts:
            cluster_name = routed_evts[0]["cluster_name"]
        else:
            cluster_name = exec_start_evts[0]["cluster_name"]

        # Query text id from any event
        query_text_id = ""
        for e in evts:
            qtid = e.get("query_text_id", "")
            if qtid and str(qtid) != "nan":
                query_text_id = str(qtid)
                break

        slo_s = slo_resolver.resolve(query_text_id if query_text_id else None)
        rpu = _safe_rpu(cluster_name)

        # Use arrival_s if available, otherwise exec_start_s
        if arrival_s is None:
            arrival_s = exec_start_s

        # End-to-end latency: arrival (or exec start as fallback) to completion.
        latency_s = completion_s - arrival_s

        # For overall bar extent
        end_s = completion_s

        violates_slo = (not success) or (latency_s > slo_s) 

        queries.append(
            {
                "query_id": qid,
                "query_text_id": query_text_id,
                "cluster_name": cluster_name,
                "rpu": rpu,
                "arrival_s": arrival_s,
                "exec_start_s": exec_start_s,
                "exec_finish_s": exec_finish_s,
                "completion_s": completion_s,
                "start_s": arrival_s,
                "end_s": end_s,
                "latency_s": latency_s,
                "slo_s": slo_s,
                "success": success,
                "violates_slo": violates_slo,
                "state": "completed",
            }
        )

    # --- Cluster lifecycle events ---
    cluster_events_list = []
    cl_mask = events["event_type"].isin(cluster_lifecycle_values)
    for _, row in events[cl_mask].iterrows():
        cname = row.get("cluster_name", "")
        details = row.get("details", {})
        cluster_events_list.append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "event_type": row["event_type"],
                "cluster_name": cname,
                "rpu": _safe_rpu(cname),
                "reason": details.get("reason"),
            }
        )

    # --- Autoscaler events ---
    autoscaler_events = []
    as_mask = events["event_type"].isin(autoscaler_values)
    for _, row in events[as_mask].iterrows():
        details = row.get("details", {})
        cname = row.get("cluster_name", "")
        autoscaler_events.append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "event_type": row["event_type"],
                "cluster_name": cname,
                "rpu": _safe_rpu(cname),
                "slo_violation": details.get("slo_violation"),
                "cost": details.get("cost"),
                "slo_threshold": details.get("slo_threshold"),
            }
        )

    # --- Routing score events (grouped by query_id) ---
    routing_scores: dict[str, list[dict]] = defaultdict(list)
    rs_mask = events["event_type"] == EventType.ROUTING_SCORE.value
    for _, row in events[rs_mask].iterrows():
        qid = row.get("query_id", "")
        details = row.get("details", {})
        routing_scores[qid].append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "cluster_name": row.get("cluster_name", ""),
                "rpu": _safe_rpu(row.get("cluster_name", "")),
                "latency_s": details.get("latency_s_for_routing"),
                "slo_violation": details.get("slo_violation"),
                "cost": details.get("cost"),
            }
        )

    # --- Latency update events ---
    latency_update_events = []
    lu_mask = events["event_type"] == EventType.LATENCY_UPDATE.value
    for _, row in events[lu_mask].iterrows():
        details = row.get("details", {})
        latency_update_events.append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "query_id": row.get("query_id", ""),
                "cluster_name": row.get("cluster_name", ""),
                "old_latency_s": details.get("old_latency_s"),
                "latency_s": details.get("latency_s"),
            }
        )

    # --- Run metadata ---
    run_meta: dict[str, Any] = {}
    rs_rows = events[events["event_type"] == EventType.RUN_START.value]
    if not rs_rows.empty:
        d = rs_rows.iloc[0].get("details", {})
        run_meta = {
            "workload_name": d.get("workload_name", ""),
            "num_queries": d.get("num_queries"),
            "routing_policy": d.get("routing_policy", ""),
            "closed_loop": d.get("closed_loop"),
        }

    # --- Run finish time ---
    run_finish_events = []
    rf_rows = events[events["event_type"] == EventType.RUN_FINISH.value]
    for _, row in rf_rows.iterrows():
        run_finish_events.append(
            {
                "rel_time_s": float(row["rel_time_s"]),
                "event_type": EventType.RUN_FINISH.value,
            }
        )

    # --- Arrival times for scrubber ---
    arrivals = events[
        events["event_type"] == EventType.ARRIVAL.value
    ].sort_values("rel_time_s")
    arrival_times = [float(t) for t in arrivals["rel_time_s"]]

    # --- Time range ---
    time_range = [
        float(events["rel_time_s"].min()),
        float(events["rel_time_s"].max()),
    ]

    return {
        "kind": log_kind,
        "queries": queries,
        "cluster_events": cluster_events_list,
        "autoscaler_events": autoscaler_events,
        "routing_scores": dict(routing_scores),
        "latency_update_events": latency_update_events,
        "run_meta": run_meta,
        "run_finish_events": run_finish_events,
        "arrival_times": arrival_times,
        "time_range": time_range,
        "default_slo_s": slo_resolver.default_slo_s,
    }

generate_html(data)

Inject timeline data into the HTML template.

Source code in src/autoslo/visualizations/render_log_viewer.py
def generate_html(data: dict) -> str:
    """Inject timeline data into the HTML template."""
    data_json = json.dumps(data, default=str).replace("</", "<\\/")
    return HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json)

render_log_viewer(log_path, output=None)

Source code in src/autoslo/visualizations/render_log_viewer.py
def render_log_viewer(log_path, output: Optional[str] = None) -> None:
    log_path = Path(log_path).resolve()
    if not log_path.exists():
        print(f"Error: {log_path} not found", file=sys.stderr)
        sys.exit(1)

    # Load SLO config from the same directory
    slo_resolver = _load_slo_resolver(log_path.parent)
    print(
        f"SLO config: default={slo_resolver.default_slo_s}s"
        f"{', per-template overrides loaded' if slo_resolver.has_overrides() else ''}"
    )

    print(f"Reading {log_path} ...")
    structured_log = StructuredLog.load(log_path)
    df = structured_log.df
    print(f"  {len(df)} events, {df['event_type'].nunique()} event types")

    _validate_rel_time(df)

    kind = _detect_log_kind(df)
    print(f"  Detected log kind: {kind}")

    data = _parse_log(structured_log, slo_resolver, kind)

    print(
        f"  {len(data['queries'])} queries across {len(set(q['cluster_name'] for q in data['queries']))} clusters"
    )

    html_content = generate_html(data)

    if output:
        out_path = Path(output).resolve()
    else:
        out_path = log_path.parent / "log_viewer.html"

    out_path.write_text(html_content)
    print(f"  Written to {out_path}")
    print(f"  Open in browser: file://{out_path}")