diff --git a/css/chart.css b/css/chart.css
index 1098c15..820dd98 100644
--- a/css/chart.css
+++ b/css/chart.css
@@ -9,6 +9,49 @@
margin: 0 -24px 24px -24px;
}
+/* Chart section takes its natural size in flex layout */
+.logs-active .chart-section {
+ flex-shrink: 0;
+ z-index: 20;
+}
+
+/* Collapse/expand toggle */
+.chart-collapse-toggle {
+ display: none;
+ width: 100%;
+ height: 24px;
+ border: none;
+ background: var(--chart-bg);
+ border-top: 1px solid var(--border);
+ cursor: pointer;
+ padding: 0;
+ color: var(--text-secondary);
+ font-size: 10px;
+ line-height: 24px;
+ text-align: center;
+ opacity: 0.6;
+ transition: opacity 0.15s ease;
+}
+
+.chart-collapse-toggle:hover {
+ opacity: 1;
+}
+
+.logs-active .chart-collapse-toggle {
+ display: block;
+}
+
+/* Collapsed state */
+.chart-section.chart-collapsed .chart-container {
+ height: 0;
+ overflow: hidden;
+ transition: height 0.2s ease;
+}
+
+.chart-section:not(.chart-collapsed) .chart-container {
+ transition: height 0.2s ease;
+}
+
@media (max-width: 600px) {
.chart-section {
margin: 0 -12px 16px -12px;
diff --git a/css/logs.css b/css/logs.css
index 37e5a7a..23e8f1d 100644
--- a/css/logs.css
+++ b/css/logs.css
@@ -14,6 +14,36 @@
display: block;
}
+/* Logs-active: make #dashboard a flex column so header + main fill exactly 100vh */
+#dashboard:has(.logs-active) {
+ height: 100vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+#dashboard:has(.logs-active) > header {
+ flex-shrink: 0;
+}
+
+.logs-active#dashboardContent {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Logs View fills remaining flex space */
+.logs-active #logsView.visible {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ padding: 0;
+}
+
/* Logs View */
#logsView {
padding: 16px 0 24px 0;
@@ -25,6 +55,16 @@
border: 1px solid var(--border);
overflow-x: auto;
transition: filter 0.2s ease-out;
+ height: calc(100vh - 200px);
+ min-height: 400px;
+}
+
+/* In logs-active mode, container fills remaining flex space */
+.logs-active .logs-table-container {
+ flex: 1;
+ min-height: 0;
+ height: auto;
+ overflow: auto;
}
@media (max-width: 600px) {
@@ -44,8 +84,9 @@
}
.logs-table {
- width: 100%;
+ min-width: 100%;
border-collapse: collapse;
+ table-layout: fixed;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
}
@@ -59,6 +100,8 @@
position: sticky;
top: 0;
white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
cursor: pointer;
user-select: none;
}
@@ -103,7 +146,7 @@
padding: 8px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
- max-width: 300px;
+ max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -175,6 +218,42 @@
color: var(--text-secondary);
}
+/* Virtual table loading placeholder rows */
+.logs-table .loading-row td {
+ background: var(--card-bg);
+ color: transparent;
+}
+
+/* Bucket-row table placeholder rows */
+.bucket-table .bucket-row {
+ cursor: default;
+}
+
+.bucket-table .bucket-row .bucket-placeholder {
+ max-width: none;
+ vertical-align: middle;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 11px;
+ border-bottom: 1px solid var(--border);
+ padding: 0 12px;
+ white-space: nowrap;
+ background: repeating-linear-gradient(
+ to bottom,
+ transparent 0px,
+ transparent 28px,
+ rgba(0, 0, 0, 0.02) 28px,
+ rgba(0, 0, 0, 0.02) 56px
+ );
+}
+
+/* Bucket table data rows (loaded on demand) */
+.bucket-table tr[data-row-idx] td {
+ height: 28px;
+ padding: 4px 8px;
+ box-sizing: border-box;
+}
+
/* 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/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/bucket-loader.js b/js/bucket-loader.js
new file mode 100644
index 0000000..bb6f415
--- /dev/null
+++ b/js/bucket-loader.js
@@ -0,0 +1,456 @@
+/*
+ * 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.
+ */
+
+/**
+ * Bucket data loader — extracted from logs.js to stay within the
+ * max-lines lint limit. Handles per-bucket on-demand data fetching,
+ * IntersectionObserver setup, placeholder replacement, and DOM
+ * virtualization with a 2000-row cap.
+ */
+
+import { DATABASE } from './config.js';
+import { state } from './state.js';
+import { query, isAbortError } from './api.js';
+import { getHostFilter, getTable, getTimeBucketStep } from './time.js';
+import { getFacetFilters } from './breakdowns/index.js';
+import { buildLogColumnsSql, LOG_COLUMN_ORDER } from './columns.js';
+import { loadSql } from './sql-loader.js';
+import {
+ buildLogRowHtml, buildLogTableHeaderHtml,
+} from './templates/logs-table.js';
+import { createLimiter } from './concurrency-limiter.js';
+
+const SECOND_MS = 1000;
+const MINUTE_MS = 60 * SECOND_MS;
+const HOUR_MS = 60 * MINUTE_MS;
+const DAY_MS = 24 * HOUR_MS;
+
+const MAX_DOM_ROWS = 2000;
+const HEAD_CACHE_SIZE = 20;
+const ROW_HEIGHT = 28;
+
+/**
+ * Parse a ClickHouse INTERVAL string to milliseconds.
+ * @param {string} interval
+ * @returns {number}
+ */
+function parseIntervalToMs(interval) {
+ const match = interval.match(/INTERVAL\s+(\d+)\s+(\w+)/i);
+ if (!match) return MINUTE_MS;
+ const amount = parseInt(match[1], 10);
+ const unit = match[2].toUpperCase().replace(/S$/, '');
+ const multipliers = {
+ SECOND: SECOND_MS,
+ MINUTE: MINUTE_MS,
+ HOUR: HOUR_MS,
+ DAY: DAY_MS,
+ };
+ return amount * (multipliers[unit] || MINUTE_MS);
+}
+
+/**
+ * Format a Date as 'YYYY-MM-DD HH:MM:SS.mmm' in UTC.
+ * @param {Date} date
+ * @returns {string}
+ */
+function formatTimestampUTC(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ const ms = String(date.getUTCMilliseconds()).padStart(3, '0');
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${ms}`;
+}
+
+// Concurrency limiter and abort state
+const bucketFetchLimiter = createLimiter(4);
+// eslint-disable-next-line prefer-const -- reassigned in setup/teardown
+let fetchController = null;
+// eslint-disable-next-line prefer-const -- reassigned in setup/teardown
+let loadObserver = null;
+// eslint-disable-next-line prefer-const -- reassigned in setup/teardown
+let evictionObserver = null;
+const loadedBuckets = new Set();
+
+// Per-bucket AbortControllers for cancelling in-flight fetches
+const bucketControllers = new Map();
+
+// LRU head-data cache (ts → rows)
+const headCache = new Map();
+
+// Stored container reference for eviction observer sentinel wiring
+// eslint-disable-next-line prefer-const -- reassigned in setup/teardown
+let storedContainer = null;
+
+/**
+ * Store rows in the LRU head cache.
+ */
+function cacheHead(ts, rows) {
+ headCache.delete(ts);
+ headCache.set(ts, rows);
+ if (headCache.size > HEAD_CACHE_SIZE) {
+ const oldest = headCache.keys().next().value;
+ headCache.delete(oldest);
+ }
+}
+
+/**
+ * Fetch log rows for a specific bucket time window.
+ * @param {string} bucketTs - Bucket start timestamp
+ * @param {number} limit - Max rows to fetch
+ * @param {number} offset - Row offset within the bucket
+ * @param {AbortSignal} signal - Abort signal
+ * @returns {Promise