16 Commits

Author SHA1 Message Date
0c8dcc2eb1 Merge branch 'main' into feature/edit-config-entities 2026-02-16 09:28:30 +01:00
d0375555af Merge pull request 'Update TODO.md with current status (closes #54)' (#55) from update-todo-md into main
Reviewed-on: #55
2026-02-16 09:26:15 +01:00
e9d284698a feat: edit existing env vars, labels, and volume mounts
Add inline edit functionality for environment variables, labels, and
volume mounts on the app detail page. Each entity row now has an Edit
button that reveals an inline form using Alpine.js.

- POST /apps/{id}/env-vars/{varID}/edit
- POST /apps/{id}/labels/{labelID}/edit
- POST /apps/{id}/volumes/{volumeID}/edit
- Path validation for volume host and container paths
- Warning banner about container restart after env var changes
- Tests for ValidateVolumePath

fixes #67
2026-02-16 00:26:07 -08:00
96a91b09ca Merge branch 'main' into update-todo-md 2026-02-16 09:26:01 +01:00
046cccf31f Merge pull request 'feat: deployment rollback to previous image (closes #71)' (#75) from feature/deployment-rollback into main
Reviewed-on: #75
2026-02-16 09:25:33 +01:00
user
2be6a748b7 feat: deployment rollback to previous image
- Add previous_image_id column to apps table (migration 006)
- Save current image as previous before deploying new one
- POST /apps/{id}/rollback endpoint with handler
- Rollback stops current container, starts previous image
- Creates deployment record for rollback operations
- Rollback button in app detail UI (only when previous image exists)
- Add btn-warning CSS class for rollback button styling

fixes #71
2026-02-16 00:23:11 -08:00
e31666ab5c Merge pull request 'feat: add user-facing deployment cancel endpoint (closes #66)' (#73) from feature/deploy-cancel into main
Reviewed-on: #73
2026-02-16 09:18:59 +01:00
user
c5f957477f feat: add user-facing deployment cancel endpoint
Add POST /apps/{id}/deployments/cancel endpoint that allows users to
cancel in-progress deployments via the web UI.

Changes:
- Add CancelDeploy() and HasActiveDeploy() public methods to deploy service
- Add HandleCancelDeploy handler
- Wire route in routes.go
- Add cancel button to app detail template (shown during active deployments)
- Add handler tests for cancel endpoint

fixes #66
2026-02-16 00:15:24 -08:00
6696db957d Update TODO.md: check off deployment cancellation 2026-02-16 09:12:08 +01:00
ebcae55302 Merge pull request 'fix: cancel in-progress deploy when webhook triggers new deploy (closes #38)' (#52) from clawbot/upaas:fix/deploy-race-condition-38 into main
Reviewed-on: #52
2026-02-16 09:06:40 +01:00
e2ad42f0ac Merge pull request 'Fix all golangci-lint issues (closes #32)' (#51) from clawbot/upaas:fix/lint-cleanup into main
Reviewed-on: #51
2026-02-16 09:06:09 +01:00
user
a80b7ac0a6 refactor: export SanitizeTail and DefaultLogTail directly instead of wrapping
- Rename sanitizeTail → SanitizeTail (exported)
- Rename defaultLogTail → DefaultLogTail (exported)
- Delete export_test.go (no longer needed)
- Update test to reference handlers.SanitizeTail/DefaultLogTail directly
2026-02-15 22:14:12 -08:00
clawbot
69a5a8c298 fix: resolve all golangci-lint issues (fixes #32) 2026-02-15 22:13:12 -08:00
3f499163a7 fix: cancel in-progress deploy when webhook triggers new deploy (closes #38)
When a webhook-triggered deploy starts for an app that already has a deploy
in progress, the existing deploy is now cancelled via context cancellation
before the new deploy begins. This prevents silently lost webhook deploys.

Changes:
- Add per-app active deploy tracking with cancel func and done channel
- Deploy() accepts cancelExisting param: true for webhook, false for manual
- Cancelled deployments are marked with new 'cancelled' status
- Add ErrDeployCancelled sentinel error
- Add DeploymentStatusCancelled model constant
- Add comprehensive tests for cancellation mechanics
2026-02-15 22:12:03 -08:00
07ac71974c Merge pull request 'fix: set DestroySession MaxAge to -1 instead of -1*time.Second (closes #39)' (#50) from clawbot/upaas:fix/destroy-session-maxage into main
Reviewed-on: #50
2026-02-16 07:09:25 +01:00
cdd7e3fd3a fix: set DestroySession MaxAge to -1 instead of -1*time.Second (closes #39)
The gorilla/sessions MaxAge field expects seconds, not nanoseconds.
Previously MaxAge was set to -1000000000 (-1 * time.Second in nanoseconds),
which worked by accident since any negative value deletes the cookie.
Changed to the conventional value of -1.
2026-02-15 22:07:57 -08:00
18 changed files with 913 additions and 88 deletions

View File

@@ -54,7 +54,7 @@
- [x] View deployment history per app - [x] View deployment history per app
- [x] Container logs viewing - [x] Container logs viewing
- [ ] Deployment rollback to previous image - [ ] Deployment rollback to previous image
- [ ] Deployment cancellation - [x] Deployment cancellation
### Manual Container Controls ### Manual Container Controls
- [x] Restart container - [x] Restart container
@@ -210,9 +210,9 @@ Protected Routes (require auth):
- [ ] Update deploy service to save previous image before building new one - [ ] Update deploy service to save previous image before building new one
### 3.3 Deployment Cancellation ### 3.3 Deployment Cancellation
- [ ] Add cancellation context to deploy service - [x] Add cancellation context to deploy service
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint - [x] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [ ] Handle cleanup of partial builds/containers - [x] Handle cleanup of partial builds/containers
## Phase 4: Lower Priority (Nice to Have) ## Phase 4: Lower Priority (Nice to Have)

View File

@@ -0,0 +1,2 @@
-- Add previous_image_id to apps for deployment rollback support
ALTER TABLE apps ADD COLUMN previous_image_id TEXT;

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -335,7 +337,7 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
deployCtx := context.WithoutCancel(request.Context()) deployCtx := context.WithoutCancel(request.Context())
go func(ctx context.Context, appToDeploy *models.App) { go func(ctx context.Context, appToDeploy *models.App) {
deployErr := h.deploy.Deploy(ctx, appToDeploy, nil) deployErr := h.deploy.Deploy(ctx, appToDeploy, nil, false)
if deployErr != nil { if deployErr != nil {
h.log.Error( h.log.Error(
"deployment failed", "deployment failed",
@@ -354,6 +356,56 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
} }
} }
// HandleCancelDeploy cancels an in-progress deployment for an app.
func (h *Handlers) HandleCancelDeploy() 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
}
cancelled := h.deploy.CancelDeploy(application.ID)
if cancelled {
h.log.Info("deployment cancelled by user", "app", application.Name)
}
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
}
}
// HandleAppRollback handles rolling back to the previous deployment image.
func (h *Handlers) HandleAppRollback() 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
}
rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
}
}
// HandleAppDeployments returns the deployments history handler. // HandleAppDeployments returns the deployments history handler.
func (h *Handlers) HandleAppDeployments() http.HandlerFunc { func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -382,22 +434,22 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
} }
} }
// defaultLogTail is the default number of log lines to fetch. // DefaultLogTail is the default number of log lines to fetch.
const defaultLogTail = "500" const DefaultLogTail = "500"
// maxLogTail is the maximum allowed value for the tail parameter. // maxLogTail is the maximum allowed value for the tail parameter.
const maxLogTail = 500 const maxLogTail = 500
// sanitizeTail validates and clamps the tail query parameter. // SanitizeTail validates and clamps the tail query parameter.
// It returns a numeric string clamped to maxLogTail, or the default if invalid. // It returns a numeric string clamped to maxLogTail, or the default if invalid.
func sanitizeTail(raw string) string { func SanitizeTail(raw string) string {
if raw == "" { if raw == "" {
return defaultLogTail return DefaultLogTail
} }
n, err := strconv.Atoi(raw) n, err := strconv.Atoi(raw)
if err != nil || n < 1 { if err != nil || n < 1 {
return defaultLogTail return DefaultLogTail
} }
if n > maxLogTail { if n > maxLogTail {
@@ -428,7 +480,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
tail := sanitizeTail(request.URL.Query().Get("tail")) tail := SanitizeTail(request.URL.Query().Get("tail"))
logs, logsErr := h.docker.ContainerLogs( logs, logsErr := h.docker.ContainerLogs(
request.Context(), request.Context(),
@@ -1090,6 +1142,207 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
} }
} }
// ErrVolumePathEmpty is returned when a volume path is empty.
var ErrVolumePathEmpty = errors.New("path must not be empty")
// ErrVolumePathNotAbsolute is returned when a volume path is not absolute.
var ErrVolumePathNotAbsolute = errors.New("path must be absolute")
// ErrVolumePathNotClean is returned when a volume path is not clean.
var ErrVolumePathNotClean = errors.New("path must be clean")
// ValidateVolumePath checks that a path is absolute and clean.
func ValidateVolumePath(p string) error {
if p == "" {
return ErrVolumePathEmpty
}
if !filepath.IsAbs(p) {
return ErrVolumePathNotAbsolute
}
cleaned := filepath.Clean(p)
if cleaned != p {
return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned)
}
return nil
}
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
label.Key = key
label.Value = value
saveErr := label.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeEdit handles editing an existing volume mount.
func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPath := request.FormValue("host_path")
containerPath := request.FormValue("container_path")
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// validateVolumePaths validates both host and container paths for a volume.
func validateVolumePaths(hostPath, containerPath string) error {
hostErr := ValidateVolumePath(hostPath)
if hostErr != nil {
return fmt.Errorf("host path: %w", hostErr)
}
containerErr := ValidateVolumePath(containerPath)
if containerErr != nil {
return fmt.Errorf("container path: %w", containerErr)
}
return nil
}
// formatDeployKey formats an SSH public key with a descriptive comment. // formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -1,9 +0,0 @@
package handlers
// ExportedSanitizeTail wraps sanitizeTail for external tests.
func ExportedSanitizeTail(input string) string {
return sanitizeTail(input)
}
// ExportedDefaultLogTail exports defaultLogTail for external tests.
const ExportedDefaultLogTail = defaultLogTail

View File

@@ -684,6 +684,47 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
func TestHandleCancelDeployRedirects(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/deployments/cancel",
nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleCancelDeploy()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/apps/"+createdApp.ID, recorder.Header().Get("Location"))
}
func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent/deployments/cancel",
nil,
)
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleCancelDeploy()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -14,16 +14,16 @@ func TestSanitizeTail(t *testing.T) {
input string input string
expected string expected string
}{ }{
{"empty uses default", "", handlers.ExportedDefaultLogTail}, {"empty uses default", "", handlers.DefaultLogTail},
{"valid small number", "50", "50"}, {"valid small number", "50", "50"},
{"valid max boundary", "500", "500"}, {"valid max boundary", "500", "500"},
{"exceeds max clamped", "501", "500"}, {"exceeds max clamped", "501", "500"},
{"very large clamped", "999999", "500"}, {"very large clamped", "999999", "500"},
{"non-numeric uses default", "abc", handlers.ExportedDefaultLogTail}, {"non-numeric uses default", "abc", handlers.DefaultLogTail},
{"all keyword uses default", "all", handlers.ExportedDefaultLogTail}, {"all keyword uses default", "all", handlers.DefaultLogTail},
{"negative uses default", "-1", handlers.ExportedDefaultLogTail}, {"negative uses default", "-1", handlers.DefaultLogTail},
{"zero uses default", "0", handlers.ExportedDefaultLogTail}, {"zero uses default", "0", handlers.DefaultLogTail},
{"float uses default", "1.5", handlers.ExportedDefaultLogTail}, {"float uses default", "1.5", handlers.DefaultLogTail},
{"one is valid", "1", "1"}, {"one is valid", "1", "1"},
} }
@@ -31,7 +31,7 @@ func TestSanitizeTail(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
got := handlers.ExportedSanitizeTail(tc.input) got := handlers.SanitizeTail(tc.input)
if got != tc.expected { if got != tc.expected {
t.Errorf("sanitizeTail(%q) = %q, want %q", tc.input, got, tc.expected) t.Errorf("sanitizeTail(%q) = %q, want %q", tc.input, got, tc.expected)
} }

View File

@@ -0,0 +1,34 @@
package handlers //nolint:testpackage // tests exported ValidateVolumePath function
import "testing"
func TestValidateVolumePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
}{
{"valid absolute path", "/data/myapp", false},
{"root path", "/", false},
{"empty path", "", true},
{"relative path", "data/myapp", true},
{"path with dotdot", "/data/../etc", true},
{"path with trailing slash", "/data/", true},
{"path with double slash", "/data//myapp", true},
{"single dot path", ".", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateVolumePath(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v",
tt.path, err, tt.wantErr)
}
})
}
}

