fix: add missing Makefile targets (docker, hooks) and test timeout (#159)
All checks were successful
Check / check (push) Successful in 4s

## Changes

- Add `docker` target (`docker build .`)
- Add `hooks` target (installs pre-commit hook running `make check`)
- Add 30-second timeout to `test` target (`-timeout 30s`)
- Update `.PHONY` to include new targets
- Update README to document all Makefile targets (`fmt-check`, `docker`, `hooks`)
- Run `make fmt` to fix JS formatting via prettier

`docker build .` passes 

closes #136, closes #137

<!-- session: agent:sdlc-manager:subagent:44375174-444b-43bf-a341-2def7ebb9fdf -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #159
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #159.
This commit is contained in:
2026-03-10 01:09:15 +01:00
committed by Jeffrey Paul
parent ab63670043
commit 49ff625ac4
7 changed files with 558 additions and 504 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean .PHONY: all build lint fmt fmt-check test check clean docker hooks
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -22,12 +22,22 @@ fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: test:
go test -v -race -cover ./... go test -v -race -cover -timeout 30s ./...
# Check runs all validation without making changes # Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong # Used by CI and Docker build - fails if anything is wrong
check: fmt-check lint test check: fmt-check lint test
@echo "==> All checks passed!" @echo "==> All checks passed!"
docker:
docker build .
hooks:
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
clean: clean:
rm -rf bin/ rm -rf bin/

View File

@@ -110,11 +110,14 @@ chi Router ──► Middleware Stack ──► Handler
### Commands ### Commands
```bash ```bash
make fmt # Format code make fmt # Format code
make lint # Run comprehensive linting make fmt-check # Check formatting (read-only, fails if unformatted)
make test # Run tests with race detection make lint # Run comprehensive linting
make check # Verify everything passes (lint, test, build, format) make test # Run tests with race detection (30s timeout)
make build # Build binary make check # Verify everything passes (fmt-check, lint, test)
make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs make check)
``` ```
### Commit Requirements ### Commit Requirements

View File

