Skip to content

Alternative Logs Table Approach#128

Open
trieloff wants to merge 22 commits intomainfrom
auggie-1
Open

Alternative Logs Table Approach#128
trieloff wants to merge 22 commits intomainfrom
auggie-1

Conversation

@trieloff
Copy link
Contributor

Alternative to #122

trieloff and others added 22 commits February 12, 2026 11:31
… 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>
…d loaded data

When hovering over the chart at a timestamp older than the last loaded log row,
scrollLogsToTimestamp() now calls loadMoreLogs() and retries (up to 5 times)
until the target row is found or no more data is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: Lars Trieloff <lars@trieloff.net>
…view

Replace the MAX_SCROLL_LOAD_RETRIES retry loop in scrollLogsToTimestamp()
with a visual gap row model. state.logsData is now a mixed array of real
data rows and synthetic gap rows representing unloaded time ranges.

Key changes:
- New sql/queries/logs-at.sql template for loading data at any timestamp
- Gap rows render as clickable placeholders showing the time range
- loadGap() splits gaps into sub-gaps as data is loaded
- Chart scrubber jumps into gaps trigger automatic loading
- Bottom gap auto-loads on scroll (infinite scroll preserved)
- Mid-range gaps load only on explicit click or chart scrubber jump

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Signed-off-by: Lars Trieloff <lars@trieloff.net>
…iew-improvements

# Conflicts:
#	web-test-runner.config.mjs
- Fix ghost row styling: consistent height, smaller spinner, better labels
- Add support for gapCount to show 'and X more entries' in gap rows
- Prioritize log requests: cancel facet requests when logs view is active
- Add host validation in fetchFullRow for SQL injection prevention
- Replace O(n) linear scan with binary search in findClosestItem
- Update tests for new gap row label format

Signed-off-by: Lars Trieloff <lars@trieloff.net>
- Header is sticky at top when logs view is active (not collapsed)
- Chart section is sticky below header
- Table header is sticky below chart
- Collapse button toggles all sticky behavior off
- Added CSS custom properties for layout heights
- Simplified collapse toggle code to stay under 1000 lines

Signed-off-by: Lars Trieloff <lars@trieloff.net>
- Add logs-active class to dashboardContent in syncUIFromState
- Use :has() selector to make header sticky when logs view is active
- Extract syncLogsViewState helper to reduce complexity

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
The table header is sticky within .logs-table-container which has
overflow:auto for horizontal scrolling. This creates a separate
stacking context, so the header sticks at top:0 of its container
rather than below the sticky header/chart.

A more complete solution would require restructuring the HTML to
move thead outside the scrollable container.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
- Show time range in gap label (e.g., '14:21–14:20')
- Show time range when loading (e.g., 'Loading 14:21–14:20 (7d)…')
- Left-align gap button with sticky positioning to stay visible
  when table is wider than viewport

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
The generic .loading class in base.css adds padding:40px and display:flex
which was causing the gap row to be 80px tall instead of 35px.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Replace scroll-based percentage check with IntersectionObserver that
triggers when the gap row gets within 200px of the viewport. This is
more reliable as it doesn't depend on which element has the scroll.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Row hover → scrubber:
- Hovering over a log row moves the scrubber to that row's timestamp
- Scrubber shows 'active' state (red color)

Chart hover → scroll:
- When data is loaded: wait 1 second, then scroll to timestamp
- When data is in a gap: wait 100ms, load gap, then scroll
- Visual feedback: 'waiting' (pulse) and 'loading' (blink) states

New scroll-sync.js module handles the synchronization logic.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
- Only check isTimestampLoaded after cursor rests (100ms)
- Use requestAnimationFrame for row hover scrubber updates
- Avoid expensive operations on every mousemove
- Two-stage delay: 100ms rest, then 900ms more if data is loaded

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Architecture:
- handleChartHover: Just updates selectionTimestamp (instant, non-blocking)
- Background processor (setInterval 50ms): Handles data checking/loading/scrolling
- Scrubber drawing handled by existing chart.js code (unchanged)

Flow:
1. Mouse moves → selectionTimestamp updated (instant)
2. Background checks every 50ms:
   - If selection age >= 100ms and data not loaded → fetch
   - If selection age >= 1000ms and data loaded → scroll
   - Aborts stale fetches when selection changes

