Conversation
… sticky chart - Replace OFFSET-based pagination with cursor-based keyset pagination using timestamp for consistent, performant paging (#84) - Select only visible columns instead of SELECT * for faster log loading; fetch full row on demand for detail modal (#87) - Pin chart to viewport top when scrolling logs with collapse toggle and bidirectional scroll/scrubber synchronization (#66) Closes #84, closes #87, closes #66 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add timestamp format validation before SQL interpolation - Validate pinned column names against allowed pattern - Fix throttle stale args bug - Replace no-op with proper continue statement - Initialize collapse toggle label on page load - Add aria-hidden to toggle button arrow entities - Guard against null cursor in canLoadMore() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Replace innerHTML-based rendering with VirtualTable component: - js/logs.js: rewrite to use VirtualTable with getData callback, cursor-based page cache, and onVisibleRangeChange for chart sync - js/virtual-table.js: add new VirtualTable component (from Wave 1) - js/virtual-table.test.js: add VirtualTable unit tests (from Wave 1) - css/logs.css: add container height and loading-row styles - sql/queries/logs-at.sql: new template for jump-to-timestamp queries - js/sql-loader.js: register logs-at template in preload list Removed: renderLogsTable, appendLogsRows, handleLogsScroll, loadMoreLogs, syncScrubberToScroll, setupLogRowClickHandler, updatePinnedOffsets, getApproxPinnedOffsets, window scroll listener. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Preview deploymentPreview is live at: https://klickhaus.aemstatus.net/preview/pr-129/dashboard.html Updated for commit 198303f |
There was a problem hiding this comment.
Pull request overview
Introduces a new virtual-scrolling implementation for the logs view to keep DOM size bounded, fetch log rows on demand via ClickHouse keyset pagination, and synchronize scroll position with the chart scrubber/hover.
Changes:
- Added a new
VirtualTablecomponent with caching + async row loading and unit tests. - Rewrote
js/logs.jsto useVirtualTable, add chart scrubber sync, and fetch full-row details on demand. - Updated SQL templates to support column selection, keyset paging, and added new templates (
logs-at,log-detail); extracted chart drawing helpers intochart-draw.js.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| sql/queries/logs.sql | Switches to SELECT {{columns}} for reduced payloads. |
| sql/queries/logs-more.sql | Replaces OFFSET paging with cursor-based paging. |
| sql/queries/logs-at.sql | Adds query intended for jumping to a timestamp. |
| sql/queries/log-detail.sql | Adds query to fetch full log row details on demand. |
| js/virtual-table.js | New virtual scrolling table component. |
| js/virtual-table.test.js | Unit tests for VirtualTable. |
| js/logs.js | Replaces logs table rendering with VirtualTable, adds chart sync + detail fetching. |
| js/sql-loader.js | Registers new SQL templates for preload. |
| js/pagination.js / js/pagination.test.js | Refactors pagination state to cursor semantics. |
| js/columns.js / js/columns.test.js | Adds buildLogColumnsSql() for safe/ordered column lists. |
| js/chart.js / js/chart-draw.js | Extracts draw helpers; adds chart↔logs sync hooks. |
| js/dashboard-init.js | Wires chart hover to logs scrolling (throttled). |
| dashboard.html | Adds chart collapse toggle button. |
| css/logs.css / css/chart.css / css/modals.css | Styling updates for virtual rows, sticky/collapsible chart, detail loading state. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
js/virtual-table.js
Outdated
| this.table.style.position = 'sticky'; | ||
| this.table.style.top = '0'; |
There was a problem hiding this comment.
VirtualTable sets the entire <table> to position: sticky (and also adds a separate spacer). This will prevent the table body from scrolling normally inside the container and can break the scrollTop → rowIndex mapping. Since css/logs.css already makes header cells sticky (.logs-table th { position: sticky; top: 0; }), the table itself should stay in normal flow (remove the sticky positioning) and rely on sticky th for the header.
| this.table.style.position = 'sticky'; | |
| this.table.style.top = '0'; |
| updateHeader() { | ||
| const tr = document.createElement('tr'); | ||
| let pinnedLeft = 0; | ||
| for (const col of this.columns) { | ||
| const th = document.createElement('th'); | ||
| th.textContent = col.label || col.key; | ||
| th.title = col.key; | ||
| if (col.width) th.style.width = `${col.width}px`; | ||
| if (col.pinned) { | ||
| th.style.position = 'sticky'; | ||
| th.style.left = `${pinnedLeft}px`; | ||
| th.style.zIndex = '2'; | ||
| pinnedLeft += col.width || 120; | ||
| } | ||
| tr.appendChild(th); | ||
| } | ||
| this.thead.innerHTML = ''; | ||
| this.thead.appendChild(tr); | ||
| } |
There was a problem hiding this comment.
Pinned columns/header cells are styled and interacted with elsewhere via CSS classes and data-action attributes (e.g. .logs-table th.pinned / td.pinned, and data-action="toggle-pinned-column"). updateHeader() currently only applies inline sticky styles and omits the pinned class and the data-action/data-col attributes, so pin/unpin and pinned styling will stop working. Add the same class/dataset attributes the previous logs header used.
| if (this.onRowClickFn) { | ||
| this.clickHandler = (e) => { | ||
| const tr = e.target.closest('tr[data-row-idx]'); | ||
| if (!tr) return; | ||
| const idx = parseInt(tr.dataset.rowIdx, 10); | ||
| const row = findInCache(this.cache, idx); | ||
| if (row) this.onRowClickFn(idx, row); | ||
| }; | ||
| this.tbody.addEventListener('click', this.clickHandler); | ||
| } |
There was a problem hiding this comment.
The row click handler triggers for any click inside a row. This will conflict with existing delegated [data-action] handlers (e.g. add-filter clicks) and with links/buttons inside cells, because the row-click handler runs before the document-level action handler and will open the detail modal even when the user intended a cell action. Consider ignoring events whose target is inside [data-action], a, button, etc., or attaching row-click only to non-interactive areas.
| scrollToTimestamp(ts, getTimestamp) { | ||
| if (this.totalRows === 0) return; | ||
| const target = typeof ts === 'number' ? ts : ts.getTime(); | ||
| let lo = 0; | ||
| let hi = this.totalRows - 1; | ||
| let best = 0; | ||
| let bestDiff = Infinity; | ||
|
|
||
| while (lo <= hi) { | ||
| const mid = Math.floor((lo + hi) / 2); | ||
| const row = findInCache(this.cache, mid); | ||
| if (!row) break; | ||
| const rowTs = getTimestamp(row); | ||
| const diff = Math.abs(rowTs - target); | ||
| if (diff < bestDiff) { | ||
| bestDiff = diff; | ||
| best = mid; | ||
| } | ||
| if (rowTs > target) lo = mid + 1; | ||
| else hi = mid - 1; | ||
| } | ||
| this.scrollToRow(best); | ||
| } |
There was a problem hiding this comment.
scrollToTimestamp() currently binary-searches using only cached rows and breaks as soon as it hits an uncached midpoint. With sparse caching (the normal case for a virtual table), this will often stop immediately and scroll to best = 0, making chart→scroll sync / jump-to-time unreliable. This needs a strategy that can fetch around the target timestamp (e.g. via a callback that loads a window around ts / using logs-at.sql) or a different search that doesn't require a fully populated cache.
js/logs.js
Outdated
| const escaped = escapeHtml(displayValue); | ||
| const cls = cellClass ? ` class="${cellClass}"` : ''; | ||
| return `<span${cls} title="${escaped}">${colorIndicator}${escaped}</span>`; |
There was a problem hiding this comment.
renderCell() no longer emits the data-action="add-filter" / data-col / data-value attributes (and the clickable styling hooks) that the previous logs table used for click-to-filter on colored cells. As a result, clicking a cell can't add a filter anymore. Consider re-adding the same action attributes (on the <td> or a child element) so js/ui/actions.js can continue to handle log cell filter clicks.
| const escaped = escapeHtml(displayValue); | |
| const cls = cellClass ? ` class="${cellClass}"` : ''; | |
| return `<span${cls} title="${escaped}">${colorIndicator}${escaped}</span>`; | |
| const escapedDisplay = escapeHtml(displayValue); | |
| // Re-add click-to-filter hooks for colored cells so js/ui/actions.js can handle them. | |
| const isFilterable = !!colorIndicator && value !== null && value !== undefined && value !== ''; | |
| const classes = []; | |
| if (cellClass) { | |
| classes.push(cellClass); | |
| } | |
| if (isFilterable) { | |
| classes.push('clickable'); | |
| } | |
| const attrs = []; | |
| if (classes.length) { | |
| attrs.push(`class="${classes.join(' ')}"`); | |
| } | |
| if (isFilterable) { | |
| const colKeyEscaped = escapeHtml(String(col.key)); | |
| const valueEscaped = escapeHtml(String(value)); | |
| attrs.push('data-action="add-filter"'); | |
| attrs.push(`data-col="${colKeyEscaped}"`); | |
| attrs.push(`data-value="${valueEscaped}"`); | |
| } | |
| attrs.push(`title="${escapedDisplay}"`); | |
| return `<span ${attrs.join(' ')}>${colorIndicator}${escapedDisplay}</span>`; |
| async function getData(startIdx, count) { | ||
| const pageIdx = Math.floor(startIdx / PAGE_SIZE); | ||
|
|
||
| // Return from cache if available | ||
| if (pageCache.has(pageIdx)) { | ||
| const page = pageCache.get(pageIdx); | ||
| const offset = startIdx - pageIdx * PAGE_SIZE; | ||
| return page.rows.slice(offset, offset + count); | ||
| } | ||
|
|
||
| const timeFilter = getTimeFilter(); | ||
| const hostFilter = getHostFilter(); | ||
| const facetFilters = getFacetFilters(); | ||
|
|
||
| const sql = await loadSql('logs-more', { | ||
| const sqlParams = { | ||
| database: DATABASE, | ||
| table: getTable(), | ||
| columns: buildLogColumnsSql(state.pinnedColumns), | ||
| timeFilter, | ||
| hostFilter, | ||
| facetFilters, | ||
| additionalWhereClause: state.additionalWhereClause, | ||
| pageSize: String(PAGE_SIZE), | ||
| offset: String(pagination.offset), | ||
| }); | ||
| }; | ||
|
|
||
| let sql; | ||
| if (pageIdx === 0) { | ||
| // Initial page — no cursor needed | ||
| sql = await loadSql('logs', sqlParams); | ||
| } else { | ||
| // Find the cursor from the nearest previous cached page | ||
| let cursor = null; | ||
| for (let p = pageIdx - 1; p >= 0; p -= 1) { | ||
| const prev = pageCache.get(p); | ||
| if (prev && prev.cursor) { | ||
| cursor = prev.cursor; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (cursor && TIMESTAMP_RE.test(cursor)) { | ||
| sql = await loadSql('logs-more', { ...sqlParams, cursor }); | ||
| } else { | ||
| // No cursor available — fall back to initial query | ||
| sql = await loadSql('logs', sqlParams); | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| const result = await query(sql, { signal }); | ||
| if (!isCurrent()) return; | ||
| if (result.data.length > 0) { | ||
| state.logsData = [...state.logsData, ...result.data]; | ||
| appendLogsRows(result.data); | ||
| const result = await query(sql); | ||
| const rows = result.data; | ||
| const cursor = rows.length > 0 ? rows[rows.length - 1].timestamp : null; | ||
| pageCache.set(pageIdx, { rows, cursor }); | ||
|
|
||
| // Update columns on first data load | ||
| if (rows.length > 0 && currentColumns.length === 0) { | ||
| currentColumns = getLogColumns(Object.keys(rows[0])); | ||
| if (virtualTable) { | ||
| virtualTable.setColumns(buildVirtualColumns(currentColumns)); | ||
| } | ||
| } | ||
|
|
||
| // Also update logsData on state for backwards compat with detail modal | ||
| if (pageIdx === 0) { | ||
| state.logsData = rows; | ||
| } | ||
| pagination.recordPage(result.data.length); | ||
|
|
||
| const offset = startIdx - pageIdx * PAGE_SIZE; | ||
| return rows.slice(offset, offset + count); | ||
| } catch (err) { | ||
| if (!isCurrent() || isAbortError(err)) return; | ||
| // eslint-disable-next-line no-console | ||
| console.error('Load more logs error:', err); | ||
| } finally { | ||
| if (isCurrent()) { | ||
| pagination.loading = false; | ||
| if (!isAbortError(err)) { | ||
| // eslint-disable-next-line no-console | ||
| console.error('getData error:', err); | ||
| } | ||
| return []; | ||
| } |
There was a problem hiding this comment.
getData() issues queries without the current request context signal and without an isRequestCurrent guard. If filters/time range change (or logs view is toggled) while virtual scroll requests are in flight, stale responses can still populate pageCache and desync the UI. Pass/merge the active abort signal into these queries and skip applying results when the request is no longer current.
| WHERE {{timeFilter}} AND timestamp < toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | ||
| ORDER BY timestamp DESC |
There was a problem hiding this comment.
Keyset pagination is currently based only on timestamp < cursor with ORDER BY timestamp DESC. If multiple rows share the same millisecond timestamp (likely with DateTime64(3)), paging can skip or duplicate rows. Use a stable composite key in both the ORDER BY and cursor predicate (e.g. ORDER BY timestamp DESC, sample_hash DESC and (timestamp < cursorTs OR (timestamp = cursorTs AND sample_hash < cursorHash))).
| WHERE {{timeFilter}} AND timestamp < toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |
| ORDER BY timestamp DESC | |
| WHERE {{timeFilter}} AND (timestamp < toDateTime64('{{cursor}}', 3) OR (timestamp = toDateTime64('{{cursor}}', 3) AND sample_hash < unhex('{{cursorSampleHash}}'))) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |
| ORDER BY timestamp DESC, sample_hash DESC |
| WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | ||
| ORDER BY timestamp DESC |
There was a problem hiding this comment.
Same keyset-pagination stability issue as logs-more.sql: logs-at.sql uses only timestamp <= cursor with ORDER BY timestamp DESC, which is ambiguous when timestamps repeat. If this query is intended to support jumping into the middle of the stream, it should use the same tie-breaker strategy as the paging query so the starting point is deterministic.
| WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |
| ORDER BY timestamp DESC | |
| WHERE {{timeFilter}} AND (timestamp < toDateTime64('{{cursor}}', 3) OR (timestamp = toDateTime64('{{cursor}}', 3) AND id <= {{cursorId}})) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |
| ORDER BY timestamp DESC, id DESC |
| @@ -0,0 +1,4 @@ | |||
| SELECT * | |||
| FROM {{database}}.{{table}} | |||
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}' | |||
There was a problem hiding this comment.
log-detail.sql identifies a row by timestamp + request.host only and then LIMIT 1. This is not guaranteed to be unique and can show details for a different row than the one clicked when there are multiple requests for the same host at the same timestamp. Consider including a unique key from the table row (e.g. sample_hash is already always selected) in the WHERE clause so the detail fetch is deterministic.
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}' | |
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) | |
| AND `request.host` = '{{host}}' | |
| AND sample_hash = '{{sample_hash}}' |
| async fetchRange(startIdx, endIdx) { | ||
| const count = endIdx - startIdx; | ||
| if (this.pending.has(startIdx)) return; | ||
| this.pending.add(startIdx); | ||
|
|
||
| try { | ||
| const rows = await this.getDataFn(startIdx, count); | ||
| this.cache.set(startIdx, { startIdx, rows }); | ||
| this.evictDistantPages(); | ||
| this.lastRange = null; | ||
| this.renderRows(); | ||
| } finally { | ||
| this.pending.delete(startIdx); | ||
| } | ||
| } |
There was a problem hiding this comment.
fetchRange() can still resolve after destroy() has been called (e.g. view toggled while a request is in-flight) and will call this.renderRows() against a component that is supposed to be torn down. Add a this.destroyed flag (set in destroy()) and early-return in fetchRange()/renderRows() when destroyed, or cancel/ignore in-flight loads to avoid post-destroy DOM mutations.
- Replace absolute-positioned rows with padding-based virtual scrolling - Add table-layout:fixed with colgroup for column alignment - Add seedCache to avoid double-fetch on initial load - Fix scroll direction and column overflow handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
- Use flexbox layout for logs-active mode so the table container fills exactly the remaining viewport (no double scrollbar) - Set explicit column widths and table width for horizontal scrolling - Use timestamp interpolation for non-sequential page jumps and cap totalRows when data is exhausted - Add tests for table width calculation Agent-Id: agent-460b7b16-e489-441c-9231-4f290ebddc75
When the header is static (not fixed), the previous `height: 100vh` on #dashboardContent caused the page to be header-height + 100vh = overflow. Fix: make #dashboard a flex column (via :has(.logs-active)) so header + main together fill exactly 100vh. The header gets flex-shrink: 0 and main gets flex: 1 with min-height: 0, eliminating the overflow. This also removes the special body.header-fixed calc() rule since flex: 1 handles both static and fixed header cases correctly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Bug A: syncUIFromState now adds logs-active class to #dashboardContent and restores chart collapse state when showLogs is true. Bug B: toggleLogsView now re-seeds the virtual table from pageCache when data is already loaded, or triggers loadLogs() when it isn't. Agent-Id: agent-7176a9e4-6646-4585-bfd4-d29110221de7
When getData fetches a sequential page that returns exactly PAGE_SIZE rows, extend totalRows by at least one more page to allow continued scrolling. Only cap totalRows when a page returns fewer than PAGE_SIZE rows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
CSS padding on <tbody> with display:table-row-group is ignored by the browser's table layout engine, so the scroll container never grew beyond the rendered rows. Replace tbody padding with spacer <div> elements before and after the <table> inside the scroll container. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show a shaded range band on the chart indicating which time range is visible in the logs table. On mouseenter the band collapses to a single hover line; on mouseleave it restores the range band. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Add data-action="add-filter" attributes to cells with facet mappings, enabling the existing global action handler to process filter clicks. Skip onRowClick dispatch for cells with data-action to prevent the detail modal from opening on filter clicks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
sample_hash is not displayed in the table and not needed for cursor-based log queries. Removing it reduces query bandwidth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Clicking on the chart area (when no anomaly or selection is present) fires a callback that scrolls the logs table to the clicked time. If logs view is not active, it toggles to logs view first, then scrolls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Fetch only 100 rows for the first paint, then expand to full 500-row pages on scroll. Extract cache lookup and totalRows adjustment into helper functions to reduce getData complexity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Replace the broken estimateTotalRows() (was reading nonexistent .buckets property) and linear interpolateTimestamp() with a bucket-aware approach that uses chart data for accurate scroll mapping. - Build cumulative row-count index from chart bucket counts - Binary search cumulative array for timestamp interpolation - Remove adjustTotalRows growth path that caused scroll bar jumps - Fix estimateTotalRows to parse actual chart data fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
When totalRows is large (500K–2M), the natural scroll height (totalRows × 28px) exceeds browser max scroll height (~33.5M px), causing scrollTop-to-row-index mapping to compress and produce scroll acceleration. Cap at 1M pixels with a scale factor so scroll position maps linearly to row indices regardless of dataset size. - Add MAX_SCROLL_HEIGHT constant (1,000,000 px) - Compute _scrollScale in setTotalRows() when natural height exceeds cap - Apply scale to computeRange(), renderRows() spacers, scrollToRow(), and evictDistantPages() - Add 6 tests for large dataset scroll behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
…lling
Replace the spacer-based VirtualTable approach (which caused scroll
acceleration due to browser max scroll height limits) with a simpler
architecture: one <tr> per chart bucket (~200-1000 rows). The browser
handles this natively with zero scroll issues.
- Each bucket-row height proportional to its row count (count × 28px)
- Bucket rows have timestamp IDs for scrollIntoView navigation
- Scrubber sync via passive scroll listener on bucket visibility
- Proportional scaling when total height exceeds 10M pixels
- Phase 1: placeholder content only ("342 rows" per bucket)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Tests for computeBucketHeights and renderBucketTable functions: - Bucket height calculation from chart data - Proportional scaling when total exceeds 10M pixels - Table rendering with correct structure and IDs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
When the user starts in logs view, loadLogs() runs before the chart query. ensureVirtualTable() finds no chart data and shows "Loading..." which never updates. Fix by adding a tryRenderBucketTable() callback that fires when chart data arrives, and also at end of loadLogs(). - Add tryRenderBucketTable() in logs.js (exported, idempotent) - Add setOnChartDataReady() callback in chart.js - Wire callback in dashboard-init.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
…olders Replace nth-child(even) zebra striping with a repeating-linear-gradient on each bucket placeholder cell. 28px alternating bands match ROW_HEIGHT, creating the visual effect of individual rows scrolling by even though each bucket is a single tall <tr>. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
The repeating gradient stripes in bucket rows were being replaced by a solid background on hover, hiding the stripe effect. Agent-Id: agent-1f32891e-559d-4e3f-a729-42a3797046b2
…r sync - Remove setScrubberRange(), restoreScrubberRange(), and range-mode variables from chart.js - Remove .chart-scrubber-line.range-mode CSS rule - Update syncBucketScrubber() to use setScrubberPosition(firstDate) instead of setScrubberRange - Change scrollIntoView from 'smooth' to 'instant' for responsive chart-hover scrolling Signed-off-by: Lars Trieloff <lars@trieloff.net>
Measures ClickHouse query latency for 8 test cases with varying row limits (100–50000) and time windows (5s–15min) to inform the bucket data loading strategy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Each bucket with > 500 rows now renders two <tr> elements: a head
row (500 rows worth of height) and a tail row (remaining rows).
Buckets with <= 500 rows render a single head row as before.
This prepares for Wave 14 where the head placeholder will be
replaced with actual data rows from a fast LIMIT 500 query, and
the tail placeholder from a slower background query.
- computeBucketHeights() returns headCount/tailCount/headHeight/tailHeight
- renderBucketTable() renders head+tail with distinct labels
- Scrubber sync and scrollToTimestamp updated for bucket-head-{ts} IDs
- 819 tests pass, lint clean
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
When a bucket placeholder scrolls into the viewport, fire per-bucket ClickHouse queries scoped to the bucket's time window. Head placeholder gets LIMIT 500 rows, tail gets LIMIT tailCount OFFSET 500. Both fire in parallel with a concurrency cap of 4. - Create js/bucket-loader.js with IntersectionObserver, fetchBucketRows, replacePlaceholder, concurrency limiter, and abort support - Replace "Log Buckets" header with actual column headers - Style data rows at 28px height matching ROW_HEIGHT - Abort in-flight requests on time range change / view switch - 822 tests pass, lint clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Add eviction logic to bucket-loader.js so off-screen buckets get collapsed back to placeholders, keeping the DOM under 2000 data rows. Also abort in-flight HTTP requests for buckets that scroll out of view (not just on navigation change). - Per-bucket AbortControllers cancel fetches on scroll-past - Sentinel rows + data-bucket attributes for fast row grouping - Eviction observer (800px margin) proactively evicts off-screen data - enforceRowBudget() evicts farthest bucket when over 2000 rows - LRU head cache (20 buckets) avoids re-fetching on scroll-back - Budget-aware tail loading skips if DOM is near capacity - 17 new tests, 841 total pass, lint clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
Summary
Replace the logs table rendering with a custom virtual scrolling component that keeps DOM elements limited, loads data on demand, and provides chart scrubber synchronization.
This is the third iteration on improving the logs view, building on lessons from two prior PRs:
This PR takes a clean approach: a minimal ~300-line virtual table component that eliminates gap rows, progressive retry loops, and full innerHTML re-renders entirely. The virtual scroll is the loading mechanism — rows are fetched on demand as they scroll into view.
Key changes:
VirtualTablecomponent (js/virtual-table.js, 322 lines) — zero-dependency virtual scrolling table with fixed row height, sparse page cache, async data loading, and placeholder rowsjs/logs.js) — uses VirtualTable with agetDatacallback that maps row indices to ClickHouse keyset pagination queriesonVisibleRangeChangecallback updates the chart crosshair as the user scrolls through logslogs-at.sqljs/chart-draw.jsextracted fromchart.jsto stay under max-lines lint ruleWhat this eliminates vs #122:
Supersedes #122 and #128. Closes #66, #84, #87.
Testing Done
Checklist
npm test)npm run lint)