Compare commits
1 Commits
feature/ed
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1525d59e |
8
TODO.md
8
TODO.md
@@ -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
|
||||||
- [x] Deployment cancellation
|
- [ ] 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
|
||||||
- [x] Add cancellation context to deploy service
|
- [ ] Add cancellation context to deploy service
|
||||||
- [x] Add `POST /apps/:id/deployments/:id/cancel` endpoint
|
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
|
||||||
- [x] Handle cleanup of partial builds/containers
|
- [ ] Handle cleanup of partial builds/containers
|
||||||
|
|
||||||
## Phase 4: Lower Priority (Nice to Have)
|
## Phase 4: Lower Priority (Nice to Have)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -1142,207 +1140,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,17 +83,14 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
// 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
|
||||||
|
|||||||
74
internal/service/deploy/deploy_rollback_test.go
Normal file
74
internal/service/deploy/deploy_rollback_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package deploy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/models"
|
||||||
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRollback_NoPreviousImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := deploy.NewTestService(slog.Default())
|
||||||
|
app := &models.App{
|
||||||
|
ID: "app-rollback-1",
|
||||||
|
PreviousImageID: sql.NullString{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Rollback(context.Background(), app)
|
||||||
|
assert.ErrorIs(t, err, deploy.ErrNoPreviousImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollback_EmptyPreviousImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := deploy.NewTestService(slog.Default())
|
||||||
|
app := &models.App{
|
||||||
|
ID: "app-rollback-2",
|
||||||
|
PreviousImageID: sql.NullString{String: "", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Rollback(context.Background(), app)
|
||||||
|
assert.ErrorIs(t, err, deploy.ErrNoPreviousImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollback_DeploymentLocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := deploy.NewTestService(slog.Default())
|
||||||
|
|
||||||
|
// Simulate a deploy holding the lock
|
||||||
|
assert.True(t, svc.TryLockApp("app-rollback-3"))
|
||||||
|
defer svc.UnlockApp("app-rollback-3")
|
||||||
|
|
||||||
|
app := &models.App{
|
||||||
|
ID: "app-rollback-3",
|
||||||
|
PreviousImageID: sql.NullString{String: "sha256:abc123", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Rollback(context.Background(), app)
|
||||||
|
assert.ErrorIs(t, err, deploy.ErrDeploymentInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollback_LockedApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := deploy.NewTestService(slog.Default())
|
||||||
|
|
||||||
|
assert.True(t, svc.TryLockApp("app-rollback-4"))
|
||||||
|
defer svc.UnlockApp("app-rollback-4")
|
||||||
|
|
||||||
|
app := &models.App{
|
||||||
|
ID: "app-rollback-4",
|
||||||
|
PreviousImageID: sql.NullString{String: "sha256:abc123", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Rollback(context.Background(), app)
|
||||||
|
assert.ErrorIs(t, err, deploy.ErrDeploymentInProgress)
|
||||||
|
}
|
||||||
@@ -112,34 +112,15 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .EnvVars}}
|
{{range .EnvVars}}
|
||||||
<tr x-data="{ editing: false }">
|
<tr>
|
||||||
<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}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @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}}/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>
|
||||||
@@ -176,33 +157,15 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Labels}}
|
{{range .Labels}}
|
||||||
<tr x-data="{ editing: false }">
|
<tr>
|
||||||
<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>
|
||||||
@@ -232,14 +195,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .Volumes}}
|
{{range .Volumes}}
|
||||||
<tr x-data="{ editing: false }">
|
<tr>
|
||||||
<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>
|
||||||
@@ -247,31 +205,12 @@
|
|||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user