Signed-off-by: Lars Trieloff <lars@trieloff.net>
requestIdleCallback only runs when browser is idle, so it won't
block the main thread during mouse movement. Falls back to
setTimeout for Safari which doesn't support requestIdleCallback.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Move all timing logic (setInterval, age checks) to a Web Worker
that runs in a completely separate thread. Main thread only:
- Posts timestamp to worker on hover (instant)
- Responds to worker messages for fetch/scroll actions

This ensures zero main thread blocking during mouse movement.

Signed-off-by: Lars Trieloff <lars@trieloff.net>
The isTimestampLoaded function calls findClosestItem which iterates
through the data array. Previously this was called on every mousemove,
causing sluggishness after data was loaded.

Now:
- handleChartHover only posts timestamp to worker (instant)
- Worker waits 100ms, then sends 'checkLoaded' message
- Main thread checks loaded status in rAF callback (once per selection)
- Worker decides to fetch or wait based on response

Signed-off-by: Lars Trieloff <lars@trieloff.net>
@github-actions
Copy link

Preview deployment

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

Updated for commit edf03c3

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

This PR implements an alternative logs-table strategy that replaces offset-based infinite scroll with “gap rows” + keyset-style loading, adds on-demand full-row fetching for the detail modal, and introduces bidirectional chart↔logs scroll/scrubber synchronization with a sticky/collapsible chart layout.

Changes:

  • Switch logs SQL from SELECT * to an explicit {{columns}} list and add new logs-at / log-detail templates.
  • Introduce gap rows in the logs table to represent unloaded time ranges and load them on demand (click/observer) instead of OFFSET paging.
  • Add chart sticky/collapse UI and bidirectional chart hover/scroll syncing via a Web Worker.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
