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

@@ -27,6 +27,12 @@ const (
httpStatusClientError = 400
)
// Display constants.
const (
shortCommitLength = 12
secondsPerMinute = 60
)
// Sentinel errors for notification failures.
var (
// ErrNtfyFailed indicates the ntfy notification request failed.
@@ -64,10 +70,16 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
func (svc *Service) NotifyBuildStart(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployment *models.Deployment,
) {
title := "Build started: " + app.Name
message := "Building from branch " + app.Branch
if deployment.CommitSHA.Valid {
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
message += " at " + shortSHA
}
svc.sendNotifications(ctx, app, title, message, "info")
}
@@ -75,10 +87,12 @@ func (svc *Service) NotifyBuildStart(
func (svc *Service) NotifyBuildSuccess(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployment *models.Deployment,
) {
duration := time.Since(deployment.StartedAt)
title := "Build success: " + app.Name
message := "Image built successfully from branch " + app.Branch
message := "Image built successfully in " + formatDuration(duration)
svc.sendNotifications(ctx, app, title, message, "success")
}
@@ -86,11 +100,13 @@ func (svc *Service) NotifyBuildSuccess(
func (svc *Service) NotifyBuildFailed(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployment *models.Deployment,
buildErr error,
) {
duration := time.Since(deployment.StartedAt)
title := "Build failed: " + app.Name
message := "Build failed: " + buildErr.Error()
message := "Build failed after " + formatDuration(duration) + ": " + buildErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
}
@@ -98,10 +114,17 @@ func (svc *Service) NotifyBuildFailed(
func (svc *Service) NotifyDeploySuccess(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployment *models.Deployment,
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy success: " + app.Name
message := "Successfully deployed from branch " + app.Branch
message := "Successfully deployed in " + formatDuration(duration)
if deployment.CommitSHA.Valid {
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
message += " (commit " + shortSHA + ")"
}
svc.sendNotifications(ctx, app, title, message, "success")
}
@@ -109,14 +132,37 @@ func (svc *Service) NotifyDeploySuccess(
func (svc *Service) NotifyDeployFailed(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployment *models.Deployment,
deployErr error,
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy failed: " + app.Name
message := "Deployment failed: " + deployErr.Error()
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
}
// formatDuration formats a duration for display.
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
minutes := int(d.Minutes())
seconds := int(d.Seconds()) % secondsPerMinute
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
// minInt returns the smaller of two integers.
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func (svc *Service) sendNotifications(
ctx context.Context,
app *models.App,
@@ -153,7 +199,7 @@ func (svc *Service) sendNotifications(
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message, priority)
if slackErr != nil {
svc.log.Error(
"failed to send slack notification",
@@ -213,6 +259,19 @@ func (svc *Service) ntfyPriority(priority string) string {
}
}
func (svc *Service) slackColor(priority string) string {
switch priority {
case "error":
return "#dc3545" // red
case "success":
return "#28a745" // green
case "info":
return "#17a2b8" // blue
default:
return "#6c757d" // gray
}
}
// SlackPayload represents a Slack webhook payload.
type SlackPayload struct {
Text string `json:"text"`
@@ -228,7 +287,7 @@ type SlackAttachment struct {
func (svc *Service) sendSlack(
ctx context.Context,
webhookURL, title, message string,
webhookURL, title, message, priority string,
) error {
svc.log.Debug(
"sending slack notification",
@@ -239,7 +298,7 @@ func (svc *Service) sendSlack(
payload := SlackPayload{
Attachments: []SlackAttachment{
{
Color: "#36a64f",
Color: svc.slackColor(priority),
Title: title,
Text: message,
},