View File

@@ -14,7 +14,7 @@ import (
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
created_at, updated_at` previous_image_id, created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -42,6 +42,7 @@ type App struct {
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString
Status AppStatus Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
@@ -140,13 +141,15 @@ func (a *App) insert(ctx context.Context) error {
INSERT INTO apps ( INSERT INTO apps (
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` previous_image_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID,
) )
if err != nil { if err != nil {
return err return err
@@ -161,6 +164,7 @@ func (a *App) update(ctx context.Context) error {
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -168,6 +172,7 @@ func (a *App) update(ctx context.Context) error {
a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.ID, a.ID,
) )
@@ -182,6 +187,7 @@ func (a *App) scan(row *sql.Row) error {
&a.ImageID, &a.Status, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash, &a.WebhookSecretHash,
&a.PreviousImageID,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -199,6 +205,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.ImageID, &app.Status, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash, &app.WebhookSecretHash,
&app.PreviousImageID,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@@ -19,6 +19,7 @@ const (
DeploymentStatusDeploying DeploymentStatus = "deploying" DeploymentStatusDeploying DeploymentStatus = "deploying"
DeploymentStatusSuccess DeploymentStatus = "success" DeploymentStatusSuccess DeploymentStatus = "success"
DeploymentStatusFailed DeploymentStatus = "failed" DeploymentStatusFailed DeploymentStatus = "failed"
DeploymentStatusCancelled DeploymentStatus = "cancelled"
) )
// Display constants. // Display constants.

View File

@@ -68,6 +68,7 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
@@ -75,20 +76,24 @@ func (s *Server) SetupRoutes() {
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables // Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes // Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports // Ports

View File

@@ -10,7 +10,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"go.uber.org/fx" "go.uber.org/fx"
@@ -269,7 +268,7 @@ func (svc *Service) DestroySession(
return fmt.Errorf("failed to get session: %w", err) return fmt.Errorf("failed to get session: %w", err)
} }
session.Options.MaxAge = -1 * int(time.Second) session.Options.MaxAge = -1
saveErr := session.Save(request, respWriter) saveErr := session.Save(request, respWriter)
if saveErr != nil { if saveErr != nil {

View File

@@ -369,3 +369,38 @@ func TestAuthenticate(testingT *testing.T) {
assert.ErrorIs(t, err, auth.ErrInvalidCredentials) assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
}) })
} }
func TestDestroySessionMaxAge(testingT *testing.T) {
testingT.Parallel()
testingT.Run("sets MaxAge to exactly -1", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil)
err := svc.DestroySession(recorder, request)
require.NoError(t, err)
// Check the Set-Cookie header to verify MaxAge is -1 (immediate expiry).
// With MaxAge = -1, the cookie should have Max-Age=0 in the HTTP header
// (per http.Cookie semantics: negative MaxAge means delete now).
cookies := recorder.Result().Cookies()
require.NotEmpty(t, cookies, "expected a Set-Cookie header")
found := false
for _, c := range cookies {
if c.MaxAge < 0 {
found = true
break
}
}
assert.True(t, found, "expected a cookie with negative MaxAge (deletion)")
})
}

