Skip to content

feat: virtual scrolling for logs view#129

Open
trieloff wants to merge 28 commits intomainfrom
virtual-logs-table-v2
Open

feat: virtual scrolling for logs view#129
trieloff wants to merge 28 commits intomainfrom
virtual-logs-table-v2

Conversation

@trieloff
Copy link
Contributor

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:

  • New VirtualTable component (js/virtual-table.js, 322 lines) — zero-dependency virtual scrolling table with fixed row height, sparse page cache, async data loading, and placeholder rows
  • Rewritten logs view (js/logs.js) — uses VirtualTable with a getData callback that maps row indices to ClickHouse keyset pagination queries
  • Chart scrubber synconVisibleRangeChange callback updates the chart crosshair as the user scrolls through logs
  • Jump to timestamp — chart hover/click loads data at any point in the time range via logs-at.sql
  • Keyset pagination (Switch logs paging to keyset pagination #84) — cursor-based pagination for consistent ordering
  • Column selection (Reduce logs payload by selecting visible columns #87) — pin/unpin columns in the logs table
  • Sticky chart ([Feature][Logs]: Keep the chart pinned to the top while scrolling through logs and make it collapsible #66) — chart stays visible while scrolling logs
  • Chart drawing extractionjs/chart-draw.js extracted from chart.js to stay under max-lines lint rule

What this eliminates vs #122:

  • Gap rows / island management / progressive retry loops
  • Full innerHTML re-rendering on each data load
  • Complex scroll threshold detection

Supersedes #122 and #128. Closes #66, #84, #87.

Testing Done

  • 777 automated tests pass
  • 95.32% code coverage (above 95% threshold)
  • Lint passes with zero errors
  • VirtualTable has 16 dedicated unit tests covering row calculation, caching, scroll-to-row, visible range callbacks, and placeholder rendering

Checklist

  • Tests pass (npm test)
  • Lint passes (npm run lint)
  • Documentation updated (if applicable)

trieloff and others added 4 commits February 12, 2026 17:44
… 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>
@github-actions
Copy link

github-actions bot commented Feb 12, 2026

Preview deployment

Preview is live at: https://klickhaus.aemstatus.net/preview/pr-129/dashboard.html

Updated for commit 198303f

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 VirtualTable component with caching + async row loading and unit tests.
  • Rewrote js/logs.js to use VirtualTable, 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 into chart-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.

Comment on lines 93 to 94
this.table.style.position = 'sticky';
this.table.style.top = '0';
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
this.table.style.position = 'sticky';
this.table.style.top = '0';

Copilot uses AI. Check for mistakes.
Comment on lines 107 to 125
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);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 141 to 150
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);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +280 to +302
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);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
js/logs.js Outdated
Comment on lines 393 to 395
const escaped = escapeHtml(displayValue);
const cls = cellClass ? ` class="${cellClass}"` : '';
return `<span${cls} title="${escaped}">${colorIndicator}${escaped}</span>`;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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>`;

Copilot uses AI. Check for mistakes.
Comment on lines 402 to 476
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 [];
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to 4
WHERE {{timeFilter}} AND timestamp < toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}}
ORDER BY timestamp DESC
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))).

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +4
WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}}
ORDER BY timestamp DESC
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,4 @@
SELECT *
FROM {{database}}.{{table}}
WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}'
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}'
WHERE timestamp = toDateTime64('{{timestamp}}', 3)
AND `request.host` = '{{host}}'
AND sample_hash = '{{sample_hash}}'

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +243
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);
}
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
claude and others added 2 commits February 13, 2026 13:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature][Logs]: Keep the chart pinned to the top while scrolling through logs and make it collapsible

3 participants