diff --git a/css/chart.css b/css/chart.css index 1098c15..616b02b 100644 --- a/css/chart.css +++ b/css/chart.css @@ -9,6 +9,55 @@ margin: 0 -24px 24px -24px; } +/* Sticky chart when logs view is active (not collapsed) */ +.logs-active:not(.logs-collapsed) .chart-section { + position: sticky; + 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; + 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; @@ -106,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/css/layout.css b/css/layout.css index d77ca5f..7131e47 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) */ +#dashboard:has(.logs-active:not(.logs-collapsed)) header { + position: sticky; + top: 0; + z-index: 30; +} + +/* When collapsed, header is not sticky */ +#dashboard:has(.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 37e5a7a..17f434b 100644 --- a/css/logs.css +++ b/css/logs.css @@ -58,6 +58,7 @@ border-bottom: 1px solid var(--border); position: sticky; top: 0; + z-index: 10; white-space: nowrap; cursor: pointer; user-select: none; @@ -175,6 +176,86 @@ color: var(--text-secondary); } +/* 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-table tr.logs-gap-row:hover td { + background: var(--card-bg); +} + +.logs-gap-cell { + text-align: left; + padding: 8px 12px; + 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 { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + cursor: pointer; + 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 { + 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; +} + +.logs-gap-icon { + font-size: 14px; + line-height: 1; + width: 14px; + text-align: center; +} + +.logs-gap-spinner { + display: inline-block; + 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 { + to { transform: rotate(360deg); } +} + /* Copy feedback toast */ .copy-feedback { position: fixed; 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/css/variables.css b/css/variables.css index 4e65158..3508b3c 100644 --- a/css/variables.css +++ b/css/variables.css @@ -4,6 +4,10 @@ */ :root { + /* Sticky layout heights for logs view */ + --header-height: 69px; + --chart-height: 250px; + --chart-toggle-height: 24px; --primary: #eb1000; --primary-dark: #c40d00; --bg: #f9fafb; diff --git a/dashboard.html b/dashboard.html index b51ccb8..3e94b0d 100644 --- a/dashboard.html +++ b/dashboard.html @@ -80,6 +80,7 @@

Requests over time

