Add real-time deployment updates and refactor JavaScript

- Add deploy stats (last deploy time, total count) to dashboard
- Add recent-deployments API endpoint for real-time updates
- Add live build logs to deployments history page
- Fix git clone regression (preserve entrypoint for simple clones)
- Refactor JavaScript into shared app.js with page init functions
- Deploy button disables immediately on click
- Auto-refresh deployment list and logs during builds
- Format JavaScript with Prettier (4-space indent)
This commit is contained in:
Jeffrey Paul 2026-01-01 05:22:56 +07:00
parent 307955dae1
commit ab7e917b03
9 changed files with 837 additions and 398 deletions

View File

@ -554,16 +554,20 @@ func (c *Client) createGitContainer(
// Build the git command based on whether we have a specific commit SHA
var cmd []string
var entrypoint []string
if cfg.commitSHA != "" {
// Clone without depth limit so we can checkout any commit, then checkout specific SHA
// Using sh -c to run multiple commands
// Using sh -c to run multiple commands - need to clear entrypoint
script := fmt.Sprintf(
"git clone --branch %s %s /repo && cd /repo && git checkout %s",
cfg.branch, cfg.repoURL, cfg.commitSHA,
)
entrypoint = []string{}
cmd = []string{"sh", "-c", script}
} else {
// Shallow clone of branch HEAD
// Shallow clone of branch HEAD - use default git entrypoint
entrypoint = nil
cmd = []string{"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo"}
}
@ -571,7 +575,7 @@ func (c *Client) createGitContainer(
resp, err := c.docker.ContainerCreate(ctx,
&container.Config{
Image: gitImage,
Entrypoint: []string{}, // Clear entrypoint when using sh -c
Entrypoint: entrypoint,
Cmd: cmd,
Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
WorkingDir: "/",

View File

@ -537,6 +537,49 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
}
}
// HandleRecentDeploymentsAPI returns JSON with recent deployments.
func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deployments, deployErr := application.GetDeployments(
request.Context(),
recentDeploymentsLimit,
)
if deployErr != nil {
h.log.Error("failed to get deployments", "error", deployErr, "app", appID)
deployments = []*models.Deployment{}
}
// Build response with formatted data
deploymentsData := make([]map[string]any, 0, len(deployments))
for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID,
"status": string(d.Status),
"duration": d.Duration(),
"shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(),
"finishedAtLabel": d.FinishedAtFormatted(),
})
}
writer.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(writer).Encode(map[string]any{
"deployments": deploymentsData,
})
}
}
// containerAction represents a container operation type.
type containerAction string

View File

@ -2,17 +2,29 @@ package handlers
import (
"net/http"
"time"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/templates"
)
// AppStats holds deployment statistics for an app.
type AppStats struct {
App *models.App
LastDeployTime *time.Time
LastDeployISO string
LastDeployLabel string
DeployCount int
}
// HandleDashboard returns the dashboard handler.
func (h *Handlers) HandleDashboard() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
apps, fetchErr := models.AllApps(request.Context(), h.db)
ctx := request.Context()
apps, fetchErr := models.AllApps(ctx, h.db)
if fetchErr != nil {
h.log.Error("failed to fetch apps", "error", fetchErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
@ -20,8 +32,41 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
return
}
// Fetch stats for each app
appStats := make([]*AppStats, 0, len(apps))
for _, app := range apps {
stats := &AppStats{App: app}
// Get deploy count
count, countErr := models.CountDeploymentsByAppID(ctx, h.db, app.ID)
if countErr != nil {
h.log.Error("failed to count deployments", "error", countErr, "app", app.ID)
} else {
stats.DeployCount = count
}
// Get latest deployment
latest, latestErr := models.LatestDeploymentForApp(ctx, h.db, app.ID)
if latestErr != nil {
h.log.Error("failed to get latest deployment", "error", latestErr, "app", app.ID)
} else if latest != nil {
if latest.FinishedAt.Valid {
stats.LastDeployTime = &latest.FinishedAt.Time
stats.LastDeployISO = latest.FinishedAt.Time.Format(time.RFC3339)
stats.LastDeployLabel = latest.FinishedAt.Time.Format("2006-01-02 15:04:05")
} else {
stats.LastDeployTime = &latest.StartedAt
stats.LastDeployISO = latest.StartedAt.Format(time.RFC3339)
stats.LastDeployLabel = latest.StartedAt.Format("2006-01-02 15:04:05")
}
}
appStats = append(appStats, stats)
}
data := h.addGlobals(map[string]any{
"Apps": apps,
"AppStats": appStats,
})
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)