View File

@@ -43,10 +43,14 @@ var (
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds") ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
// ErrDeploymentInProgress indicates another deployment is already running. // ErrDeploymentInProgress indicates another deployment is already running.
ErrDeploymentInProgress = errors.New("deployment already in progress for this app") ErrDeploymentInProgress = errors.New("deployment already in progress for this app")
// ErrDeployCancelled indicates the deployment was cancelled by a newer deploy.
ErrDeployCancelled = errors.New("deployment cancelled by newer deploy")
// ErrBuildTimeout indicates the build phase exceeded the timeout. // ErrBuildTimeout indicates the build phase exceeded the timeout.
ErrBuildTimeout = errors.New("build timeout exceeded") ErrBuildTimeout = errors.New("build timeout exceeded")
// ErrDeployTimeout indicates the deploy phase exceeded the timeout. // ErrDeployTimeout indicates the deploy phase exceeded the timeout.
ErrDeployTimeout = errors.New("deploy timeout exceeded") ErrDeployTimeout = errors.New("deploy timeout exceeded")
// ErrNoPreviousImage indicates there is no previous image to rollback to.
ErrNoPreviousImage = errors.New("no previous image available for rollback")
) )
// logFlushInterval is how often to flush buffered logs to the database. // logFlushInterval is how often to flush buffered logs to the database.
@@ -205,6 +209,12 @@ type ServiceParams struct {
Notify *notify.Service Notify *notify.Service
} }
// activeDeploy tracks a running deployment so it can be cancelled.
type activeDeploy struct {
cancel context.CancelFunc
done chan struct{}
}
// Service provides deployment functionality. // Service provides deployment functionality.
type Service struct { type Service struct {
log *slog.Logger log *slog.Logger
@@ -213,6 +223,7 @@ type Service struct {
notify *notify.Service notify *notify.Service
config *config.Config config *config.Config
params *ServiceParams params *ServiceParams
activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking
appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks
} }
@@ -274,12 +285,39 @@ func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deploymen
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename) return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
} }
// Deploy deploys an app. // HasActiveDeploy returns true if there is an active deployment for the given app.
func (svc *Service) HasActiveDeploy(appID string) bool {
_, ok := svc.activeDeploys.Load(appID)
return ok
}
// CancelDeploy cancels any in-progress deployment for the given app
// and waits for it to finish before returning. Returns true if a deployment
// was cancelled, false if there was nothing to cancel.
func (svc *Service) CancelDeploy(appID string) bool {
if !svc.HasActiveDeploy(appID) {
return false
}
svc.cancelActiveDeploy(appID)
return true
}
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
// any in-progress deploy for the same app will be cancelled before starting.
// If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
func (svc *Service) Deploy( func (svc *Service) Deploy(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
webhookEventID *int64, webhookEventID *int64,
cancelExisting bool,
) error { ) error {
if cancelExisting {
svc.cancelActiveDeploy(app.ID)
}
// Try to acquire per-app deployment lock // Try to acquire per-app deployment lock
if !svc.tryLockApp(app.ID) { if !svc.tryLockApp(app.ID) {
svc.log.Warn("deployment already in progress", "app", app.Name) svc.log.Warn("deployment already in progress", "app", app.Name)
@@ -288,45 +326,186 @@ func (svc *Service) Deploy(
} }
defer svc.unlockApp(app.ID) defer svc.unlockApp(app.ID)
// Set up cancellable context and register as active deploy
deployCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
ad := &activeDeploy{cancel: cancel, done: done}
svc.activeDeploys.Store(app.ID, ad)
defer func() {
cancel()
close(done)
svc.activeDeploys.Delete(app.ID)
}()
// Fetch webhook event and create deployment record // Fetch webhook event and create deployment record
webhookEvent := svc.fetchWebhookEvent(ctx, webhookEventID) webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID, webhookEvent) // Use a background context for DB operations that must complete regardless of cancellation
bgCtx := context.WithoutCancel(deployCtx)
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
if err != nil { if err != nil {
return err return err
} }
svc.logWebhookPayload(ctx, deployment, webhookEvent) svc.logWebhookPayload(bgCtx, deployment, webhookEvent)
err = svc.updateAppStatusBuilding(ctx, app) err = svc.updateAppStatusBuilding(bgCtx, app)
if err != nil { if err != nil {
return err return err
} }
svc.notify.NotifyBuildStart(ctx, app, deployment) svc.notify.NotifyBuildStart(bgCtx, app, deployment)
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
}
// Rollback rolls back an app to its previous image.
// It stops the current container, starts a new one with the previous image,
// and creates a deployment record for the rollback.
func (svc *Service) Rollback(ctx context.Context, app *models.App) error {
if !app.PreviousImageID.Valid || app.PreviousImageID.String == "" {
return ErrNoPreviousImage
}
// Acquire per-app deployment lock
if !svc.tryLockApp(app.ID) {
return ErrDeploymentInProgress
}
defer svc.unlockApp(app.ID)
bgCtx := context.WithoutCancel(ctx)
deployment, err := svc.createRollbackDeployment(bgCtx, app)
if err != nil {
return err
}
return svc.executeRollback(ctx, bgCtx, app, deployment)
}
// createRollbackDeployment creates a deployment record for a rollback operation.
func (svc *Service) createRollbackDeployment(
ctx context.Context,
app *models.App,
) (*models.Deployment, error) {
deployment := models.NewDeployment(svc.db)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusDeploying
deployment.ImageID = sql.NullString{String: app.PreviousImageID.String, Valid: true}
saveErr := deployment.Save(ctx)
if saveErr != nil {
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
}
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
return deployment, nil
}
// executeRollback performs the container swap for a rollback.
func (svc *Service) executeRollback(
ctx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
previousImageID := app.PreviousImageID.String
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
return fmt.Errorf("failed to build container options: %w", err)
}
rollbackOpts.Image = previousImageID
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
return fmt.Errorf("failed to start rollback container: %w", startErr)
}
_ = deployment.AppendLog(bgCtx, "Rollback container started")
currentImageID := app.ImageID
app.ImageID = sql.NullString{String: previousImageID, Valid: true}
app.PreviousImageID = currentImageID
app.Status = models.AppStatusRunning
saveErr := app.Save(bgCtx)
if saveErr != nil {
return fmt.Errorf("failed to update app after rollback: %w", saveErr)
}
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusSuccess)
_ = deployment.AppendLog(bgCtx, "Rollback complete")
svc.log.Info("rollback completed", "app", app.Name, "image", previousImageID)
return nil
}
// runBuildAndDeploy executes the build and deploy phases, handling cancellation.
func (svc *Service) runBuildAndDeploy(
deployCtx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
// Build phase with timeout // Build phase with timeout
imageID, err := svc.buildImageWithTimeout(ctx, app, deployment) imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil {
return cancelErr
}
return err return err
} }
svc.notify.NotifyBuildSuccess(ctx, app, deployment) svc.notify.NotifyBuildSuccess(bgCtx, app, deployment)
// Deploy phase with timeout // Deploy phase with timeout
err = svc.deployContainerWithTimeout(ctx, app, deployment, imageID) err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil {
return cancelErr
}
return err return err
} }
err = svc.updateAppRunning(ctx, app, imageID) // Save current image as previous before updating to new one
if app.ImageID.Valid && app.ImageID.String != "" {
app.PreviousImageID = app.ImageID
}
err = svc.updateAppRunning(bgCtx, app, imageID)
if err != nil { if err != nil {
return err return err
} }
// Use context.WithoutCancel to ensure health check completes even if // Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends). // the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(context.WithoutCancel(ctx), app, deployment) go svc.checkHealthAfterDelay(bgCtx, app, deployment)
return nil return nil
} }
@@ -463,6 +642,43 @@ func (svc *Service) unlockApp(appID string) {
svc.getAppLock(appID).Unlock() svc.getAppLock(appID).Unlock()
} }
// cancelActiveDeploy cancels any in-progress deployment for the given app
// and waits for it to finish before returning.
func (svc *Service) cancelActiveDeploy(appID string) {
val, ok := svc.activeDeploys.Load(appID)
if !ok {
return
}
ad, ok := val.(*activeDeploy)
if !ok {
return
}
svc.log.Info("cancelling in-progress deployment", "app_id", appID)
ad.cancel()
<-ad.done
}
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
// and if so, marks the deployment as cancelled. Returns ErrDeployCancelled or nil.
func (svc *Service) checkCancelled(
deployCtx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil
}
svc.log.Info("deployment cancelled by newer deploy", "app", app.Name)
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
return ErrDeployCancelled
}
func (svc *Service) fetchWebhookEvent( func (svc *Service) fetchWebhookEvent(
ctx context.Context, ctx context.Context,
webhookEventID *int64, webhookEventID *int64,

View File

@@ -0,0 +1,133 @@
package deploy_test
import (
"context"
"log/slog"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
// Should not panic or block when no active deploy exists
svc.CancelActiveDeploy("nonexistent-app")
}
func TestCancelActiveDeploy_CancelsAndWaits(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-1", cancel, done)
// Simulate a running deploy that respects cancellation
var deployFinished bool
go func() {
<-ctx.Done()
deployFinished = true
close(done)
}()
svc.CancelActiveDeploy("app-1")
assert.True(t, deployFinished, "deploy should have finished after cancellation")
}
func TestCancelActiveDeploy_BlocksUntilDone(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-2", cancel, done)
// Simulate slow cleanup after cancellation
go func() {
<-ctx.Done()
time.Sleep(50 * time.Millisecond)
close(done)
}()
start := time.Now()
svc.CancelActiveDeploy("app-2")
elapsed := time.Since(start)
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond,
"cancelActiveDeploy should block until the deploy finishes")
}
func TestTryLockApp_PreventsConcurrent(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
assert.True(t, svc.TryLockApp("app-1"), "first lock should succeed")
assert.False(t, svc.TryLockApp("app-1"), "second lock should fail")
svc.UnlockApp("app-1")
assert.True(t, svc.TryLockApp("app-1"), "lock after unlock should succeed")
svc.UnlockApp("app-1")
}
func TestCancelActiveDeploy_AllowsNewDeploy(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
// Simulate an active deploy holding the lock
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-3", cancel, done)
// Lock the app as if a deploy is in progress
assert.True(t, svc.TryLockApp("app-3"))
// Simulate deploy goroutine: release lock on cancellation
var mu sync.Mutex
released := false
go func() {
<-ctx.Done()
svc.UnlockApp("app-3")
mu.Lock()
released = true
mu.Unlock()
close(done)
}()
// Cancel should cause the old deploy to release its lock
svc.CancelActiveDeploy("app-3")
mu.Lock()
assert.True(t, released)
mu.Unlock()
// Now a new deploy should be able to acquire the lock
assert.True(t, svc.TryLockApp("app-3"), "should be able to lock after cancellation")
svc.UnlockApp("app-3")
}

View File

@@ -0,0 +1,33 @@
package deploy
import (
"context"
"log/slog"
)
// NewTestService creates a Service with minimal dependencies for testing.
func NewTestService(log *slog.Logger) *Service {
return &Service{
log: log,
}
}
// CancelActiveDeploy exposes cancelActiveDeploy for testing.
func (svc *Service) CancelActiveDeploy(appID string) {
svc.cancelActiveDeploy(appID)
}
// RegisterActiveDeploy registers an active deploy for testing.
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
}
// TryLockApp exposes tryLockApp for testing.
func (svc *Service) TryLockApp(appID string) bool {
return svc.tryLockApp(appID)
}
// UnlockApp exposes unlockApp for testing.
func (svc *Service) UnlockApp(appID string) {
svc.unlockApp(appID)
}

