rewrite log viewer panes: smart auto-scroll with follow button
- 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
This commit is contained in:
parent
d4eae284b5
commit
be6080280e
@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
scrollToBottom(el) {
|
scrollToBottom(el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
// Use double RAF to ensure DOM has fully updated and reflowed
|
|
||||||
requestAnimationFrame(() => {
|
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
|
* Copy text to clipboard
|
||||||
*/
|
*/
|
||||||
@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => {
|
|||||||
showBuildLogs: !!config.initialDeploymentId,
|
showBuildLogs: !!config.initialDeploymentId,
|
||||||
deploying: false,
|
deploying: false,
|
||||||
deployments: [],
|
deployments: [],
|
||||||
|
// Track whether user wants auto-scroll (per log pane)
|
||||||
|
_containerAutoScroll: true,
|
||||||
|
_buildAutoScroll: true,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
|
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
|
||||||
this.fetchAll();
|
this.fetchAll();
|
||||||
setInterval(() => this.fetchAll(), 1000);
|
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() {
|
fetchAll() {
|
||||||
@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
||||||
const data = await res.json();
|
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.containerStatus = data.status;
|
||||||
this.$nextTick(() => {
|
if (changed && this._containerAutoScroll) {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
this.$nextTick(() => {
|
||||||
});
|
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.containerLogs = "Failed to fetch logs";
|
this.containerLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
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.buildStatus = data.status;
|
||||||
this.$nextTick(() => {
|
if (changed && this._buildAutoScroll) {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
this.$nextTick(() => {
|
||||||
});
|
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.buildLogs = "Failed to fetch logs";
|
this.buildLogs = "Failed to fetch logs";
|
||||||
}
|
}
|
||||||
@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
logs: "",
|
logs: "",
|
||||||
status: config.status || "",
|
status: config.status || "",
|
||||||
pollInterval: null,
|
pollInterval: null,
|
||||||
|
_autoScroll: true,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Read initial logs from script tag (avoids escaping issues)
|
// Read initial logs from script tag (avoids escaping issues)
|
||||||
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
||||||
this.logs = initialLogsEl?.textContent || "Loading...";
|
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
|
// Only poll if deployment is in progress
|
||||||
if (Alpine.store("utils").isDeploying(this.status)) {
|
if (Alpine.store("utils").isDeploying(this.status)) {
|
||||||
this.fetchLogs();
|
this.fetchLogs();
|
||||||
@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.logs = newLogs;
|
this.logs = newLogs;
|
||||||
this.status = data.status;
|
this.status = data.status;
|
||||||
|
|
||||||
// Scroll to bottom only when content changes
|
// Scroll to bottom only when content changes AND user hasn't scrolled up
|
||||||
if (logsChanged) {
|
if (logsChanged && this._autoScroll) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -279,8 +279,17 @@
|
|||||||
<h2 class="section-title">Container Logs</h2>
|
<h2 class="section-title">Container Logs</h2>
|
||||||
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="containerLogs"></pre>
|
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_containerAutoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -329,8 +338,17 @@
|
|||||||
<h2 class="section-title">Last Deployment Build Logs</h2>
|
<h2 class="section-title">Last Deployment Build Logs</h2>
|
||||||
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="buildLogs"></pre>
|
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_buildAutoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -85,8 +85,17 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
<div class="relative">
|
||||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="logs"></pre>
|
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
|
||||||
|
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="logs"></pre>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
x-show="!_autoScroll"
|
||||||
|
x-transition
|
||||||
|
@click="_autoScroll = true; Alpine.store('utils').scrollToBottom($refs.logsWrapper)"
|
||||||
|
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
|
||||||
|
title="Scroll to bottom"
|
||||||
|
>↓ Follow</button>
|
||||||
</div>
|
</div>
|
||||||
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user