View File

@ -302,3 +302,24 @@ func LatestDeploymentForApp(
return deploy, nil
}
// CountDeploymentsByAppID returns the total number of deployments for an app.
func CountDeploymentsByAppID(
ctx context.Context,
deployDB *database.Database,
appID string,
) (int, error) {
var count int
row := deployDB.QueryRow(ctx,
"SELECT COUNT(*) FROM deployments WHERE app_id = ?",
appID,
)
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting deployments: %w", err)
}
return count, nil
}

View File

@ -69,6 +69,7 @@ func (s *Server) SetupRoutes() {
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())

View File

@ -4,7 +4,7 @@
*/
(function () {
'use strict';
"use strict";
/**
* Copy text to clipboard
@ -13,40 +13,43 @@
*/
function copyToClipboard(text, button) {
const originalText = button.textContent;
const originalTitle = button.getAttribute('title');
const originalTitle = button.getAttribute("title");
navigator.clipboard.writeText(text).then(function() {
navigator.clipboard
.writeText(text)
.then(function () {
// Success feedback
button.textContent = 'Copied!';
button.classList.add('text-success-500');
button.textContent = "Copied!";
button.classList.add("text-success-500");
setTimeout(function () {
button.textContent = originalText;
button.classList.remove('text-success-500');
button.classList.remove("text-success-500");
if (originalTitle) {
button.setAttribute('title', originalTitle);
button.setAttribute("title", originalTitle);
}
}, 2000);
}).catch(function(err) {
})
.catch(function (err) {
// Fallback for older browsers
console.error('Failed to copy:', err);
console.error("Failed to copy:", err);
// Try fallback method
var textArea = document.createElement('textarea');
var textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
button.textContent = 'Copied!';
document.execCommand("copy");
button.textContent = "Copied!";
setTimeout(function () {
button.textContent = originalText;
}, 2000);
} catch (e) {
button.textContent = 'Failed';
button.textContent = "Failed";
setTimeout(function () {
button.textContent = originalText;
}, 2000);
@ -61,23 +64,23 @@
* Looks for elements with data-copy attribute
*/
function initCopyButtons() {
var copyButtons = document.querySelectorAll('[data-copy]');
var copyButtons = document.querySelectorAll("[data-copy]");
copyButtons.forEach(function (button) {
button.addEventListener('click', function(e) {
button.addEventListener("click", function (e) {
e.preventDefault();
var text = button.getAttribute('data-copy');
var text = button.getAttribute("data-copy");
copyToClipboard(text, button);
});
});
// Also handle buttons that copy content from a sibling element
var copyTargetButtons = document.querySelectorAll('[data-copy-target]');
var copyTargetButtons = document.querySelectorAll("[data-copy-target]");
copyTargetButtons.forEach(function (button) {
button.addEventListener('click', function(e) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute('data-copy-target');
var targetId = button.getAttribute("data-copy-target");
var target = document.getElementById(targetId);
if (target) {
var text = target.textContent || target.value;
@ -92,11 +95,11 @@
* Looks for forms with data-confirm attribute
*/
function initConfirmations() {
var confirmForms = document.querySelectorAll('form[data-confirm]');
var confirmForms = document.querySelectorAll("form[data-confirm]");
confirmForms.forEach(function (form) {
form.addEventListener('submit', function(e) {
var message = form.getAttribute('data-confirm');
form.addEventListener("submit", function (e) {
var message = form.getAttribute("data-confirm");
if (!confirm(message)) {
e.preventDefault();
}
@ -104,11 +107,11 @@
});
// Also handle buttons with data-confirm
var confirmButtons = document.querySelectorAll('button[data-confirm]');
var confirmButtons = document.querySelectorAll("button[data-confirm]");
confirmButtons.forEach(function (button) {
button.addEventListener('click', function(e) {
var message = button.getAttribute('data-confirm');
button.addEventListener("click", function (e) {
var message = button.getAttribute("data-confirm");
if (!confirm(message)) {
e.preventDefault();
}
@ -121,22 +124,22 @@
* Looks for buttons with data-toggle attribute
*/
function initToggles() {
var toggleButtons = document.querySelectorAll('[data-toggle]');
var toggleButtons = document.querySelectorAll("[data-toggle]");
toggleButtons.forEach(function (button) {
button.addEventListener('click', function(e) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute('data-toggle');
var targetId = button.getAttribute("data-toggle");
var target = document.getElementById(targetId);
if (target) {
target.classList.toggle('hidden');
target.classList.toggle("hidden");
// Update button text if data-toggle-text is provided
var toggleText = button.getAttribute('data-toggle-text');
var toggleText = button.getAttribute("data-toggle-text");
if (toggleText) {
var currentText = button.textContent;
button.textContent = toggleText;
button.setAttribute('data-toggle-text', currentText);
button.setAttribute("data-toggle-text", currentText);
}
}
});
@ -148,14 +151,15 @@
* Looks for elements with data-auto-dismiss attribute
*/
function initAutoDismiss() {
var dismissElements = document.querySelectorAll('[data-auto-dismiss]');
var dismissElements = document.querySelectorAll("[data-auto-dismiss]");
dismissElements.forEach(function (element) {
var delay = parseInt(element.getAttribute('data-auto-dismiss'), 10) || 5000;
var delay =
parseInt(element.getAttribute("data-auto-dismiss"), 10) || 5000;
setTimeout(function () {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
element.style.transition = "opacity 0.3s ease-out";
element.style.opacity = "0";
setTimeout(function () {
element.remove();
@ -169,17 +173,19 @@
* Looks for buttons with data-dismiss attribute
*/
function initDismissButtons() {
var dismissButtons = document.querySelectorAll('[data-dismiss]');
var dismissButtons = document.querySelectorAll("[data-dismiss]");
dismissButtons.forEach(function (button) {
button.addEventListener('click', function(e) {
button.addEventListener("click", function (e) {
e.preventDefault();
var targetId = button.getAttribute('data-dismiss');
var target = targetId ? document.getElementById(targetId) : button.closest('.alert');
var targetId = button.getAttribute("data-dismiss");
var target = targetId
? document.getElementById(targetId)
: button.closest(".alert");
if (target) {
target.style.transition = 'opacity 0.3s ease-out';
target.style.opacity = '0';
target.style.transition = "opacity 0.3s ease-out";
target.style.opacity = "0";
setTimeout(function () {
target.remove();
@ -201,15 +207,461 @@
}
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Expose copyToClipboard globally for inline onclick handlers if needed
window.upaas = {
copyToClipboard: copyToClipboard
};
// ============================================
// Deployment & Status Utilities
// ============================================
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
function formatRelativeTime(dateStr) {
var date = new Date(dateStr);
var now = new Date();
var diffMs = now - date;
var diffSec = Math.floor(diffMs / 1000);
var diffMin = Math.floor(diffSec / 60);
var diffHour = Math.floor(diffMin / 60);
var 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();
}
/**
* Update all elements with class 'relative-time' to show relative times
*/
function updateRelativeTimes() {
document.querySelectorAll(".relative-time").forEach(function (el) {
var time = el.getAttribute("data-time");
if (time) {
el.textContent = formatRelativeTime(time);
}
});
}
/**
* Get the badge class for a given status
*/
function 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)
*/
function statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
}
/**
* Update a status badge element
*/
function updateStatusBadge(el, status, extraClasses) {
if (!el) return;
el.className =
statusBadgeClass(status) + (extraClasses ? " " + extraClasses : "");
el.textContent = statusLabel(status);
}
/**
* Scroll an element to the bottom
*/
function scrollToBottom(el) {
if (el) {
el.scrollTop = el.scrollHeight;
}
}
/**
* Disable a deploy button immediately
*/
function disableDeployButton(btn, btnText) {
if (!btn) return;
btn.disabled = true;
btn.classList.add("opacity-50", "cursor-not-allowed");
if (btnText) {
btnText.textContent = "Deploying...";
} else {
btn.textContent = "Deploying...";
}
}
/**
* Enable a deploy button
*/
function enableDeployButton(btn, btnText) {
if (!btn) return;
btn.disabled = false;
btn.classList.remove("opacity-50", "cursor-not-allowed");
if (btnText) {
btnText.textContent = "Deploy Now";
} else {
btn.textContent = "Deploy Now";
}
}
/**
* Update deploy button based on status
*/
function updateDeployButton(btn, btnText, status) {
var isDeploying = status === "building" || status === "deploying";
if (isDeploying) {
disableDeployButton(btn, btnText);
} else {
enableDeployButton(btn, btnText);
}
}
// ============================================
// App Detail Page
// ============================================
/**
* Initialize app detail page polling and updates
* @param {object} config - Configuration object
* @param {string} config.appId - App ID
* @param {number|null} config.initialDeploymentId - Initial deployment ID or null
*/
function initAppDetailPage(config) {
var appId = config.appId;
var currentDeploymentId = config.initialDeploymentId;
var containerLogsEl = document.getElementById("container-logs");
var containerLogsWrapper = document.getElementById(
"container-logs-wrapper",
);
var containerStatusEl = document.getElementById("container-status");
var buildLogsEl = document.getElementById("build-logs");
var buildLogsWrapper = document.getElementById("build-logs-wrapper");
var buildStatusEl = document.getElementById("build-status");
var buildLogsSection = document.getElementById("build-logs-section");
var appStatusEl = document.getElementById("app-status");
var deployBtn = document.getElementById("deploy-btn");
var deployBtnText = document.getElementById("deploy-btn-text");
// Disable deploy button immediately on form submit
var deployForm = document.getElementById("deploy-form");
if (deployForm && deployBtn) {
deployForm.addEventListener("submit", function () {
disableDeployButton(deployBtn, deployBtnText);
});
}
function fetchAppStatus() {
fetch("/apps/" + appId + "/status")
.then(function (r) {
return r.json();
})
.then(function (data) {
updateStatusBadge(appStatusEl, data.status);
updateDeployButton(deployBtn, deployBtnText, data.status);
if (
data.latestDeploymentID &&
data.latestDeploymentID !== currentDeploymentId
) {
currentDeploymentId = data.latestDeploymentID;
if (buildLogsSection) {
buildLogsSection.style.display = "";
}
fetchBuildLogs();
}
})
.catch(function (err) {
console.error("Status fetch error:", err);
});
}
function fetchContainerLogs() {
fetch("/apps/" + appId + "/container-logs")
.then(function (r) {
return r.json();
})
.then(function (data) {
if (containerLogsEl)
containerLogsEl.textContent =
data.logs || "No logs available";
updateStatusBadge(
containerStatusEl,
data.status,
"text-xs",
);
scrollToBottom(containerLogsWrapper);
})
.catch(function (err) {
if (containerLogsEl)
containerLogsEl.textContent = "Failed to fetch logs";
});
}
function fetchBuildLogs() {
if (!currentDeploymentId || !buildLogsEl) return;
fetch(
"/apps/" +
appId +
"/deployments/" +
currentDeploymentId +
"/logs",
)
.then(function (r) {
return r.json();
})
.then(function (data) {
buildLogsEl.textContent =
data.logs || "No build logs available";
updateStatusBadge(buildStatusEl, data.status, "text-xs");
scrollToBottom(buildLogsWrapper);
})
.catch(function (err) {
buildLogsEl.textContent = "Failed to fetch logs";
});
}
function fetchRecentDeployments() {
fetch("/apps/" + appId + "/recent-deployments")
.then(function (r) {
return r.json();
})
.then(function (data) {
var tbody = document.getElementById("deployments-tbody");
if (!tbody || !data.deployments) return;
if (data.deployments.length === 0) {
tbody.innerHTML =
'<tr><td colspan="4" class="text-gray-500 text-sm">No deployments yet.</td></tr>';
return;
}
var html = "";
for (var i = 0; i < data.deployments.length; i++) {
var d = data.deployments[i];
html += "<tr>";
html +=
'<td class="text-gray-500"><span class="relative-time" data-time="' +
d.finishedAtISO +
'" title="' +
d.finishedAtLabel +
'">' +
d.finishedAtLabel +
"</span></td>";
html +=
'<td class="text-gray-500">' +
(d.duration || "-") +
"</td>";
html +=
'<td><span class="' +
statusBadgeClass(d.status) +
'">' +
statusLabel(d.status) +
"</span></td>";
html +=
'<td class="font-mono text-gray-500 text-xs">' +
d.shortCommit +
"</td>";
html += "</tr>";
}
tbody.innerHTML = html;
updateRelativeTimes();
})
.catch(function (err) {
console.error("Deployments fetch error:", err);
});
}
// Initial fetch and polling
fetchAppStatus();
fetchContainerLogs();
fetchBuildLogs();
fetchRecentDeployments();
setInterval(fetchAppStatus, 1000);
setInterval(fetchContainerLogs, 1000);
setInterval(fetchBuildLogs, 1000);
setInterval(fetchRecentDeployments, 1000);
}
// ============================================
// Deployments History Page
// ============================================
/**
* Initialize deployments page polling and updates
* @param {object} config - Configuration object
* @param {string} config.appId - App ID
*/
function initDeploymentsPage(config) {
var appId = config.appId;
var deployBtn = document.getElementById("deploy-btn");
var deployBtnEmpty = document.getElementById("deploy-btn-empty");
var liveLogsSection = document.getElementById("live-logs-section");
var liveLogsEl = document.getElementById("live-logs");
var liveLogsWrapper = document.getElementById("live-logs-wrapper");
var liveStatusEl = document.getElementById("live-status");
var currentDeploymentId = null;
var isDeploying = false;
// Disable deploy buttons immediately on submit
var deployForm = document.getElementById("deploy-form");
var deployFormEmpty = document.getElementById("deploy-form-empty");
if (deployForm && deployBtn) {
deployForm.addEventListener("submit", function () {
disableDeployButton(deployBtn, null);
isDeploying = true;
if (liveLogsSection) {
liveLogsSection.style.display = "";
if (liveLogsEl)
liveLogsEl.textContent = "Starting deployment...";
updateStatusBadge(liveStatusEl, "building", "text-xs");
}
});
}
if (deployFormEmpty && deployBtnEmpty) {
deployFormEmpty.addEventListener("submit", function () {
disableDeployButton(deployBtnEmpty, null);
});
}
function fetchAppStatus() {
fetch("/apps/" + appId + "/status")
.then(function (r) {
return r.json();
})
.then(function (data) {
var status = data.status;
var deploying =
status === "building" || status === "deploying";
updateDeployButton(deployBtn, null, status);
updateDeployButton(deployBtnEmpty, null, status);
if (
data.latestDeploymentID &&
data.latestDeploymentID !== currentDeploymentId
) {
currentDeploymentId = data.latestDeploymentID;
if (deploying && liveLogsSection) {
liveLogsSection.style.display = "";
isDeploying = true;
}
}
if (!deploying && isDeploying) {
isDeploying = false;
window.location.reload();
}
})
.catch(function (err) {
console.error("Status fetch error:", err);
});
}
function fetchLiveLogs() {
if (!currentDeploymentId || !isDeploying) return;
fetch(
"/apps/" +
appId +
"/deployments/" +
currentDeploymentId +
"/logs",
)
.then(function (r) {
return r.json();
})
.then(function (data) {
if (liveLogsEl)
liveLogsEl.textContent =
data.logs || "Waiting for logs...";
updateStatusBadge(liveStatusEl, data.status, "text-xs");
scrollToBottom(liveLogsWrapper);
// Update matching deployment card if present
var card = document.querySelector(
'[data-deployment-id="' + currentDeploymentId + '"]',
);
if (card) {
var logsContent = card.querySelector(".logs-content");
var logsWrapper = card.querySelector(".logs-wrapper");
var statusBadge =
card.querySelector(".deployment-status");
if (logsContent)
logsContent.textContent = data.logs || "Loading...";
if (logsWrapper) scrollToBottom(logsWrapper);
if (statusBadge)
updateStatusBadge(statusBadge, data.status);
}
})
.catch(function (err) {
console.error("Logs fetch error:", err);
});
}
// Check for in-progress deployments on page load
var inProgressCards = document.querySelectorAll(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCards.length > 0) {
var card = inProgressCards[0];
currentDeploymentId = parseInt(
card.getAttribute("data-deployment-id"),
10,
);
isDeploying = true;
if (liveLogsSection) liveLogsSection.style.display = "";
}
// Initial fetch and polling
fetchAppStatus();
fetchLiveLogs();
setInterval(fetchAppStatus, 1000);
setInterval(fetchLiveLogs, 1000);
}
// Initialize relative times on all pages
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
updateRelativeTimes();
});
} else {
updateRelativeTimes();
}
// Update relative times every minute
setInterval(updateRelativeTimes, 60000);
// Expose API globally
window.upaas = {
copyToClipboard: copyToClipboard,
formatRelativeTime: formatRelativeTime,
updateRelativeTimes: updateRelativeTimes,
statusBadgeClass: statusBadgeClass,
statusLabel: statusLabel,
updateStatusBadge: updateStatusBadge,
scrollToBottom: scrollToBottom,
initAppDetailPage: initAppDetailPage,
initDeploymentsPage: initDeploymentsPage,
};
})();

View File

@ -290,7 +290,7 @@
<th>Commit</th>
</tr>
</thead>
<tbody class="table-body">
<tbody id="deployments-tbody" class="table-body">
{{range .Deployments}}
<tr>
<td class="text-gray-500">
@ -343,157 +343,9 @@
</main>
<script>
(function() {
// Relative time formatting
function formatRelativeTime(dateStr) {
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();
}
function updateRelativeTimes() {
document.querySelectorAll('.relative-time').forEach(el => {
const time = el.getAttribute('data-time');
if (time) {
el.textContent = formatRelativeTime(time);
}
upaas.initAppDetailPage({
appId: "{{.App.ID}}",
initialDeploymentId: {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}}
});
}
// Update relative times on load and every minute
updateRelativeTimes();
setInterval(updateRelativeTimes, 60000);
const appId = "{{.App.ID}}";
const containerLogsEl = document.getElementById('container-logs');
const containerLogsWrapper = document.getElementById('container-logs-wrapper');
const containerStatusEl = document.getElementById('container-status');
const buildLogsEl = document.getElementById('build-logs');
const buildLogsWrapper = document.getElementById('build-logs-wrapper');
const buildStatusEl = document.getElementById('build-status');
const buildLogsSection = document.getElementById('build-logs-section');
const appStatusEl = document.getElementById('app-status');
const deployBtn = document.getElementById('deploy-btn');
const deployBtnText = document.getElementById('deploy-btn-text');
let currentDeploymentId = {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}};
function updateAppStatusBadge(status) {
appStatusEl.className = '';
if (status === 'running') {
appStatusEl.className = 'badge-success';
} else if (status === 'building' || status === 'deploying') {
appStatusEl.className = 'badge-warning';
} else if (status === 'error') {
appStatusEl.className = 'badge-error';
} else {
appStatusEl.className = 'badge-neutral';
}
appStatusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
function updateDeployButton(status) {
const isDeploying = (status === 'building' || status === 'deploying');
deployBtn.disabled = isDeploying;
deployBtnText.textContent = isDeploying ? 'Deploying...' : 'Deploy Now';
if (isDeploying) {
deployBtn.classList.add('opacity-50', 'cursor-not-allowed');
} else {
deployBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
function updateStatusBadge(el, status) {
if (!el) return;
el.className = '';
if (status === 'running' || status === 'success') {
el.className = 'badge-success text-xs';
} else if (status === 'building' || status === 'deploying') {
el.className = 'badge-warning text-xs';
} else if (status === 'failed' || status === 'error') {
el.className = 'badge-error text-xs';
} else {
el.className = 'badge-neutral text-xs';
}
el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
function scrollToBottom(el) {
if (el) {
el.scrollTop = el.scrollHeight;
}
}
function fetchAppStatus() {
fetch('/apps/' + appId + '/status')
.then(response => response.json())
.then(data => {
updateAppStatusBadge(data.status);
updateDeployButton(data.status);
// Check if there's a new deployment
if (data.latestDeploymentID && data.latestDeploymentID !== currentDeploymentId) {
currentDeploymentId = data.latestDeploymentID;
// Show build logs section if hidden
if (buildLogsSection) {
buildLogsSection.style.display = '';
}
// Immediately fetch new build logs
fetchBuildLogs();
}
})
.catch(err => {
console.error('Failed to fetch app status:', err);
});
}
function fetchContainerLogs() {
fetch('/apps/' + appId + '/container-logs')
.then(response => response.json())
.then(data => {
containerLogsEl.textContent = data.logs || 'No logs available';
updateStatusBadge(containerStatusEl, data.status);
scrollToBottom(containerLogsWrapper);
})
.catch(err => {
containerLogsEl.textContent = 'Failed to fetch logs: ' + err.message;
});
}
function fetchBuildLogs() {
if (!currentDeploymentId || !buildLogsEl) return;
fetch('/apps/' + appId + '/deployments/' + currentDeploymentId + '/logs')
.then(response => response.json())
.then(data => {
buildLogsEl.textContent = data.logs || 'No build logs available';
updateStatusBadge(buildStatusEl, data.status);
scrollToBottom(buildLogsWrapper);
})
.catch(err => {
buildLogsEl.textContent = 'Failed to fetch logs: ' + err.message;
});
}
// Initial fetch
fetchAppStatus();
fetchContainerLogs();
fetchBuildLogs();
// Refresh every second
setInterval(fetchAppStatus, 1000);
setInterval(fetchContainerLogs, 1000);
setInterval(fetchBuildLogs, 1000);
})();
</script>
{{end}}

View File

@ -19,7 +19,7 @@
</a>
</div>
{{if .Apps}}
{{if .AppStats}}
<div class="card overflow-hidden">
<table class="table">
<thead class="table-header">
@ -28,37 +28,49 @@
<th>Repository</th>
<th>Branch</th>
<th>Status</th>
<th>Last Deploy</th>
<th>Deploys</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Apps}}
{{range .AppStats}}
<tr class="table-row-hover">
<td>
<a href="/apps/{{.ID}}" class="text-primary-600 hover:text-primary-800 font-medium">
{{.Name}}
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 font-medium">
{{.App.Name}}
</a>
</td>
<td class="text-gray-500 font-mono text-xs">{{.RepoURL}}</td>
<td class="text-gray-500">{{.Branch}}</td>
<td class="text-gray-500 font-mono text-xs">{{.App.RepoURL}}</td>
<td class="text-gray-500">{{.App.Branch}}</td>
<td>
{{if eq .Status "running"}}
{{if eq .App.Status "running"}}
<span class="badge-success">Running</span>
{{else if eq .Status "building"}}
{{else if eq .App.Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .Status "error"}}
{{else if eq .App.Status "error"}}
<span class="badge-error">Error</span>
{{else if eq .Status "stopped"}}
{{else if eq .App.Status "stopped"}}
<span class="badge-neutral">Stopped</span>
{{else}}
<span class="badge-neutral">{{.Status}}</span>
<span class="badge-neutral">{{.App.Status}}</span>
{{end}}
</td>
<td class="text-gray-500 text-sm">
{{if .LastDeployTime}}
<span class="relative-time cursor-default" data-time="{{.LastDeployISO}}" title="{{.LastDeployLabel}}">
{{.LastDeployLabel}}
</span>
{{else}}
<span class="text-gray-400">-</span>
{{end}}
</td>
<td class="text-gray-500 text-sm">{{.DeployCount}}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<a href="/apps/{{.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.ID}}/deploy" class="inline">
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form>
</div>

View File

@ -17,38 +17,39 @@
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
<form method="POST" action="/apps/{{.App.ID}}/deploy">
<button type="submit" class="btn-success">Deploy Now</button>
<form id="deploy-form" method="POST" action="/apps/{{.App.ID}}/deploy">
<button id="deploy-btn" type="submit" class="btn-success">Deploy Now</button>
</form>
</div>
<!-- Live Build Logs (shown during active deployment) -->
<div id="live-logs-section" class="card p-6 mb-4" style="display: none;">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Live Build Logs</h2>
<span id="live-status" class="badge-neutral text-xs">Building</span>
</div>
<div id="live-logs-wrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
<pre id="live-logs" class="text-gray-100 text-xs font-mono whitespace-pre-wrap">Waiting for logs...</pre>
</div>
</div>
<div id="deployments-list" class="space-y-4">
{{if .Deployments}}
<div class="space-y-4">
{{range .Deployments}}
<div class="card p-6">
<div class="card p-6 deployment-card" data-deployment-id="{{.ID}}" data-status="{{.Status}}">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{.StartedAt.Format "2006-01-02 15:04:05"}}</span>
<span class="relative-time" data-time="{{.StartedAt.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.StartedAt.Format "2006-01-02 15:04:05"}}">{{.StartedAt.Format "2006-01-02 15:04:05"}}</span>
{{if .FinishedAt.Valid}}
<span class="text-gray-400">-</span>
<span>{{.FinishedAt.Time.Format "15:04:05"}}</span>
<span class="text-gray-400"></span>
<span>{{.Duration}}</span>
{{end}}
</div>
<div>
{{if eq .Status "success"}}
<span class="badge-success">Success</span>
{{else if eq .Status "failed"}}
<span class="badge-error">Failed</span>
{{else if eq .Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .Status "deploying"}}
<span class="badge-info">Deploying</span>
{{else}}
<span class="badge-neutral">{{.Status}}</span>
{{end}}
<span class="deployment-status {{if eq .Status "success"}}badge-success{{else if eq .Status "failed"}}badge-error{{else if eq .Status "building"}}badge-warning{{else if eq .Status "deploying"}}badge-info{{else}}badge-neutral{{end}}">{{if eq .Status "success"}}Success{{else if eq .Status "failed"}}Failed{{else if eq .Status "building"}}Building{{else if eq .Status "deploying"}}Deploying{{else}}{{.Status}}{{end}}</span>
</div>
</div>
@ -56,7 +57,7 @@
{{if .CommitSHA.Valid}}
<div>
<span class="font-medium text-gray-700">Commit:</span>
<span class="font-mono text-gray-500 ml-1">{{.CommitSHA.String}}</span>
<span class="font-mono text-gray-500 ml-1">{{.ShortCommit}}</span>
</div>
{{end}}
@ -75,20 +76,21 @@
{{end}}
</div>
{{if .Logs.Valid}}
<details class="mt-4">
{{if or .Logs.Valid (eq .Status "building") (eq .Status "deploying")}}
<details class="mt-4 deployment-logs" {{if or (eq .Status "building") (eq .Status "deploying")}}open{{end}}>
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 mr-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
View Logs
</summary>
<pre class="mt-3 p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono leading-relaxed">{{.Logs.String}}</pre>
<div class="logs-wrapper mt-3 overflow-auto" style="max-height: 400px;">
<pre class="logs-content p-4 bg-gray-900 text-gray-100 rounded-lg text-xs font-mono leading-relaxed whitespace-pre-wrap">{{if .Logs.Valid}}{{.Logs.String}}{{else}}Loading...{{end}}</pre>
</div>
</details>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card">
<div class="empty-state">
@ -98,12 +100,19 @@
<h3 class="empty-state-title">No deployments yet</h3>
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
<div class="mt-6">
<form method="POST" action="/apps/{{.App.ID}}/deploy">
<button type="submit" class="btn-success">Deploy Now</button>
<form id="deploy-form-empty" method="POST" action="/apps/{{.App.ID}}/deploy">
<button id="deploy-btn-empty" type="submit" class="btn-success">Deploy Now</button>
</form>
</div>
</div>
</div>
{{end}}
</div>
</main>
<script>
upaas.initDeploymentsPage({
appId: "{{.App.ID}}"
});
</script>
{{end}}