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:
parent
307955dae1
commit
ab7e917b03
@ -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: "/",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
550
static/js/app.js
550
static/js/app.js
@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user