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" .}}
-
+
+
+
+
+