diff --git a/static/js/app-detail.js b/static/js/app-detail.js new file mode 100644 index 0000000..406763f --- /dev/null +++ b/static/js/app-detail.js @@ -0,0 +1,194 @@ +/** + * upaas - App Detail Page Component + * + * Handles the single-app view: status polling, container logs, + * build logs, and recent deployments list. + */ + +document.addEventListener("alpine:init", () => { + 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); + }, + })); +}); diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100644 index c5f1758..0000000 --- a/static/js/app.js +++ /dev/null @@ -1,581 +0,0 @@ -/** - * 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?.dataset.logs || "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); - } - }); -}); diff --git a/static/js/components.js b/static/js/components.js new file mode 100644 index 0000000..2563b51 --- /dev/null +++ b/static/js/components.js @@ -0,0 +1,71 @@ +/** + * upaas - Reusable Alpine.js Components + * + * Small, self-contained components: copy button, confirm dialog, + * auto-dismiss alerts, and relative time display. + */ + +document.addEventListener("alpine:init", () => { + // ============================================ + // 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); + }, + })); +}); diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..7cbb024 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,21 @@ +/** + * upaas - Dashboard Page Component + * + * Periodically updates relative timestamps on the main dashboard. + */ + +document.addEventListener("alpine:init", () => { + 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); + }, + })); +}); diff --git a/static/js/deployment.js b/static/js/deployment.js new file mode 100644 index 0000000..7f00418 --- /dev/null +++ b/static/js/deployment.js @@ -0,0 +1,176 @@ +/** + * upaas - Deployment Components + * + * Deployment card (individual deployment log viewer) and + * deployments history page (list of all deployments). + */ + +document.addEventListener("alpine:init", () => { + // ============================================ + // 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?.dataset.logs || "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); + }, + })); +}); diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..d2e6141 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,143 @@ +/** + * upaas - Global Utilities Store + * + * Shared formatting, status helpers, and clipboard utilities used across all pages. + */ + +document.addEventListener("alpine:init", () => { + 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; + } + } + }, + }); +}); + +// ============================================ +// 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); + } + }); +}); diff --git a/templates/base.html b/templates/base.html index 3a1e752..6265c72 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,7 +15,11 @@ {{template "footer" .}} - + + + + + {{end}}