Merge pull request 'rewrite log viewer panes (closes #17)' (#27) from clawbot/upaas:fix/log-viewer-rewrite into main

Reviewed-on: #27
This commit is contained in:
Jeffrey Paul 2026-02-16 05:51:12 +01:00
commit 4bad74081b
3 changed files with 88 additions and 20 deletions

View File

@ -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);
}); });

View File

@ -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>

View File

@ -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>