Add deployment improvements and UI enhancements

- Clone specific commit SHA from webhook instead of just branch HEAD
- Log webhook payload in deployment logs
- Add build/deploy timing to ntfy and Slack notifications
- Implement container rollback on deploy failure
- Remove old container only after successful deployment
- Show relative times in deployment history (hover for full date)
- Update port mappings UI with labeled text inputs
- Add footer with version info, license, and repo link
- Format deploy key comment as upaas_DATE_appname
This commit is contained in:
2025-12-30 15:05:26 +07:00
parent bc275f7b9c
commit b3ac3c60c2
15 changed files with 1111 additions and 141 deletions

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
@@ -27,7 +28,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
data := h.addGlobals(map[string]any{})
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil {
@@ -127,22 +128,28 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
recentDeploymentsLimit,
)
host := request.Host
webhookURL := "https://" + host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, host)
data := map[string]any{
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
"Ports": ports,
"Deployments": deployments,
"WebhookURL": webhookURL,
"DeployKey": deployKey,
"Success": request.URL.Query().Get("success"),
// Get latest deployment for build logs pane
var latestDeployment *models.Deployment
if len(deployments) > 0 {
latestDeployment = deployments[0]
}
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
data := h.addGlobals(map[string]any{
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
"Ports": ports,
"Deployments": deployments,
"LatestDeployment": latestDeployment,
"WebhookURL": webhookURL,
"DeployKey": deployKey,
"Success": request.URL.Query().Get("success"),
})
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
@@ -172,9 +179,9 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
return
}
data := map[string]any{
data := h.addGlobals(map[string]any{
"App": application,
}
})
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil {
@@ -325,10 +332,10 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
deploymentsHistoryLimit,
)
data := map[string]any{
data := h.addGlobals(map[string]any{
"App": application,
"Deployments": deployments,
}
})
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil {
@@ -388,6 +395,148 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
}
}
// HandleDeploymentLogsAPI returns JSON with deployment logs.
func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
deploymentIDStr := chi.URLParam(request, "deploymentID")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
if deployErr != nil || deployment == nil || deployment.AppID != appID {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "application/json")
logs := ""
if deployment.Logs.Valid {
logs = deployment.Logs.String
}
response := map[string]any{
"logs": logs,
"status": deployment.Status,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// containerLogsAPITail is the default number of log lines for the container logs API.
const containerLogsAPITail = "100"
// HandleContainerLogsAPI returns JSON with container logs.
func (h *Handlers) HandleContainerLogsAPI() 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
}
writer.Header().Set("Content-Type", "application/json")
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
response := map[string]any{
"logs": "No container running\n",
"status": "stopped",
}
_ = json.NewEncoder(writer).Encode(response)
return
}
logs, logsErr := h.docker.ContainerLogs(
request.Context(),
containerInfo.ID,
containerLogsAPITail,
)
if logsErr != nil {
h.log.Error("failed to get container logs",
"error", logsErr,
"app", application.Name,
"container", containerInfo.ID,
)
response := map[string]any{
"logs": "Failed to fetch container logs\n",
"status": "error",
}
_ = json.NewEncoder(writer).Encode(response)
return
}
status := "stopped"
if containerInfo.Running {
status = "running"
}
response := map[string]any{
"logs": logs,
"status": status,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// HandleAppStatusAPI returns JSON with app status and latest deployment info.
func (h *Handlers) HandleAppStatusAPI() 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
}
writer.Header().Set("Content-Type", "application/json")
// Get latest deployment
deployments, _ := application.GetDeployments(request.Context(), 1)
var latestDeploymentID int64
var latestDeploymentStatus string
if len(deployments) > 0 {
latestDeploymentID = deployments[0].ID
latestDeploymentStatus = string(deployments[0].Status)
}
response := map[string]any{
"status": string(application.Status),
"latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// containerAction represents a container operation type.
type containerAction string
@@ -778,8 +927,8 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
}
// formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com.
func formatDeployKey(pubKey string, createdAt time.Time, host string) string {
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
const minKeyParts = 2
parts := strings.Fields(pubKey)
@@ -787,7 +936,7 @@ func formatDeployKey(pubKey string, createdAt time.Time, host string) string {
return pubKey
}
comment := "upaas-" + createdAt.Format("2006-01-02") + "-" + host
comment := "upaas_" + createdAt.Format("2006-01-02") + "_" + appName
return parts[0] + " " + parts[1] + " " + comment
}

View File

@@ -11,7 +11,7 @@ func (h *Handlers) HandleLoginGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
data := h.addGlobals(map[string]any{})
err := tmpl.ExecuteTemplate(writer, "login.html", data)
if err != nil {
@@ -36,9 +36,9 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
username := request.FormValue("username")
password := request.FormValue("password")
data := map[string]any{
data := h.addGlobals(map[string]any{
"Username": username,
}
})
if username == "" || password == "" {
data["Error"] = "Username and password are required"

View File

@@ -20,9 +20,9 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
return
}
data := map[string]any{
data := h.addGlobals(map[string]any{
"Apps": apps,
}
})
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
if execErr != nil {

View File

@@ -45,6 +45,7 @@ type Handlers struct {
deploy *deploy.Service
webhook *webhook.Service
docker *docker.Client
globals *globals.Globals
}
// New creates a new Handlers instance.
@@ -59,9 +60,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
deploy: params.Deploy,
webhook: params.Webhook,
docker: params.Docker,
globals: params.Globals,
}, nil
}
// addGlobals adds version info to template data map.
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
data["Version"] = h.globals.Version
data["Appname"] = h.globals.Appname
return data
}
func (h *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,

View File

@@ -16,7 +16,7 @@ func (h *Handlers) HandleSetupGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
data := h.addGlobals(map[string]any{})
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
if err != nil {
@@ -51,16 +51,16 @@ func validateSetupForm(formData setupFormData) string {
}
// renderSetupError renders the setup page with an error message.
func renderSetupError(
func (h *Handlers) renderSetupError(
tmpl *templates.TemplateExecutor,
writer http.ResponseWriter,
username string,
errorMsg string,
) {
data := map[string]any{
data := h.addGlobals(map[string]any{
"Username": username,
"Error": errorMsg,
}
})
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
}
@@ -83,7 +83,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
}
if validationErr := validateSetupForm(formData); validationErr != "" {
renderSetupError(tmpl, writer, formData.username, validationErr)
h.renderSetupError(tmpl, writer, formData.username, validationErr)
return
}
@@ -95,7 +95,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
)
if createErr != nil {
h.log.Error("failed to create user", "error", createErr)
renderSetupError(tmpl, writer, formData.username, "Failed to create user")
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
return
}
@@ -103,7 +103,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("failed to create session", "error", sessionErr)
renderSetupError(
h.renderSetupError(
tmpl,
writer,
formData.username,