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