@@ -6,189 +6,215 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("appDetail", (config) => ({ Alpine.data("appDetail", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: config.initialDeploymentId, currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown", appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...", containerLogs: "Loading container logs...",
containerStatus: "unknown", containerStatus: "unknown",
buildLogs: config.initialDeploymentId buildLogs: config.initialDeploymentId
? "Loading build logs..." ? "Loading build logs..."
: "No deployments yet", : "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown", buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId, showBuildLogs: !!config.initialDeploymentId,
deploying: false, deploying: false,
deployments: [], deployments: [],
// Track whether user wants auto-scroll (per log pane) // Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true, _containerAutoScroll: true,
_buildAutoScroll: true, _buildAutoScroll: true,
_pollTimer: null, _pollTimer: null,
init() { init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll(); this.fetchAll();
this._schedulePoll(); this._schedulePoll();
// Set up scroll listeners after DOM is ready // Set up scroll listeners after DOM is ready
this.$nextTick(() => { this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll'); this._initScrollTracking(
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll'); this.$refs.containerLogsWrapper,
}); "_containerAutoScroll",
}, );
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
});
},
_schedulePoll() { _schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer); if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000; const interval = Alpine.store("utils").isDeploying(this.appStatus)
this._pollTimer = setTimeout(() => { ? 1000
this.fetchAll(); : 10000;
this._schedulePoll(); this._pollTimer = setTimeout(() => {
}, interval); this.fetchAll();
}, this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) { _initScrollTracking(el, flag) {
if (!el) return; if (!el) return;
el.addEventListener('scroll', () => { el.addEventListener(
this[flag] = Alpine.store("utils").isScrolledToBottom(el); "scroll",
}, { passive: true }); () => {
}, this[flag] = Alpine.store("utils").isScrolledToBottom(el);
},
{ passive: true },
);
},
fetchAll() { fetchAll() {
this.fetchAppStatus(); this.fetchAppStatus();
// Only fetch logs when the respective pane is visible // Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) { if (
this.fetchContainerLogs(); this.$refs.containerLogsWrapper &&
} this._isElementVisible(this.$refs.containerLogsWrapper)
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) { ) {
this.fetchBuildLogs(); this.fetchContainerLogs();
} }
this.fetchRecentDeployments(); if (
}, this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
},
_isElementVisible(el) { _isElementVisible(el) {
if (!el) return false; if (!el) return false;
// Check if element is in viewport (roughly) // Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight; return rect.bottom > 0 && rect.top < window.innerHeight;
}, },
async fetchAppStatus() { async fetchAppStatus() {
try { try {
const res = await fetch(`/apps/${this.appId}/status`); const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json(); const data = await res.json();
const wasDeploying = this.deploying; const wasDeploying = this.deploying;
this.appStatus = data.status; this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status); this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes // Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) { if (this.deploying !== wasDeploying) {
this._schedulePoll(); this._schedulePoll();
} }
if ( if (
data.latestDeploymentID && data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId data.latestDeploymentID !== this.currentDeploymentId
) { ) {
this.currentDeploymentId = data.latestDeploymentID; this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true; this.showBuildLogs = true;
this.fetchBuildLogs(); this.fetchBuildLogs();
} }
} catch (err) { } catch (err) {
console.error("Status fetch error:", err); console.error("Status fetch error:", err);
} }
}, },
async fetchContainerLogs() { async fetchContainerLogs() {
try { try {
const res = await fetch(`/apps/${this.appId}/container-logs`); const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json(); const data = await res.json();
const newLogs = data.logs || "No logs available"; const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs; const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs; this.containerLogs = newLogs;
this.containerStatus = data.status; this.containerStatus = data.status;
if (changed && this._containerAutoScroll) { if (changed && this._containerAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); Alpine.store("utils").scrollToBottom(
}); this.$refs.containerLogsWrapper,
} );
} catch (err) { });
this.containerLogs = "Failed to fetch logs"; }
} } catch (err) {
}, this.containerLogs = "Failed to fetch logs";
}
},
async fetchBuildLogs() { async fetchBuildLogs() {
if (!this.currentDeploymentId) return; if (!this.currentDeploymentId) return;
try { try {
const res = await fetch( const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`, `/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
); );
const data = await res.json(); const data = await res.json();
const newLogs = data.logs || "No build logs available"; const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs; const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs; this.buildLogs = newLogs;
this.buildStatus = data.status; this.buildStatus = data.status;
if (changed && this._buildAutoScroll) { if (changed && this._buildAutoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); Alpine.store("utils").scrollToBottom(
}); this.$refs.buildLogsWrapper,
} );
} catch (err) { });
this.buildLogs = "Failed to fetch logs"; }
} } catch (err) {
}, this.buildLogs = "Failed to fetch logs";
}
},
async fetchRecentDeployments() { async fetchRecentDeployments() {
try { try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`); const res = await fetch(
const data = await res.json(); `/apps/${this.appId}/recent-deployments`,
this.deployments = data.deployments || []; );
} catch (err) { const data = await res.json();
console.error("Deployments fetch error:", err); this.deployments = data.deployments || [];
} } catch (err) {
}, console.error("Deployments fetch error:", err);
}
},
submitDeploy() { submitDeploy() {
this.deploying = true; this.deploying = true;
}, },
get statusBadgeClass() { get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus); return Alpine.store("utils").statusBadgeClass(this.appStatus);
}, },
get statusLabel() { get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus); return Alpine.store("utils").statusLabel(this.appStatus);
}, },
get containerStatusBadgeClass() { get containerStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) + Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs" " text-xs"
); );
}, },
get containerStatusLabel() { get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus); return Alpine.store("utils").statusLabel(this.containerStatus);
}, },
get buildStatusBadgeClass() { get buildStatusBadgeClass() {
return ( return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs" Alpine.store("utils").statusBadgeClass(this.buildStatus) +
); " text-xs"
}, );
},
get buildStatusLabel() { get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus); return Alpine.store("utils").statusLabel(this.buildStatus);
}, },
deploymentStatusClass(status) { deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status); return Alpine.store("utils").statusBadgeClass(status);
}, },
deploymentStatusLabel(status) { deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status); return Alpine.store("utils").statusLabel(status);
}, },
formatTime(isoTime) { formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime); return Alpine.store("utils").formatRelativeTime(isoTime);
}, },
})); }));
}); });

View File

@@ -6,66 +6,66 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
// ============================================ // ============================================
// Copy Button Component // Copy Button Component
// ============================================ // ============================================
Alpine.data("copyButton", (targetId) => ({ Alpine.data("copyButton", (targetId) => ({
copied: false, copied: false,
async copy() { async copy() {
const target = document.getElementById(targetId); const target = document.getElementById(targetId);
if (!target) return; if (!target) return;
const text = target.textContent || target.value; const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text); const success = await Alpine.store("utils").copyToClipboard(text);
if (success) { if (success) {
this.copied = true; this.copied = true;
setTimeout(() => { setTimeout(() => {
this.copied = false; this.copied = false;
}, 2000); }, 2000);
} }
}, },
})); }));
// ============================================ // ============================================
// Confirm Action Component // Confirm Action Component
// ============================================ // ============================================
Alpine.data("confirmAction", (message) => ({ Alpine.data("confirmAction", (message) => ({
confirm(event) { confirm(event) {
if (!window.confirm(message)) { if (!window.confirm(message)) {
event.preventDefault(); event.preventDefault();
} }
}, },
})); }));
// ============================================ // ============================================
// Auto-dismiss Alert Component // Auto-dismiss Alert Component
// ============================================ // ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({ Alpine.data("autoDismiss", (delay = 5000) => ({
show: true, show: true,
init() { init() {
setTimeout(() => { setTimeout(() => {
this.dismiss(); this.dismiss();
}, delay); }, delay);
}, },
dismiss() { dismiss() {
this.show = false; this.show = false;
setTimeout(() => { setTimeout(() => {
this.$el.remove(); this.$el.remove();
}, 300); }, 300);
}, },
})); }));
// ============================================ // ============================================
// Relative Time Component // Relative Time Component
// ============================================ // ============================================
Alpine.data("relativeTime", (isoTime) => ({ Alpine.data("relativeTime", (isoTime) => ({
display: "", display: "",
init() { init() {
this.update(); this.update();
// Update every minute // Update every minute
setInterval(() => this.update(), 60000); setInterval(() => this.update(), 60000);
}, },
update() { update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime); this.display = Alpine.store("utils").formatRelativeTime(isoTime);
}, },
})); }));
}); });

View File

@@ -5,17 +5,18 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("dashboard", () => ({ Alpine.data("dashboard", () => ({
init() { init() {
// Update relative times every minute // Update relative times every minute
setInterval(() => { setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => { this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time); el.textContent =
} Alpine.store("utils").formatRelativeTime(time);
}); }
}, 60000); });
}, }, 60000);
})); },
}));
}); });

View File

@@ -6,171 +6,180 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
// ============================================ // ============================================
// Deployment Card Component (for individual deployment cards) // Deployment Card Component (for individual deployment cards)
// ============================================ // ============================================
Alpine.data("deploymentCard", (config) => ({ Alpine.data("deploymentCard", (config) => ({
appId: config.appId, appId: config.appId,
deploymentId: config.deploymentId, deploymentId: config.deploymentId,
logs: "", logs: "",
status: config.status || "", status: config.status || "",
pollInterval: null, pollInterval: null,
_autoScroll: true, _autoScroll: true,
init() { init() {
// Read initial logs from script tag (avoids escaping issues) // Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs"); const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading..."; this.logs = initialLogsEl?.dataset.logs || "Loading...";
// Set up scroll tracking // Set up scroll tracking
this.$nextTick(() => { this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper; const wrapper = this.$refs.logsWrapper;
if (wrapper) { if (wrapper) {
wrapper.addEventListener('scroll', () => { wrapper.addEventListener(
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper); "scroll",
}, { passive: true }); () => {
} this._autoScroll =
}); Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
}
});
// Only poll if deployment is in progress // Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) { if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs(); this.fetchLogs();
this.pollInterval = setInterval(() => this.fetchLogs(), 1000); this.pollInterval = setInterval(() => this.fetchLogs(), 1000);
} }
}, },
destroy() { destroy() {
if (this.pollInterval) { if (this.pollInterval) {
clearInterval(this.pollInterval); clearInterval(this.pollInterval);
} }
}, },
async fetchLogs() { async fetchLogs() {
try { try {
const res = await fetch( const res = await fetch(
`/apps/${this.appId}/deployments/${this.deploymentId}/logs`, `/apps/${this.appId}/deployments/${this.deploymentId}/logs`,
); );
const data = await res.json(); const data = await res.json();
const newLogs = data.logs || "No logs available"; const newLogs = data.logs || "No logs available";
const logsChanged = newLogs !== this.logs; const logsChanged = newLogs !== this.logs;
this.logs = newLogs; this.logs = newLogs;
this.status = data.status; this.status = data.status;
// Scroll to bottom only when content changes AND user hasn't scrolled up // Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) { if (logsChanged && this._autoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); Alpine.store("utils").scrollToBottom(
}); this.$refs.logsWrapper,
} );
});
}
// Stop polling if deployment is done // Stop polling if deployment is done
if (!Alpine.store("utils").isDeploying(data.status)) { if (!Alpine.store("utils").isDeploying(data.status)) {
if (this.pollInterval) { if (this.pollInterval) {
clearInterval(this.pollInterval); clearInterval(this.pollInterval);
this.pollInterval = null; this.pollInterval = null;
} }
// Reload page to show final state with duration etc // Reload page to show final state with duration etc
window.location.reload(); window.location.reload();
} }
} catch (err) { } catch (err) {
console.error("Logs fetch error:", err); console.error("Logs fetch error:", err);
} }
}, },
get statusBadgeClass() { get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.status); return Alpine.store("utils").statusBadgeClass(this.status);
}, },
get statusLabel() { get statusLabel() {
return Alpine.store("utils").statusLabel(this.status); return Alpine.store("utils").statusLabel(this.status);
}, },
})); }));
// ============================================ // ============================================
// Deployments History Page Component // Deployments History Page Component
// ============================================ // ============================================
Alpine.data("deploymentsPage", (config) => ({ Alpine.data("deploymentsPage", (config) => ({
appId: config.appId, appId: config.appId,
currentDeploymentId: null, currentDeploymentId: null,
isDeploying: false, isDeploying: false,
init() { init() {
// Check for in-progress deployments on page load // Check for in-progress deployments on page load
const inProgressCard = document.querySelector( const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]', '[data-status="building"], [data-status="deploying"]',
); );
if (inProgressCard) { if (inProgressCard) {
this.currentDeploymentId = parseInt( this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"), inProgressCard.getAttribute("data-deployment-id"),
10, 10,
); );
this.isDeploying = true; this.isDeploying = true;
} }
this.fetchAppStatus(); this.fetchAppStatus();
this._scheduleStatusPoll(); this._scheduleStatusPoll();
}, },
_statusPollTimer: null, _statusPollTimer: null,
_scheduleStatusPoll() { _scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer); if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000; const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => { this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus(); this.fetchAppStatus();
this._scheduleStatusPoll(); this._scheduleStatusPoll();
}, interval); }, interval);
}, },
async fetchAppStatus() { async fetchAppStatus() {
try { try {
const res = await fetch(`/apps/${this.appId}/status`); const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json(); const data = await res.json();
// Use deployment status, not app status - it's more reliable during transitions // Use deployment status, not app status - it's more reliable during transitions
const deploying = Alpine.store("utils").isDeploying( const deploying = Alpine.store("utils").isDeploying(
data.latestDeploymentStatus, data.latestDeploymentStatus,
); );
// Detect new deployment // Detect new deployment
if ( if (
data.latestDeploymentID && data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId data.latestDeploymentID !== this.currentDeploymentId
) { ) {
// Check if we have a card for this deployment // Check if we have a card for this deployment
const hasCard = document.querySelector( const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`, `[data-deployment-id="${data.latestDeploymentID}"]`,
); );
if (deploying && !hasCard) { if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it // New deployment started but no card exists - reload to show it
window.location.reload(); window.location.reload();
return; return;
} }
this.currentDeploymentId = data.latestDeploymentID; this.currentDeploymentId = data.latestDeploymentID;
} }
// Update deploying state based on latest deployment status // Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) { if (deploying && !this.isDeploying) {
this.isDeploying = true; this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) { } else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state // Deployment finished - reload to show final state
this.isDeploying = false; this.isDeploying = false;
window.location.reload(); window.location.reload();
} }
} catch (err) { } catch (err) {
console.error("Status fetch error:", err); console.error("Status fetch error:", err);
} }
}, },
submitDeploy() { submitDeploy() {
this.isDeploying = true; this.isDeploying = true;
}, },
formatTime(isoTime) { formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime); return Alpine.store("utils").formatRelativeTime(isoTime);
}, },
})); }));
}); });

