feat: add edit support for env vars, labels, and volumes
- Add POST /apps/{id}/env-vars/{varID}/edit endpoint
- Add POST /apps/{id}/labels/{labelID}/edit endpoint
- Add POST /apps/{id}/volumes/{volumeID}/edit endpoint
- Add inline edit UI with Alpine.js toggle in app_detail template
- Models already support Save() with update when ID != 0
Closes #67
This commit is contained in:
parent
e31666ab5c
commit
8156305705
@ -896,6 +896,54 @@ func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 edit env var", "error", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleLabelAdd handles adding a label.
|
// HandleLabelAdd handles adding a label.
|
||||||
func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
|
func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@ -943,6 +991,54 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 edit label", "error", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleVolumeAdd handles adding a volume mount.
|
// HandleVolumeAdd handles adding a volume mount.
|
||||||
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@ -1021,6 +1117,56 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
volume.HostPath = hostPath
|
||||||
|
volume.ContainerPath = containerPath
|
||||||
|
volume.ReadOnly = readOnly
|
||||||
|
|
||||||
|
saveErr := volume.Save(request.Context())
|
||||||
|
if saveErr != nil {
|
||||||
|
h.log.Error("failed to edit volume", "error", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandlePortAdd handles adding a port mapping.
|
// HandlePortAdd handles adding a port mapping.
|
||||||
func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
|||||||
@ -54,47 +54,50 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.SessionAuth())
|
r.Use(s.mw.SessionAuth())
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
r.Get("/", s.handlers.HandleDashboard())
|
r.Get("/", s.handlers.HandleDashboard())
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
r.Post("/logout", s.handlers.HandleLogout())
|
r.Post("/logout", s.handlers.HandleLogout())
|
||||||
|
|
||||||
// App routes
|
// App routes
|
||||||
r.Get("/apps/new", s.handlers.HandleAppNew())
|
r.Get("/apps/new", s.handlers.HandleAppNew())
|
||||||
r.Post("/apps", s.handlers.HandleAppCreate())
|
r.Post("/apps", s.handlers.HandleAppCreate())
|
||||||
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
|
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
|
||||||
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
|
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
|
||||||
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.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())
|
||||||
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
|
||||||
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}/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}/delete", s.handlers.HandleEnvVarDelete())
|
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
||||||
|
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}/delete", s.handlers.HandleLabelDelete())
|
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
|
||||||
|
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}/delete", s.handlers.HandleVolumeDelete())
|
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
|
||||||
|
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
|
||||||
|
|
||||||
// Ports
|
// Ports
|
||||||
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
||||||
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -106,15 +106,33 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .EnvVars}}
|
{{range .EnvVars}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<template x-if="!editing">
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
<td class="text-right">
|
</template>
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
<template x-if="!editing">
|
||||||
{{ .CSRFField }}
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
</template>
|
||||||
</form>
|
<template x-if="!editing">
|
||||||
</td>
|
<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-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>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="editing">
|
||||||
|
<td colspan="3">
|
||||||
|
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex flex-col sm:flex-row 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>
|
||||||
@ -151,15 +169,33 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Labels}}
|
{{range .Labels}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<template x-if="!editing">
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
<td class="text-right">
|
</template>
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
<template x-if="!editing">
|
||||||
{{ .CSRFField }}
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
</template>
|
||||||
</form>
|
<template x-if="!editing">
|
||||||
</td>
|
<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)">
|
||||||
|
{{ .CSRFField }}
|
||||||
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="editing">
|
||||||
|
<td colspan="3">
|
||||||
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex flex-col sm:flex-row 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>
|
||||||
@ -189,22 +225,46 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .Volumes}}
|
{{range .Volumes}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
<td class="font-mono">{{.HostPath}}</td>
|
<template x-if="!editing">
|
||||||
<td class="font-mono">{{.ContainerPath}}</td>
|
<td class="font-mono">{{.HostPath}}</td>
|
||||||
<td>
|
</template>
|
||||||
{{if .ReadOnly}}
|
<template x-if="!editing">
|
||||||
<span class="badge-neutral">Read-only</span>
|
<td class="font-mono">{{.ContainerPath}}</td>
|
||||||
{{else}}
|
</template>
|
||||||
<span class="badge-info">Read-write</span>
|
<template x-if="!editing">
|
||||||
{{end}}
|
<td>
|
||||||
</td>
|
{{if .ReadOnly}}
|
||||||
<td class="text-right">
|
<span class="badge-neutral">Read-only</span>
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
{{else}}
|
||||||
{{ .CSRFField }}
|
<span class="badge-info">Read-write</span>
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
{{end}}
|
||||||
</form>
|
</td>
|
||||||
</td>
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
|
<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)">
|
||||||
|
{{ .CSRFField }}
|
||||||
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="editing">
|
||||||
|
<td colspan="4">
|
||||||
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex flex-col sm:flex-row 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-2 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">
|
||||||
|
Read-only
|
||||||
|
</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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user