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">▶</button>\n <button id="btn-reset" title="Reset">■</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,"&").replace(/</g,"<")\n .replace(/>/g,">").replace(/"/g,""");\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
_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
_validate_rel_time(df)
Assert rel_time_s is present and contains relative timestamps.
Source code in src/autoslo/visualizations/render_log_viewer.py
_safe_rpu(cluster_name)
Extract RPU from cluster name, returning None on failure.
Source code in src/autoslo/visualizations/render_log_viewer.py
_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
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 | |
generate_html(data)
Inject timeline data into the HTML template.