View File

@@ -5,139 +5,144 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.store("utils", { Alpine.store("utils", {
/** /**
* Format a date string as relative time (e.g., "5 minutes ago") * Format a date string as relative time (e.g., "5 minutes ago")
*/ */
formatRelativeTime(dateStr) { formatRelativeTime(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
const diffMs = now - date; const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000); const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60); const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60); const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24); const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now"; if (diffSec < 60) return "just now";
if (diffMin < 60) if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago"); return (
if (diffHour < 24) diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago"); );
if (diffDay < 7) if (diffHour < 24)
return diffDay + (diffDay === 1 ? " day ago" : " days ago"); return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
return date.toLocaleDateString(); if (diffDay < 7)
}, return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
/** /**
* Get the badge class for a given status * Get the badge class for a given status
*/ */
statusBadgeClass(status) { statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success"; if (status === "running" || status === "success")
if (status === "building" || status === "deploying") return "badge-success";
return "badge-warning"; if (status === "building" || status === "deploying")
if (status === "failed" || status === "error") return "badge-error"; return "badge-warning";
return "badge-neutral"; if (status === "failed" || status === "error") return "badge-error";
}, return "badge-neutral";
},
/** /**
* Format status for display (capitalize first letter) * Format status for display (capitalize first letter)
*/ */
statusLabel(status) { statusLabel(status) {
if (!status) return ""; if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1); return status.charAt(0).toUpperCase() + status.slice(1);
}, },
/** /**
* Check if status indicates active deployment * Check if status indicates active deployment
*/ */
isDeploying(status) { isDeploying(status) {
return status === "building" || status === "deploying"; return status === "building" || status === "deploying";
}, },
/** /**
* Scroll an element to the bottom * Scroll an element to the bottom
*/ */
scrollToBottom(el) { scrollToBottom(el) {
if (el) { if (el) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
}); });
} }
}, },
/** /**
* Check if a scrollable element is at (or near) the bottom. * Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines. * Tolerance of 30px accounts for rounding and partial lines.
*/ */
isScrolledToBottom(el, tolerance = 30) { isScrolledToBottom(el, tolerance = 30) {
if (!el) return true; if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance; return (
}, el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
},
/** /**
* Copy text to clipboard * Copy text to clipboard
*/ */
async copyToClipboard(text, button) { async copyToClipboard(text, button) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} catch (err) { } catch (err) {
// Fallback for older browsers // Fallback for older browsers
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
textArea.value = text; textArea.value = text;
textArea.style.position = "fixed"; textArea.style.position = "fixed";
textArea.style.left = "-9999px"; textArea.style.left = "-9999px";
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
try { try {
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textArea); document.body.removeChild(textArea);
return true; return true;
} catch (e) { } catch (e) {
document.body.removeChild(textArea); document.body.removeChild(textArea);
return false; return false;
} }
} }
}, },
}); });
}); });
// ============================================ // ============================================
// Legacy support - expose utilities globally // Legacy support - expose utilities globally
// ============================================ // ============================================
window.upaas = { window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js // These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) { formatRelativeTime(dateStr) {
if (!dateStr) return ""; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
const diffMs = now - date; const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000); const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60); const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60); const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24); const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now"; if (diffSec < 60) return "just now";
if (diffMin < 60) if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago"); return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24) if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago"); return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7) if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago"); return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString(); return date.toLocaleDateString();
}, },
// Placeholder functions - templates should migrate to Alpine.js // Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {}, initAppDetailPage() {},
initDeploymentsPage() {}, initDeploymentsPage() {},
}; };
// Update relative times on page load for non-Alpine elements // Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => { document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = window.upaas.formatRelativeTime(time); el.textContent = window.upaas.formatRelativeTime(time);
} }
}); });
}); });