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