sql/queries/logs.sql Switch logs query to SELECT {{columns}}.
sql/queries/logs-more.sql Replace OFFSET paging with cursor-style predicate (template).
sql/queries/logs-at.sql New query to fetch a page “at/before” a timestamp for gap loading.
sql/queries/log-detail.sql New detail query to fetch a full log row for the modal.
js/url-state.js Centralize logs-vs-filters DOM sync and apply logs layout classes.
js/templates/logs-table.test.js Add unit tests for rendering gap rows.
js/templates/logs-table.js Add gap-row HTML builder and gap-label formatting helpers.
js/sql-loader.js Register new SQL templates for preload/lookup.
js/scroll-sync.js Main-thread scroll/scrubber sync module with worker integration.
js/scroll-sync-worker.js Worker to debounce/sequence hover→check→fetch→scroll.
js/pagination.test.js Update tests for cursor-based PaginationState.
js/pagination.js Replace offset tracking with cursor tracking.
js/logs.js Implement gap-row model, gap loading, on-demand detail fetch, scrubber sync, and chart collapse handling.
js/dashboard-init.js Wire chart hover/leave callbacks and logs row hover callbacks; reprioritize logs vs facets loading.
js/columns.test.js New tests for building safe column lists for SQL.
js/columns.js Add buildLogColumnsSql() and always-needed columns list.
js/chart.js Extract drawing helpers, add hover/leave hooks, and export scrubber positioning.
js/chart-draw.js New module containing extracted canvas drawing primitives.
dashboard.html Add chart collapse toggle button.
css/variables.css Add CSS variables for sticky layout heights.
css/modals.css Style “loading” state in log detail modal.
css/logs.css Add gap-row styles and tweak sticky header z-index.
css/layout.css Make header sticky in logs view (via :has(...)).
css/chart.css Add sticky/collapsible chart styles and scrubber state styles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 605 to +608
try {
const result = await query(sql, { signal });
if (!isCurrent()) return;
if (result.data.length > 0) {
state.logsData = [...state.logsData, ...result.data];
appendLogsRows(result.data);

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.

loadGap returns early when the request is no longer current (if (!isCurrent()) return;), but gap.gapLoading is already set to true and never reset in that path. The same happens on abort errors due to the return in the catch guard. Ensure gap.gapLoading (and the DOM) are restored on all exit paths (e.g., via finally, or resetting before each early return).

Copilot uses AI. Check for mistakes.
// Set up scroll listener for infinite scroll on window
window.addEventListener('scroll', handleLogsScroll);
// Set up scroll listener for scrubber sync
document.body.addEventListener('scroll', handleLogsScroll);
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.

Listening for scroll on document.body is unreliable for page scrolling (many browsers dispatch the scroll event on document/window, and body may not be the scrolling element). Since .logs-table-container doesn’t manage vertical scrolling, attach this handler to window (or to the actual scrolling container if that changes) to ensure scrubber sync runs while scrolling the page.

Suggested change
document.body.addEventListener('scroll', handleLogsScroll);
window.addEventListener('scroll', handleLogsScroll);

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.

This detail lookup filters only by timestamp and request.host, which may not uniquely identify a log row (multiple requests can share the same millisecond timestamp on the same host). Since the main logs query always includes sample_hash, consider filtering by sample_hash (optionally with timestamp as an extra guard) to guarantee the modal shows the exact selected entry.

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.
if (isGapRow(item)) {
const gapStartMs = parseUTC(item.gapStart).getTime();
const gapEndMs = parseUTC(item.gapEnd).getTime();
if (targetMs <= gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true };
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.

Gap containment check treats gapStart as inclusive (targetMs <= gapStartMs), but gapStart is also the timestamp of a real row (the anchor above the gap). This can misclassify an actually-loaded timestamp as “in a gap” (e.g., when targetMs === gapStartMs), causing unnecessary fetches and incorrect isTimestampLoaded results. Make the upper bound strict (targetMs < gapStartMs) and/or adjust how gap rows are keyed in getItemTimestampMs to avoid equality with real rows.

Suggested change
if (targetMs <= gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true };
if (targetMs < gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true };

Copilot uses AI. Check for mistakes.
function showDetailLoading() {
const table = document.getElementById('logDetailTable');
if (table) {
table.innerHTML = '<tbody><tr><td class="log-detail-loading">Loading full row data\u2026</td></tr></tbody>';
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.

showDetailLoading() injects a single <td> into #logDetailTable, but the detail table rendering uses two columns (and group rows set colspan="2"). Add colspan="2" to the loading cell (or render a 2-column row) so the loading state spans the full table width and doesn’t produce an invalid table structure.

Suggested change
table.innerHTML = '<tbody><tr><td class="log-detail-loading">Loading full row data\u2026</td></tr></tbody>';
table.innerHTML = '<tbody><tr><td colspan="2" class="log-detail-loading">Loading full row data\u2026</td></tr></tbody>';

Copilot uses AI. Check for mistakes.

container.addEventListener('mouseout', (e) => {
const row = e.target.closest('tr');
if (!row) 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.

The mouseout handler fires whenever the pointer moves between cells within the same row (or to descendants), so onRowLeaveFn() will be called repeatedly and can cause scrubber flicker. Prefer mouseenter/mouseleave (or check e.relatedTarget to ensure the pointer actually left the row/container) before clearing the active state.

Suggested change
if (!row) return;
if (!row) return;
const related = e.relatedTarget;
if (related && row.contains(related)) return;

Copilot uses AI. Check for mistakes.
Comment on lines +307 to +316
async function fetchFullRow(partialRow) {
const { timestamp } = partialRow;
const tsStr = String(timestamp);
if (!TIMESTAMP_RE.test(tsStr)) {
// eslint-disable-next-line no-console
console.warn('fetchFullRow: invalid timestamp format, aborting', tsStr);
return null;
}
const host = partialRow['request.host'] || '';
if (!HOST_RE.test(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.

fetchFullRow() looks up the detail row by timestamp + request.host, which may not uniquely identify the selected log entry. Since logs queries always include sample_hash, pass that through and query the detail row by a unique key (e.g., sample_hash, optionally alongside timestamp) to avoid returning the wrong record when there are multiple matches.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +61
case 'fetch':
// Worker says: fetch data for this timestamp
updateScrubberState(false, true);
checkAndLoadGapFn?.(timestamp).then(() => {
worker?.postMessage({ type: 'fetchComplete', timestamp });
updateScrubberState(false, false);
}).catch(() => {
updateScrubberState(false, false);
});
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.

In the worker 'fetch' handler, checkAndLoadGapFn?.(timestamp).then(...) can throw if checkAndLoadGapFn is null/undefined because .then is evaluated on the call result. Guard the call explicitly or wrap with Promise.resolve(checkAndLoadGapFn?.(timestamp)) before chaining to keep this path safe (especially if the worker posts messages before init completes).

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,5 @@
SELECT {{columns}}
FROM {{database}}.{{table}}
WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}}
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.

timestamp <= toDateTime64('{{target}}', 3) will include the boundary row at target. Since gaps are created using the timestamp of the last loaded row above, this will re-fetch that row and can introduce duplicates when the gap is replaced. Use a strict < comparison (or a composite keyset, e.g., (timestamp, sample_hash)) so paging into a gap never re-includes the anchor row.

Suggested change
WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}}
WHERE {{timeFilter}} AND timestamp < toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}}

Copilot uses AI. Check for mistakes.
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.

3 participants