+
diff --git a/js/chart-draw.js b/js/chart-draw.js
new file mode 100644
index 0000000..cfee4ca
--- /dev/null
+++ b/js/chart-draw.js
@@ -0,0 +1,227 @@
+/*
+ * 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.
+ */
+
+/**
+ * Chart drawing helpers — canvas rendering primitives extracted from chart.js.
+ */
+
+import { formatNumber } from './format.js';
+import {
+ addAnomalyBounds,
+ parseUTC,
+} from './chart-state.js';
+
+/**
+ * Initialize canvas for chart rendering
+ */
+export function initChartCanvas() {
+ const canvas = document.getElementById('chart');
+ const ctx = canvas.getContext('2d');
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+ ctx.scale(dpr, dpr);
+ return { canvas, ctx, rect };
+}
+
+/**
+ * Draw Y axis with grid lines and labels
+ */
+export function drawYAxis(ctx, chartDimensions, cssVar, minValue, maxValue) {
+ const {
+ width, height, padding, chartHeight, labelInset,
+ } = chartDimensions;
+ ctx.fillStyle = cssVar('--text-secondary');
+ ctx.font = '11px -apple-system, sans-serif';
+ ctx.textAlign = 'left';
+
+ for (let i = 1; i <= 4; i += 1) {
+ const val = minValue + (maxValue - minValue) * (i / 4);
+ const y = height - padding.bottom - ((chartHeight * i) / 4);
+
+ ctx.strokeStyle = cssVar('--grid-line');
+ ctx.beginPath();
+ ctx.moveTo(padding.left, y);
+ ctx.lineTo(width - padding.right, y);
+ ctx.stroke();
+
+ ctx.fillStyle = cssVar('--text-secondary');
+ ctx.fillText(formatNumber(val), padding.left + labelInset, y - 4);
+ }
+}
+
+/**
+ * Draw X axis labels
+ */
+export function drawXAxisLabels(ctx, data, chartDims, startTime, timeRange, cssVar) {
+ const {
+ width, height, padding, chartWidth, labelInset,
+ } = chartDims;
+ ctx.fillStyle = cssVar('--text-secondary');
+ const isMobile = width < 500;
+ const tickIndices = isMobile
+ ? [0, Math.floor((data.length - 1) / 2), data.length - 1]
+ : Array.from(
+ { length: 6 },
+ (_, idx) => Math.round((idx * (data.length - 1)) / 5),
+ );
+
+ const validIndices = tickIndices.filter((i) => i < data.length);
+ for (const i of validIndices) {
+ const time = parseUTC(data[i].t);
+ const elapsed = time.getTime() - startTime;
+ const x = padding.left + (elapsed / timeRange) * chartWidth;
+ const timeStr = time.toLocaleTimeString('en-US', {
+ hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC',
+ });
+ const showDate = timeRange > 24 * 60 * 60 * 1000;
+ const label = showDate
+ ? `${time.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${timeStr}`
+ : timeStr;
+ const yPos = height - padding.bottom + 20;
+
+ if (i === 0) {
+ ctx.textAlign = 'left';
+ ctx.fillText(label, padding.left + labelInset, yPos);
+ } else if (i === data.length - 1) {
+ ctx.textAlign = 'right';
+ ctx.fillText(
+ `${label} (UTC)`,
+ width - padding.right - labelInset,
+ yPos,
+ );
+ } else {
+ ctx.textAlign = 'center';
+ ctx.fillText(label, x, yPos);
+ }
+ }
+}
+
+/**
+ * Draw anomaly highlight for a detected step
+ */
+export function drawAnomalyHighlight(ctx, step, data, chartDimensions, getX, getY, stacks) {
+ const { height, padding, chartWidth } = chartDimensions;
+ const { stackedServer, stackedClient, stackedOk } = stacks;
+
+ const startX = getX(step.startIndex);
+ const endX = getX(step.endIndex);
+ const minBandWidth = Math.max((chartWidth / data.length) * 2, 16);
+ const bandPadding = minBandWidth / 2;
+ const bandLeft = startX - bandPadding;
+ const bandRight = step.startIndex === step.endIndex
+ ? startX + bandPadding : endX + bandPadding;
+
+ const anomalyStart = parseUTC(data[step.startIndex].t);
+ const anomalyEnd = parseUTC(data[step.endIndex].t);
+ addAnomalyBounds({
+ left: bandLeft,
+ right: bandRight,
+ startTime: anomalyStart,
+ endTime: anomalyEnd,
+ rank: step.rank,
+ });
+
+ const opacityMultiplier = step.rank === 1 ? 1 : 0.7;
+ const categoryColors = {
+ red: [240, 68, 56],
+ yellow: [247, 144, 9],
+ green: [18, 183, 106],
+ };
+ const rgb = categoryColors[step.category] || categoryColors.green;
+ const [cr, cg, cb] = rgb;
+
+ const seriesBounds = {
+ red: [(i) => getY(stackedServer[i]), () => getY(0)],
+ yellow: [
+ (i) => getY(stackedClient[i]),
+ (i) => getY(stackedServer[i]),
+ ],
+ green: [
+ (i) => getY(stackedOk[i]),
+ (i) => getY(stackedClient[i]),
+ ],
+ };
+ const bounds = seriesBounds[step.category] || seriesBounds.green;
+ const [getSeriesTop, getSeriesBottom] = bounds;
+
+ const points = [];
+ for (let i = step.startIndex; i <= step.endIndex; i += 1) {
+ points.push({ x: getX(i), y: getSeriesTop(i) });
+ }
+ for (let i = step.endIndex; i >= step.startIndex; i -= 1) {
+ points.push({ x: getX(i), y: getSeriesBottom(i) });
+ }
+
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${0.35 * opacityMultiplier})`;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i += 1) {
+ ctx.lineTo(points[i].x, points[i].y);
+ }
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.strokeStyle = `rgba(${cr}, ${cg}, ${cb}, 0.8)`;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 4]);
+ [bandLeft, bandRight].forEach((bx) => {
+ ctx.beginPath();
+ ctx.moveTo(bx, padding.top);
+ ctx.lineTo(bx, height - padding.bottom);
+ ctx.stroke();
+ });
+ ctx.setLineDash([]);
+
+ const mag = step.magnitude;
+ const magnitudeLabel = mag >= 1
+ ? `${mag >= 10 ? Math.round(mag) : mag.toFixed(1).replace(/\.0$/, '')}x`
+ : `${Math.round(mag * 100)}%`;
+ ctx.font = '500 11px -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
+ const arrow = step.type === 'spike' ? '\u25B2' : '\u25BC';
+ ctx.fillText(
+ `${step.rank} ${arrow} ${magnitudeLabel}`,
+ (bandLeft + bandRight) / 2,
+ padding.top + 12,
+ );
+}
+
+/**
+ * Draw a stacked area with line on top
+ */
+export function drawStackedArea(ctx, data, getX, getY, topStack, bottomStack, colors) {
+ if (!topStack.some((v, i) => v > bottomStack[i])) return;
+
+ ctx.beginPath();
+ ctx.moveTo(getX(0), getY(bottomStack[0]));
+ for (let i = 0; i < data.length; i += 1) {
+ ctx.lineTo(getX(i), getY(topStack[i]));
+ }
+ for (let i = data.length - 1; i >= 0; i -= 1) {
+ ctx.lineTo(getX(i), getY(bottomStack[i]));
+ }
+ ctx.closePath();
+ ctx.fillStyle = colors.fill;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(getX(0), getY(topStack[0]));
+ for (let i = 1; i < data.length; i += 1) {
+ ctx.lineTo(getX(i), getY(topStack[i]));
+ }
+ ctx.strokeStyle = colors.line;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+}
diff --git a/js/chart.js b/js/chart.js
index a2a940f..d446a9f 100644
--- a/js/chart.js
+++ b/js/chart.js
@@ -19,6 +19,9 @@ import { query, isAbortError } from './api.js';
import {
getFacetFilters, loadPreviewBreakdowns, revertPreviewBreakdowns, isPreviewActive,
} from './breakdowns/index.js';
+import {
+ initChartCanvas, drawYAxis, drawXAxisLabels, drawAnomalyHighlight, drawStackedArea,
+} from './chart-draw.js';
import { DATABASE } from './config.js';
import { formatNumber } from './format.js';
import { getRequestContext, isRequestCurrent } from './request-context.js';
@@ -51,7 +54,6 @@ import {
setLastChartData,
getLastChartData,
getDataAtTime,
- addAnomalyBounds,
resetAnomalyBounds,
setDetectedSteps,
getDetectedSteps,
@@ -90,174 +92,41 @@ let isDragging = false;
let dragStartX = null;
let justCompletedDrag = false;
-/**
- * Initialize canvas for chart rendering
- */
-function initChartCanvas() {
- const canvas = document.getElementById('chart');
- const ctx = canvas.getContext('2d');
- const dpr = window.devicePixelRatio || 1;
- const rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * dpr;
- canvas.height = rect.height * dpr;
- ctx.scale(dpr, dpr);
- return { canvas, ctx, rect };
-}
+// Callbacks for chart→scroll sync (set by dashboard-init.js)
+let onChartHoverTimestamp = null;
+let onChartLeaveCallback = null;
/**
- * Draw Y axis with grid lines and labels
+ * Set callback for chart hover → scroll sync
+ * @param {Function} callback - Called with timestamp when hovering chart in logs view
*/
-function drawYAxis(ctx, chartDimensions, cssVar, minValue, maxValue) {
- const {
- width, height, padding, chartHeight, labelInset,
- } = chartDimensions;
- ctx.fillStyle = cssVar('--text-secondary');
- ctx.font = '11px -apple-system, sans-serif';
- ctx.textAlign = 'left';
-
- for (let i = 1; i <= 4; i += 1) {
- const val = minValue + (maxValue - minValue) * (i / 4);
- const y = height - padding.bottom - ((chartHeight * i) / 4);
-
- ctx.strokeStyle = cssVar('--grid-line');
- ctx.beginPath();
- ctx.moveTo(padding.left, y);
- ctx.lineTo(width - padding.right, y);
- ctx.stroke();
-
- ctx.fillStyle = cssVar('--text-secondary');
- ctx.fillText(formatNumber(val), padding.left + labelInset, y - 4);
- }
+export function setOnChartHoverTimestamp(callback) {
+ onChartHoverTimestamp = callback;
}
/**
- * Draw X axis labels
+ * Set callback for chart mouse leave
+ * @param {Function} callback - Called when mouse leaves chart
*/
-function drawXAxisLabels(ctx, data, chartDimensions, intendedStartTime, intendedTimeRange, cssVar) {
- const {
- width, height, padding, chartWidth, labelInset,
- } = chartDimensions;
- ctx.fillStyle = cssVar('--text-secondary');
- const isMobile = width < 500;
- const tickIndices = isMobile
- ? [0, Math.floor((data.length - 1) / 2), data.length - 1]
- : Array.from({ length: 6 }, (_, idx) => Math.round((idx * (data.length - 1)) / 5));
-
- const validIndices = tickIndices.filter((i) => i < data.length);
- for (const i of validIndices) {
- const time = parseUTC(data[i].t);
- const elapsed = time.getTime() - intendedStartTime;
- const x = padding.left + (elapsed / intendedTimeRange) * chartWidth;
- const timeStr = time.toLocaleTimeString('en-US', {
- hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC',
- });
- const showDate = intendedTimeRange > 24 * 60 * 60 * 1000;
- const label = showDate
- ? `${time.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${timeStr}`
- : timeStr;
- const yPos = height - padding.bottom + 20;
-
- if (i === 0) {
- ctx.textAlign = 'left';
- ctx.fillText(label, padding.left + labelInset, yPos);
- } else if (i === data.length - 1) {
- ctx.textAlign = 'right';
- ctx.fillText(`${label} (UTC)`, width - padding.right - labelInset, yPos);
- } else {
- ctx.textAlign = 'center';
- ctx.fillText(label, x, yPos);
- }
- }
-}
-
-/**
- * Draw anomaly highlight for a detected step
- */
-function drawAnomalyHighlight(ctx, step, data, chartDimensions, getX, getY, stacks) {
- const { height, padding, chartWidth } = chartDimensions;
- const { stackedServer, stackedClient, stackedOk } = stacks;
-
- const startX = getX(step.startIndex);
- const endX = getX(step.endIndex);
- const minBandWidth = Math.max((chartWidth / data.length) * 2, 16);
- const bandPadding = minBandWidth / 2;
- const bandLeft = startX - bandPadding;
- const bandRight = step.startIndex === step.endIndex ? startX + bandPadding : endX + bandPadding;
-
- const startTime = parseUTC(data[step.startIndex].t);
- const endTime = parseUTC(data[step.endIndex].t);
- addAnomalyBounds({
- left: bandLeft, right: bandRight, startTime, endTime, rank: step.rank,
- });
-
- const opacityMultiplier = step.rank === 1 ? 1 : 0.7;
- const categoryColors = { red: [240, 68, 56], yellow: [247, 144, 9], green: [18, 183, 106] };
- const [cr, cg, cb] = categoryColors[step.category] || categoryColors.green;
-
- const seriesBounds = {
- red: [(i) => getY(stackedServer[i]), () => getY(0)],
- yellow: [(i) => getY(stackedClient[i]), (i) => getY(stackedServer[i])],
- green: [(i) => getY(stackedOk[i]), (i) => getY(stackedClient[i])],
- };
- const [getSeriesTop, getSeriesBottom] = seriesBounds[step.category] || seriesBounds.green;
-
- const points = [];
- for (let i = step.startIndex; i <= step.endIndex; i += 1) {
- points.push({ x: getX(i), y: getSeriesTop(i) });
- }
- for (let i = step.endIndex; i >= step.startIndex; i -= 1) {
- points.push({ x: getX(i), y: getSeriesBottom(i) });
- }
-
- ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${0.35 * opacityMultiplier})`;
- ctx.beginPath();
- ctx.moveTo(points[0].x, points[0].y);
- for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
- ctx.closePath();
- ctx.fill();
-
- ctx.strokeStyle = `rgba(${cr}, ${cg}, ${cb}, 0.8)`;
- ctx.lineWidth = 1.5;
- ctx.setLineDash([4, 4]);
- [bandLeft, bandRight].forEach((bx) => {
- ctx.beginPath();
- ctx.moveTo(bx, padding.top);
- ctx.lineTo(bx, height - padding.bottom);
- ctx.stroke();
- });
- ctx.setLineDash([]);
-
- const mag = step.magnitude;
- const magnitudeLabel = mag >= 1
- ? `${mag >= 10 ? Math.round(mag) : mag.toFixed(1).replace(/\.0$/, '')}x`
- : `${Math.round(mag * 100)}%`;
- ctx.font = '500 11px -apple-system, sans-serif';
- ctx.textAlign = 'center';
- ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
- const arrow = step.type === 'spike' ? '\u25B2' : '\u25BC';
- ctx.fillText(`${step.rank} ${arrow} ${magnitudeLabel}`, (bandLeft + bandRight) / 2, padding.top + 12);
+export function setOnChartLeave(callback) {
+ onChartLeaveCallback = callback;
}
/**
- * Draw a stacked area with line on top
+ * Position the scrubber line at a given timestamp (called from logs.js scroll sync)
+ * @param {Date} timestamp - Timestamp to position scrubber at
*/
-function drawStackedArea(ctx, data, getX, getY, topStack, bottomStack, colors) {
- if (!topStack.some((v, i) => v > bottomStack[i])) return;
-
- ctx.beginPath();
- ctx.moveTo(getX(0), getY(bottomStack[0]));
- for (let i = 0; i < data.length; i += 1) ctx.lineTo(getX(i), getY(topStack[i]));
- for (let i = data.length - 1; i >= 0; i -= 1) ctx.lineTo(getX(i), getY(bottomStack[i]));
- ctx.closePath();
- ctx.fillStyle = colors.fill;
- ctx.fill();
-
- ctx.beginPath();
- ctx.moveTo(getX(0), getY(topStack[0]));
- for (let i = 1; i < data.length; i += 1) ctx.lineTo(getX(i), getY(topStack[i]));
- ctx.strokeStyle = colors.line;
- ctx.lineWidth = 2;
- ctx.stroke();
+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');
}
export function renderChart(data) {
@@ -732,6 +601,7 @@ export function setupChartNavigation(callback) {
scrubberStatusBar.classList.remove('visible');
hideReleaseTooltip();
canvas.style.cursor = '';
+ if (onChartLeaveCallback) onChartLeaveCallback();
});
container.addEventListener('mousemove', (e) => {
@@ -740,6 +610,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..ab51e3f 100644
--- a/js/columns.js
+++ b/js/columns.js
@@ -181,3 +181,30 @@ 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.
+ */
+const VALID_COLUMN_RE = /^[a-z][a-z0-9_.]*$/i;
+
+export function buildLogColumnsSql(pinnedColumns = []) {
+ const seen = new Set();
+ const cols = [];
+ 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}\``);
+ }
+ }
+ 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 e561242..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,
+ renderChart, setOnChartHoverTimestamp, setOnChartLeave,
} from './chart.js';
import {
loadAllBreakdowns, loadBreakdown, getBreakdowns, markSlowestFacet, resetFacetTimings,
@@ -40,8 +40,10 @@ import {
getFilterForValue,
} from './filters.js';
import {
- loadLogs, toggleLogsView, setLogsElements, setOnShowFiltersView,
+ 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 {
@@ -160,7 +162,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 +186,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);
}
@@ -202,6 +210,16 @@ export function initDashboard(config = {}) {
}
});
+ // 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());
setOnStateRestored(loadDashboard);
diff --git a/js/logs.js b/js/logs.js
index 9a13d2b..0fe59e9 100644
--- a/js/logs.js
+++ b/js/logs.js
@@ -12,16 +12,62 @@
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 } from './columns.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 } 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}$/;
+// 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, gapCount) {
+ const gap = {
+ isGap: true,
+ gapStart,
+ gapEnd,
+ gapLoading: false,
+ };
+ if (gapCount !== undefined) {
+ gap.gapCount = gapCount;
+ }
+ return gap;
+}
+
+/**
+ * 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.
@@ -94,10 +140,12 @@ function updatePinnedOffsets(container, pinned) {
let logsView = null;
let viewToggleBtn = null;
let filtersView = null;
-
-// Pagination state
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');
@@ -241,47 +289,118 @@ 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