Fix realtime build logs scrolling in deployment history

- Reload page when new deployment starts but no card exists in DOM
- Only update logs content when it changes to avoid unnecessary reflows
- Use double requestAnimationFrame for reliable scroll-to-bottom timing
This commit is contained in:
Jeffrey Paul 2026-01-01 06:10:25 -08:00
parent 2cbcd3d72a
commit 2b63219f66

View File

@ -22,9 +22,7 @@ document.addEventListener("alpine:init", () => {
if (diffSec < 60) return "just now";
if (diffMin < 60)
return (
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
@ -36,8 +34,7 @@ document.addEventListener("alpine:init", () => {
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success")
return "badge-success";
if (status === "running" || status === "success") return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
@ -218,9 +215,7 @@ document.addEventListener("alpine:init", () => {
this.containerLogs = data.logs || "No logs available";
this.containerStatus = data.status;
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(
this.$refs.containerLogsWrapper,
);
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
});
} catch (err) {
this.containerLogs = "Failed to fetch logs";
@ -237,9 +232,7 @@ document.addEventListener("alpine:init", () => {
this.buildLogs = data.logs || "No build logs available";
this.buildStatus = data.status;
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(
this.$refs.buildLogsWrapper,
);
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
});
} catch (err) {
this.buildLogs = "Failed to fetch logs";
@ -248,9 +241,7 @@ document.addEventListener("alpine:init", () => {
async fetchRecentDeployments() {
try {
const res = await fetch(
`/apps/${this.appId}/recent-deployments`,
);
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
@ -283,8 +274,7 @@ document.addEventListener("alpine:init", () => {
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) +
" text-xs"
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
);
},
@ -337,14 +327,25 @@ document.addEventListener("alpine:init", () => {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
const deploying = Alpine.store("utils").isDeploying(
data.status,
);
const deploying = Alpine.store("utils").isDeploying(data.status);
// Detect new deployment
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
// Check if we have a card for this deployment
const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`,
);
if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it
window.location.reload();
return;
}
this.currentDeploymentId = data.latestDeploymentID;
if (deploying) {
this.isDeploying = true;
@ -376,19 +377,31 @@ document.addEventListener("alpine:init", () => {
if (card) {
const logsContent = card.querySelector(".logs-content");
const logsWrapper = card.querySelector(".logs-wrapper");
const statusBadge =
card.querySelector(".deployment-status");
if (logsContent)
logsContent.textContent = data.logs || "Loading...";
if (logsWrapper)
Alpine.store("utils").scrollToBottom(logsWrapper);
const statusBadge = card.querySelector(".deployment-status");
if (logsContent) {
const newLogs = data.logs || "Loading...";
// Only update if content changed to avoid unnecessary reflows
if (logsContent.textContent !== newLogs) {
logsContent.textContent = newLogs;
// Scroll after content update - use double rAF to ensure DOM is ready
if (logsWrapper) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
logsWrapper.scrollTop = logsWrapper.scrollHeight;
});
});
}
}
}
if (statusBadge) {
statusBadge.className =
"deployment-status " +
Alpine.store("utils").statusBadgeClass(data.status);
statusBadge.textContent = Alpine.store(
"utils",
).statusLabel(data.status);
statusBadge.textContent = Alpine.store("utils").statusLabel(
data.status,
);
}
}
} catch (err) {
@ -415,8 +428,7 @@ document.addEventListener("alpine:init", () => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent =
Alpine.store("utils").formatRelativeTime(time);
el.textContent = Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);