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

@ -3,462 +3,474 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
// ============================================ // ============================================
// Global Utilities Store // Global Utilities Store
// ============================================ // ============================================
Alpine.store("utils", { Alpine.store("utils", {
/** /**
* Format a date string as relative time (e.g., "5 minutes ago") * Format a date string as relative time (e.g., "5 minutes ago")
*/ */
formatRelativeTime(dateStr) { formatRelativeTime(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
const diffMs = now - date; const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000); const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60); const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60); const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24); const diffDay = Math.floor(diffHour / 24);
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)
); return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffHour < 24) if (diffDay < 7)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago"); return diffDay + (diffDay === 1 ? " day ago" : " days ago");
if (diffDay < 7) return date.toLocaleDateString();
return diffDay + (diffDay === 1 ? " day ago" : " days ago"); },
return date.toLocaleDateString();
},
/** /**
* 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"; return "badge-neutral";
return "badge-neutral"; },
},
/** /**
* Format status for display (capitalize first letter) * Format status for display (capitalize first letter)
*/ */
statusLabel(status) { statusLabel(status) {
if (!status) return ""; if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1); return status.charAt(0).toUpperCase() + status.slice(1);
}, },
/** /**
* Check if status indicates active deployment * Check if status indicates active deployment
*/ */
isDeploying(status) { isDeploying(status) {
return status === "building" || status === "deploying"; return status === "building" || status === "deploying";
}, },
/** /**
* Scroll an element to the bottom * Scroll an element to the bottom
*/ */
scrollToBottom(el) { scrollToBottom(el) {
if (el) { if (el) {
// Use requestAnimationFrame for smoother scrolling after DOM update // Use requestAnimationFrame for smoother scrolling after DOM update
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
}); });
} }
}, },
/** /**
* Copy text to clipboard * Copy text to clipboard
*/ */
async copyToClipboard(text, button) { async copyToClipboard(text, button) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} catch (err) { } catch (err) {
// Fallback for older browsers // Fallback for older browsers
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
textArea.value = text; textArea.value = text;
textArea.style.position = "fixed"; textArea.style.position = "fixed";
textArea.style.left = "-9999px"; textArea.style.left = "-9999px";
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
try { try {
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textArea); document.body.removeChild(textArea);
return true; return true;
} catch (e) { } catch (e) {
document.body.removeChild(textArea); document.body.removeChild(textArea);
return false; return false;
} }
} }
}, },
}); });
// ============================================ // ============================================
// Copy Button Component // Copy Button Component
// ============================================ // ============================================
Alpine.data("copyButton", (targetId) => ({ Alpine.data("copyButton", (targetId) => ({
copied: false, copied: false,
async copy() { async copy() {
const target = document.getElementById(targetId); const target = document.getElementById(targetId);
if (!target) return; if (!target) return;
const text = target.textContent || target.value; const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text); const success = await Alpine.store("utils").copyToClipboard(text);
if (success) { if (success) {
this.copied = true; this.copied = true;
setTimeout(() => { setTimeout(() => {
this.copied = false; this.copied = false;
}, 2000); }, 2000);
} }
}, },
})); }));
// ============================================ // ============================================
// Confirm Action Component // Confirm Action Component
// ============================================ // ============================================
Alpine.data("confirmAction", (message) => ({ Alpine.data("confirmAction", (message) => ({
confirm(event) { confirm(event) {
if (!window.confirm(message)) { if (!window.confirm(message)) {
event.preventDefault(); event.preventDefault();
} }
}, },
})); }));
// ============================================ // ============================================
// Auto-dismiss Alert Component // Auto-dismiss Alert Component
// ============================================ // ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({ Alpine.data("autoDismiss", (delay = 5000) => ({
show: true, show: true,
init() { init() {
setTimeout(() => { setTimeout(() => {
this.dismiss(); this.dismiss();
}, delay); }, delay);
}, },
dismiss() { dismiss() {
this.show = false; this.show = false;
setTimeout(() => { setTimeout(() => {
this.$el.remove(); this.$el.remove();
}, 300); }, 300);
}, },
})); }));
// ============================================ // ============================================
// Relative Time Component // Relative Time Component
// ============================================ // ============================================
Alpine.data("relativeTime", (isoTime) => ({ Alpine.data("relativeTime", (isoTime) => ({
display: "", display: "",
init() { init() {
this.update(); this.update();
// Update every minute // Update every minute
setInterval(() => this.update(), 60000); setInterval(() => this.update(), 60000);
}, },
update() { update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime); this.display = Alpine.store("utils").formatRelativeTime(isoTime);
}, },
})); }));
// ============================================ // ============================================
// App Detail Page Component // App Detail Page Component
// ============================================ // ============================================
Alpine.data("appDetail", (config) => ({ Alpine.data("appDetail", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: config.initialDeploymentId, currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown", appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...", containerLogs: "Loading container logs...",
containerStatus: "unknown", containerStatus: "unknown",
buildLogs: config.initialDeploymentId buildLogs: config.initialDeploymentId
? "Loading build logs..." ? "Loading build logs..."
: "No deployments yet", : "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown", buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId, showBuildLogs: !!config.initialDeploymentId,
deploying: false, deploying: false,
deployments: [], deployments: [],
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);
}, },
fetchAll() { fetchAll() {
this.fetchAppStatus(); this.fetchAppStatus();
this.fetchContainerLogs(); this.fetchContainerLogs();
this.fetchBuildLogs(); this.fetchBuildLogs();
this.fetchRecentDeployments(); this.fetchRecentDeployments();
}, },
async fetchAppStatus() { async fetchAppStatus() {
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();
this.appStatus = data.status; this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status); this.deploying = Alpine.store("utils").isDeploying(data.status);
if ( if (
data.latestDeploymentID && data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId data.latestDeploymentID !== this.currentDeploymentId
) { ) {
this.currentDeploymentId = data.latestDeploymentID; this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true; this.showBuildLogs = true;
this.fetchBuildLogs(); this.fetchBuildLogs();
} }
} catch (err) { } catch (err) {
console.error("Status fetch error:", err); console.error("Status fetch error:", err);
} }
}, },
async fetchContainerLogs() { async fetchContainerLogs() {
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"; 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) {
}); this.containerLogs = "Failed to fetch logs";
} catch (err) { }
this.containerLogs = "Failed to fetch logs"; },
}
},
async fetchBuildLogs() { async fetchBuildLogs() {
if (!this.currentDeploymentId) return; if (!this.currentDeploymentId) return;
try { try {
const res = await fetch( const res = await fetch(
`/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"; 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) {
}); this.buildLogs = "Failed to fetch logs";
} catch (err) { }
this.buildLogs = "Failed to fetch logs"; },
}
},
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();
); this.deployments = data.deployments || [];
const data = await res.json(); } catch (err) {
this.deployments = data.deployments || []; console.error("Deployments fetch error:", err);
} catch (err) { }
console.error("Deployments fetch error:", err); },
}
},
submitDeploy() { submitDeploy() {
this.deploying = true; this.deploying = true;
}, },
get statusBadgeClass() { get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus); return Alpine.store("utils").statusBadgeClass(this.appStatus);
}, },
get statusLabel() { get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus); return Alpine.store("utils").statusLabel(this.appStatus);
}, },
get containerStatusBadgeClass() { get containerStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) + Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs" " text-xs"
); );
}, },
get containerStatusLabel() { get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus); return Alpine.store("utils").statusLabel(this.containerStatus);
}, },
get buildStatusBadgeClass() { get buildStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
" text-xs" );
); },
},
get buildStatusLabel() { get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus); return Alpine.store("utils").statusLabel(this.buildStatus);
}, },
deploymentStatusClass(status) { deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status); return Alpine.store("utils").statusBadgeClass(status);
}, },
deploymentStatusLabel(status) { deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status); return Alpine.store("utils").statusLabel(status);
}, },
formatTime(isoTime) { formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime); return Alpine.store("utils").formatRelativeTime(isoTime);
}, },
})); }));
// ============================================ // ============================================
// Deployments History Page Component // Deployments History Page Component
// ============================================ // ============================================
Alpine.data("deploymentsPage", (config) => ({ Alpine.data("deploymentsPage", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: null, currentDeploymentId: null,
isDeploying: false, isDeploying: false,
init() { init() {
// Check for in-progress deployments on page load // Check for in-progress deployments on page load
const inProgressCard = document.querySelector( const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]', '[data-status="building"], [data-status="deploying"]',
); );
if (inProgressCard) { if (inProgressCard) {
this.currentDeploymentId = parseInt( this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"), inProgressCard.getAttribute("data-deployment-id"),
10, 10,
); );
this.isDeploying = true; this.isDeploying = true;
} }
this.fetchAppStatus(); this.fetchAppStatus();
setInterval(() => { setInterval(() => {
this.fetchAppStatus(); this.fetchAppStatus();
this.fetchDeploymentLogs(); this.fetchDeploymentLogs();
}, 1000); }, 1000);
}, },
async fetchAppStatus() { async fetchAppStatus() {
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,
);
if ( // Detect new deployment
data.latestDeploymentID && if (
data.latestDeploymentID !== this.currentDeploymentId data.latestDeploymentID &&
) { data.latestDeploymentID !== this.currentDeploymentId
this.currentDeploymentId = data.latestDeploymentID; ) {
if (deploying) { // Check if we have a card for this deployment
this.isDeploying = true; const hasCard = document.querySelector(
} `[data-deployment-id="${data.latestDeploymentID}"]`,
} );
// Reload page when deployment finishes if (deploying && !hasCard) {
if (!deploying && this.isDeploying) { // New deployment started but no card exists - reload to show it
this.isDeploying = false; window.location.reload();
window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
async fetchDeploymentLogs() { return;
if (!this.currentDeploymentId || !this.isDeploying) return; }
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
// Update the deployment card this.currentDeploymentId = data.latestDeploymentID;
const card = document.querySelector( if (deploying) {
`[data-deployment-id="${this.currentDeploymentId}"]`,
);
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);
if (statusBadge) {
statusBadge.className =
"deployment-status " +
Alpine.store("utils").statusBadgeClass(data.status);
statusBadge.textContent = Alpine.store(
"utils",
).statusLabel(data.status);
}
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true; this.isDeploying = true;
}, }
}
formatTime(isoTime) { // Reload page when deployment finishes
return Alpine.store("utils").formatRelativeTime(isoTime); if (!deploying && this.isDeploying) {
}, this.isDeploying = false;
})); window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
// ============================================ async fetchDeploymentLogs() {
// Dashboard Page - Relative Time Updates if (!this.currentDeploymentId || !this.isDeploying) return;
// ============================================ try {
Alpine.data("dashboard", () => ({ const res = await fetch(
init() { `/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
// Update relative times every minute );
setInterval(() => { const data = await res.json();
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); // Update the deployment card
if (time) { const card = document.querySelector(
el.textContent = `[data-deployment-id="${this.currentDeploymentId}"]`,
Alpine.store("utils").formatRelativeTime(time); );
} if (card) {
const logsContent = card.querySelector(".logs-content");
const logsWrapper = card.querySelector(".logs-wrapper");
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;
});
}); });
}, 60000); }
}, }
})); }
if (statusBadge) {
statusBadge.className =
"deployment-status " +
Alpine.store("utils").statusBadgeClass(data.status);
statusBadge.textContent = Alpine.store("utils").statusLabel(
data.status,
);
}
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true;
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// Dashboard Page - Relative Time Updates
// ============================================
Alpine.data("dashboard", () => ({
init() {
// Update relative times every minute
setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);
},
}));
}); });
// ============================================ // ============================================
// Legacy support - expose utilities globally // Legacy support - expose utilities globally
// ============================================ // ============================================
window.upaas = { window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js // These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) { formatRelativeTime(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
const diffMs = now - date; const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000); const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60); const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60); const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24); const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now"; if (diffSec < 60) return "just now";
if (diffMin < 60) if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago"); return 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)
return diffDay + (diffDay === 1 ? " day ago" : " days ago"); return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString(); return date.toLocaleDateString();
}, },
// Placeholder functions - templates should migrate to Alpine.js // Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {}, initAppDetailPage() {},
initDeploymentsPage() {}, initDeploymentsPage() {},
}; };
// Update relative times on page load for non-Alpine elements // Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => { document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = window.upaas.formatRelativeTime(time); el.textContent = window.upaas.formatRelativeTime(time);
} }
}); });
}); });