+ 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} Full row data or null on failure + */ +async function fetchFullRow(partialRow) { + const { timestamp } = partialRow; + const tsStr = String(timestamp); + if (!TIMESTAMP_RE.test(tsStr)) { + // eslint-disable-next-line no-console + console.warn('fetchFullRow: invalid timestamp format, aborting', tsStr); + return null; + } + const host = partialRow['request.host'] || ''; + if (!HOST_RE.test(host)) { + // 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(), + timestamp: tsStr, + 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 (!row || isGapRow(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 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 = {}; @@ -310,81 +429,72 @@ 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)); + const headerCells = container.querySelectorAll('.logs-table thead th'); + const colCount = headerCells.length; - // Get starting index from existing rows - const existingRows = tbody.querySelectorAll('tr').length; + const temp = document.createElement('tbody'); + temp.innerHTML = buildGapRowHtml({ gap, rowIdx: gapIdx, colCount }); + const newRow = temp.querySelector('tr'); + if (newRow) gapTr.replaceWith(newRow); +} - let html = ''; - for (let i = 0; i < data.length; i += 1) { - const rowIdx = existingRows + i; - html += buildLogRowHtml({ - row: data[i], columns: fullColumns, rowIdx, pinned, +// 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); }); - } - - tbody.insertAdjacentHTML('beforeend', html); - - updatePinnedOffsets(container, pinned); + }, { 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'); - 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; @@ -401,71 +511,366 @@ 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 += ''; container.innerHTML = html; updatePinnedOffsets(container, pinned); + + // Set up IntersectionObserver for auto-loading bottom gap + setupGapObserver(); } -async function loadMoreLogs() { - if (!pagination.canLoadMore()) return; - pagination.loading = true; +/** + * 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; + + if (!TIMESTAMP_RE.test(gap.gapStart)) { + // eslint-disable-next-line no-console + console.warn('loadGap: invalid gapStart format', gap.gapStart); + return; + } + + 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(), - timeFilter, - hostFilter, - facetFilters, + columns: buildLogColumnsSql(state.pinnedColumns), + timeFilter: getTimeFilter(), + hostFilter: getHostFilter(), + facetFilters: getFacetFilters(), additionalWhereClause: state.additionalWhereClause, pageSize: String(PAGE_SIZE), - offset: String(pagination.offset), + 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.length); + + 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); + } +} + +loadGapFn = loadGap; // Enable IntersectionObserver gap loading + +// 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); + }); + + // 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 +function updateCollapseToggleLabel() { + const btn = document.getElementById('chartCollapseToggle'); + 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'; +} + +// Set up collapse toggle click handler +export function initChartCollapseToggle() { + const btn = document.getElementById('chartCollapseToggle'); + if (!btn) return; + btn.addEventListener('click', () => { + 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(); + }); + 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); + if (remaining <= 0) { + if (timer) { + clearTimeout(timer); + timer = null; + } + pendingArgs = null; + lastCall = now; + fn(...args); + } else { + pendingArgs = args; + if (!timer) { + timer = setTimeout(() => { + lastCall = Date.now(); + timer = null; + const latestArgs = pendingArgs; + pendingArgs = null; + fn(...latestArgs); + }, 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; + // 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 || isGapRow(rowData) || !rowData.timestamp) return; + + const timestamp = parseUTC(rowData.timestamp); + setScrubberPosition(timestamp); +} + +const throttledSyncScrubber = throttle(syncScrubberToScroll, 100); + +/** 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(); + return null; +} + +/** 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 }; + 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 || midMs > targetMs) low = mid + 1; + else high = mid; + } + let closestIdx = 0; + let closestDiff = Infinity; + let closestIsGap = false; + 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(); + 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; + closestIdx = i; + closestIsGap = true; + } + } else if (item.timestamp) { + const diff = Math.abs(parseUTC(item.timestamp).getTime() - targetMs); + if (diff < closestDiff) { + closestDiff = diff; + closestIdx = i; + closestIsGap = false; + } + } + } + return { index: closestIdx, isGap: closestIsGap }; +} + +/** + * Check if a timestamp has loaded data (not in a gap). + * @param {Date|number} timestamp + * @returns {boolean} + */ +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) { + await loadGap(index); + 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() { // Only handle scroll when logs view is visible if (!state.showLogs) return; - const { scrollHeight } = document.documentElement; - const scrollTop = window.scrollY; - const clientHeight = window.innerHeight; - - // Load more when scrolled to last 50% - const scrollPercent = (scrollTop + clientHeight) / scrollHeight; - if (pagination.shouldTriggerLoad(scrollPercent, state.logsLoading)) { - loadMoreLogs(); - } + // Sync chart scrubber to topmost visible log row + throttledSyncScrubber(); } export function setLogsElements(view, toggleBtn, filtersViewEl) { @@ -473,11 +878,24 @@ 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(); + + // Set up chart collapse toggle + 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 @@ -492,14 +910,28 @@ 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'); 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 + 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()); @@ -529,6 +961,7 @@ export async function loadLogs(requestContext = getRequestContext('dashboard')) const sql = await loadSql('logs', { database: DATABASE, table: getTable(), + columns: buildLogColumnsSql(state.pinnedColumns), timeFilter, hostFilter, facetFilters, @@ -540,9 +973,18 @@ export async function loadLogs(requestContext = getRequestContext('dashboard')) const result = await query(sql, { signal }); if (!isCurrent()) return; state.logsData = result.data; - renderLogsTable(result.data); + 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; - pagination.recordPage(result.data.length); } 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..70d24dd 100644 --- a/js/pagination.js +++ b/js/pagination.js @@ -14,25 +14,28 @@ 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() { - 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 f153aeb..611aca7 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,55 +60,98 @@ 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); }); }); 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; @@ -132,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); }); diff --git a/js/scroll-sync-worker.js b/js/scroll-sync-worker.js new file mode 100644 index 0000000..88f9915 --- /dev/null +++ b/js/scroll-sync-worker.js @@ -0,0 +1,122 @@ +/* + * 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; + } +} + +// 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) { + self.postMessage({ type: 'scroll', timestamp: selectionTimestamp }); + selectionTimestamp = null; // Prevent repeated scrolling + stopChecking(); + } else { + self.postMessage({ type: 'waiting' }); + } + } else if (isLoading) { + // Already loading - just wait + self.postMessage({ type: 'waiting' }); + } +} + +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; + loadedCheckRequested = false; + } + startChecking(); + break; + + case 'leave': + // Cursor left chart + selectionTimestamp = null; + isDataLoaded = false; + isLoading = false; + loadedCheckRequested = false; + stopChecking(); + self.postMessage({ type: 'clear' }); + break; + + case 'loaded': + // Main thread reports data status (response to checkLoaded) + if (timestamp === selectionTimestamp) { + if (loaded) { + isDataLoaded = true; + isLoading = false; + } else { + // Need to fetch + isLoading = true; + self.postMessage({ type: 'fetch', timestamp: selectionTimestamp }); + } + } + 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 new file mode 100644 index 0000000..d18232f --- /dev/null +++ b/js/scroll-sync.js @@ -0,0 +1,141 @@ +/* + * 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 + * + * Architecture: + * - 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'; +import { setScrubberPosition } from './chart.js'; +import { parseUTC } from './chart-state.js'; + +let scrubberLine = null; +let checkAndLoadGapFn = null; +let scrollToTimestampFn = null; +let isTimestampLoadedFn = null; + +// Web Worker for background timing +let worker = null; + +function updateScrubberState(waiting, loading) { + if (!scrubberLine) return; + scrubberLine.classList.toggle('waiting', waiting); + scrubberLine.classList.toggle('loading', loading); +} + +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); + checkAndLoadGapFn?.(timestamp).then(() => { + worker?.postMessage({ type: 'fetchComplete', timestamp }); + updateScrubberState(false, false); + }).catch(() => { + updateScrubberState(false, false); + }); + break; + + case 'scroll': + // Worker says: scroll to this timestamp + 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; + } +} + +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; + } +} + +/** + * 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; + + // Initialize worker on first hover + if (!worker) initWorker(); + + // Post to worker (non-blocking) - worker will request loaded status when needed + worker?.postMessage({ type: 'hover', timestamp: timestamp.getTime() }); +} + +function handleChartLeave() { + worker?.postMessage({ type: 'leave' }); + updateScrubberState(false, false); +} + +/** + * Initialize scroll sync with required callbacks + */ +export function initScrollSync({ checkAndLoadGap, scrollToTimestamp, isTimestampLoaded }) { + checkAndLoadGapFn = checkAndLoadGap; + scrollToTimestampFn = scrollToTimestamp; + isTimestampLoadedFn = isTimestampLoaded; + scrubberLine = document.querySelector('.chart-scrubber-line'); + return { + onChartHover: handleChartHover, + onChartLeave: handleChartLeave, + }; +} + +/** Handle row hover - move scrubber to row's timestamp */ +export function handleRowHover(rowData) { + if (!rowData?.timestamp || !state.showLogs) return; + requestAnimationFrame(() => { + setScrubberPosition(parseUTC(rowData.timestamp)); + scrubberLine?.classList.add('active'); + }); +} + +/** Handle row hover end */ +export function handleRowLeave() { + scrubberLine?.classList.remove('active'); +} diff --git a/js/sql-loader.js b/js/sql-loader.js index 5e40c99..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', @@ -73,6 +74,7 @@ const ALL_TEMPLATES = [ 'facet-search-pattern', 'investigate-facet', 'investigate-selection', + 'log-detail', ]; /** diff --git a/js/templates/logs-table.js b/js/templates/logs-table.js index 7ad606e..9909bef 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,79 @@ 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" or "30m" + */ +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 (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 + * @returns {string} + */ +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, 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 timeRange = `${startTime}\u2013${endTime}`; + const loadingClass = gap.gapLoading ? ' loading' : ''; + + let labelText; + if (gap.gapLoading) { + labelText = `Loading ${timeRange} (${duration})\u2026`; + } else if (gap.gapCount !== undefined && gap.gapCount > 0) { + labelText = `\u2026 ${formatCount(gap.gapCount)} more entries (${duration})`; + } else { + labelText = `\u2026 ${duration} of logs (${timeRange})`; + } + + const iconHtml = gap.gapLoading + ? '' + : '\u2193'; + + return ` + + + +`; +} diff --git a/js/templates/logs-table.test.js b/js/templates/logs-table.test.js index 93642ce..e0fcede 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,81 @@ 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 of logs'); + }); + + 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 of logs'); + }); + + 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 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'); + }); +}); 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')) { 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-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/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