upaas/static/js/app.js
sneak ab7e917b03 Add real-time deployment updates and refactor JavaScript
- Add deploy stats (last deploy time, total count) to dashboard
- Add recent-deployments API endpoint for real-time updates
- Add live build logs to deployments history page
- Fix git clone regression (preserve entrypoint for simple clones)
- Refactor JavaScript into shared app.js with page init functions
- Deploy button disables immediately on click
- Auto-refresh deployment list and logs during builds
- Format JavaScript with Prettier (4-space indent)
2026-01-01 05:22:56 +07:00

668 lines
23 KiB
JavaScript

/**
* upaas - Frontend JavaScript utilities
* Vanilla JS, no dependencies
*/
(function () {
"use strict";
/**
* Copy text to clipboard
* @param {string} text - Text to copy
* @param {HTMLElement} button - Button element to update feedback
*/
function copyToClipboard(text, button) {
const originalText = button.textContent;
const originalTitle = button.getAttribute("title");
navigator.clipboard
.writeText(text)
.then(function () {
// Success feedback
button.textContent = "Copied!";
button.classList.add("text-success-500");
setTimeout(function () {
button.textContent = originalText;
button.classList.remove("text-success-500");
if (originalTitle) {
button.setAttribute("title", originalTitle);
}
}, 2000);
})
.catch(function (err) {
// Fallback for older browsers
console.error("Failed to copy:", err);
// Try fallback method
var textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
button.textContent = "Copied!";
setTimeout(function () {
button.textContent = originalText;
}, 2000);
} catch (e) {
button.textContent = "Failed";
setTimeout(function () {
button.textContent = originalText;
}, 2000);
}
document.body.removeChild(textArea);
});
}
/**
* Initialize copy buttons
* Looks for elements with data-copy attribute
*/
function initCopyButtons() {
var copyButtons = document.querySelectorAll("[data-copy]");
copyButtons.forEach(function (button) {
button.addEventListener("click", function (e) {
e.preventDefault();
var text = button.getAttribute("data-copy");
copyToClipboard(text, button);
});
});
// Also handle buttons that copy content from a sibling element
var copyTargetButtons = document.querySelectorAll("[data-copy-target]");
copyTargetButtons.forEach(function (button) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute("data-copy-target");
var target = document.getElementById(targetId);
if (target) {
var text = target.textContent || target.value;
copyToClipboard(text, button);
}
});
});
}
/**
* Confirm destructive actions
* Looks for forms with data-confirm attribute
*/
function initConfirmations() {
var confirmForms = document.querySelectorAll("form[data-confirm]");
confirmForms.forEach(function (form) {
form.addEventListener("submit", function (e) {
var message = form.getAttribute("data-confirm");
if (!confirm(message)) {
e.preventDefault();
}
});
});
// Also handle buttons with data-confirm
var confirmButtons = document.querySelectorAll("button[data-confirm]");
confirmButtons.forEach(function (button) {
button.addEventListener("click", function (e) {
var message = button.getAttribute("data-confirm");
if (!confirm(message)) {
e.preventDefault();
}
});
});
}
/**
* Toggle visibility of elements
* Looks for buttons with data-toggle attribute
*/
function initToggles() {
var toggleButtons = document.querySelectorAll("[data-toggle]");
toggleButtons.forEach(function (button) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute("data-toggle");
var target = document.getElementById(targetId);
if (target) {
target.classList.toggle("hidden");
// Update button text if data-toggle-text is provided
var toggleText = button.getAttribute("data-toggle-text");
if (toggleText) {
var currentText = button.textContent;
button.textContent = toggleText;
button.setAttribute("data-toggle-text", currentText);
}
}
});
});
}
/**
* Auto-dismiss alerts after a delay
* Looks for elements with data-auto-dismiss attribute
*/
function initAutoDismiss() {
var dismissElements = document.querySelectorAll("[data-auto-dismiss]");
dismissElements.forEach(function (element) {
var delay =
parseInt(element.getAttribute("data-auto-dismiss"), 10) || 5000;
setTimeout(function () {
element.style.transition = "opacity 0.3s ease-out";
element.style.opacity = "0";
setTimeout(function () {
element.remove();
}, 300);
}, delay);
});
}
/**
* Manual dismiss for alerts
* Looks for buttons with data-dismiss attribute
*/
function initDismissButtons() {
var dismissButtons = document.querySelectorAll("[data-dismiss]");
dismissButtons.forEach(function (button) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute("data-dismiss");
var target = targetId
? document.getElementById(targetId)
: button.closest(".alert");
if (target) {
target.style.transition = "opacity 0.3s ease-out";
target.style.opacity = "0";
setTimeout(function () {
target.remove();
}, 300);
}
});
});
}
/**
* Initialize all features when DOM is ready
*/
function init() {
initCopyButtons();
initConfirmations();
initToggles();
initAutoDismiss();
initDismissButtons();
}
// Run on DOM ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// ============================================
// Deployment & Status Utilities
// ============================================
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
function formatRelativeTime(dateStr) {
var date = new Date(dateStr);
var now = new Date();
var diffMs = now - date;
var diffSec = Math.floor(diffMs / 1000);
var diffMin = Math.floor(diffSec / 60);
var diffHour = Math.floor(diffMin / 60);
var diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
}
/**
* Update all elements with class 'relative-time' to show relative times
*/
function updateRelativeTimes() {
document.querySelectorAll(".relative-time").forEach(function (el) {
var time = el.getAttribute("data-time");
if (time) {
el.textContent = formatRelativeTime(time);
}
});
}
/**
* Get the badge class for a given status
*/
function statusBadgeClass(status) {
if (status === "running" || status === "success")
return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
return "badge-neutral";
}
/**
* Format status for display (capitalize first letter)
*/
function statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
}
/**
* Update a status badge element
*/
function updateStatusBadge(el, status, extraClasses) {
if (!el) return;
el.className =
statusBadgeClass(status) + (extraClasses ? " " + extraClasses : "");
el.textContent = statusLabel(status);
}
/**
* Scroll an element to the bottom
*/
function scrollToBottom(el) {
if (el) {
el.scrollTop = el.scrollHeight;
}
}
/**
* Disable a deploy button immediately
*/
function disableDeployButton(btn, btnText) {
if (!btn) return;
btn.disabled = true;
btn.classList.add("opacity-50", "cursor-not-allowed");
if (btnText) {
btnText.textContent = "Deploying...";
} else {
btn.textContent = "Deploying...";
}
}
/**
* Enable a deploy button
*/
function enableDeployButton(btn, btnText) {
if (!btn) return;
btn.disabled = false;
btn.classList.remove("opacity-50", "cursor-not-allowed");
if (btnText) {
btnText.textContent = "Deploy Now";
} else {
btn.textContent = "Deploy Now";
}
}
/**
* Update deploy button based on status
*/
function updateDeployButton(btn, btnText, status) {
var isDeploying = status === "building" || status === "deploying";
if (isDeploying) {
disableDeployButton(btn, btnText);
} else {
enableDeployButton(btn, btnText);
}
}
// ============================================
// App Detail Page
// ============================================
/**
* Initialize app detail page polling and updates
* @param {object} config - Configuration object
* @param {string} config.appId - App ID
* @param {number|null} config.initialDeploymentId - Initial deployment ID or null
*/
function initAppDetailPage(config) {
var appId = config.appId;
var currentDeploymentId = config.initialDeploymentId;
var containerLogsEl = document.getElementById("container-logs");
var containerLogsWrapper = document.getElementById(
"container-logs-wrapper",
);
var containerStatusEl = document.getElementById("container-status");
var buildLogsEl = document.getElementById("build-logs");
var buildLogsWrapper = document.getElementById("build-logs-wrapper");
var buildStatusEl = document.getElementById("build-status");
var buildLogsSection = document.getElementById("build-logs-section");
var appStatusEl = document.getElementById("app-status");
var deployBtn = document.getElementById("deploy-btn");
var deployBtnText = document.getElementById("deploy-btn-text");
// Disable deploy button immediately on form submit
var deployForm = document.getElementById("deploy-form");
if (deployForm && deployBtn) {
deployForm.addEventListener("submit", function () {
disableDeployButton(deployBtn, deployBtnText);
});
}
function fetchAppStatus() {
fetch("/apps/" + appId + "/status")
.then(function (r) {
return r.json();
})
.then(function (data) {
updateStatusBadge(appStatusEl, data.status);
updateDeployButton(deployBtn, deployBtnText, data.status);
if (
data.latestDeploymentID &&
data.latestDeploymentID !== currentDeploymentId
) {
currentDeploymentId = data.latestDeploymentID;
if (buildLogsSection) {
buildLogsSection.style.display = "";
}
fetchBuildLogs();
}
})
.catch(function (err) {
console.error("Status fetch error:", err);
});
}
function fetchContainerLogs() {
fetch("/apps/" + appId + "/container-logs")
.then(function (r) {
return r.json();
})
.then(function (data) {
if (containerLogsEl)
containerLogsEl.textContent =
data.logs || "No logs available";
updateStatusBadge(
containerStatusEl,
data.status,
"text-xs",
);
scrollToBottom(containerLogsWrapper);
})
.catch(function (err) {
if (containerLogsEl)
containerLogsEl.textContent = "Failed to fetch logs";
});
}
function fetchBuildLogs() {
if (!currentDeploymentId || !buildLogsEl) return;
fetch(
"/apps/" +
appId +
"/deployments/" +
currentDeploymentId +
"/logs",
)
.then(function (r) {
return r.json();
})
.then(function (data) {
buildLogsEl.textContent =
data.logs || "No build logs available";
updateStatusBadge(buildStatusEl, data.status, "text-xs");
scrollToBottom(buildLogsWrapper);
})
.catch(function (err) {
buildLogsEl.textContent = "Failed to fetch logs";
});
}
function fetchRecentDeployments() {
fetch("/apps/" + appId + "/recent-deployments")
.then(function (r) {
return r.json();
})
.then(function (data) {
var tbody = document.getElementById("deployments-tbody");
if (!tbody || !data.deployments) return;
if (data.deployments.length === 0) {
tbody.innerHTML =
'<tr><td colspan="4" class="text-gray-500 text-sm">No deployments yet.</td></tr>';
return;
}
var html = "";
for (var i = 0; i < data.deployments.length; i++) {
var d = data.deployments[i];
html += "<tr>";
html +=
'<td class="text-gray-500"><span class="relative-time" data-time="' +
d.finishedAtISO +
'" title="' +
d.finishedAtLabel +
'">' +
d.finishedAtLabel +
"</span></td>";
html +=
'<td class="text-gray-500">' +
(d.duration || "-") +
"</td>";
html +=
'<td><span class="' +
statusBadgeClass(d.status) +
'">' +
statusLabel(d.status) +
"</span></td>";
html +=
'<td class="font-mono text-gray-500 text-xs">' +
d.shortCommit +
"</td>";
html += "</tr>";
}
tbody.innerHTML = html;
updateRelativeTimes();
})
.catch(function (err) {
console.error("Deployments fetch error:", err);
});
}
// Initial fetch and polling
fetchAppStatus();
fetchContainerLogs();
fetchBuildLogs();
fetchRecentDeployments();
setInterval(fetchAppStatus, 1000);
setInterval(fetchContainerLogs, 1000);
setInterval(fetchBuildLogs, 1000);
setInterval(fetchRecentDeployments, 1000);
}
// ============================================
// Deployments History Page
// ============================================
/**
* Initialize deployments page polling and updates
* @param {object} config - Configuration object
* @param {string} config.appId - App ID
*/
function initDeploymentsPage(config) {
var appId = config.appId;
var deployBtn = document.getElementById("deploy-btn");
var deployBtnEmpty = document.getElementById("deploy-btn-empty");
var liveLogsSection = document.getElementById("live-logs-section");
var liveLogsEl = document.getElementById("live-logs");
var liveLogsWrapper = document.getElementById("live-logs-wrapper");
var liveStatusEl = document.getElementById("live-status");
var currentDeploymentId = null;
var isDeploying = false;
// Disable deploy buttons immediately on submit
var deployForm = document.getElementById("deploy-form");
var deployFormEmpty = document.getElementById("deploy-form-empty");
if (deployForm && deployBtn) {
deployForm.addEventListener("submit", function () {
disableDeployButton(deployBtn, null);
isDeploying = true;
if (liveLogsSection) {
liveLogsSection.style.display = "";
if (liveLogsEl)
liveLogsEl.textContent = "Starting deployment...";
updateStatusBadge(liveStatusEl, "building", "text-xs");
}
});
}
if (deployFormEmpty && deployBtnEmpty) {
deployFormEmpty.addEventListener("submit", function () {
disableDeployButton(deployBtnEmpty, null);
});
}
function fetchAppStatus() {
fetch("/apps/" + appId + "/status")
.then(function (r) {
return r.json();
})
.then(function (data) {
var status = data.status;
var deploying =
status === "building" || status === "deploying";
updateDeployButton(deployBtn, null, status);
updateDeployButton(deployBtnEmpty, null, status);
if (
data.latestDeploymentID &&
data.latestDeploymentID !== currentDeploymentId
) {
currentDeploymentId = data.latestDeploymentID;
if (deploying && liveLogsSection) {
liveLogsSection.style.display = "";
isDeploying = true;
}
}
if (!deploying && isDeploying) {
isDeploying = false;
window.location.reload();
}
})
.catch(function (err) {
console.error("Status fetch error:", err);
});
}
function fetchLiveLogs() {
if (!currentDeploymentId || !isDeploying) return;
fetch(
"/apps/" +
appId +
"/deployments/" +
currentDeploymentId +
"/logs",
)
.then(function (r) {
return r.json();
})
.then(function (data) {
if (liveLogsEl)
liveLogsEl.textContent =
data.logs || "Waiting for logs...";
updateStatusBadge(liveStatusEl, data.status, "text-xs");
scrollToBottom(liveLogsWrapper);
// Update matching deployment card if present
var card = document.querySelector(
'[data-deployment-id="' + currentDeploymentId + '"]',
);
if (card) {
var logsContent = card.querySelector(".logs-content");
var logsWrapper = card.querySelector(".logs-wrapper");
var statusBadge =
card.querySelector(".deployment-status");
if (logsContent)
logsContent.textContent = data.logs || "Loading...";
if (logsWrapper) scrollToBottom(logsWrapper);
if (statusBadge)
updateStatusBadge(statusBadge, data.status);
}
})
.catch(function (err) {
console.error("Logs fetch error:", err);
});
}
// Check for in-progress deployments on page load
var inProgressCards = document.querySelectorAll(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCards.length > 0) {
var card = inProgressCards[0];
currentDeploymentId = parseInt(
card.getAttribute("data-deployment-id"),
10,
);
isDeploying = true;
if (liveLogsSection) liveLogsSection.style.display = "";
}
// Initial fetch and polling
fetchAppStatus();
fetchLiveLogs();
setInterval(fetchAppStatus, 1000);
setInterval(fetchLiveLogs, 1000);
}
// Initialize relative times on all pages
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
updateRelativeTimes();
});
} else {
updateRelativeTimes();
}
// Update relative times every minute
setInterval(updateRelativeTimes, 60000);
// Expose API globally
window.upaas = {
copyToClipboard: copyToClipboard,
formatRelativeTime: formatRelativeTime,
updateRelativeTimes: updateRelativeTimes,
statusBadgeClass: statusBadgeClass,
statusLabel: statusLabel,
updateStatusBadge: updateStatusBadge,
scrollToBottom: scrollToBottom,
initAppDetailPage: initAppDetailPage,
initDeploymentsPage: initDeploymentsPage,
};
})();