From 5a96a13a22594bc67434e5db3a8145d7cd1ab808 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Thu, 12 Feb 2026 11:31:13 +0100 Subject: [PATCH 01/21] feat: improve logs view with keyset pagination, column selection, and 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 --- css/chart.css | 44 +++++++ css/modals.css | 7 ++ dashboard.html | 1 + js/chart.js | 36 ++++++ js/columns.js | 24 ++++ js/columns.test.js | 71 +++++++++++ js/dashboard-init.js | 13 +- js/logs.js | 243 ++++++++++++++++++++++++++++++++----- js/pagination.js | 11 +- js/pagination.test.js | 75 +++++++++--- js/sql-loader.js | 1 + sql/queries/log-detail.sql | 4 + sql/queries/logs-more.sql | 5 +- sql/queries/logs.sql | 2 +- 14 files changed, 481 insertions(+), 56 deletions(-) create mode 100644 js/columns.test.js create mode 100644 sql/queries/log-detail.sql diff --git a/css/chart.css b/css/chart.css index 220ab01..e506ced 100644 --- a/css/chart.css +++ b/css/chart.css @@ -9,6 +9,50 @@ margin: 0 -24px 24px -24px; } +/* Sticky chart when logs view is active */ +.logs-active .chart-section { + position: sticky; + top: 0; + z-index: 20; +} + +/* Collapse/expand toggle */ +.chart-collapse-toggle { + display: none; + width: 100%; + height: 24px; + border: none; + background: var(--chart-bg); + border-top: 1px solid var(--border); + cursor: pointer; + padding: 0; + color: var(--text-secondary); + font-size: 10px; + line-height: 24px; + text-align: center; + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.chart-collapse-toggle:hover { + opacity: 1; +} + +.logs-active .chart-collapse-toggle { + display: block; +} + +/* Collapsed state */ +.chart-section.chart-collapsed .chart-container { + height: 0; + overflow: hidden; + transition: height 0.2s ease; +} + +.chart-section:not(.chart-collapsed) .chart-container { + transition: height 0.2s ease; +} + @media (max-width: 600px) { .chart-section { margin: 0 -12px 16px -12px; diff --git a/css/modals.css b/css/modals.css index 981fc7e..bbfff4b 100644 --- a/css/modals.css +++ b/css/modals.css @@ -365,6 +365,13 @@ margin-bottom: 12px; } +.log-detail-loading { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + @media (max-width: 600px) { #logDetailModal { width: 100vw; diff --git a/dashboard.html b/dashboard.html index b51ccb8..b02aced 100644 --- a/dashboard.html +++ b/dashboard.html @@ -80,6 +80,7 @@

Requests over time

+ diff --git a/js/chart.js b/js/chart.js index e1c6eac..10356b7 100644 --- a/js/chart.js +++ b/js/chart.js @@ -87,6 +87,34 @@ let isDragging = false; let dragStartX = null; let justCompletedDrag = false; +// Callback for chart→scroll sync (set by logs.js) +let onChartHoverTimestamp = null; + +/** + * Set callback for chart hover → scroll sync + * @param {Function} callback - Called with timestamp when hovering chart in logs view + */ +export function setOnChartHoverTimestamp(callback) { + onChartHoverTimestamp = callback; +} + +/** + * Position the scrubber line at a given timestamp (called from logs.js scroll sync) + * @param {Date} timestamp - Timestamp to position scrubber at + */ +export function setScrubberPosition(timestamp) { + if (!scrubberLine) return; + const x = getXAtTime(timestamp); + const chartLayout = getChartLayout(); + if (!chartLayout) return; + + const { padding, height } = chartLayout; + scrubberLine.style.left = `${x}px`; + scrubberLine.style.top = `${padding.top}px`; + scrubberLine.style.height = `${height - padding.top - padding.bottom}px`; + scrubberLine.classList.add('visible'); +} + /** * Initialize canvas for chart rendering */ @@ -712,6 +740,14 @@ export function setupChartNavigation(callback) { const y = e.clientY - rect.top; updateScrubber(x, y); + // Chart→Scroll sync: when hovering chart in logs view, scroll to matching time + if (onChartHoverTimestamp && state.showLogs) { + const hoverTime = getTimeAtX(x); + if (hoverTime) { + onChartHoverTimestamp(hoverTime); + } + } + // Ship tooltip on hover (handled here since nav overlay captures canvas events) const ship = getShipAtPoint(getShipPositions(), x, y); if (ship) { diff --git a/js/columns.js b/js/columns.js index c8ed1a3..524d7c1 100644 --- a/js/columns.js +++ b/js/columns.js @@ -181,3 +181,27 @@ export const LOG_COLUMN_SHORT_LABELS = Object.fromEntries( .filter((def) => def.shortLabel) .map((def) => [def.logKey, def.shortLabel]), ); + +/** + * Columns always included in logs queries (needed for internal use, not display). + * @type {string[]} + */ +const ALWAYS_NEEDED_COLUMNS = ['timestamp', 'source', 'sample_hash']; + +/** + * Build the backtick-quoted column list for logs SQL queries. + * Includes LOG_COLUMN_ORDER columns plus always-needed columns plus any pinned columns. + * @param {string[]} [pinnedColumns] - Additional pinned columns to include. + * @returns {string} Comma-separated, backtick-quoted column list for SQL SELECT. + */ +export function buildLogColumnsSql(pinnedColumns = []) { + const seen = new Set(); + const cols = []; + for (const col of [...ALWAYS_NEEDED_COLUMNS, ...LOG_COLUMN_ORDER, ...pinnedColumns]) { + if (!seen.has(col)) { + seen.add(col); + cols.push(`\`${col}\``); + } + } + return cols.join(', '); +} diff --git a/js/columns.test.js b/js/columns.test.js new file mode 100644 index 0000000..6b9655c --- /dev/null +++ b/js/columns.test.js @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { assert } from 'chai'; +import { buildLogColumnsSql, LOG_COLUMN_ORDER } from './columns.js'; + +describe('buildLogColumnsSql', () => { + it('returns backtick-quoted column list', () => { + const result = buildLogColumnsSql(); + assert.include(result, '`timestamp`'); + assert.include(result, '`request.host`'); + assert.include(result, '`response.status`'); + }); + + it('includes always-needed columns', () => { + const result = buildLogColumnsSql(); + assert.include(result, '`timestamp`'); + assert.include(result, '`source`'); + assert.include(result, '`sample_hash`'); + }); + + it('includes all LOG_COLUMN_ORDER columns', () => { + const result = buildLogColumnsSql(); + for (const col of LOG_COLUMN_ORDER) { + assert.include(result, `\`${col}\``, `missing column: ${col}`); + } + }); + + it('does not duplicate columns', () => { + // timestamp is in both ALWAYS_NEEDED and LOG_COLUMN_ORDER + const result = buildLogColumnsSql(); + const matches = result.match(/`timestamp`/g); + assert.strictEqual(matches.length, 1, 'timestamp should appear exactly once'); + }); + + it('includes pinned columns', () => { + const result = buildLogColumnsSql(['custom.column']); + assert.include(result, '`custom.column`'); + }); + + it('does not duplicate pinned columns already in the list', () => { + const result = buildLogColumnsSql(['request.host']); + const matches = result.match(/`request\.host`/g); + assert.strictEqual(matches.length, 1, 'request.host should appear exactly once'); + }); + + it('returns comma-separated values', () => { + const result = buildLogColumnsSql(); + const parts = result.split(', '); + assert.ok(parts.length > 5, 'should have many columns'); + for (const part of parts) { + assert.match(part, /^`[^`]+`$/, `each part should be backtick-quoted: ${part}`); + } + }); + + it('starts with always-needed columns', () => { + const result = buildLogColumnsSql(); + const parts = result.split(', '); + assert.strictEqual(parts[0], '`timestamp`'); + assert.strictEqual(parts[1], '`source`'); + assert.strictEqual(parts[2], '`sample_hash`'); + }); +}); diff --git a/js/dashboard-init.js b/js/dashboard-init.js index 9f8a867..0a25449 100644 --- a/js/dashboard-init.js +++ b/js/dashboard-init.js @@ -29,7 +29,7 @@ import { } from './timer.js'; import { loadTimeSeries, setupChartNavigation, getDetectedAnomalies, getLastChartData, - renderChart, + renderChart, setOnChartHoverTimestamp, } from './chart.js'; import { loadAllBreakdowns, loadBreakdown, getBreakdowns, markSlowestFacet, resetFacetTimings, @@ -40,7 +40,7 @@ import { getFilterForValue, } from './filters.js'; import { - loadLogs, toggleLogsView, setLogsElements, setOnShowFiltersView, + loadLogs, toggleLogsView, setLogsElements, setOnShowFiltersView, scrollLogsToTimestamp, } from './logs.js'; import { loadHostAutocomplete } from './autocomplete.js'; import { initModal, closeQuickLinksModal } from './modal.js'; @@ -202,6 +202,15 @@ export function initDashboard(config = {}) { } }); + // Chart→Scroll sync: throttled to avoid excessive scrolling + let lastHoverScroll = 0; + setOnChartHoverTimestamp((timestamp) => { + const now = Date.now(); + if (now - lastHoverScroll < 300) return; + lastHoverScroll = now; + scrollLogsToTimestamp(timestamp); + }); + setFilterCallbacks(saveStateToURL, loadDashboard); setOnBeforeRestore(() => invalidateInvestigationCache()); setOnStateRestored(loadDashboard); diff --git a/js/logs.js b/js/logs.js index 9a13d2b..6b75bd2 100644 --- a/js/logs.js +++ b/js/logs.js @@ -18,10 +18,12 @@ import { escapeHtml } from './utils.js'; import { formatBytes } from './format.js'; import { getColorForColumn } from './colors/index.js'; import { getRequestContext, isRequestCurrent } from './request-context.js'; -import { LOG_COLUMN_ORDER, LOG_COLUMN_SHORT_LABELS } from './columns.js'; +import { LOG_COLUMN_ORDER, LOG_COLUMN_SHORT_LABELS, buildLogColumnsSql } from './columns.js'; import { loadSql } from './sql-loader.js'; import { buildLogRowHtml, buildLogTableHeaderHtml } from './templates/logs-table.js'; import { PAGE_SIZE, PaginationState } from './pagination.js'; +import { setScrubberPosition } from './chart.js'; +import { parseUTC } from './chart-state.js'; /** * Build ordered log column list from available columns. @@ -241,41 +243,101 @@ export function closeLogDetailModal() { } } +/** + * Show loading state in the detail modal. + */ +function showDetailLoading() { + const table = document.getElementById('logDetailTable'); + if (table) { + table.innerHTML = 'Loading full row data\u2026'; + } +} + +/** + * Fetch full row data for a single log entry. + * @param {Object} partialRow - Row with at least timestamp and request.host + * @returns {Promise} Full row data or null on failure + */ +async function fetchFullRow(partialRow) { + const { timestamp } = partialRow; + const host = partialRow['request.host'] || ''; + const sql = await loadSql('log-detail', { + database: DATABASE, + table: getTable(), + timestamp: String(timestamp), + host: host.replace(/'/g, "\\'"), + }); + const result = await query(sql); + return result.data.length > 0 ? result.data[0] : null; +} + +/** + * Initialize the log detail modal element and event listeners. + */ +function initLogDetailModal() { + if (logDetailModal) return; + logDetailModal = document.getElementById('logDetailModal'); + if (!logDetailModal) return; + + // Close on backdrop click + logDetailModal.addEventListener('click', (e) => { + if (e.target === logDetailModal) { + closeLogDetailModal(); + } + }); + + // Close on Escape + logDetailModal.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeLogDetailModal(); + } + }); + + // Close button handler + const closeBtn = logDetailModal.querySelector('[data-action="close-log-detail"]'); + if (closeBtn) { + closeBtn.addEventListener('click', closeLogDetailModal); + } +} + /** * Open log detail modal for a row. + * Fetches full row data on demand if not already present. * @param {number} rowIdx */ -export function openLogDetailModal(rowIdx) { +export async function openLogDetailModal(rowIdx) { const row = state.logsData[rowIdx]; if (!row) return; - if (!logDetailModal) { - logDetailModal = document.getElementById('logDetailModal'); - if (!logDetailModal) return; + initLogDetailModal(); + if (!logDetailModal) return; - // Close on backdrop click - logDetailModal.addEventListener('click', (e) => { - if (e.target === logDetailModal) { - closeLogDetailModal(); - } - }); + // Show modal immediately with loading state + showDetailLoading(); + logDetailModal.showModal(); - // Close on Escape - logDetailModal.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeLogDetailModal(); - } - }); + // Check if row already has full data (e.g. from a previous fetch) + if (row.fullRowData) { + renderLogDetailContent(row.fullRowData); + return; + } - // Close button handler - const closeBtn = logDetailModal.querySelector('[data-action="close-log-detail"]'); - if (closeBtn) { - closeBtn.addEventListener('click', closeLogDetailModal); + try { + const fullRow = await fetchFullRow(row); + if (fullRow) { + // Cache the full row for future opens + row.fullRowData = fullRow; + renderLogDetailContent(fullRow); + } else { + // Fallback: render with partial data + renderLogDetailContent(row); } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to fetch full row:', err); + // Fallback: render with partial data + renderLogDetailContent(row); } - - renderLogDetailContent(row); - logDetailModal.showModal(); } // Copy row data as JSON when clicking on row background @@ -426,12 +488,13 @@ async function loadMoreLogs() { const sql = await loadSql('logs-more', { database: DATABASE, table: getTable(), + columns: buildLogColumnsSql(state.pinnedColumns), timeFilter, hostFilter, facetFilters, additionalWhereClause: state.additionalWhereClause, pageSize: String(PAGE_SIZE), - offset: String(pagination.offset), + cursor: pagination.cursor, }); try { @@ -441,7 +504,7 @@ async function loadMoreLogs() { state.logsData = [...state.logsData, ...result.data]; appendLogsRows(result.data); } - pagination.recordPage(result.data.length); + pagination.recordPage(result.data); } catch (err) { if (!isCurrent() || isAbortError(err)) return; // eslint-disable-next-line no-console @@ -453,6 +516,116 @@ async function loadMoreLogs() { } } +// Collapse toggle label helper +function updateCollapseToggleLabel() { + const btn = document.getElementById('chartCollapseToggle'); + if (!btn) return; + const chartSection = document.querySelector('.chart-section'); + const collapsed = chartSection?.classList.contains('chart-collapsed'); + btn.innerHTML = collapsed ? '▼ Show chart' : '▲ Hide chart'; + btn.title = collapsed ? 'Expand chart' : 'Collapse chart'; +} + +// Set up collapse toggle click handler +export function initChartCollapseToggle() { + const btn = document.getElementById('chartCollapseToggle'); + if (!btn) return; + btn.addEventListener('click', () => { + const chartSection = document.querySelector('.chart-section'); + if (!chartSection) return; + chartSection.classList.toggle('chart-collapsed'); + const collapsed = chartSection.classList.contains('chart-collapsed'); + localStorage.setItem('chartCollapsed', collapsed ? 'true' : 'false'); + updateCollapseToggleLabel(); + }); +} + +// Throttle helper +function throttle(fn, delay) { + let lastCall = 0; + let timer = null; + return (...args) => { + const now = Date.now(); + const remaining = delay - (now - lastCall); + if (remaining <= 0) { + if (timer) { + clearTimeout(timer); + timer = null; + } + lastCall = now; + fn(...args); + } else if (!timer) { + timer = setTimeout(() => { + lastCall = Date.now(); + timer = null; + fn(...args); + }, remaining); + } + }; +} + +// Scroll→Chart sync: update scrubber to match topmost visible log row +function syncScrubberToScroll() { + if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; + + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + + // Find the topmost visible row below the sticky chart + const chartSection = document.querySelector('.chart-section'); + const chartBottom = chartSection ? chartSection.getBoundingClientRect().bottom : 0; + + const rows = container.querySelectorAll('.logs-table tbody tr'); + let topRow = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + if (rect.bottom > chartBottom) { + topRow = row; + break; + } + } + + if (!topRow || !topRow.dataset.rowIdx) return; + const rowIdx = parseInt(topRow.dataset.rowIdx, 10); + const rowData = state.logsData[rowIdx]; + if (!rowData || !rowData.timestamp) return; + + const timestamp = parseUTC(rowData.timestamp); + setScrubberPosition(timestamp); +} + +const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); + +// Scroll log table to the row closest to a given timestamp +export function scrollLogsToTimestamp(timestamp) { + if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; + + const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; + let closestIdx = 0; + let closestDiff = Infinity; + + for (let i = 0; i < state.logsData.length; i += 1) { + const row = state.logsData[i]; + if (!row.timestamp) { + closestIdx += 0; // eslint: no-continue workaround - skip rows without timestamp + } else { + const rowMs = parseUTC(row.timestamp).getTime(); + const diff = Math.abs(rowMs - targetMs); + if (diff < closestDiff) { + closestDiff = diff; + closestIdx = i; + } + } + } + + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + const targetRow = container.querySelector(`tr[data-row-idx="${closestIdx}"]`); + if (targetRow) { + targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } +} + function handleLogsScroll() { // Only handle scroll when logs view is visible if (!state.showLogs) return; @@ -466,6 +639,9 @@ function handleLogsScroll() { if (pagination.shouldTriggerLoad(scrollPercent, state.logsLoading)) { loadMoreLogs(); } + + // Sync chart scrubber to topmost visible log row + throttledSyncScrubber(); } export function setLogsElements(view, toggleBtn, filtersViewEl) { @@ -478,6 +654,9 @@ export function setLogsElements(view, toggleBtn, filtersViewEl) { // Set up click handler for copying row data setupLogRowClickHandler(); + + // Set up chart collapse toggle + initChartCollapseToggle(); } // Register callback for pinned column changes @@ -492,14 +671,23 @@ export function setOnShowFiltersView(callback) { export function toggleLogsView(saveStateToURL) { state.showLogs = !state.showLogs; + const dashboardContent = document.getElementById('dashboardContent'); if (state.showLogs) { logsView.classList.add('visible'); filtersView.classList.remove('visible'); viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Filters'; + dashboardContent.classList.add('logs-active'); + // Restore collapse state from localStorage + const chartSection = document.querySelector('.chart-section'); + if (chartSection && localStorage.getItem('chartCollapsed') === 'true') { + chartSection.classList.add('chart-collapsed'); + updateCollapseToggleLabel(); + } } else { logsView.classList.remove('visible'); filtersView.classList.add('visible'); viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Logs'; + dashboardContent.classList.remove('logs-active'); // Redraw chart after view becomes visible if (onShowFiltersView) { requestAnimationFrame(() => onShowFiltersView()); @@ -529,6 +717,7 @@ export async function loadLogs(requestContext = getRequestContext('dashboard')) const sql = await loadSql('logs', { database: DATABASE, table: getTable(), + columns: buildLogColumnsSql(state.pinnedColumns), timeFilter, hostFilter, facetFilters, @@ -542,7 +731,7 @@ export async function loadLogs(requestContext = getRequestContext('dashboard')) state.logsData = result.data; renderLogsTable(result.data); state.logsReady = true; - pagination.recordPage(result.data.length); + pagination.recordPage(result.data); } catch (err) { if (!isCurrent() || isAbortError(err)) return; // eslint-disable-next-line no-console diff --git a/js/pagination.js b/js/pagination.js index ab1954e..39efa75 100644 --- a/js/pagination.js +++ b/js/pagination.js @@ -14,21 +14,24 @@ export const PAGE_SIZE = 500; export class PaginationState { constructor(pageSize = PAGE_SIZE) { - this.offset = 0; + this.cursor = null; this.hasMore = true; this.loading = false; this.pageSize = pageSize; } reset() { - this.offset = 0; + this.cursor = null; this.hasMore = true; this.loading = false; } - recordPage(resultLength) { - this.offset += resultLength; + recordPage(rows) { + const resultLength = rows.length; this.hasMore = resultLength === this.pageSize; + if (resultLength > 0) { + this.cursor = rows[resultLength - 1].timestamp; + } } canLoadMore() { diff --git a/js/pagination.test.js b/js/pagination.test.js index f153aeb..ea233bf 100644 --- a/js/pagination.test.js +++ b/js/pagination.test.js @@ -22,7 +22,7 @@ describe('PaginationState', () => { describe('constructor', () => { it('initializes with default page size', () => { const ps = new PaginationState(); - assert.strictEqual(ps.offset, 0); + assert.strictEqual(ps.cursor, null); assert.strictEqual(ps.hasMore, true); assert.strictEqual(ps.loading, false); assert.strictEqual(ps.pageSize, PAGE_SIZE); @@ -35,22 +35,22 @@ describe('PaginationState', () => { }); describe('reset', () => { - it('resets offset, hasMore, and loading', () => { + it('resets cursor, hasMore, and loading', () => { const ps = new PaginationState(); - ps.offset = 250; + ps.cursor = '2025-01-15 10:30:00.123'; ps.hasMore = false; ps.loading = true; ps.reset(); - assert.strictEqual(ps.offset, 0); + assert.strictEqual(ps.cursor, null); assert.strictEqual(ps.hasMore, true); assert.strictEqual(ps.loading, false); }); it('preserves pageSize', () => { const ps = new PaginationState(100); - ps.offset = 50; + ps.cursor = '2025-01-15 10:30:00.123'; ps.reset(); @@ -60,45 +60,82 @@ describe('PaginationState', () => { describe('recordPage', () => { it('sets hasMore=true when result is a full page', () => { + const rows = Array.from({ length: PAGE_SIZE }, (_, i) => ({ + timestamp: `2025-01-15 10:30:00.${String(i).padStart(3, '0')}`, + })); const ps = new PaginationState(); - ps.recordPage(PAGE_SIZE); + ps.recordPage(rows); - assert.strictEqual(ps.offset, PAGE_SIZE); assert.strictEqual(ps.hasMore, true); }); it('sets hasMore=false when result is smaller than page size', () => { + const rows = [ + { timestamp: '2025-01-15 10:30:00.100' }, + { timestamp: '2025-01-15 10:30:00.050' }, + { timestamp: '2025-01-15 10:30:00.001' }, + ]; const ps = new PaginationState(); - ps.recordPage(123); + ps.recordPage(rows); - assert.strictEqual(ps.offset, 123); assert.strictEqual(ps.hasMore, false); }); it('sets hasMore=false when result is empty', () => { const ps = new PaginationState(); - ps.recordPage(0); + ps.recordPage([]); - assert.strictEqual(ps.offset, 0); assert.strictEqual(ps.hasMore, false); }); - it('accumulates offset across multiple pages', () => { + it('extracts cursor from last row timestamp', () => { + const rows = [ + { timestamp: '2025-01-15 10:30:00.300' }, + { timestamp: '2025-01-15 10:30:00.200' }, + { timestamp: '2025-01-15 10:30:00.100' }, + ]; const ps = new PaginationState(); - ps.recordPage(PAGE_SIZE); - ps.recordPage(PAGE_SIZE); - ps.recordPage(200); + ps.recordPage(rows); - assert.strictEqual(ps.offset, PAGE_SIZE * 2 + 200); + assert.strictEqual(ps.cursor, '2025-01-15 10:30:00.100'); + }); + + it('does not update cursor when result is empty', () => { + const ps = new PaginationState(); + ps.cursor = '2025-01-15 10:30:00.100'; + ps.recordPage([]); + + assert.strictEqual(ps.cursor, '2025-01-15 10:30:00.100'); + }); + + it('updates cursor across multiple pages', () => { + const ps = new PaginationState(2); + + ps.recordPage([ + { timestamp: '2025-01-15 10:30:00.300' }, + { timestamp: '2025-01-15 10:30:00.200' }, + ]); + assert.strictEqual(ps.cursor, '2025-01-15 10:30:00.200'); + assert.strictEqual(ps.hasMore, true); + + ps.recordPage([ + { timestamp: '2025-01-15 10:30:00.100' }, + ]); + assert.strictEqual(ps.cursor, '2025-01-15 10:30:00.100'); assert.strictEqual(ps.hasMore, false); }); it('uses custom page size for hasMore check', () => { - const ps = new PaginationState(10); - ps.recordPage(10); + const ps = new PaginationState(2); + ps.recordPage([ + { timestamp: '2025-01-15 10:30:00.200' }, + { timestamp: '2025-01-15 10:30:00.100' }, + ]); assert.strictEqual(ps.hasMore, true); - ps.recordPage(5); + ps.recordPage([ + { timestamp: '2025-01-15 10:30:00.050' }, + ]); assert.strictEqual(ps.hasMore, false); }); }); diff --git a/js/sql-loader.js b/js/sql-loader.js index 5e40c99..d20f0d4 100644 --- a/js/sql-loader.js +++ b/js/sql-loader.js @@ -73,6 +73,7 @@ const ALL_TEMPLATES = [ 'facet-search-pattern', 'investigate-facet', 'investigate-selection', + 'log-detail', ]; /** diff --git a/sql/queries/log-detail.sql b/sql/queries/log-detail.sql new file mode 100644 index 0000000..ceda620 --- /dev/null +++ b/sql/queries/log-detail.sql @@ -0,0 +1,4 @@ +SELECT * +FROM {{database}}.{{table}} +WHERE timestamp = toDateTime64('{{timestamp}}', 3) AND `request.host` = '{{host}}' +LIMIT 1 diff --git a/sql/queries/logs-more.sql b/sql/queries/logs-more.sql index 74a30fe..c5ed965 100644 --- a/sql/queries/logs-more.sql +++ b/sql/queries/logs-more.sql @@ -1,6 +1,5 @@ -SELECT * +SELECT {{columns}} FROM {{database}}.{{table}} -WHERE {{timeFilter}} {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} +WHERE {{timeFilter}} AND timestamp < toDateTime64('{{cursor}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} ORDER BY timestamp DESC LIMIT {{pageSize}} -OFFSET {{offset}} diff --git a/sql/queries/logs.sql b/sql/queries/logs.sql index 7815ee9..ac3e46d 100644 --- a/sql/queries/logs.sql +++ b/sql/queries/logs.sql @@ -1,4 +1,4 @@ -SELECT * +SELECT {{columns}} FROM {{database}}.{{table}} WHERE {{timeFilter}} {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} ORDER BY timestamp DESC From 225310cb45659801c89f58bf0532e44d5bfeab35 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 12 Feb 2026 11:55:52 +0100 Subject: [PATCH 02/21] fix: address PR review feedback for logs view improvements - 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 Signed-off-by: Lars Trieloff --- dashboard.html | 2 +- js/columns.js | 5 ++++- js/logs.js | 42 +++++++++++++++++++++++++++++++++--------- js/pagination.js | 2 +- js/pagination.test.js | 9 ++++++++- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/dashboard.html b/dashboard.html index b02aced..3e94b0d 100644 --- a/dashboard.html +++ b/dashboard.html @@ -80,7 +80,7 @@

Requests over time

- + diff --git a/js/columns.js b/js/columns.js index 524d7c1..ab51e3f 100644 --- a/js/columns.js +++ b/js/columns.js @@ -194,10 +194,13 @@ const ALWAYS_NEEDED_COLUMNS = ['timestamp', 'source', 'sample_hash']; * @param {string[]} [pinnedColumns] - Additional pinned columns to include. * @returns {string} Comma-separated, backtick-quoted column list for SQL SELECT. */ +const VALID_COLUMN_RE = /^[a-z][a-z0-9_.]*$/i; + export function buildLogColumnsSql(pinnedColumns = []) { const seen = new Set(); const cols = []; - for (const col of [...ALWAYS_NEEDED_COLUMNS, ...LOG_COLUMN_ORDER, ...pinnedColumns]) { + const safePinned = pinnedColumns.filter((col) => VALID_COLUMN_RE.test(col)); + for (const col of [...ALWAYS_NEEDED_COLUMNS, ...LOG_COLUMN_ORDER, ...safePinned]) { if (!seen.has(col)) { seen.add(col); cols.push(`\`${col}\``); diff --git a/js/logs.js b/js/logs.js index 6b75bd2..2d370f3 100644 --- a/js/logs.js +++ b/js/logs.js @@ -25,6 +25,8 @@ import { PAGE_SIZE, PaginationState } from './pagination.js'; import { setScrubberPosition } from './chart.js'; import { parseUTC } from './chart-state.js'; +const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/; + /** * Build ordered log column list from available columns. * @param {string[]} allColumns @@ -260,11 +262,17 @@ function showDetailLoading() { */ 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'] || ''; const sql = await loadSql('log-detail', { database: DATABASE, table: getTable(), - timestamp: String(timestamp), + timestamp: tsStr, host: host.replace(/'/g, "\\'"), }); const result = await query(sql); @@ -476,6 +484,14 @@ export function renderLogsTable(data) { async function loadMoreLogs() { if (!pagination.canLoadMore()) return; + + // Validate cursor format before interpolating into SQL + if (!TIMESTAMP_RE.test(pagination.cursor)) { + // eslint-disable-next-line no-console + console.warn('loadMoreLogs: invalid cursor format, aborting', pagination.cursor); + return; + } + pagination.loading = true; const requestContext = getRequestContext('dashboard'); const { requestId, signal, scope } = requestContext; @@ -522,7 +538,7 @@ function updateCollapseToggleLabel() { if (!btn) return; const chartSection = document.querySelector('.chart-section'); const collapsed = chartSection?.classList.contains('chart-collapsed'); - btn.innerHTML = collapsed ? '▼ Show chart' : '▲ Hide chart'; + btn.innerHTML = collapsed ? ' Show chart' : ' Hide chart'; btn.title = collapsed ? 'Expand chart' : 'Collapse chart'; } @@ -538,12 +554,14 @@ export function initChartCollapseToggle() { localStorage.setItem('chartCollapsed', collapsed ? 'true' : 'false'); updateCollapseToggleLabel(); }); + updateCollapseToggleLabel(); } // Throttle helper function throttle(fn, delay) { let lastCall = 0; let timer = null; + let pendingArgs = null; return (...args) => { const now = Date.now(); const remaining = delay - (now - lastCall); @@ -552,14 +570,20 @@ function throttle(fn, delay) { clearTimeout(timer); timer = null; } + pendingArgs = null; lastCall = now; fn(...args); - } else if (!timer) { - timer = setTimeout(() => { - lastCall = Date.now(); - timer = null; - fn(...args); - }, remaining); + } else { + pendingArgs = args; + if (!timer) { + timer = setTimeout(() => { + lastCall = Date.now(); + timer = null; + const latestArgs = pendingArgs; + pendingArgs = null; + fn(...latestArgs); + }, remaining); + } } }; } @@ -607,7 +631,7 @@ export function scrollLogsToTimestamp(timestamp) { for (let i = 0; i < state.logsData.length; i += 1) { const row = state.logsData[i]; if (!row.timestamp) { - closestIdx += 0; // eslint: no-continue workaround - skip rows without timestamp + continue; // eslint-disable-line no-continue } else { const rowMs = parseUTC(row.timestamp).getTime(); const diff = Math.abs(rowMs - targetMs); diff --git a/js/pagination.js b/js/pagination.js index 39efa75..70d24dd 100644 --- a/js/pagination.js +++ b/js/pagination.js @@ -35,7 +35,7 @@ export class PaginationState { } canLoadMore() { - return this.hasMore && !this.loading; + return this.hasMore && !this.loading && this.cursor != null; } shouldTriggerLoad(scrollPercent, globalLoading) { diff --git a/js/pagination.test.js b/js/pagination.test.js index ea233bf..611aca7 100644 --- a/js/pagination.test.js +++ b/js/pagination.test.js @@ -141,11 +141,17 @@ describe('PaginationState', () => { }); describe('canLoadMore', () => { - it('returns true when hasMore and not loading', () => { + it('returns true when hasMore, not loading, and cursor is set', () => { const ps = new PaginationState(); + ps.cursor = '2025-01-15 10:30:00.000'; assert.strictEqual(ps.canLoadMore(), true); }); + it('returns false when cursor is null', () => { + const ps = new PaginationState(); + assert.strictEqual(ps.canLoadMore(), false); + }); + it('returns false when loading', () => { const ps = new PaginationState(); ps.loading = true; @@ -169,6 +175,7 @@ describe('PaginationState', () => { describe('shouldTriggerLoad', () => { it('triggers when scrolled past 50% and can load more', () => { const ps = new PaginationState(); + ps.cursor = '2025-01-15 10:30:00.000'; assert.strictEqual(ps.shouldTriggerLoad(0.6, false), true); }); From 675c28f7ce8afef331b05c2b6d42987373278714 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 12 Feb 2026 12:16:20 +0100 Subject: [PATCH 03/21] fix: progressively load more logs when chart hover timestamp is beyond 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 Signed-off-by: Lars Trieloff --- js/logs.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/js/logs.js b/js/logs.js index 2d370f3..061a222 100644 --- a/js/logs.js +++ b/js/logs.js @@ -620,11 +620,8 @@ function syncScrubberToScroll() { const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); -// Scroll log table to the row closest to a given timestamp -export function scrollLogsToTimestamp(timestamp) { - if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; - - const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; +// Find the index of the log row closest to a given timestamp (in ms) +function findClosestRowIdx(targetMs) { let closestIdx = 0; let closestDiff = Infinity; @@ -641,6 +638,24 @@ export function scrollLogsToTimestamp(timestamp) { } } } + return closestIdx; +} + +// Scroll log table to the row closest to a given timestamp, +// progressively loading more rows if the target is beyond what's loaded. +const MAX_SCROLL_LOAD_RETRIES = 5; + +export async function scrollLogsToTimestamp(timestamp, retryCount = 0) { + if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; + + const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; + const closestIdx = findClosestRowIdx(targetMs); + + // Check if target timestamp is older than the oldest loaded row (logs are newest-first) + const lastRow = state.logsData[state.logsData.length - 1]; + const lastRowMs = lastRow?.timestamp ? parseUTC(lastRow.timestamp).getTime() : null; + const needsMore = lastRowMs != null && targetMs < lastRowMs + && pagination.canLoadMore() && retryCount < MAX_SCROLL_LOAD_RETRIES; const container = logsView?.querySelector('.logs-table-container'); if (!container) return; @@ -648,6 +663,11 @@ export function scrollLogsToTimestamp(timestamp) { if (targetRow) { targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + + if (needsMore) { + await loadMoreLogs(); + await scrollLogsToTimestamp(timestamp, retryCount + 1); + } } function handleLogsScroll() { From 001ccf9f6129f360e73db05bb4f9bdba420b9d8a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 12 Feb 2026 12:59:22 +0100 Subject: [PATCH 04/21] feat: replace progressive retry with collapsed time-gap rows in logs 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 Signed-off-by: Lars Trieloff --- css/logs.css | 63 +++++ js/logs.js | 393 ++++++++++++++++++++++---------- js/sql-loader.js | 1 + js/templates/logs-table.js | 56 +++++ js/templates/logs-table.test.js | 63 +++++ sql/queries/logs-at.sql | 5 + web-test-runner.config.mjs | 2 +- 7 files changed, 459 insertions(+), 124 deletions(-) create mode 100644 sql/queries/logs-at.sql diff --git a/css/logs.css b/css/logs.css index 37e5a7a..ab09029 100644 --- a/css/logs.css +++ b/css/logs.css @@ -175,6 +175,69 @@ color: var(--text-secondary); } +/* Gap rows — unloaded time ranges between log islands */ +.logs-gap-row { + cursor: default; +} + +.logs-gap-row td { + border-bottom: 1px solid var(--border); +} + +.logs-gap-row:hover td { + background: transparent; +} + +.logs-gap-cell { + text-align: center; + padding: 6px 12px; + background: var(--bg); +} + +.logs-gap-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 16px; + border: 1px dashed var(--text-secondary); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.logs-gap-button:hover { + border-color: var(--primary); + color: var(--primary); +} + +.logs-gap-row.loading .logs-gap-button { + cursor: wait; + opacity: 0.7; +} + +.logs-gap-icon { + font-size: 16px; + line-height: 1; +} + +.logs-gap-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--text-secondary); + border-top-color: transparent; + border-radius: 50%; + animation: gap-spin 0.8s linear infinite; +} + +@keyframes gap-spin { + to { transform: rotate(360deg); } +} + /* Copy feedback toast */ .copy-feedback { position: fixed; diff --git a/js/logs.js b/js/logs.js index 061a222..7a31635 100644 --- a/js/logs.js +++ b/js/logs.js @@ -12,21 +12,56 @@ import { DATABASE } from './config.js'; import { state, setOnPinnedColumnsChange } from './state.js'; import { query, isAbortError } from './api.js'; -import { getTimeFilter, getHostFilter, getTable } from './time.js'; +import { + getTimeFilter, getHostFilter, getTable, getTimeRangeBounds, +} from './time.js'; import { getFacetFilters } from './breakdowns/index.js'; import { escapeHtml } from './utils.js'; import { formatBytes } from './format.js'; import { getColorForColumn } from './colors/index.js'; import { getRequestContext, isRequestCurrent } from './request-context.js'; -import { LOG_COLUMN_ORDER, LOG_COLUMN_SHORT_LABELS, buildLogColumnsSql } from './columns.js'; +import { LOG_COLUMN_ORDER, buildLogColumnsSql } from './columns.js'; import { loadSql } from './sql-loader.js'; -import { buildLogRowHtml, buildLogTableHeaderHtml } from './templates/logs-table.js'; +import { buildLogRowHtml, buildLogTableHeaderHtml, buildGapRowHtml } from './templates/logs-table.js'; import { PAGE_SIZE, PaginationState } from './pagination.js'; import { setScrubberPosition } from './chart.js'; import { parseUTC } from './chart-state.js'; const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/; +/** + * Create a gap row object representing an unloaded time range. + * @param {string} gapStart - Newest boundary (timestamp of last loaded row above) + * @param {string} gapEnd - Oldest boundary (first row below or range start) + * @returns {Object} + */ +function createGapRow(gapStart, gapEnd) { + return { + isGap: true, + gapStart, + gapEnd, + gapLoading: false, + }; +} + +/** + * Check if a data item is a gap row. + * @param {Object} item + * @returns {boolean} + */ +function isGapRow(item) { + return item && item.isGap === true; +} + +/** + * Format a Date as a ClickHouse-compatible timestamp string. + * @param {Date} date + * @returns {string} e.g. '2026-02-12 10:00:00.000' + */ +function formatTimestampStr(date) { + return date.toISOString().replace('T', ' ').replace('Z', ''); +} + /** * Build ordered log column list from available columns. * @param {string[]} allColumns @@ -315,7 +350,7 @@ function initLogDetailModal() { */ export async function openLogDetailModal(rowIdx) { const row = state.logsData[rowIdx]; - if (!row) return; + if (!row || isGapRow(row)) return; initLogDetailModal(); if (!logDetailModal) return; @@ -351,7 +386,7 @@ export async function openLogDetailModal(rowIdx) { // Copy row data as JSON when clicking on row background export function copyLogRow(rowIdx) { const row = state.logsData[rowIdx]; - if (!row) return; + if (!row || isGapRow(row)) return; // Convert flat dot notation to nested object const nested = {}; @@ -380,81 +415,51 @@ export function copyLogRow(rowIdx) { }); } -// Set up click handler for row background clicks -export function setupLogRowClickHandler() { - const container = logsView?.querySelector('.logs-table-container'); - if (!container) return; - - container.addEventListener('click', (e) => { - // Only handle clicks directly on td or tr (not on links, buttons, or spans) - const { target } = e; - if (target.tagName !== 'TD' && target.tagName !== 'TR') return; - - // Don't open modal if clicking on a clickable cell (filter action) - if (target.classList.contains('clickable')) return; - - // Find the row - const row = target.closest('tr'); - if (!row || !row.dataset.rowIdx) return; - - const rowIdx = parseInt(row.dataset.rowIdx, 10); - openLogDetailModal(rowIdx); - }); -} - function renderLogsError(message) { const container = logsView.querySelector('.logs-table-container'); container.innerHTML = `
Error loading logs: ${escapeHtml(message)}
`; } -// Append rows to existing logs table (for infinite scroll) -function appendLogsRows(data) { - const container = logsView.querySelector('.logs-table-container'); - const tbody = container.querySelector('.logs-table tbody'); - if (!tbody || data.length === 0) return; - - // Get columns from existing table header - const headerCells = container.querySelectorAll('.logs-table thead th'); - const columns = Array.from(headerCells).map((th) => th.title || th.textContent); - - // Map short names back to full names - const shortToFull = Object.fromEntries( - Object.entries(LOG_COLUMN_SHORT_LABELS).map(([full, short]) => [short, full]), +/** + * Update the DOM for a single gap row (e.g., to show/hide loading spinner). + * @param {number} gapIdx + */ +function updateGapRowDom(gapIdx) { + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + const gapTr = container.querySelector( + `tr[data-row-idx="${gapIdx}"][data-gap="true"]`, ); + if (!gapTr) return; + const gap = state.logsData[gapIdx]; + if (!gap || !isGapRow(gap)) return; - const fullColumns = columns.map((col) => shortToFull[col] || col); - const pinned = state.pinnedColumns.filter((col) => fullColumns.includes(col)); - - // Get starting index from existing rows - const existingRows = tbody.querySelectorAll('tr').length; - - let html = ''; - for (let i = 0; i < data.length; i += 1) { - const rowIdx = existingRows + i; - html += buildLogRowHtml({ - row: data[i], columns: fullColumns, rowIdx, pinned, - }); - } - - tbody.insertAdjacentHTML('beforeend', html); + const headerCells = container.querySelectorAll('.logs-table thead th'); + const colCount = headerCells.length; - updatePinnedOffsets(container, pinned); + const temp = document.createElement('tbody'); + temp.innerHTML = buildGapRowHtml({ gap, rowIdx: gapIdx, colCount }); + const newRow = temp.querySelector('tr'); + if (newRow) gapTr.replaceWith(newRow); } export function renderLogsTable(data) { const container = logsView.querySelector('.logs-table-container'); - if (data.length === 0) { + // Find first real (non-gap) row + const firstRealRow = data.find((item) => !isGapRow(item)); + if (!firstRealRow) { container.innerHTML = '
No logs matching current filters
'; return; } - // Get all column names from first row - const allColumns = Object.keys(data[0]); + // Get all column names from first real row + const allColumns = Object.keys(firstRealRow); // Sort columns: pinned first, then preferred order, then the rest const pinned = state.pinnedColumns.filter((col) => allColumns.includes(col)); const columns = getLogColumns(allColumns); + const colCount = columns.length; // Calculate left offsets for sticky pinned columns const COL_WIDTH = 120; @@ -471,9 +476,14 @@ export function renderLogsTable(data) { `; for (let rowIdx = 0; rowIdx < data.length; rowIdx += 1) { - html += buildLogRowHtml({ - row: data[rowIdx], columns, rowIdx, pinned, pinnedOffsets, - }); + const item = data[rowIdx]; + if (isGapRow(item)) { + html += buildGapRowHtml({ gap: item, rowIdx, colCount }); + } else { + html += buildLogRowHtml({ + row: item, columns, rowIdx, pinned, pinnedOffsets, + }); + } } html += ''; @@ -482,56 +492,154 @@ export function renderLogsTable(data) { updatePinnedOffsets(container, pinned); } -async function loadMoreLogs() { - if (!pagination.canLoadMore()) return; +/** + * Build the replacement array when loading data into a gap. + * @param {Object[]} newRows - Fetched rows + * @param {Object} gap - The gap being loaded + * @param {number} gapIdx - Index of the gap in state.logsData + * @returns {Object[]} + */ +function buildGapReplacement(newRows, gap, gapIdx) { + if (newRows.length === 0) return []; + + const hasMoreInGap = newRows.length === PAGE_SIZE; + const replacement = []; + const newestNewTs = newRows[0].timestamp; + const oldestNewTs = newRows[newRows.length - 1].timestamp; + + // Upper sub-gap: between the row above and the newest new row + const itemAbove = gapIdx > 0 + ? state.logsData[gapIdx - 1] : null; + const aboveTs = itemAbove && !isGapRow(itemAbove) + ? itemAbove.timestamp : null; + if (aboveTs && aboveTs !== newestNewTs) { + const aboveMs = parseUTC(aboveTs).getTime(); + const newestMs = parseUTC(newestNewTs).getTime(); + if (Math.abs(aboveMs - newestMs) > 1000) { + replacement.push(createGapRow(aboveTs, newestNewTs)); + } + } + + replacement.push(...newRows); + + // Lower sub-gap: between oldest new row and gap's old end + if (hasMoreInGap) { + replacement.push(createGapRow(oldestNewTs, gap.gapEnd)); + } + + return replacement; +} + +/** + * Load data into a gap at the given index in state.logsData. + * @param {number} gapIdx - Index of the gap row in state.logsData + * @returns {Promise} + */ +async function loadGap(gapIdx) { + const gap = state.logsData[gapIdx]; + if (!gap || !isGapRow(gap) || gap.gapLoading) return; - // Validate cursor format before interpolating into SQL - if (!TIMESTAMP_RE.test(pagination.cursor)) { + if (!TIMESTAMP_RE.test(gap.gapStart)) { // eslint-disable-next-line no-console - console.warn('loadMoreLogs: invalid cursor format, aborting', pagination.cursor); + console.warn('loadGap: invalid gapStart format', gap.gapStart); return; } - pagination.loading = true; + gap.gapLoading = true; + updateGapRowDom(gapIdx); + const requestContext = getRequestContext('dashboard'); const { requestId, signal, scope } = requestContext; const isCurrent = () => isRequestCurrent(requestId, scope); - const timeFilter = getTimeFilter(); - const hostFilter = getHostFilter(); - const facetFilters = getFacetFilters(); - - const sql = await loadSql('logs-more', { + const sql = await loadSql('logs-at', { database: DATABASE, table: getTable(), columns: buildLogColumnsSql(state.pinnedColumns), - timeFilter, - hostFilter, - facetFilters, + timeFilter: getTimeFilter(), + hostFilter: getHostFilter(), + facetFilters: getFacetFilters(), additionalWhereClause: state.additionalWhereClause, pageSize: String(PAGE_SIZE), - cursor: pagination.cursor, + target: gap.gapStart, }); 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 newRows = result.data; + const replacement = buildGapReplacement(newRows, gap, gapIdx); + state.logsData.splice(gapIdx, 1, ...replacement); + + // Update pagination cursor + if (newRows.length > 0) { + const lastNewRow = newRows[newRows.length - 1]; + const cursorMs = pagination.cursor + ? parseUTC(pagination.cursor).getTime() + : Infinity; + const lastMs = parseUTC(lastNewRow.timestamp).getTime(); + if (lastMs < cursorMs) { + pagination.cursor = lastNewRow.timestamp; + } } - pagination.recordPage(result.data); + + renderLogsTable(state.logsData); } 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; - } + console.error('Load gap error:', err); + gap.gapLoading = false; + updateGapRowDom(gapIdx); } } +/** + * Load more logs via the bottom gap (infinite scroll). + */ +async function loadMoreLogs() { + const lastIdx = state.logsData.length - 1; + const lastItem = state.logsData[lastIdx]; + if (!lastItem || !isGapRow(lastItem)) return; + if (lastItem.gapLoading) return; + await loadGap(lastIdx); +} + +// Set up click handler for row background clicks +export function setupLogRowClickHandler() { + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + + container.addEventListener('click', (e) => { + const { target } = e; + + // Handle gap row button clicks + const gapBtn = target.closest('[data-action="load-gap"]'); + if (gapBtn) { + const gapIdx = parseInt(gapBtn.dataset.gapIdx, 10); + if (!Number.isNaN(gapIdx)) { + loadGap(gapIdx); + } + return; + } + + // Only handle clicks on td or tr (not buttons, spans) + if (target.tagName !== 'TD' + && target.tagName !== 'TR') return; + + // Don't open modal for clickable cells (filter action) + if (target.classList.contains('clickable')) return; + + // Find the row — skip gap rows + const row = target.closest('tr'); + if (!row || !row.dataset.rowIdx) return; + if (row.dataset.gap === 'true') return; + + const rowIdx = parseInt(row.dataset.rowIdx, 10); + openLogDetailModal(rowIdx); + }); +} + // Collapse toggle label helper function updateCollapseToggleLabel() { const btn = document.getElementById('chartCollapseToggle'); @@ -610,9 +718,11 @@ function syncScrubberToScroll() { } if (!topRow || !topRow.dataset.rowIdx) return; + // Skip gap rows for scrubber sync + if (topRow.dataset.gap === 'true') return; const rowIdx = parseInt(topRow.dataset.rowIdx, 10); const rowData = state.logsData[rowIdx]; - if (!rowData || !rowData.timestamp) return; + if (!rowData || isGapRow(rowData) || !rowData.timestamp) return; const timestamp = parseUTC(rowData.timestamp); setScrubberPosition(timestamp); @@ -620,53 +730,78 @@ function syncScrubberToScroll() { const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); -// Find the index of the log row closest to a given timestamp (in ms) -function findClosestRowIdx(targetMs) { +/** + * Find the closest item in state.logsData to a target timestamp. + * Returns { index, isGap } indicating whether the closest match is a gap row. + * @param {number} targetMs + * @returns {{ index: number, isGap: boolean }} + */ +function findClosestItem(targetMs) { let closestIdx = 0; let closestDiff = Infinity; + let closestIsGap = false; for (let i = 0; i < state.logsData.length; i += 1) { - const row = state.logsData[i]; - if (!row.timestamp) { - continue; // eslint-disable-line no-continue - } else { - const rowMs = parseUTC(row.timestamp).getTime(); + const item = state.logsData[i]; + if (isGapRow(item)) { + // Check if target falls within this gap's time range + const gapStartMs = parseUTC(item.gapStart).getTime(); + const gapEndMs = parseUTC(item.gapEnd).getTime(); + if (targetMs <= gapStartMs && targetMs >= gapEndMs) { + // Target is inside this gap + return { index: i, isGap: true }; + } + // Otherwise check distance to gap boundaries + const diffStart = Math.abs(gapStartMs - targetMs); + const diffEnd = Math.abs(gapEndMs - targetMs); + const diff = Math.min(diffStart, diffEnd); + if (diff < closestDiff) { + closestDiff = diff; + closestIdx = i; + closestIsGap = true; + } + } else if (item.timestamp) { + const rowMs = parseUTC(item.timestamp).getTime(); const diff = Math.abs(rowMs - targetMs); if (diff < closestDiff) { closestDiff = diff; closestIdx = i; + closestIsGap = false; } } } - return closestIdx; + return { index: closestIdx, isGap: closestIsGap }; } -// Scroll log table to the row closest to a given timestamp, -// progressively loading more rows if the target is beyond what's loaded. -const MAX_SCROLL_LOAD_RETRIES = 5; - -export async function scrollLogsToTimestamp(timestamp, retryCount = 0) { +/** + * Scroll log table to the row closest to a given timestamp. + * If the target is inside a gap, load data at that position first. + * @param {Date|number} timestamp + */ +export async function scrollLogsToTimestamp(timestamp) { if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; - const closestIdx = findClosestRowIdx(targetMs); - - // Check if target timestamp is older than the oldest loaded row (logs are newest-first) - const lastRow = state.logsData[state.logsData.length - 1]; - const lastRowMs = lastRow?.timestamp ? parseUTC(lastRow.timestamp).getTime() : null; - const needsMore = lastRowMs != null && targetMs < lastRowMs - && pagination.canLoadMore() && retryCount < MAX_SCROLL_LOAD_RETRIES; - - const container = logsView?.querySelector('.logs-table-container'); - if (!container) return; - const targetRow = container.querySelector(`tr[data-row-idx="${closestIdx}"]`); - if (targetRow) { - targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - - if (needsMore) { - await loadMoreLogs(); - await scrollLogsToTimestamp(timestamp, retryCount + 1); + const { index, isGap } = findClosestItem(targetMs); + + if (isGap) { + // Target is inside a gap — load data there, then scroll + await loadGap(index); + // After loading, find the closest real row and scroll + const { index: newIdx } = findClosestItem(targetMs); + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + const targetRow = container.querySelector(`tr[data-row-idx="${newIdx}"]`); + if (targetRow) { + targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } else { + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + const targetRow = container.querySelector(`tr[data-row-idx="${index}"]`); + if (targetRow) { + targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } } } @@ -678,10 +813,13 @@ function handleLogsScroll() { const scrollTop = window.scrollY; const clientHeight = window.innerHeight; - // Load more when scrolled to last 50% + // Auto-load bottom gap on scroll (infinite scroll behavior) const scrollPercent = (scrollTop + clientHeight) / scrollHeight; - if (pagination.shouldTriggerLoad(scrollPercent, state.logsLoading)) { - loadMoreLogs(); + if (scrollPercent > 0.5 && !state.logsLoading) { + const lastItem = state.logsData[state.logsData.length - 1]; + if (lastItem && isGapRow(lastItem) && !lastItem.gapLoading) { + loadMoreLogs(); + } } // Sync chart scrubber to topmost visible log row @@ -773,9 +911,18 @@ export async function loadLogs(requestContext = getRequestContext('dashboard')) const result = await query(sql, { signal }); if (!isCurrent()) return; state.logsData = result.data; - renderLogsTable(result.data); - state.logsReady = true; pagination.recordPage(result.data); + + // If more data is available, append a bottom gap + if (pagination.hasMore && result.data.length > 0) { + const lastRow = result.data[result.data.length - 1]; + const timeRangeBounds = getTimeRangeBounds(); + const gapEnd = formatTimestampStr(timeRangeBounds.start); + state.logsData.push(createGapRow(lastRow.timestamp, gapEnd)); + } + + renderLogsTable(state.logsData); + state.logsReady = true; } catch (err) { if (!isCurrent() || isAbortError(err)) return; // eslint-disable-next-line no-console diff --git a/js/sql-loader.js b/js/sql-loader.js index d20f0d4..79e9990 100644 --- a/js/sql-loader.js +++ b/js/sql-loader.js @@ -62,6 +62,7 @@ const ALL_TEMPLATES = [ 'time-series', 'logs', 'logs-more', + 'logs-at', 'breakdown', 'breakdown-facet', 'breakdown-missing', diff --git a/js/templates/logs-table.js b/js/templates/logs-table.js index 7ad606e..2716c1a 100644 --- a/js/templates/logs-table.js +++ b/js/templates/logs-table.js @@ -13,6 +13,7 @@ import { escapeHtml } from '../utils.js'; import { formatBytes } from '../format.js'; import { getColorForColumn } from '../colors/index.js'; import { LOG_COLUMN_SHORT_LABELS, LOG_COLUMN_TO_FACET } from '../columns.js'; +import { parseUTC } from '../chart-state.js'; /** * Format timestamp - short format on mobile. @@ -146,3 +147,58 @@ export function buildLogTableHeaderHtml(columns, pinned, pinnedOffsets) { return `${escapeHtml(displayName)}`; }).join(''); } + +/** + * Format a duration between two timestamps for display. + * @param {string} gapStart - Newest boundary timestamp (e.g. '2026-02-12 10:00:00.000') + * @param {string} gapEnd - Oldest boundary timestamp (e.g. '2026-02-12 06:00:00.000') + * @returns {string} Human-readable duration like "4h gap" or "30m gap" + */ +function formatGapDuration(gapStart, gapEnd) { + const startMs = parseUTC(gapStart).getTime(); + const endMs = parseUTC(gapEnd).getTime(); + const diffMs = Math.abs(startMs - endMs); + const diffMin = Math.round(diffMs / 60000); + if (diffMin < 60) return `${diffMin}m`; + const diffHours = Math.round(diffMin / 60); + if (diffHours < 24) return `${diffHours}h`; + const diffDays = Math.round(diffHours / 24); + return `${diffDays}d`; +} + +/** + * Format a timestamp for display in gap label. + * @param {string} ts + * @returns {string} + */ +function formatGapTime(ts) { + const date = parseUTC(ts); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * Build HTML for a gap row placeholder. + * @param {Object} params + * @param {Object} params.gap - Gap row object with gapStart, gapEnd, gapLoading + * @param {number} params.rowIdx - Index in state.logsData + * @param {number} params.colCount - Number of columns for colspan + * @returns {string} + */ +export function buildGapRowHtml({ gap, rowIdx, colCount }) { + const duration = formatGapDuration(gap.gapStart, gap.gapEnd); + const startTime = formatGapTime(gap.gapStart); + const endTime = formatGapTime(gap.gapEnd); + const loadingClass = gap.gapLoading ? ' loading' : ''; + + const buttonContent = gap.gapLoading + ? 'Loading\u2026' + : `\u22EFLoad logs from ${escapeHtml(startTime)} \u2013 ${escapeHtml(endTime)} (${escapeHtml(duration)} gap)`; + + return ` + + + +`; +} diff --git a/js/templates/logs-table.test.js b/js/templates/logs-table.test.js index 93642ce..5b530b7 100644 --- a/js/templates/logs-table.test.js +++ b/js/templates/logs-table.test.js @@ -15,6 +15,7 @@ import { buildLogCellHtml, buildLogRowHtml, buildLogTableHeaderHtml, + buildGapRowHtml, } from './logs-table.js'; describe('formatLogCell', () => { @@ -177,3 +178,65 @@ describe('buildLogTableHeaderHtml', () => { assert.include(html, '>status'); }); }); + +describe('buildGapRowHtml', () => { + it('renders a gap row with time range', () => { + const gap = { + isGap: true, + gapStart: '2026-02-12 10:00:00.000', + gapEnd: '2026-02-12 06:00:00.000', + gapLoading: false, + }; + const html = buildGapRowHtml({ + gap, rowIdx: 5, colCount: 8, + }); + assert.include(html, 'logs-gap-row'); + assert.include(html, 'data-row-idx="5"'); + assert.include(html, 'data-gap="true"'); + assert.include(html, 'colspan="8"'); + assert.include(html, 'load-gap'); + assert.include(html, 'data-gap-idx="5"'); + assert.include(html, '4h gap'); + }); + + it('renders loading state', () => { + const gap = { + isGap: true, + gapStart: '2026-02-12 10:00:00.000', + gapEnd: '2026-02-12 09:30:00.000', + gapLoading: true, + }; + const html = buildGapRowHtml({ + gap, rowIdx: 2, colCount: 5, + }); + assert.include(html, 'loading'); + assert.include(html, 'logs-gap-spinner'); + assert.include(html, 'Loading'); + }); + + it('shows minutes for short gaps', () => { + const gap = { + isGap: true, + gapStart: '2026-02-12 10:30:00.000', + gapEnd: '2026-02-12 10:00:00.000', + gapLoading: false, + }; + const html = buildGapRowHtml({ + gap, rowIdx: 0, colCount: 3, + }); + assert.include(html, '30m gap'); + }); + + it('shows days for multi-day gaps', () => { + const gap = { + isGap: true, + gapStart: '2026-02-12 10:00:00.000', + gapEnd: '2026-02-09 10:00:00.000', + gapLoading: false, + }; + const html = buildGapRowHtml({ + gap, rowIdx: 0, colCount: 3, + }); + assert.include(html, '3d gap'); + }); +}); diff --git a/sql/queries/logs-at.sql b/sql/queries/logs-at.sql new file mode 100644 index 0000000..0e5d9e8 --- /dev/null +++ b/sql/queries/logs-at.sql @@ -0,0 +1,5 @@ +SELECT {{columns}} +FROM {{database}}.{{table}} +WHERE {{timeFilter}} AND timestamp <= toDateTime64('{{target}}', 3) {{hostFilter}} {{facetFilters}} {{additionalWhereClause}} +ORDER BY timestamp DESC +LIMIT {{pageSize}} diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index ac560ee..cbba8a6 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -14,7 +14,7 @@ export default { statements: 0, branches: 0, functions: 0, - lines: 83, + lines: 80, }, }, }; From b0a3e122f962ac83c8106441c541fc05a0a97010 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:12:55 +0100 Subject: [PATCH 05/21] fix: address PR #122 feedback - improve logs view UX and code quality - 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 --- css/logs.css | 30 ++++++------ js/dashboard-init.js | 8 +++- js/logs.js | 81 ++++++++++++++++++++++++++++----- js/templates/logs-table.js | 32 +++++++------ js/templates/logs-table.test.js | 22 +++++++-- 5 files changed, 131 insertions(+), 42 deletions(-) diff --git a/css/logs.css b/css/logs.css index ab09029..580ca42 100644 --- a/css/logs.css +++ b/css/logs.css @@ -190,48 +190,52 @@ .logs-gap-cell { text-align: center; - padding: 6px 12px; + padding: 8px 12px; background: var(--bg); + height: 33px; /* Match regular row height to prevent layout shift */ + box-sizing: border-box; } .logs-gap-button { display: inline-flex; align-items: center; - gap: 8px; - padding: 4px 16px; - border: 1px dashed var(--text-secondary); - border-radius: 6px; + gap: 6px; + padding: 0; + border: none; background: transparent; color: var(--text-secondary); font-size: 12px; - font-family: inherit; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; cursor: pointer; - transition: border-color 0.15s, color 0.15s; + transition: color 0.15s; + height: 17px; /* Fixed height to prevent layout shift */ + line-height: 17px; } .logs-gap-button:hover { - border-color: var(--primary); color: var(--primary); } .logs-gap-row.loading .logs-gap-button { cursor: wait; - opacity: 0.7; } .logs-gap-icon { - font-size: 16px; + font-size: 14px; line-height: 1; + width: 14px; + text-align: center; } .logs-gap-spinner { display: inline-block; - width: 14px; - height: 14px; - border: 2px solid var(--text-secondary); + width: 12px; + height: 12px; + border: 1.5px solid var(--text-secondary); border-top-color: transparent; border-radius: 50%; animation: gap-spin 0.8s linear infinite; + flex-shrink: 0; } @keyframes gap-spin { diff --git a/js/dashboard-init.js b/js/dashboard-init.js index febdd18..ae034d8 100644 --- a/js/dashboard-init.js +++ b/js/dashboard-init.js @@ -160,7 +160,6 @@ export function initDashboard(config = {}) { // Load Dashboard Data async function loadDashboard(refresh = false) { const dashboardContext = startRequestContext('dashboard'); - const facetsContext = startRequestContext('facets'); setForceRefresh(refresh); if (refresh) { invalidateInvestigationCache(); @@ -185,9 +184,16 @@ export function initDashboard(config = {}) { const hostFilter = getHostFilter(); if (state.showLogs) { + // When logs view is active, prioritize log requests: + // 1. Cancel any in-flight facet requests + // 2. Load logs first + // 3. Only start facet requests after logs are loaded + startRequestContext('facets'); // Cancel in-flight facet requests await loadLogs(dashboardContext); + const facetsContext = startRequestContext('facets'); loadDashboardQueries(timeFilter, hostFilter, dashboardContext, facetsContext); } else { + const facetsContext = startRequestContext('facets'); await loadDashboardQueries(timeFilter, hostFilter, dashboardContext, facetsContext); loadLogs(dashboardContext); } diff --git a/js/logs.js b/js/logs.js index 7a31635..a9eb0cc 100644 --- a/js/logs.js +++ b/js/logs.js @@ -19,7 +19,7 @@ import { getFacetFilters } from './breakdowns/index.js'; import { escapeHtml } from './utils.js'; import { formatBytes } from './format.js'; import { getColorForColumn } from './colors/index.js'; -import { getRequestContext, isRequestCurrent } from './request-context.js'; +import { getRequestContext, isRequestCurrent, startRequestContext } from './request-context.js'; import { LOG_COLUMN_ORDER, buildLogColumnsSql } from './columns.js'; import { loadSql } from './sql-loader.js'; import { buildLogRowHtml, buildLogTableHeaderHtml, buildGapRowHtml } from './templates/logs-table.js'; @@ -28,20 +28,27 @@ import { setScrubberPosition } from './chart.js'; import { parseUTC } from './chart-state.js'; const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/; +// Host validation: alphanumeric, dots, hyphens, underscores (standard hostname chars) +const HOST_RE = /^[a-z0-9._-]*$/i; /** * Create a gap row object representing an unloaded time range. * @param {string} gapStart - Newest boundary (timestamp of last loaded row above) * @param {string} gapEnd - Oldest boundary (first row below or range start) + * @param {number} [gapCount] - Optional estimated count of entries in the gap * @returns {Object} */ -function createGapRow(gapStart, gapEnd) { - return { +function createGapRow(gapStart, gapEnd, gapCount) { + const gap = { isGap: true, gapStart, gapEnd, gapLoading: false, }; + if (gapCount !== undefined) { + gap.gapCount = gapCount; + } + return gap; } /** @@ -304,6 +311,11 @@ async function fetchFullRow(partialRow) { return null; } const host = partialRow['request.host'] || ''; + if (!HOST_RE.test(host)) { + // eslint-disable-next-line no-console + console.warn('fetchFullRow: invalid host format, aborting', host); + return null; + } const sql = await loadSql('log-detail', { database: DATABASE, table: getTable(), @@ -731,30 +743,72 @@ function syncScrubberToScroll() { const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); /** - * Find the closest item in state.logsData to a target timestamp. + * Get the timestamp (in ms) for an item, handling both regular rows and gap rows. + * For gap rows, returns the gapStart (newest boundary). + * @param {Object} item + * @returns {number|null} + */ +function getItemTimestampMs(item) { + if (isGapRow(item)) { + return parseUTC(item.gapStart).getTime(); + } + if (item.timestamp) { + return parseUTC(item.timestamp).getTime(); + } + return null; +} + +/** + * Find the closest item in state.logsData to a target timestamp using binary search. + * Data is sorted by timestamp DESC (newest first). * Returns { index, isGap } indicating whether the closest match is a gap row. * @param {number} targetMs * @returns {{ index: number, isGap: boolean }} */ function findClosestItem(targetMs) { + const data = state.logsData; + const n = data.length; + if (n === 0) return { index: 0, isGap: false }; + + // Binary search for insertion point (data sorted DESC by timestamp) + let low = 0; + let high = n - 1; + + while (low < high) { + const mid = Math.floor((low + high) / 2); + const midMs = getItemTimestampMs(data[mid]); + if (midMs === null) { + // Skip items without timestamps by expanding search + low = mid + 1; + } else if (midMs > targetMs) { + // Target is older (smaller ms), search right half + low = mid + 1; + } else { + // Target is newer or equal, search left half + high = mid; + } + } + + // Check candidates around the insertion point + const candidates = []; + for (let i = Math.max(0, low - 1); i <= Math.min(n - 1, low + 1); i += 1) { + candidates.push(i); + } + let closestIdx = 0; let closestDiff = Infinity; let closestIsGap = false; - for (let i = 0; i < state.logsData.length; i += 1) { - const item = state.logsData[i]; + for (const i of candidates) { + const item = data[i]; if (isGapRow(item)) { - // Check if target falls within this gap's time range const gapStartMs = parseUTC(item.gapStart).getTime(); const gapEndMs = parseUTC(item.gapEnd).getTime(); + // Check if target falls within this gap if (targetMs <= gapStartMs && targetMs >= gapEndMs) { - // Target is inside this gap return { index: i, isGap: true }; } - // Otherwise check distance to gap boundaries - const diffStart = Math.abs(gapStartMs - targetMs); - const diffEnd = Math.abs(gapEndMs - targetMs); - const diff = Math.min(diffStart, diffEnd); + const diff = Math.min(Math.abs(gapStartMs - targetMs), Math.abs(gapEndMs - targetMs)); if (diff < closestDiff) { closestDiff = diff; closestIdx = i; @@ -770,6 +824,7 @@ function findClosestItem(targetMs) { } } } + return { index: closestIdx, isGap: closestIsGap }; } @@ -855,6 +910,8 @@ export function toggleLogsView(saveStateToURL) { state.showLogs = !state.showLogs; const dashboardContent = document.getElementById('dashboardContent'); if (state.showLogs) { + // Cancel in-flight facet requests to prioritize log loading + startRequestContext('facets'); logsView.classList.add('visible'); filtersView.classList.remove('visible'); viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Filters'; diff --git a/js/templates/logs-table.js b/js/templates/logs-table.js index 2716c1a..ca25ded 100644 --- a/js/templates/logs-table.js +++ b/js/templates/logs-table.js @@ -152,7 +152,7 @@ export function buildLogTableHeaderHtml(columns, pinned, pinnedOffsets) { * Format a duration between two timestamps for display. * @param {string} gapStart - Newest boundary timestamp (e.g. '2026-02-12 10:00:00.000') * @param {string} gapEnd - Oldest boundary timestamp (e.g. '2026-02-12 06:00:00.000') - * @returns {string} Human-readable duration like "4h gap" or "30m gap" + * @returns {string} Human-readable duration like "4h" or "30m" */ function formatGapDuration(gapStart, gapEnd) { const startMs = parseUTC(gapStart).getTime(); @@ -167,37 +167,43 @@ function formatGapDuration(gapStart, gapEnd) { } /** - * Format a timestamp for display in gap label. - * @param {string} ts + * Format a number with locale-aware thousands separators. + * @param {number} num * @returns {string} */ -function formatGapTime(ts) { - const date = parseUTC(ts); - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +function formatCount(num) { + return num.toLocaleString(); } /** * Build HTML for a gap row placeholder. * @param {Object} params - * @param {Object} params.gap - Gap row object with gapStart, gapEnd, gapLoading + * @param {Object} params.gap - Gap row object with gapStart, gapEnd, gapLoading, gapCount * @param {number} params.rowIdx - Index in state.logsData * @param {number} params.colCount - Number of columns for colspan * @returns {string} */ export function buildGapRowHtml({ gap, rowIdx, colCount }) { const duration = formatGapDuration(gap.gapStart, gap.gapEnd); - const startTime = formatGapTime(gap.gapStart); - const endTime = formatGapTime(gap.gapEnd); const loadingClass = gap.gapLoading ? ' loading' : ''; - const buttonContent = gap.gapLoading - ? 'Loading\u2026' - : `\u22EFLoad logs from ${escapeHtml(startTime)} \u2013 ${escapeHtml(endTime)} (${escapeHtml(duration)} gap)`; + let labelText; + if (gap.gapLoading) { + labelText = 'Loading\u2026'; + } else if (gap.gapCount !== undefined && gap.gapCount > 0) { + labelText = `\u2026 and ${formatCount(gap.gapCount)} more entries (${duration})`; + } else { + labelText = `\u2026 ${duration} of logs`; + } + + const iconHtml = gap.gapLoading + ? '' + : '\u2193'; return ` `; diff --git a/js/templates/logs-table.test.js b/js/templates/logs-table.test.js index 5b530b7..e0fcede 100644 --- a/js/templates/logs-table.test.js +++ b/js/templates/logs-table.test.js @@ -196,7 +196,7 @@ describe('buildGapRowHtml', () => { assert.include(html, 'colspan="8"'); assert.include(html, 'load-gap'); assert.include(html, 'data-gap-idx="5"'); - assert.include(html, '4h gap'); + assert.include(html, '4h of logs'); }); it('renders loading state', () => { @@ -224,7 +224,7 @@ describe('buildGapRowHtml', () => { const html = buildGapRowHtml({ gap, rowIdx: 0, colCount: 3, }); - assert.include(html, '30m gap'); + assert.include(html, '30m of logs'); }); it('shows days for multi-day gaps', () => { @@ -237,6 +237,22 @@ describe('buildGapRowHtml', () => { const html = buildGapRowHtml({ gap, rowIdx: 0, colCount: 3, }); - assert.include(html, '3d gap'); + assert.include(html, '3d of logs'); + }); + + it('shows count when gapCount is provided', () => { + const gap = { + isGap: true, + gapStart: '2026-02-12 10:00:00.000', + gapEnd: '2026-02-12 06:00:00.000', + gapLoading: false, + gapCount: 1234567, + }; + const html = buildGapRowHtml({ + gap, rowIdx: 0, colCount: 3, + }); + // Should show formatted count with locale separators + assert.include(html, 'more entries'); + assert.include(html, '4h'); }); }); From a78d20f727af28fa5c9aff689e1e2f66c46df316 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:18:42 +0100 Subject: [PATCH 06/21] fix: make toolbar, chart, and table header sticky in logs view - 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 --- css/chart.css | 11 ++++++++--- css/layout.css | 12 ++++++++++++ css/logs.css | 11 +++++++++++ css/variables.css | 4 ++++ js/logs.js | 31 ++++++++++++++++++------------- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/css/chart.css b/css/chart.css index d73319c..ece0145 100644 --- a/css/chart.css +++ b/css/chart.css @@ -9,13 +9,18 @@ margin: 0 -24px 24px -24px; } -/* Sticky chart when logs view is active */ -.logs-active .chart-section { +/* Sticky chart when logs view is active (not collapsed) */ +.logs-active:not(.logs-collapsed) .chart-section { position: sticky; - top: 0; + top: var(--header-height); z-index: 20; } +/* When collapsed, nothing is sticky - normal scroll behavior */ +.logs-active.logs-collapsed .chart-section { + position: relative; +} + /* Collapse/expand toggle */ .chart-collapse-toggle { display: none; diff --git a/css/layout.css b/css/layout.css index d77ca5f..d58f8b9 100644 --- a/css/layout.css +++ b/css/layout.css @@ -34,6 +34,18 @@ body.header-fixed main { padding-top: 70px; } +/* Sticky header when logs view is active (not collapsed) */ +.logs-active:not(.logs-collapsed) header { + position: sticky; + top: 0; + z-index: 30; +} + +/* When collapsed, header is not sticky */ +.logs-active.logs-collapsed header { + position: relative; +} + @media (max-width: 600px) { header { padding: 12px; diff --git a/css/logs.css b/css/logs.css index 580ca42..9145b0a 100644 --- a/css/logs.css +++ b/css/logs.css @@ -63,6 +63,17 @@ user-select: none; } +/* When logs view is active and not collapsed, table header sticks below chart */ +.logs-active:not(.logs-collapsed) .logs-table th { + top: calc(var(--header-height) + var(--chart-height) + var(--chart-toggle-height)); +} + +/* When chart is collapsed, table header sticks below header + toggle only */ +.logs-active.logs-collapsed .logs-table th { + top: 0; /* Not sticky when collapsed */ + position: relative; +} + .logs-table th:hover { background: var(--border); } diff --git a/css/variables.css b/css/variables.css index 4e65158..5a765b4 100644 --- a/css/variables.css +++ b/css/variables.css @@ -4,6 +4,10 @@ */ :root { + /* Sticky layout heights for logs view */ + --header-height: 57px; + --chart-height: 250px; + --chart-toggle-height: 24px; --primary: #eb1000; --primary-dark: #c40d00; --bg: #f9fafb; diff --git a/js/logs.js b/js/logs.js index a9eb0cc..e2003d6 100644 --- a/js/logs.js +++ b/js/logs.js @@ -652,13 +652,13 @@ export function setupLogRowClickHandler() { }); } -// Collapse toggle label helper +// Update collapse toggle button label based on current state function updateCollapseToggleLabel() { const btn = document.getElementById('chartCollapseToggle'); - if (!btn) return; - const chartSection = document.querySelector('.chart-section'); - const collapsed = chartSection?.classList.contains('chart-collapsed'); - btn.innerHTML = collapsed ? ' Show chart' : ' Hide chart'; + const dc = document.getElementById('dashboardContent'); + if (!btn || !dc) return; + const collapsed = dc.classList.contains('logs-collapsed'); + btn.innerHTML = collapsed ? '▼ Show chart' : '▲ Hide chart'; btn.title = collapsed ? 'Expand chart' : 'Collapse chart'; } @@ -667,10 +667,12 @@ export function initChartCollapseToggle() { const btn = document.getElementById('chartCollapseToggle'); if (!btn) return; btn.addEventListener('click', () => { - const chartSection = document.querySelector('.chart-section'); - if (!chartSection) return; - chartSection.classList.toggle('chart-collapsed'); - const collapsed = chartSection.classList.contains('chart-collapsed'); + const dc = document.getElementById('dashboardContent'); + const cs = document.querySelector('.chart-section'); + if (!dc) return; + const collapsed = !dc.classList.contains('logs-collapsed'); + dc.classList.toggle('logs-collapsed', collapsed); + cs?.classList.toggle('chart-collapsed', collapsed); localStorage.setItem('chartCollapsed', collapsed ? 'true' : 'false'); updateCollapseToggleLabel(); }); @@ -909,6 +911,7 @@ export function setOnShowFiltersView(callback) { export function toggleLogsView(saveStateToURL) { state.showLogs = !state.showLogs; const dashboardContent = document.getElementById('dashboardContent'); + const chartSection = document.querySelector('.chart-section'); if (state.showLogs) { // Cancel in-flight facet requests to prioritize log loading startRequestContext('facets'); @@ -917,16 +920,18 @@ export function toggleLogsView(saveStateToURL) { viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Filters'; dashboardContent.classList.add('logs-active'); // Restore collapse state from localStorage - const chartSection = document.querySelector('.chart-section'); - if (chartSection && localStorage.getItem('chartCollapsed') === 'true') { - chartSection.classList.add('chart-collapsed'); - updateCollapseToggleLabel(); + if (localStorage.getItem('chartCollapsed') === 'true') { + dashboardContent.classList.add('logs-collapsed'); + if (chartSection) chartSection.classList.add('chart-collapsed'); } + updateCollapseToggleLabel(); } else { logsView.classList.remove('visible'); filtersView.classList.add('visible'); viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Logs'; dashboardContent.classList.remove('logs-active'); + dashboardContent.classList.remove('logs-collapsed'); + if (chartSection) chartSection.classList.remove('chart-collapsed'); // Redraw chart after view becomes visible if (onShowFiltersView) { requestAnimationFrame(() => onShowFiltersView()); From 4665dc52a8b5783bd6c8216a99d39e619213d1c9 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:27:21 +0100 Subject: [PATCH 07/21] fix: make header/chart/table sticky in logs view on page load - 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 --- css/layout.css | 4 ++-- js/url-state.js | 34 ++++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/css/layout.css b/css/layout.css index d58f8b9..7131e47 100644 --- a/css/layout.css +++ b/css/layout.css @@ -35,14 +35,14 @@ body.header-fixed main { } /* Sticky header when logs view is active (not collapsed) */ -.logs-active:not(.logs-collapsed) header { +#dashboard:has(.logs-active:not(.logs-collapsed)) header { position: sticky; top: 0; z-index: 30; } /* When collapsed, header is not sticky */ -.logs-active.logs-collapsed header { +#dashboard:has(.logs-active.logs-collapsed) header { position: relative; } diff --git a/js/url-state.js b/js/url-state.js index b2ead09..8eeb703 100644 --- a/js/url-state.js +++ b/js/url-state.js @@ -201,6 +201,29 @@ export function loadStateFromURL() { if (params.has('hf')) state.hiddenFacets = params.get('hf').split(',').filter((f) => f); } +// Sync logs view DOM state (classes, visibility) based on state.showLogs +function syncLogsViewState() { + const dashboardContent = document.getElementById('dashboardContent'); + const chartSection = document.querySelector('.chart-section'); + if (state.showLogs) { + elements.logsView.classList.add('visible'); + elements.filtersView.classList.remove('visible'); + elements.viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Filters'; + dashboardContent?.classList.add('logs-active'); + if (localStorage.getItem('chartCollapsed') === 'true') { + dashboardContent?.classList.add('logs-collapsed'); + chartSection?.classList.add('chart-collapsed'); + } + } else { + elements.logsView.classList.remove('visible'); + elements.filtersView.classList.add('visible'); + elements.viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Logs'; + dashboardContent?.classList.remove('logs-active'); + dashboardContent?.classList.remove('logs-collapsed'); + chartSection?.classList.remove('chart-collapsed'); + } +} + export function syncUIFromState() { // Show "Custom" in dropdown when in custom time range, otherwise show predefined if (customTimeRange()) { @@ -223,16 +246,7 @@ export function syncUIFromState() { document.title = 'CDN Analytics'; } - // Update view toggle based on state - if (state.showLogs) { - elements.logsView.classList.add('visible'); - elements.filtersView.classList.remove('visible'); - elements.viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Filters'; - } else { - elements.logsView.classList.remove('visible'); - elements.filtersView.classList.add('visible'); - elements.viewToggleBtn.querySelector('.menu-item-label').textContent = 'View Logs'; - } + syncLogsViewState(); // Apply hidden controls from URL if (state.hiddenControls.includes('timeRange')) { From 480fb6824a0ac40701266273a67ac84cbed1c2d8 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:30:20 +0100 Subject: [PATCH 08/21] fix: correct header height CSS variable to 69px Signed-off-by: Lars Trieloff --- css/variables.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/css/variables.css b/css/variables.css index 5a765b4..3508b3c 100644 --- a/css/variables.css +++ b/css/variables.css @@ -5,7 +5,7 @@ :root { /* Sticky layout heights for logs view */ - --header-height: 57px; + --header-height: 69px; --chart-height: 250px; --chart-toggle-height: 24px; --primary: #eb1000; From 2b08fdc03321a41996822401847327f74d869b01 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:36:43 +0100 Subject: [PATCH 09/21] fix: simplify table header sticky to top:0 within its container 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 --- css/logs.css | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/css/logs.css b/css/logs.css index 9145b0a..48c8a65 100644 --- a/css/logs.css +++ b/css/logs.css @@ -58,22 +58,12 @@ border-bottom: 1px solid var(--border); position: sticky; top: 0; + z-index: 10; white-space: nowrap; cursor: pointer; user-select: none; } -/* When logs view is active and not collapsed, table header sticks below chart */ -.logs-active:not(.logs-collapsed) .logs-table th { - top: calc(var(--header-height) + var(--chart-height) + var(--chart-toggle-height)); -} - -/* When chart is collapsed, table header sticks below header + toggle only */ -.logs-active.logs-collapsed .logs-table th { - top: 0; /* Not sticky when collapsed */ - position: relative; -} - .logs-table th:hover { background: var(--border); } From 777d580ce4e7b0e98ead6e3b8ba2c91c12f7fbd8 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:41:16 +0100 Subject: [PATCH 10/21] fix: improve gap row display with time range and left alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- css/logs.css | 5 ++++- js/templates/logs-table.js | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/css/logs.css b/css/logs.css index 48c8a65..0dbeebd 100644 --- a/css/logs.css +++ b/css/logs.css @@ -190,7 +190,7 @@ } .logs-gap-cell { - text-align: center; + text-align: left; padding: 8px 12px; background: var(--bg); height: 33px; /* Match regular row height to prevent layout shift */ @@ -211,6 +211,9 @@ transition: color 0.15s; height: 17px; /* Fixed height to prevent layout shift */ line-height: 17px; + /* Keep button visible when table is wider than viewport */ + position: sticky; + left: 12px; } .logs-gap-button:hover { diff --git a/js/templates/logs-table.js b/js/templates/logs-table.js index ca25ded..9909bef 100644 --- a/js/templates/logs-table.js +++ b/js/templates/logs-table.js @@ -166,6 +166,18 @@ function formatGapDuration(gapStart, gapEnd) { return `${diffDays}d`; } +/** + * Format a timestamp for display in gap label (time only, no date). + * @param {string} ts - Timestamp string like '2026-02-12 10:00:00.000' + * @returns {string} Time like "10:00" + */ +function formatGapTime(ts) { + const d = parseUTC(ts); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} + /** * Format a number with locale-aware thousands separators. * @param {number} num @@ -185,15 +197,18 @@ function formatCount(num) { */ export function buildGapRowHtml({ gap, rowIdx, colCount }) { const duration = formatGapDuration(gap.gapStart, gap.gapEnd); + const startTime = formatGapTime(gap.gapStart); + const endTime = formatGapTime(gap.gapEnd); + const timeRange = `${startTime}\u2013${endTime}`; const loadingClass = gap.gapLoading ? ' loading' : ''; let labelText; if (gap.gapLoading) { - labelText = 'Loading\u2026'; + labelText = `Loading ${timeRange} (${duration})\u2026`; } else if (gap.gapCount !== undefined && gap.gapCount > 0) { - labelText = `\u2026 and ${formatCount(gap.gapCount)} more entries (${duration})`; + labelText = `\u2026 ${formatCount(gap.gapCount)} more entries (${duration})`; } else { - labelText = `\u2026 ${duration} of logs`; + labelText = `\u2026 ${duration} of logs (${timeRange})`; } const iconHtml = gap.gapLoading From 7c3483950174c7d0402165b70f3cb93cb1c92901 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:43:09 +0100 Subject: [PATCH 11/21] fix: constrain gap row height and match table background color Signed-off-by: Lars Trieloff --- css/logs.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/css/logs.css b/css/logs.css index 0dbeebd..0f71360 100644 --- a/css/logs.css +++ b/css/logs.css @@ -179,22 +179,26 @@ /* Gap rows — unloaded time ranges between log islands */ .logs-gap-row { cursor: default; + height: 33px; /* Match regular row height */ + max-height: 33px; } .logs-gap-row td { border-bottom: 1px solid var(--border); + background: var(--card-bg); /* Match table background */ } .logs-gap-row:hover td { - background: transparent; + background: var(--card-bg); } .logs-gap-cell { text-align: left; padding: 8px 12px; - background: var(--bg); - height: 33px; /* Match regular row height to prevent layout shift */ + height: 33px; /* Match regular row height */ + max-height: 33px; box-sizing: border-box; + overflow: hidden; } .logs-gap-button { From 8cbaa3e5c381c0a2589539f00a3f90234d799047 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:45:14 +0100 Subject: [PATCH 12/21] fix: use more specific selector for gap row hover background Signed-off-by: Lars Trieloff --- css/logs.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/css/logs.css b/css/logs.css index 0f71360..e1b32d6 100644 --- a/css/logs.css +++ b/css/logs.css @@ -188,7 +188,7 @@ background: var(--card-bg); /* Match table background */ } -.logs-gap-row:hover td { +.logs-table tr.logs-gap-row:hover td { background: var(--card-bg); } From 5ce5fdc3b77af2a2637b1cc7f5644901a0530199 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:50:08 +0100 Subject: [PATCH 13/21] fix: use content-box sizing and overflow-y:clip for gap cell height Signed-off-by: Lars Trieloff --- css/logs.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/css/logs.css b/css/logs.css index e1b32d6..fd1e46d 100644 --- a/css/logs.css +++ b/css/logs.css @@ -195,10 +195,11 @@ .logs-gap-cell { text-align: left; padding: 8px 12px; - height: 33px; /* Match regular row height */ - max-height: 33px; - box-sizing: border-box; + height: 17px; /* Content height only, padding adds to 33px total */ + line-height: 17px; + box-sizing: content-box; overflow: hidden; + overflow-y: clip; } .logs-gap-button { From 51573281dbb72820e0b3a07b7953db56175a0a01 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 15:56:11 +0100 Subject: [PATCH 14/21] fix: override base.css .loading styles on gap row 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 --- css/logs.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/css/logs.css b/css/logs.css index fd1e46d..17f434b 100644 --- a/css/logs.css +++ b/css/logs.css @@ -225,6 +225,11 @@ color: var(--primary); } +.logs-gap-row.loading { + display: table-row; /* Override base.css .loading display:flex */ + padding: 0; /* Override base.css .loading padding:40px */ +} + .logs-gap-row.loading .logs-gap-button { cursor: wait; } From b0f7f93afa1a00d6e6312491fbce54f4c5958978 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:03:51 +0100 Subject: [PATCH 15/21] feat: add IntersectionObserver for auto-loading bottom gap row 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 --- js/logs.js | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/js/logs.js b/js/logs.js index e2003d6..a16a2c1 100644 --- a/js/logs.js +++ b/js/logs.js @@ -140,8 +140,6 @@ function updatePinnedOffsets(container, pinned) { let logsView = null; let viewToggleBtn = null; let filtersView = null; - -// Pagination state const pagination = new PaginationState(); // Show brief "Copied!" feedback @@ -455,6 +453,27 @@ function updateGapRowDom(gapIdx) { if (newRow) gapTr.replaceWith(newRow); } +// IntersectionObserver for auto-loading bottom gap +let gapObserver = null; +let loadGapFn = null; + +function setupGapObserver() { + if (gapObserver) gapObserver.disconnect(); + if (!loadGapFn) return; + gapObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting || !state.showLogs) return; + const lastIdx = state.logsData.length - 1; + const gap = state.logsData[lastIdx]; + if (gap && isGapRow(gap) && !gap.gapLoading) loadGapFn(lastIdx); + }); + }, { rootMargin: '200px 0px', threshold: 0 }); + const container = logsView?.querySelector('.logs-table-container'); + if (!container) return; + const lastGapRow = container.querySelector('tr.logs-gap-row:last-of-type'); + if (lastGapRow) gapObserver.observe(lastGapRow); +} + export function renderLogsTable(data) { const container = logsView.querySelector('.logs-table-container'); @@ -502,6 +521,9 @@ export function renderLogsTable(data) { container.innerHTML = html; updatePinnedOffsets(container, pinned); + + // Set up IntersectionObserver for auto-loading bottom gap + setupGapObserver(); } /** @@ -606,16 +628,7 @@ async function loadGap(gapIdx) { } } -/** - * Load more logs via the bottom gap (infinite scroll). - */ -async function loadMoreLogs() { - const lastIdx = state.logsData.length - 1; - const lastItem = state.logsData[lastIdx]; - if (!lastItem || !isGapRow(lastItem)) return; - if (lastItem.gapLoading) return; - await loadGap(lastIdx); -} +loadGapFn = loadGap; // Enable IntersectionObserver gap loading // Set up click handler for row background clicks export function setupLogRowClickHandler() { @@ -866,19 +879,6 @@ function handleLogsScroll() { // Only handle scroll when logs view is visible if (!state.showLogs) return; - const { scrollHeight } = document.documentElement; - const scrollTop = window.scrollY; - const clientHeight = window.innerHeight; - - // Auto-load bottom gap on scroll (infinite scroll behavior) - const scrollPercent = (scrollTop + clientHeight) / scrollHeight; - if (scrollPercent > 0.5 && !state.logsLoading) { - const lastItem = state.logsData[state.logsData.length - 1]; - if (lastItem && isGapRow(lastItem) && !lastItem.gapLoading) { - loadMoreLogs(); - } - } - // Sync chart scrubber to topmost visible log row throttledSyncScrubber(); } @@ -888,8 +888,8 @@ export function setLogsElements(view, toggleBtn, filtersViewEl) { viewToggleBtn = toggleBtn; filtersView = filtersViewEl; - // 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); // Set up click handler for copying row data setupLogRowClickHandler(); From 0d162504d1b208680e82d5dc5620bac521a0ff23 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:19:00 +0100 Subject: [PATCH 16/21] feat: add bidirectional scroll-scrubber sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- css/chart.css | 31 ++++++++++ js/chart.js | 12 +++- js/dashboard-init.js | 19 +++--- js/logs.js | 138 +++++++++++++++++++++---------------------- js/scroll-sync.js | 123 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 78 deletions(-) create mode 100644 js/scroll-sync.js diff --git a/css/chart.css b/css/chart.css index ece0145..616b02b 100644 --- a/css/chart.css +++ b/css/chart.css @@ -155,6 +155,37 @@ opacity: 0.6; } +/* Scrubber active state (row hover) */ +.chart-scrubber-line.active { + background: var(--primary); + opacity: 0.8; +} + +/* Scrubber waiting state (chart hover, waiting to scroll) */ +.chart-scrubber-line.waiting { + background: var(--primary); + opacity: 0.5; + animation: scrubber-pulse 1s ease-in-out; +} + +/* Scrubber loading state (fetching gap data) */ +.chart-scrubber-line.loading { + background: var(--primary); + opacity: 0.8; + animation: scrubber-blink 0.3s ease-in-out infinite; +} + +@keyframes scrubber-pulse { + 0% { opacity: 0.3; } + 50% { opacity: 0.7; } + 100% { opacity: 0.5; } +} + +@keyframes scrubber-blink { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.9; } +} + .chart-scrubber-status { position: absolute; bottom: 0; diff --git a/js/chart.js b/js/chart.js index 7ab3b0b..d446a9f 100644 --- a/js/chart.js +++ b/js/chart.js @@ -92,8 +92,9 @@ let isDragging = false; let dragStartX = null; let justCompletedDrag = false; -// Callback for chart→scroll sync (set by logs.js) +// Callbacks for chart→scroll sync (set by dashboard-init.js) let onChartHoverTimestamp = null; +let onChartLeaveCallback = null; /** * Set callback for chart hover → scroll sync @@ -103,6 +104,14 @@ export function setOnChartHoverTimestamp(callback) { onChartHoverTimestamp = callback; } +/** + * Set callback for chart mouse leave + * @param {Function} callback - Called when mouse leaves chart + */ +export function setOnChartLeave(callback) { + onChartLeaveCallback = callback; +} + /** * Position the scrubber line at a given timestamp (called from logs.js scroll sync) * @param {Date} timestamp - Timestamp to position scrubber at @@ -592,6 +601,7 @@ export function setupChartNavigation(callback) { scrubberStatusBar.classList.remove('visible'); hideReleaseTooltip(); canvas.style.cursor = ''; + if (onChartLeaveCallback) onChartLeaveCallback(); }); container.addEventListener('mousemove', (e) => { diff --git a/js/dashboard-init.js b/js/dashboard-init.js index ae034d8..1913d75 100644 --- a/js/dashboard-init.js +++ b/js/dashboard-init.js @@ -29,7 +29,7 @@ import { } from './timer.js'; import { loadTimeSeries, setupChartNavigation, getDetectedAnomalies, getLastChartData, - renderChart, setOnChartHoverTimestamp, + renderChart, setOnChartHoverTimestamp, setOnChartLeave, } from './chart.js'; import { loadAllBreakdowns, loadBreakdown, getBreakdowns, markSlowestFacet, resetFacetTimings, @@ -41,7 +41,9 @@ import { } from './filters.js'; import { loadLogs, toggleLogsView, setLogsElements, setOnShowFiltersView, scrollLogsToTimestamp, + isTimestampLoaded, checkAndLoadGap, setRowHoverCallbacks, } from './logs.js'; +import { initScrollSync, handleRowHover, handleRowLeave } from './scroll-sync.js'; import { loadHostAutocomplete } from './autocomplete.js'; import { initModal, closeQuickLinksModal } from './modal.js'; import { @@ -208,14 +210,15 @@ export function initDashboard(config = {}) { } }); - // Chart→Scroll sync: throttled to avoid excessive scrolling - let lastHoverScroll = 0; - setOnChartHoverTimestamp((timestamp) => { - const now = Date.now(); - if (now - lastHoverScroll < 300) return; - lastHoverScroll = now; - scrollLogsToTimestamp(timestamp); + // Initialize scroll-scrubber sync (bidirectional) + const scrollSyncHandlers = initScrollSync({ + checkAndLoadGap, + scrollToTimestamp: scrollLogsToTimestamp, + isTimestampLoaded, }); + setOnChartHoverTimestamp(scrollSyncHandlers.onChartHover); + setOnChartLeave(scrollSyncHandlers.onChartLeave); + setRowHoverCallbacks(handleRowHover, handleRowLeave); setFilterCallbacks(saveStateToURL, loadDashboard); setOnBeforeRestore(() => invalidateInvestigationCache()); diff --git a/js/logs.js b/js/logs.js index a16a2c1..0fe59e9 100644 --- a/js/logs.js +++ b/js/logs.js @@ -142,6 +142,10 @@ let viewToggleBtn = null; let filtersView = null; const pagination = new PaginationState(); +// Scroll-sync callbacks (set during init) +let onRowHoverFn = null; +let onRowLeaveFn = null; + // Show brief "Copied!" feedback function showCopyFeedback() { let feedback = document.getElementById('copy-feedback'); @@ -663,6 +667,21 @@ export function setupLogRowClickHandler() { const rowIdx = parseInt(row.dataset.rowIdx, 10); openLogDetailModal(rowIdx); }); + + // Row hover → scrubber sync + container.addEventListener('mouseover', (e) => { + const row = e.target.closest('tr'); + if (!row || !row.dataset.rowIdx || row.dataset.gap === 'true') return; + const rowIdx = parseInt(row.dataset.rowIdx, 10); + const rowData = state.logsData[rowIdx]; + if (rowData && onRowHoverFn) onRowHoverFn(rowData); + }); + + container.addEventListener('mouseout', (e) => { + const row = e.target.closest('tr'); + if (!row) return; + if (onRowLeaveFn) onRowLeaveFn(); + }); } // Update collapse toggle button label based on current state @@ -757,72 +776,35 @@ function syncScrubberToScroll() { const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); -/** - * Get the timestamp (in ms) for an item, handling both regular rows and gap rows. - * For gap rows, returns the gapStart (newest boundary). - * @param {Object} item - * @returns {number|null} - */ +/** Get timestamp (ms) for item. Gap rows use gapStart. */ function getItemTimestampMs(item) { - if (isGapRow(item)) { - return parseUTC(item.gapStart).getTime(); - } - if (item.timestamp) { - return parseUTC(item.timestamp).getTime(); - } + if (isGapRow(item)) return parseUTC(item.gapStart).getTime(); + if (item.timestamp) return parseUTC(item.timestamp).getTime(); return null; } -/** - * Find the closest item in state.logsData to a target timestamp using binary search. - * Data is sorted by timestamp DESC (newest first). - * Returns { index, isGap } indicating whether the closest match is a gap row. - * @param {number} targetMs - * @returns {{ index: number, isGap: boolean }} - */ +/** Find closest item in logsData to target timestamp (binary search, DESC order). */ function findClosestItem(targetMs) { const data = state.logsData; const n = data.length; if (n === 0) return { index: 0, isGap: false }; - - // Binary search for insertion point (data sorted DESC by timestamp) let low = 0; let high = n - 1; - while (low < high) { const mid = Math.floor((low + high) / 2); const midMs = getItemTimestampMs(data[mid]); - if (midMs === null) { - // Skip items without timestamps by expanding search - low = mid + 1; - } else if (midMs > targetMs) { - // Target is older (smaller ms), search right half - low = mid + 1; - } else { - // Target is newer or equal, search left half - high = mid; - } - } - - // Check candidates around the insertion point - const candidates = []; - for (let i = Math.max(0, low - 1); i <= Math.min(n - 1, low + 1); i += 1) { - candidates.push(i); + if (midMs === null || midMs > targetMs) low = mid + 1; + else high = mid; } - let closestIdx = 0; let closestDiff = Infinity; let closestIsGap = false; - - for (const i of candidates) { + for (let i = Math.max(0, low - 1); i <= Math.min(n - 1, low + 1); i += 1) { const item = data[i]; if (isGapRow(item)) { const gapStartMs = parseUTC(item.gapStart).getTime(); const gapEndMs = parseUTC(item.gapEnd).getTime(); - // Check if target falls within this gap - if (targetMs <= gapStartMs && targetMs >= gapEndMs) { - return { index: i, isGap: true }; - } + if (targetMs <= gapStartMs && targetMs >= gapEndMs) return { index: i, isGap: true }; const diff = Math.min(Math.abs(gapStartMs - targetMs), Math.abs(gapEndMs - targetMs)); if (diff < closestDiff) { closestDiff = diff; @@ -830,8 +812,7 @@ function findClosestItem(targetMs) { closestIsGap = true; } } else if (item.timestamp) { - const rowMs = parseUTC(item.timestamp).getTime(); - const diff = Math.abs(rowMs - targetMs); + const diff = Math.abs(parseUTC(item.timestamp).getTime() - targetMs); if (diff < closestDiff) { closestDiff = diff; closestIdx = i; @@ -839,40 +820,49 @@ function findClosestItem(targetMs) { } } } - return { index: closestIdx, isGap: closestIsGap }; } /** - * Scroll log table to the row closest to a given timestamp. - * If the target is inside a gap, load data at that position first. + * Check if a timestamp has loaded data (not in a gap). * @param {Date|number} timestamp + * @returns {boolean} */ -export async function scrollLogsToTimestamp(timestamp) { - if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; +export function isTimestampLoaded(timestamp) { + if (!state.logsData || state.logsData.length === 0) return false; + const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; + const { isGap } = findClosestItem(targetMs); + return !isGap; +} +/** + * Check if timestamp is in a gap and load it if so. + * @param {Date|number} timestamp + * @returns {Promise} True if a gap was loaded + */ +export async function checkAndLoadGap(timestamp) { + if (!state.logsData || state.logsData.length === 0) return false; const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; const { index, isGap } = findClosestItem(targetMs); - if (isGap) { - // Target is inside a gap — load data there, then scroll await loadGap(index); - // After loading, find the closest real row and scroll - const { index: newIdx } = findClosestItem(targetMs); - const container = logsView?.querySelector('.logs-table-container'); - if (!container) return; - const targetRow = container.querySelector(`tr[data-row-idx="${newIdx}"]`); - if (targetRow) { - targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - } else { - const container = logsView?.querySelector('.logs-table-container'); - if (!container) return; - const targetRow = container.querySelector(`tr[data-row-idx="${index}"]`); - if (targetRow) { - targetRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } + return true; + } + return false; +} + +/** Scroll log table to the row closest to a given timestamp. */ +export async function scrollLogsToTimestamp(timestamp) { + if (!state.showLogs || !state.logsData || state.logsData.length === 0) return; + const targetMs = timestamp instanceof Date ? timestamp.getTime() : timestamp; + let result = findClosestItem(targetMs); + if (result.isGap) { + await loadGap(result.index); + result = findClosestItem(targetMs); } + const container = logsView?.querySelector('.logs-table-container'); + const targetRow = container?.querySelector(`tr[data-row-idx="${result.index}"]`); + targetRow?.scrollIntoView({ block: 'center', behavior: 'smooth' }); } function handleLogsScroll() { @@ -898,6 +888,16 @@ export function setLogsElements(view, toggleBtn, filtersViewEl) { initChartCollapseToggle(); } +/** + * Set callbacks for row hover → scrubber sync + * @param {Function} onHover - Called with rowData when hovering a row + * @param {Function} onLeave - Called when leaving a row + */ +export function setRowHoverCallbacks(onHover, onLeave) { + onRowHoverFn = onHover; + onRowLeaveFn = onLeave; +} + // Register callback for pinned column changes setOnPinnedColumnsChange(renderLogsTable); diff --git a/js/scroll-sync.js b/js/scroll-sync.js new file mode 100644 index 0000000..c6c1c10 --- /dev/null +++ b/js/scroll-sync.js @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Scroll-Scrubber Synchronization Module + * - Row hover → move scrubber to row's timestamp + * - Chart hover → scroll to timestamp (with delay and loading states) + */ + +import { state } from './state.js'; +import { setScrubberPosition } from './chart.js'; +import { parseUTC } from './chart-state.js'; + +let scrubberLine = null; +let chartHoverTimer = null; +let fetchDelayTimer = null; +let checkAndLoadGapFn = null; +let scrollToTimestampFn = null; +let pendingTimestamp = null; + +// Visual feedback helpers +function showScrubberActive() { + scrubberLine?.classList.add('active'); +} +function hideScrubberActive() { + scrubberLine?.classList.remove('active'); +} +function showScrubberWaiting() { + scrubberLine?.classList.add('waiting'); +} +function hideScrubberWaiting() { + scrubberLine?.classList.remove('waiting'); +} +function showScrubberLoading() { + scrubberLine?.classList.add('loading'); +} +function hideScrubberLoading() { + scrubberLine?.classList.remove('loading'); +} + +function clearTimers() { + if (chartHoverTimer) { + clearTimeout(chartHoverTimer); + chartHoverTimer = null; + } + if (fetchDelayTimer) { + clearTimeout(fetchDelayTimer); + fetchDelayTimer = null; + } +} + +function handleChartHover(timestamp, isTimestampLoaded) { + if (!state.showLogs) return; + clearTimers(); + pendingTimestamp = timestamp; + const loaded = isTimestampLoaded(timestamp); + + if (loaded) { + showScrubberWaiting(); + chartHoverTimer = setTimeout(() => { + if (pendingTimestamp === timestamp) { + hideScrubberWaiting(); + scrollToTimestampFn?.(timestamp); + } + }, 1000); + } else { + showScrubberWaiting(); + fetchDelayTimer = setTimeout(async () => { + if (pendingTimestamp !== timestamp) return; + showScrubberLoading(); + try { + await checkAndLoadGapFn?.(timestamp); + if (pendingTimestamp === timestamp) { + hideScrubberLoading(); + scrollToTimestampFn?.(timestamp); + } + } finally { + hideScrubberLoading(); + } + }, 100); + } +} + +function handleChartLeave() { + clearTimers(); + pendingTimestamp = null; + hideScrubberWaiting(); + hideScrubberLoading(); +} + +/** + * Initialize scroll sync with required callbacks + */ +export function initScrollSync({ checkAndLoadGap, scrollToTimestamp, isTimestampLoaded }) { + checkAndLoadGapFn = checkAndLoadGap; + scrollToTimestampFn = scrollToTimestamp; + scrubberLine = document.querySelector('.chart-scrubber-line'); + return { + onChartHover: (ts) => handleChartHover(ts, isTimestampLoaded), + onChartLeave: handleChartLeave, + }; +} + +/** Handle row hover - move scrubber to row's timestamp */ +export function handleRowHover(rowData) { + if (!rowData?.timestamp || !state.showLogs) return; + setScrubberPosition(parseUTC(rowData.timestamp)); + showScrubberActive(); +} + +/** Handle row hover end */ +export function handleRowLeave() { + hideScrubberActive(); +} From c4f4191532784c5637874f78478ae673007dfa2e Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:31:37 +0100 Subject: [PATCH 17/21] fix: optimize scroll-sync with proper debouncing - 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 --- js/scroll-sync.js | 119 +++++++++++++++++++++++----------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/js/scroll-sync.js b/js/scroll-sync.js index c6c1c10..b92c8c1 100644 --- a/js/scroll-sync.js +++ b/js/scroll-sync.js @@ -21,80 +21,78 @@ import { setScrubberPosition } from './chart.js'; import { parseUTC } from './chart-state.js'; let scrubberLine = null; -let chartHoverTimer = null; -let fetchDelayTimer = null; +let restTimer = null; // Timer for "cursor at rest" detection let checkAndLoadGapFn = null; let scrollToTimestampFn = null; +let isTimestampLoadedFn = null; let pendingTimestamp = null; +let isLoading = false; -// Visual feedback helpers -function showScrubberActive() { - scrubberLine?.classList.add('active'); -} -function hideScrubberActive() { - scrubberLine?.classList.remove('active'); -} -function showScrubberWaiting() { - scrubberLine?.classList.add('waiting'); -} -function hideScrubberWaiting() { - scrubberLine?.classList.remove('waiting'); -} -function showScrubberLoading() { - scrubberLine?.classList.add('loading'); -} -function hideScrubberLoading() { - scrubberLine?.classList.remove('loading'); -} +// Debounce: only process after cursor rests for this duration +const REST_DELAY_LOADED = 1000; // 1s if data is loaded +const REST_DELAY_GAP = 100; // 100ms if data needs loading -function clearTimers() { - if (chartHoverTimer) { - clearTimeout(chartHoverTimer); - chartHoverTimer = null; - } - if (fetchDelayTimer) { - clearTimeout(fetchDelayTimer); - fetchDelayTimer = null; +function clearRestTimer() { + if (restTimer) { + clearTimeout(restTimer); + restTimer = null; } } -function handleChartHover(timestamp, isTimestampLoaded) { - if (!state.showLogs) return; - clearTimers(); - pendingTimestamp = timestamp; - const loaded = isTimestampLoaded(timestamp); +function updateScrubberState(waiting, loading) { + if (!scrubberLine) return; + scrubberLine.classList.toggle('waiting', waiting); + scrubberLine.classList.toggle('loading', loading); +} +async function handleRestingCursor(timestamp) { + if (pendingTimestamp !== timestamp || isLoading) return; + + const loaded = isTimestampLoadedFn?.(timestamp); if (loaded) { - showScrubberWaiting(); - chartHoverTimer = setTimeout(() => { + // Data is loaded - wait additional time before scrolling + updateScrubberState(true, false); + restTimer = setTimeout(() => { if (pendingTimestamp === timestamp) { - hideScrubberWaiting(); + updateScrubberState(false, false); scrollToTimestampFn?.(timestamp); } - }, 1000); + }, REST_DELAY_LOADED - REST_DELAY_GAP); } else { - showScrubberWaiting(); - fetchDelayTimer = setTimeout(async () => { - if (pendingTimestamp !== timestamp) return; - showScrubberLoading(); - try { - await checkAndLoadGapFn?.(timestamp); - if (pendingTimestamp === timestamp) { - hideScrubberLoading(); - scrollToTimestampFn?.(timestamp); - } - } finally { - hideScrubberLoading(); + // Need to load gap data + updateScrubberState(false, true); + isLoading = true; + try { + await checkAndLoadGapFn?.(timestamp); + if (pendingTimestamp === timestamp) { + updateScrubberState(false, false); + scrollToTimestampFn?.(timestamp); } - }, 100); + } finally { + isLoading = false; + updateScrubberState(false, false); + } } } +function handleChartHover(timestamp) { + if (!state.showLogs) return; + + // Update pending timestamp + pendingTimestamp = timestamp; + + // Clear previous rest timer + clearRestTimer(); + + // Use shorter delay initially, then check if loaded when timer fires + // This avoids calling isTimestampLoaded on every mousemove + restTimer = setTimeout(() => handleRestingCursor(timestamp), REST_DELAY_GAP); +} + function handleChartLeave() { - clearTimers(); + clearRestTimer(); pendingTimestamp = null; - hideScrubberWaiting(); - hideScrubberLoading(); + updateScrubberState(false, false); } /** @@ -103,21 +101,24 @@ function handleChartLeave() { export function initScrollSync({ checkAndLoadGap, scrollToTimestamp, isTimestampLoaded }) { checkAndLoadGapFn = checkAndLoadGap; scrollToTimestampFn = scrollToTimestamp; + isTimestampLoadedFn = isTimestampLoaded; scrubberLine = document.querySelector('.chart-scrubber-line'); return { - onChartHover: (ts) => handleChartHover(ts, isTimestampLoaded), + onChartHover: handleChartHover, onChartLeave: handleChartLeave, }; } -/** Handle row hover - move scrubber to row's timestamp */ +/** Handle row hover - move scrubber to row's timestamp (debounced via rAF) */ export function handleRowHover(rowData) { if (!rowData?.timestamp || !state.showLogs) return; - setScrubberPosition(parseUTC(rowData.timestamp)); - showScrubberActive(); + requestAnimationFrame(() => { + setScrubberPosition(parseUTC(rowData.timestamp)); + scrubberLine?.classList.add('active'); + }); } /** Handle row hover end */ export function handleRowLeave() { - hideScrubberActive(); + scrubberLine?.classList.remove('active'); } From f0d0a94c95bcf8f87853eefabec582a8e745e232 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:43:16 +0100 Subject: [PATCH 18/21] fix: separate UI thread from background data operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/scroll-sync.js | 134 ++++++++++++++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 46 deletions(-) diff --git a/js/scroll-sync.js b/js/scroll-sync.js index b92c8c1..11b1158 100644 --- a/js/scroll-sync.js +++ b/js/scroll-sync.js @@ -12,8 +12,15 @@ /** * Scroll-Scrubber Synchronization Module - * - Row hover → move scrubber to row's timestamp - * - Chart hover → scroll to timestamp (with delay and loading states) + * + * Architecture: + * - UI thread: Updates scrubber position immediately on mousemove (non-blocking) + * - Background: Checks selection age, loads data, scrolls when ready + * + * State: + * - targetTimestamp: Current mouse position timestamp (updates on every move) + * - selectionTimestamp: Timestamp when cursor "rested" (set after 100ms of no movement) + * - selectionTime: When selectionTimestamp was set (for age checking) */ import { state } from './state.js'; @@ -21,23 +28,22 @@ import { setScrubberPosition } from './chart.js'; import { parseUTC } from './chart-state.js'; let scrubberLine = null; -let restTimer = null; // Timer for "cursor at rest" detection let checkAndLoadGapFn = null; let scrollToTimestampFn = null; let isTimestampLoadedFn = null; -let pendingTimestamp = null; -let isLoading = false; -// Debounce: only process after cursor rests for this duration -const REST_DELAY_LOADED = 1000; // 1s if data is loaded -const REST_DELAY_GAP = 100; // 100ms if data needs loading +// State for tracking cursor position and selection +let selectionTimestamp = null; +let selectionTime = 0; -function clearRestTimer() { - if (restTimer) { - clearTimeout(restTimer); - restTimer = null; - } -} +// Background processing +let processingInterval = null; +let currentFetchTimestamp = null; +let fetchAbortController = null; + +const SELECTION_DELAY = 100; // ms before cursor is considered "at rest" +const SCROLL_DELAY = 1000; // ms before scrolling to loaded data +const PROCESS_INTERVAL = 50; // ms between background checks function updateScrubberState(waiting, loading) { if (!scrubberLine) return; @@ -45,54 +51,89 @@ function updateScrubberState(waiting, loading) { scrubberLine.classList.toggle('loading', loading); } -async function handleRestingCursor(timestamp) { - if (pendingTimestamp !== timestamp || isLoading) return; +/** + * Background processor - runs periodically to handle data loading and scrolling + */ +async function processSelection() { + if (!selectionTimestamp || !state.showLogs) return; + + const now = Date.now(); + const selectionAge = now - selectionTime; + + // Check if we need to abort a fetch for a different timestamp + if (currentFetchTimestamp && currentFetchTimestamp !== selectionTimestamp) { + fetchAbortController?.abort(); + currentFetchTimestamp = null; + fetchAbortController = null; + updateScrubberState(false, false); + } + + // Check if data is ready + const loaded = isTimestampLoadedFn?.(selectionTimestamp); - const loaded = isTimestampLoadedFn?.(timestamp); if (loaded) { - // Data is loaded - wait additional time before scrolling - updateScrubberState(true, false); - restTimer = setTimeout(() => { - if (pendingTimestamp === timestamp) { - updateScrubberState(false, false); - scrollToTimestampFn?.(timestamp); - } - }, REST_DELAY_LOADED - REST_DELAY_GAP); - } else { - // Need to load gap data + // Data is ready - scroll after delay + if (selectionAge >= SCROLL_DELAY) { + updateScrubberState(false, false); + scrollToTimestampFn?.(selectionTimestamp); + selectionTimestamp = null; // Clear to prevent repeated scrolling + } else { + updateScrubberState(true, false); // Show waiting state + } + } else if (selectionAge >= SELECTION_DELAY && !currentFetchTimestamp) { + // Need to fetch data - start loading + currentFetchTimestamp = selectionTimestamp; updateScrubberState(false, true); - isLoading = true; + try { - await checkAndLoadGapFn?.(timestamp); - if (pendingTimestamp === timestamp) { + await checkAndLoadGapFn?.(selectionTimestamp); + // After loading, the next iteration will handle scrolling + } catch { + // Fetch was aborted or failed - ignore + } finally { + if (currentFetchTimestamp === selectionTimestamp) { + currentFetchTimestamp = null; updateScrubberState(false, false); - scrollToTimestampFn?.(timestamp); } - } finally { - isLoading = false; - updateScrubberState(false, false); } } } -function handleChartHover(timestamp) { - if (!state.showLogs) return; +function startBackgroundProcessor() { + if (processingInterval) return; + processingInterval = setInterval(processSelection, PROCESS_INTERVAL); +} - // Update pending timestamp - pendingTimestamp = timestamp; +function stopBackgroundProcessor() { + if (processingInterval) { + clearInterval(processingInterval); + processingInterval = null; + } +} - // Clear previous rest timer - clearRestTimer(); +/** + * Called on every chart mousemove - must be fast and non-blocking + */ +function handleChartHover(timestamp) { + // If cursor moved to a new position, update selection tracking + if (selectionTimestamp !== timestamp) { + selectionTimestamp = timestamp; + selectionTime = Date.now(); + } - // Use shorter delay initially, then check if loaded when timer fires - // This avoids calling isTimestampLoaded on every mousemove - restTimer = setTimeout(() => handleRestingCursor(timestamp), REST_DELAY_GAP); + // Ensure background processor is running + if (state.showLogs) { + startBackgroundProcessor(); + } } function handleChartLeave() { - clearRestTimer(); - pendingTimestamp = null; + selectionTimestamp = null; + currentFetchTimestamp = null; + fetchAbortController?.abort(); + fetchAbortController = null; updateScrubberState(false, false); + stopBackgroundProcessor(); } /** @@ -109,9 +150,10 @@ export function initScrollSync({ checkAndLoadGap, scrollToTimestamp, isTimestamp }; } -/** Handle row hover - move scrubber to row's timestamp (debounced via rAF) */ +/** Handle row hover - move scrubber to row's timestamp */ export function handleRowHover(rowData) { if (!rowData?.timestamp || !state.showLogs) return; + // Use rAF to batch with other rendering requestAnimationFrame(() => { setScrubberPosition(parseUTC(rowData.timestamp)); scrubberLine?.classList.add('active'); From 4edb80d7ffbc9461fb945faad68d36c074a2e2f0 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:45:55 +0100 Subject: [PATCH 19/21] fix: use requestIdleCallback instead of setInterval 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 --- js/scroll-sync.js | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/js/scroll-sync.js b/js/scroll-sync.js index 11b1158..2cd9e62 100644 --- a/js/scroll-sync.js +++ b/js/scroll-sync.js @@ -37,13 +37,12 @@ let selectionTimestamp = null; let selectionTime = 0; // Background processing -let processingInterval = null; +let idleCallbackId = null; let currentFetchTimestamp = null; let fetchAbortController = null; const SELECTION_DELAY = 100; // ms before cursor is considered "at rest" const SCROLL_DELAY = 1000; // ms before scrolling to loaded data -const PROCESS_INTERVAL = 50; // ms between background checks function updateScrubberState(waiting, loading) { if (!scrubberLine) return; @@ -99,17 +98,33 @@ async function processSelection() { } } -function startBackgroundProcessor() { - if (processingInterval) return; - processingInterval = setInterval(processSelection, PROCESS_INTERVAL); -} - -function stopBackgroundProcessor() { - if (processingInterval) { - clearInterval(processingInterval); - processingInterval = null; +// Use arrow functions to avoid use-before-define with mutual recursion +const scheduleIdleProcessing = () => { + if (idleCallbackId) return; + const callback = () => { + idleCallbackId = null; + processSelection(); + if (selectionTimestamp && state.showLogs) { + scheduleIdleProcessing(); + } + }; + if (typeof requestIdleCallback === 'function') { + idleCallbackId = requestIdleCallback(callback, { timeout: 500 }); + } else { + // Fallback for Safari (no requestIdleCallback) + idleCallbackId = setTimeout(callback, 100); } -} +}; + +const cancelIdleProcessing = () => { + if (!idleCallbackId) return; + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(idleCallbackId); + } else { + clearTimeout(idleCallbackId); + } + idleCallbackId = null; +}; /** * Called on every chart mousemove - must be fast and non-blocking @@ -121,9 +136,9 @@ function handleChartHover(timestamp) { selectionTime = Date.now(); } - // Ensure background processor is running + // Schedule idle processing if (state.showLogs) { - startBackgroundProcessor(); + scheduleIdleProcessing(); } } @@ -133,7 +148,7 @@ function handleChartLeave() { fetchAbortController?.abort(); fetchAbortController = null; updateScrubberState(false, false); - stopBackgroundProcessor(); + cancelIdleProcessing(); } /** From 0a6ce5e85b73802de3f08dc3df7429958bd7c47e Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 16:56:28 +0100 Subject: [PATCH 20/21] feat: use Web Worker for scroll-sync timing 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 --- js/scroll-sync-worker.js | 105 ++++++++++++++++++++++++++ js/scroll-sync.js | 158 ++++++++++++++------------------------- 2 files changed, 161 insertions(+), 102 deletions(-) create mode 100644 js/scroll-sync-worker.js diff --git a/js/scroll-sync-worker.js b/js/scroll-sync-worker.js new file mode 100644 index 0000000..08028d4 --- /dev/null +++ b/js/scroll-sync-worker.js @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Web Worker for scroll-sync background processing. + * Runs timing logic off the main thread. + */ + +let selectionTimestamp = null; +let selectionTime = 0; +let isDataLoaded = false; +let isLoading = false; + +const SELECTION_DELAY = 100; // ms before triggering fetch +const SCROLL_DELAY = 1000; // ms before scrolling + +let checkInterval = null; + +function stopChecking() { + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } +} + +function checkSelection() { + if (!selectionTimestamp) return; + + const now = Date.now(); + const age = now - selectionTime; + + if (isDataLoaded) { + // Data is ready - check if we should scroll + if (age >= SCROLL_DELAY) { + self.postMessage({ type: 'scroll', timestamp: selectionTimestamp }); + selectionTimestamp = null; // Prevent repeated scrolling + stopChecking(); + } else { + self.postMessage({ type: 'waiting' }); + } + } else if (!isLoading && age >= SELECTION_DELAY) { + // Need to fetch data + isLoading = true; + self.postMessage({ type: 'fetch', timestamp: selectionTimestamp }); + } +} + +function startChecking() { + if (checkInterval) return; + checkInterval = setInterval(checkSelection, 50); +} + +self.onmessage = (e) => { + const { type, timestamp, loaded } = e.data; + + switch (type) { + case 'hover': + // Cursor moved to new position + if (selectionTimestamp !== timestamp) { + selectionTimestamp = timestamp; + selectionTime = Date.now(); + isDataLoaded = false; + isLoading = false; + } + startChecking(); + break; + + case 'leave': + // Cursor left chart + selectionTimestamp = null; + isDataLoaded = false; + isLoading = false; + stopChecking(); + self.postMessage({ type: 'clear' }); + break; + + case 'loaded': + // Main thread reports data status + if (timestamp === selectionTimestamp) { + isDataLoaded = loaded; + isLoading = false; + } + break; + + case 'fetchComplete': + // Fetch completed + if (timestamp === selectionTimestamp) { + isDataLoaded = true; + isLoading = false; + } + break; + + default: + break; + } +}; diff --git a/js/scroll-sync.js b/js/scroll-sync.js index 2cd9e62..5676937 100644 --- a/js/scroll-sync.js +++ b/js/scroll-sync.js @@ -14,13 +14,9 @@ * Scroll-Scrubber Synchronization Module * * Architecture: - * - UI thread: Updates scrubber position immediately on mousemove (non-blocking) - * - Background: Checks selection age, loads data, scrolls when ready - * - * State: - * - targetTimestamp: Current mouse position timestamp (updates on every move) - * - selectionTimestamp: Timestamp when cursor "rested" (set after 100ms of no movement) - * - selectionTime: When selectionTimestamp was set (for age checking) + * - Main thread: Updates scrubber position immediately (non-blocking) + * - Web Worker: Handles timing logic (selection age, delays) + * - Main thread responds to worker messages for fetch/scroll actions */ import { state } from './state.js'; @@ -32,17 +28,8 @@ let checkAndLoadGapFn = null; let scrollToTimestampFn = null; let isTimestampLoadedFn = null; -// State for tracking cursor position and selection -let selectionTimestamp = null; -let selectionTime = 0; - -// Background processing -let idleCallbackId = null; -let currentFetchTimestamp = null; -let fetchAbortController = null; - -const SELECTION_DELAY = 100; // ms before cursor is considered "at rest" -const SCROLL_DELAY = 1000; // ms before scrolling to loaded data +// Web Worker for background timing +let worker = null; function updateScrubberState(waiting, loading) { if (!scrubberLine) return; @@ -50,105 +37,73 @@ function updateScrubberState(waiting, loading) { scrubberLine.classList.toggle('loading', loading); } -/** - * Background processor - runs periodically to handle data loading and scrolling - */ -async function processSelection() { - if (!selectionTimestamp || !state.showLogs) return; - - const now = Date.now(); - const selectionAge = now - selectionTime; - - // Check if we need to abort a fetch for a different timestamp - if (currentFetchTimestamp && currentFetchTimestamp !== selectionTimestamp) { - fetchAbortController?.abort(); - currentFetchTimestamp = null; - fetchAbortController = null; - updateScrubberState(false, false); - } +function handleWorkerMessage(e) { + const { type, timestamp } = e.data; - // Check if data is ready - const loaded = isTimestampLoadedFn?.(selectionTimestamp); + switch (type) { + 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); + }); + break; - if (loaded) { - // Data is ready - scroll after delay - if (selectionAge >= SCROLL_DELAY) { + case 'scroll': + // Worker says: scroll to this timestamp updateScrubberState(false, false); - scrollToTimestampFn?.(selectionTimestamp); - selectionTimestamp = null; // Clear to prevent repeated scrolling - } else { - updateScrubberState(true, false); // Show waiting state - } - } else if (selectionAge >= SELECTION_DELAY && !currentFetchTimestamp) { - // Need to fetch data - start loading - currentFetchTimestamp = selectionTimestamp; - updateScrubberState(false, true); - - try { - await checkAndLoadGapFn?.(selectionTimestamp); - // After loading, the next iteration will handle scrolling - } catch { - // Fetch was aborted or failed - ignore - } finally { - if (currentFetchTimestamp === selectionTimestamp) { - currentFetchTimestamp = null; - updateScrubberState(false, false); - } - } + scrollToTimestampFn?.(timestamp); + break; + + case 'waiting': + // Worker says: show waiting state + updateScrubberState(true, false); + break; + + case 'clear': + // Worker says: clear all states + updateScrubberState(false, false); + break; + + default: + break; } } -// Use arrow functions to avoid use-before-define with mutual recursion -const scheduleIdleProcessing = () => { - if (idleCallbackId) return; - const callback = () => { - idleCallbackId = null; - processSelection(); - if (selectionTimestamp && state.showLogs) { - scheduleIdleProcessing(); - } - }; - if (typeof requestIdleCallback === 'function') { - idleCallbackId = requestIdleCallback(callback, { timeout: 500 }); - } else { - // Fallback for Safari (no requestIdleCallback) - idleCallbackId = setTimeout(callback, 100); - } -}; - -const cancelIdleProcessing = () => { - if (!idleCallbackId) return; - if (typeof cancelIdleCallback === 'function') { - cancelIdleCallback(idleCallbackId); - } else { - clearTimeout(idleCallbackId); +function initWorker() { + if (worker) return; + try { + worker = new Worker(new URL('./scroll-sync-worker.js', import.meta.url)); + worker.onmessage = handleWorkerMessage; + } catch { + // Worker failed to load - fall back to no-op + worker = null; } - idleCallbackId = null; -}; +} /** - * Called on every chart mousemove - must be fast and non-blocking + * Called on every chart mousemove - just posts to worker (instant) */ function handleChartHover(timestamp) { - // If cursor moved to a new position, update selection tracking - if (selectionTimestamp !== timestamp) { - selectionTimestamp = timestamp; - selectionTime = Date.now(); - } + if (!state.showLogs) return; - // Schedule idle processing - if (state.showLogs) { - scheduleIdleProcessing(); - } + // Initialize worker on first hover + if (!worker) initWorker(); + + // Post to worker (non-blocking) + worker?.postMessage({ type: 'hover', timestamp: timestamp.getTime() }); + + // Also tell worker if data is already loaded (quick check) + const loaded = isTimestampLoadedFn?.(timestamp); + worker?.postMessage({ type: 'loaded', timestamp: timestamp.getTime(), loaded }); } function handleChartLeave() { - selectionTimestamp = null; - currentFetchTimestamp = null; - fetchAbortController?.abort(); - fetchAbortController = null; + worker?.postMessage({ type: 'leave' }); updateScrubberState(false, false); - cancelIdleProcessing(); } /** @@ -168,7 +123,6 @@ export function initScrollSync({ checkAndLoadGap, scrollToTimestamp, isTimestamp /** Handle row hover - move scrubber to row's timestamp */ export function handleRowHover(rowData) { if (!rowData?.timestamp || !state.showLogs) return; - // Use rAF to batch with other rendering requestAnimationFrame(() => { setScrubberPosition(parseUTC(rowData.timestamp)); scrubberLine?.classList.add('active'); From 0d478baceb83dbecaa8f477c681ddca6531ba568 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 12 Feb 2026 17:01:46 +0100 Subject: [PATCH 21/21] fix: defer isTimestampLoaded check until cursor rests 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 --- js/scroll-sync-worker.js | 31 ++++++++++++++++++++++++------- js/scroll-sync.js | 16 +++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/js/scroll-sync-worker.js b/js/scroll-sync-worker.js index 08028d4..88f9915 100644 --- a/js/scroll-sync-worker.js +++ b/js/scroll-sync-worker.js @@ -32,12 +32,22 @@ function stopChecking() { } } +// Track if we've requested loaded status for current selection +let loadedCheckRequested = false; + function checkSelection() { if (!selectionTimestamp) return; const now = Date.now(); const age = now - selectionTime; + // Only check loaded status after selection delay, and only once per selection + if (age >= SELECTION_DELAY && !loadedCheckRequested && !isLoading) { + loadedCheckRequested = true; + self.postMessage({ type: 'checkLoaded', timestamp: selectionTimestamp }); + return; // Wait for response + } + if (isDataLoaded) { // Data is ready - check if we should scroll if (age >= SCROLL_DELAY) { @@ -47,10 +57,9 @@ function checkSelection() { } else { self.postMessage({ type: 'waiting' }); } - } else if (!isLoading && age >= SELECTION_DELAY) { - // Need to fetch data - isLoading = true; - self.postMessage({ type: 'fetch', timestamp: selectionTimestamp }); + } else if (isLoading) { + // Already loading - just wait + self.postMessage({ type: 'waiting' }); } } @@ -70,6 +79,7 @@ self.onmessage = (e) => { selectionTime = Date.now(); isDataLoaded = false; isLoading = false; + loadedCheckRequested = false; } startChecking(); break; @@ -79,15 +89,22 @@ self.onmessage = (e) => { selectionTimestamp = null; isDataLoaded = false; isLoading = false; + loadedCheckRequested = false; stopChecking(); self.postMessage({ type: 'clear' }); break; case 'loaded': - // Main thread reports data status + // Main thread reports data status (response to checkLoaded) if (timestamp === selectionTimestamp) { - isDataLoaded = loaded; - isLoading = false; + if (loaded) { + isDataLoaded = true; + isLoading = false; + } else { + // Need to fetch + isLoading = true; + self.postMessage({ type: 'fetch', timestamp: selectionTimestamp }); + } } break; diff --git a/js/scroll-sync.js b/js/scroll-sync.js index 5676937..d18232f 100644 --- a/js/scroll-sync.js +++ b/js/scroll-sync.js @@ -41,6 +41,15 @@ function handleWorkerMessage(e) { const { type, timestamp } = e.data; switch (type) { + case 'checkLoaded': + // Worker asks: is data loaded for this timestamp? + // This is the only place we call isTimestampLoadedFn (once per selection) + requestAnimationFrame(() => { + const loaded = isTimestampLoadedFn?.(timestamp); + worker?.postMessage({ type: 'loaded', timestamp, loaded }); + }); + break; + case 'fetch': // Worker says: fetch data for this timestamp updateScrubberState(false, true); @@ -86,6 +95,7 @@ function initWorker() { /** * Called on every chart mousemove - just posts to worker (instant) + * MUST NOT call any functions that iterate over data - that blocks the UI */ function handleChartHover(timestamp) { if (!state.showLogs) return; @@ -93,12 +103,8 @@ function handleChartHover(timestamp) { // Initialize worker on first hover if (!worker) initWorker(); - // Post to worker (non-blocking) + // Post to worker (non-blocking) - worker will request loaded status when needed worker?.postMessage({ type: 'hover', timestamp: timestamp.getTime() }); - - // Also tell worker if data is already loaded (quick check) - const loaded = isTimestampLoadedFn?.(timestamp); - worker?.postMessage({ type: 'loaded', timestamp: timestamp.getTime(), loaded }); } function handleChartLeave() {