/** * upaas - Frontend JavaScript with Alpine.js */ 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); 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"; }, /** * 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"; }, /** * Scroll an element to the bottom */ scrollToBottom(el) { if (el) { requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); } }, /** * Check if a scrollable element is at (or near) the bottom. * Tolerance of 30px accounts for rounding and partial lines. */ isScrolledToBottom(el, tolerance = 30) { if (!el) return true; return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance; }, /** * 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); } }, })); // ============================================ // 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); }, })); // ============================================ // 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: [], // Track whether user wants auto-scroll (per log pane) _containerAutoScroll: true, _buildAutoScroll: true, _pollTimer: null, init() { this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.fetchAll(); this._schedulePoll(); // Set up scroll listeners after DOM is ready this.$nextTick(() => { this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll'); this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll'); }); }, _schedulePoll() { if (this._pollTimer) clearTimeout(this._pollTimer); const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000; this._pollTimer = setTimeout(() => { this.fetchAll(); this._schedulePoll(); }, interval); }, _initScrollTracking(el, flag) { if (!el) return; el.addEventListener('scroll', () => { this[flag] = Alpine.store("utils").isScrolledToBottom(el); }, { passive: true }); }, fetchAll() { this.fetchAppStatus(); // Only fetch logs when the respective pane is visible if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) { this.fetchContainerLogs(); } if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) { this.fetchBuildLogs(); } this.fetchRecentDeployments(); }, _isElementVisible(el) { if (!el) return false; // Check if element is in viewport (roughly) const rect = el.getBoundingClientRect(); return rect.bottom > 0 && rect.top < window.innerHeight; }, async fetchAppStatus() { try { const res = await fetch(`/apps/${this.appId}/status`); const data = await res.json(); const wasDeploying = this.deploying; this.appStatus = data.status; this.deploying = Alpine.store("utils").isDeploying(data.status); // Re-schedule polling when deployment state changes if (this.deploying !== wasDeploying) { this._schedulePoll(); } 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(); const newLogs = data.logs || "No logs available"; const changed = newLogs !== this.containerLogs; this.containerLogs = newLogs; this.containerStatus = data.status; if (changed && this._containerAutoScroll) { 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(); const newLogs = data.logs || "No build logs available"; const changed = newLogs !== this.buildLogs; this.buildLogs = newLogs; this.buildStatus = data.status; if (changed && this._buildAutoScroll) { 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); } }, submitDeploy() { this.deploying = true; }, get statusBadgeClass() { return Alpine.store("utils").statusBadgeClass(this.appStatus); }, get statusLabel() { return Alpine.store("utils").statusLabel(this.appStatus); }, get containerStatusBadgeClass() { return ( Alpine.store("utils").statusBadgeClass(this.containerStatus) + " text-xs" ); }, get containerStatusLabel() { return Alpine.store("utils").statusLabel(this.containerStatus); }, get buildStatusBadgeClass() { return ( Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs" ); }, get buildStatusLabel() { return Alpine.store("utils").statusLabel(this.buildStatus); }, deploymentStatusClass(status) { return Alpine.store("utils").statusBadgeClass(status); }, deploymentStatusLabel(status) { return Alpine.store("utils").statusLabel(status); }, formatTime(isoTime) { return Alpine.store("utils").formatRelativeTime(isoTime); }, })); // ============================================ // Deployment Card Component (for individual deployment cards) // ============================================ Alpine.data("deploymentCard", (config) => ({ appId: config.appId, deploymentId: config.deploymentId, logs: "", status: config.status || "", pollInterval: null, _autoScroll: true, init() { // Read initial logs from script tag (avoids escaping issues) const initialLogsEl = this.$el.querySelector(".initial-logs"); this.logs = initialLogsEl?.textContent || "Loading..."; // Set up scroll tracking this.$nextTick(() => { const wrapper = this.$refs.logsWrapper; if (wrapper) { wrapper.addEventListener('scroll', () => { this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper); }, { passive: true }); } }); // Only poll if deployment is in progress if (Alpine.store("utils").isDeploying(this.status)) { this.fetchLogs(); this.pollInterval = setInterval(() => this.fetchLogs(), 1000); } }, destroy() { if (this.pollInterval) { clearInterval(this.pollInterval); } }, async fetchLogs() { try { const res = await fetch( `/apps/${this.appId}/deployments/${this.deploymentId}/logs`, ); const data = await res.json(); const newLogs = data.logs || "No logs available"; const logsChanged = newLogs !== this.logs; this.logs = newLogs; this.status = data.status; // Scroll to bottom only when content changes AND user hasn't scrolled up if (logsChanged && this._autoScroll) { this.$nextTick(() => { Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); }); } // Stop polling if deployment is done if (!Alpine.store("utils").isDeploying(data.status)) { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } // Reload page to show final state with duration etc window.location.reload(); } } catch (err) { console.error("Logs fetch error:", err); } }, get statusBadgeClass() { return Alpine.store("utils").statusBadgeClass(this.status); }, get statusLabel() { return Alpine.store("utils").statusLabel(this.status); }, })); // ============================================ // 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; } this.fetchAppStatus(); this._scheduleStatusPoll(); }, _statusPollTimer: null, _scheduleStatusPoll() { if (this._statusPollTimer) clearTimeout(this._statusPollTimer); const interval = this.isDeploying ? 1000 : 10000; this._statusPollTimer = setTimeout(() => { this.fetchAppStatus(); this._scheduleStatusPoll(); }, interval); }, async fetchAppStatus() { try { const res = await fetch(`/apps/${this.appId}/status`); const data = await res.json(); // Use deployment status, not app status - it's more reliable during transitions const deploying = Alpine.store("utils").isDeploying( data.latestDeploymentStatus, ); // 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}"]`, ); if (deploying && !hasCard) { // New deployment started but no card exists - reload to show it window.location.reload(); return; } this.currentDeploymentId = data.latestDeploymentID; } // Update deploying state based on latest deployment status if (deploying && !this.isDeploying) { this.isDeploying = true; this._scheduleStatusPoll(); // Switch to fast polling } else if (!deploying && this.isDeploying) { // Deployment finished - reload to show final state this.isDeploying = false; window.location.reload(); } } catch (err) { console.error("Status 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); 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); } }); });