Fix real-time build log streaming and scroll behavior

- Use line-by-line reading for Docker build output instead of io.Copy
  to ensure each log line is written immediately without buffering
- Add isNearBottom() helper to check scroll position before auto-scroll
- Only auto-scroll logs if user was already near bottom (better UX)
- Use requestAnimationFrame for smoother scroll-to-bottom animation
This commit is contained in:
2025-12-31 14:44:15 -08:00
parent f1cc7d65a6
commit d2f2747ae6
2 changed files with 80 additions and 22 deletions

View File

@@ -59,12 +59,23 @@ document.addEventListener("alpine:init", () => {
return status === "building" || status === "deploying";
},
/**
* Check if element is scrolled near the bottom (within threshold)
*/
isNearBottom(el, threshold = 100) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
},
/**
* Scroll an element to the bottom
*/
scrollToBottom(el) {
if (el) {
el.scrollTop = el.scrollHeight;
// Use requestAnimationFrame for smoother scrolling after DOM update
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
},
@@ -210,14 +221,18 @@ document.addEventListener("alpine:init", () => {
async fetchContainerLogs() {
try {
const wrapper = this.$refs.containerLogsWrapper;
const wasNearBottom =
Alpine.store("utils").isNearBottom(wrapper);
const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json();
this.containerLogs = data.logs || "No logs available";
this.containerStatus = data.status;
this.$nextTick(() => {
const wrapper = this.$refs.containerLogsWrapper;
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
});
if (wasNearBottom) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(wrapper);
});
}
} catch (err) {
this.containerLogs = "Failed to fetch logs";
}
@@ -226,16 +241,20 @@ document.addEventListener("alpine:init", () => {
async fetchBuildLogs() {
if (!this.currentDeploymentId) return;
try {
const wrapper = this.$refs.buildLogsWrapper;
const wasNearBottom =
Alpine.store("utils").isNearBottom(wrapper);
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
this.buildLogs = data.logs || "No build logs available";
this.buildStatus = data.status;
this.$nextTick(() => {
const wrapper = this.$refs.buildLogsWrapper;
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
});
if (wasNearBottom) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(wrapper);
});
}
} catch (err) {
this.buildLogs = "Failed to fetch logs";
}
@@ -364,16 +383,22 @@ document.addEventListener("alpine:init", () => {
async fetchLiveLogs() {
if (!this.currentDeploymentId || !this.isDeploying) return;
try {
const wrapper = this.$refs.liveLogsWrapper;
const wasNearBottom =
Alpine.store("utils").isNearBottom(wrapper);
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
this.liveLogs = data.logs || "Waiting for logs...";
this.liveStatus = data.status;
this.$nextTick(() => {
const wrapper = this.$refs.liveLogsWrapper;
if (wrapper) Alpine.store("utils").scrollToBottom(wrapper);
});
if (wasNearBottom) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(wrapper);
});
}
// Update matching deployment card if present
const card = document.querySelector(
@@ -382,11 +407,13 @@ document.addEventListener("alpine:init", () => {
if (card) {
const logsContent = card.querySelector(".logs-content");
const logsWrapper = card.querySelector(".logs-wrapper");
const cardWasNearBottom =
Alpine.store("utils").isNearBottom(logsWrapper);
const statusBadge =
card.querySelector(".deployment-status");
if (logsContent)
logsContent.textContent = data.logs || "Loading...";
if (logsWrapper)
if (logsWrapper && cardWasNearBottom)
Alpine.store("utils").scrollToBottom(logsWrapper);
if (statusBadge) {
statusBadge.className =