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>
…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>
Preview deploymentPreview is live at: https://klickhaus.aemstatus.net/preview/pr-128/dashboard.html Updated for commit edf03c3 |
There was a problem hiding this comment.
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 newlogs-at/log-detailtemplates. - 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.
| try { | ||
| const result = await query(sql, { signal }); | ||
| if (!isCurrent()) return; | ||
| if (result.data.length > 0) { | ||
| state.logsData = [...state.logsData, ...result.data]; | ||
| appendLogsRows(result.data); | ||
|
|
There was a problem hiding this comment.
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).
| // 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); |
There was a problem hiding this comment.
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.
| document.body.addEventListener('scroll', handleLogsScroll); | |
| window.addEventListener('scroll', handleLogsScroll); |
| @@ -0,0 +1,4 @@ | |||
| SELECT * | |||
| FROM {{database}}.{{table}} | |||
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}' | |||
There was a problem hiding this comment.
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.
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}' | |
| WHERE timestamp = toDateTime64('{{timestamp}}', 3) | |
| AND `request.host` = '{{host}}' | |
| AND sample_hash = '{{sample_hash}}' |
| 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 }; |
There was a problem hiding this comment.
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.
| if (targetMs <= gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true }; | |
| if (targetMs < gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true }; |
| 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>'; |
There was a problem hiding this comment.
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.
| 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>'; |
|
|
||
| container.addEventListener('mouseout', (e) => { | ||
| const row = e.target.closest('tr'); | ||
| if (!row) return; |
There was a problem hiding this comment.
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.
| if (!row) return; | |
| if (!row) return; | |
| const related = e.relatedTarget; | |
| if (related && row.contains(related)) return; |
| 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)) { |
There was a problem hiding this comment.
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.
| 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); | ||
| }); |
There was a problem hiding this comment.
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).
| @@ -0,0 +1,5 @@ | |||
| SELECT {{columns}} | |||
| FROM {{database}}.{{table}} | |||
| WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |||
There was a problem hiding this comment.
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.
| WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} | |
| WHERE {{timeFilter}} AND timestamp < toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} |
Alternative to #122