View File

@@ -143,7 +143,7 @@ func (svc *Service) triggerDeployment(
// even if the HTTP request context is cancelled. // even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx) deployCtx := context.WithoutCancel(ctx)
deployErr := svc.deploy.Deploy(deployCtx, app, &eventID) deployErr := svc.deploy.Deploy(deployCtx, app, &eventID, true)
if deployErr != nil { if deployErr != nil {
svc.log.Error("deployment failed", "error", deployErr, "app", appName) svc.log.Error("deployment failed", "error", deployErr, "app", appName)
} }

View File

@@ -57,6 +57,10 @@
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2;
} }
.btn-warning {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-warning-500 text-white hover:bg-warning-700 active:bg-orange-800 focus:ring-orange-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-text { .btn-text {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
} }

View File

@@ -40,6 +40,16 @@
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span> <span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
</button> </button>
</form> </form>
<form method="POST" action="/apps/{{.App.ID}}/deployments/cancel" class="inline" x-show="deploying" x-cloak x-data="confirmAction('Cancel the current deployment?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-danger">Cancel Deploy</button>
</form>
{{if .App.PreviousImageID.Valid}}
<form method="POST" action="/apps/{{.App.ID}}/rollback" class="inline" x-data="confirmAction('Roll back to the previous deployment?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-warning">Rollback</button>
</form>
{{end}}
</div> </div>
</div> </div>
@@ -102,15 +112,34 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .EnvVars}} {{range .EnvVars}}
<tr> <tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)"> <button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
{{ .CSRFField }} <form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -147,15 +176,33 @@
</td> </td>
</tr> </tr>
{{range .Labels}} {{range .Labels}}
<tr> <tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<td class="text-right"> <td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
{{ .CSRFField }} {{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -185,9 +232,14 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .Volumes}} {{range .Volumes}}
<tr> <tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono">{{.HostPath}}</td> <td class="font-mono">{{.HostPath}}</td>
</template>
<template x-if="!editing">
<td class="font-mono">{{.ContainerPath}}</td> <td class="font-mono">{{.ContainerPath}}</td>
</template>
<template x-if="!editing">
<td> <td>
{{if .ReadOnly}} {{if .ReadOnly}}
<span class="badge-neutral">Read-only</span> <span class="badge-neutral">Read-only</span>
@@ -195,12 +247,31 @@
<span class="badge-info">Read-write</span> <span class="badge-info">Read-write</span>
{{end}} {{end}}
</td> </td>
</template>
<template x-if="!editing">
<td class="text-right"> <td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
{{ .CSRFField }} {{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
</template>
<template x-if="editing">
<td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
RO
</label>
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>