From be6080280e9866922b0231f2e17542aa9b52a786 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 15 Feb 2026 20:48:43 -0800 Subject: [PATCH] rewrite log viewer panes: smart auto-scroll with follow button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track scroll position per log pane (container logs, build logs, deployment cards) - Auto-scroll to bottom only when user is already at bottom (tail-follow) - When user scrolls up to review earlier output, pause auto-scroll - Show a '↓ Follow' button when auto-scroll is paused; clicking resumes - Only scroll on actual content changes (skip no-op updates) - Use overflow-y: auto for proper scrollable containers - Add break-words to prevent horizontal overflow on long lines Closes #17 --- static/js/app.js | 69 ++++++++++++++++++++++++++++++-------- templates/app_detail.html | 26 +++++++++++--- templates/deployments.html | 13 +++++-- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index b2bc46d..cd567a9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => { */ scrollToBottom(el) { if (el) { - // Use double RAF to ensure DOM has fully updated and reflowed requestAnimationFrame(() => { - requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight; - }); + el.scrollTop = el.scrollHeight; }); } }, + /** + * Check if a scrollable element is at (or near) the bottom. + * Tolerance of 30px accounts for rounding and partial lines. + */ + isScrolledToBottom(el, tolerance = 30) { + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance; + }, + /** * Copy text to clipboard */ @@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => { showBuildLogs: !!config.initialDeploymentId, deploying: false, deployments: [], + // Track whether user wants auto-scroll (per log pane) + _containerAutoScroll: true, + _buildAutoScroll: true, init() { this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.fetchAll(); setInterval(() => this.fetchAll(), 1000); + + // Set up scroll listeners after DOM is ready + this.$nextTick(() => { + this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll'); + this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll'); + }); + }, + + _initScrollTracking(el, flag) { + if (!el) return; + el.addEventListener('scroll', () => { + this[flag] = Alpine.store("utils").isScrolledToBottom(el); + }, { passive: true }); }, fetchAll() { @@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => { try { const res = await fetch(`/apps/${this.appId}/container-logs`); const data = await res.json(); - this.containerLogs = data.logs || "No logs available"; + const newLogs = data.logs || "No logs available"; + const changed = newLogs !== this.containerLogs; + this.containerLogs = newLogs; this.containerStatus = data.status; - this.$nextTick(() => { - Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); - }); + if (changed && this._containerAutoScroll) { + this.$nextTick(() => { + Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); + }); + } } catch (err) { this.containerLogs = "Failed to fetch logs"; } @@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => { `/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`, ); const data = await res.json(); - this.buildLogs = data.logs || "No build logs available"; + const newLogs = data.logs || "No build logs available"; + const changed = newLogs !== this.buildLogs; + this.buildLogs = newLogs; this.buildStatus = data.status; - this.$nextTick(() => { - Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); - }); + if (changed && this._buildAutoScroll) { + this.$nextTick(() => { + Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); + }); + } } catch (err) { this.buildLogs = "Failed to fetch logs"; } @@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => { logs: "", status: config.status || "", pollInterval: null, + _autoScroll: true, init() { // Read initial logs from script tag (avoids escaping issues) const initialLogsEl = this.$el.querySelector(".initial-logs"); this.logs = initialLogsEl?.textContent || "Loading..."; + // Set up scroll tracking + this.$nextTick(() => { + const wrapper = this.$refs.logsWrapper; + if (wrapper) { + wrapper.addEventListener('scroll', () => { + this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper); + }, { passive: true }); + } + }); + // Only poll if deployment is in progress if (Alpine.store("utils").isDeploying(this.status)) { this.fetchLogs(); @@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => { this.logs = newLogs; this.status = data.status; - // Scroll to bottom only when content changes - if (logsChanged) { + // Scroll to bottom only when content changes AND user hasn't scrolled up + if (logsChanged && this._autoScroll) { this.$nextTick(() => { Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); }); diff --git a/templates/app_detail.html b/templates/app_detail.html index caefa22..5685f64 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -279,8 +279,17 @@

Container Logs

-
-

+        
+
+

+            
+
@@ -329,8 +338,17 @@

Last Deployment Build Logs

-
-

+        
+
+

+            
+
diff --git a/templates/deployments.html b/templates/deployments.html index 3f83911..b50dcb3 100644 --- a/templates/deployments.html +++ b/templates/deployments.html @@ -85,8 +85,17 @@ {{end}} -
-

+                
+
+

+                    
+
{{if .Logs.Valid}}{{end}}