- 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)
668 lines
23 KiB
JavaScript
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,
|
|
};
|
|
})();
|