feat: Enhance static file serving and image management notifications
- Implemented a new static file serving mechanism under the `/assets/*` path with cache control based on the environment. - Refactored the image management UI to include a notification area for displaying messages related to image pruning operations. - Updated the `ImageManagerPrune` handler to run pruning in the background and notify the client of the operation's status. - Added event handling in the frontend to display success or error messages based on the pruning result. - Removed deprecated static route handling for improved clarity and maintainability.
- commit
- e39fdc86958033405dee3379d432ee915d73c22e
- author
- bamen · · 2025-03-30 13:01 UTC
- parent
- ad19124
- signature
- unsigned
4 files · +140 -39
M internal/httpserve/handlers/container_management.go +61 -5
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package handlers | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "strings" | |
| @@ -18,6 +19,14 @@ import ( | |||
| 18 | 19 | "github.com/labstack/echo/v4" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 22 | + | // Shared state for last prune result | |
| 23 | + | var lastPruneResult struct { | |
| 24 | + | Done bool | |
| 25 | + | Success bool | |
| 26 | + | Message string | |
| 27 | + | mutex sync.Mutex | |
| 28 | + | } | |
| 29 | + | ||
| 21 | 30 | type MapOperation string | |
| 22 | 31 | ||
| 23 | 32 | var ( | |
| @@ -107,6 +116,25 @@ func ActionSuccess(a *server.App) string { | |||
| 107 | 116 | ||
| 108 | 117 | // ImageManagerComponent handles the /image-manager route (HTMX route) | |
| 109 | 118 | func ImageManagerComponent(c echo.Context, a *server.App) error { | |
| 119 | + | // Check last prune status and add trigger header if needed | |
| 120 | + | lastPruneResult.mutex.Lock() | |
| 121 | + | if lastPruneResult.Done { | |
| 122 | + | var level string | |
| 123 | + | if lastPruneResult.Success { | |
| 124 | + | level = "success" | |
| 125 | + | } else { | |
| 126 | + | level = "error" | |
| 127 | + | } | |
| 128 | + | // Prepare JSON payload for HX-Trigger | |
| 129 | + | payload := map[string]string{"level": level, "message": lastPruneResult.Message} | |
| 130 | + | jsonPayload, _ := json.Marshal(payload) // Error handling omitted for brevity | |
| 131 | + | c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload))) | |
| 132 | + | ||
| 133 | + | // Reset the flag | |
| 134 | + | lastPruneResult.Done = false | |
| 135 | + | } | |
| 136 | + | lastPruneResult.mutex.Unlock() | |
| 137 | + | ||
| 110 | 138 | images, err := docker.ListContainerImages() | |
| 111 | 139 | if err != nil { | |
| 112 | 140 | return sendError(c, err) | |
| @@ -179,11 +207,39 @@ func ImageManagerDelete(c echo.Context, a *server.App) error { | |||
| 179 | 207 | ||
| 180 | 208 | // ImageManagerPrune handles the /image-manager/prune route | |
| 181 | 209 | func ImageManagerPrune(c echo.Context, a *server.App) error { | |
| 182 | - | numPurged, err := docker.PruneImages() | |
| 183 | - | if err != nil { | |
| 184 | - | return sendError(c, err) | |
| 185 | - | } | |
| 186 | - | return c.String(http.StatusOK, fmt.Sprintf("Successfully purged %d images", numPurged)) | |
| 210 | + | // Run pruning in a background goroutine | |
| 211 | + | go func() { | |
| 212 | + | var success bool | |
| 213 | + | var message string | |
| 214 | + | logger.Info("Starting background image prune...") | |
| 215 | + | numPurged, err := docker.PruneImages() // Remove context argument | |
| 216 | + | if err != nil { | |
| 217 | + | logger.Error("Background image prune failed", "error", err) | |
| 218 | + | success = false | |
| 219 | + | message = fmt.Sprintf("Image prune failed: %v", err) | |
| 220 | + | // Optionally, notify the user or admin about the failure (e.g., via websocket, SSE, or a status endpoint) | |
| 221 | + | return | |
| 222 | + | } else { | |
| 223 | + | logger.Info("Background image prune completed", "purged_count", numPurged) | |
| 224 | + | success = true | |
| 225 | + | message = fmt.Sprintf("Successfully purged %d images.", numPurged) | |
| 226 | + | // Optionally, notify the user about completion (e.g., via websocket, SSE, or updating the UI) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // Update shared state | |
| 230 | + | lastPruneResult.mutex.Lock() | |
| 231 | + | lastPruneResult.Done = true | |
| 232 | + | lastPruneResult.Success = success | |
| 233 | + | lastPruneResult.Message = message | |
| 234 | + | lastPruneResult.mutex.Unlock() | |
| 235 | + | }() | |
| 236 | + | ||
| 237 | + | // Immediately return 202 Accepted to the client | |
| 238 | + | c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) // Set appropriate content type if needed for HTMX | |
| 239 | + | c.Response().WriteHeader(http.StatusAccepted) | |
| 240 | + | // Optionally, return a message or updated component indicating the process has started | |
| 241 | + | // For now, just return a simple message in the response body | |
| 242 | + | return c.String(http.StatusAccepted, "Image pruning started in the background.") | |
| 187 | 243 | } | |
| 188 | 244 | ||
| 189 | 245 | // ContainerManagerComponent handles the /container-manager route | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package handlers | 1 | package handlers | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/json" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -18,6 +19,14 @@ import ( | |||||
| 18 | "github.com/labstack/echo/v4" | 19 | "github.com/labstack/echo/v4" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 22 | + | // Shared state for last prune result | |||
| 23 | + | var lastPruneResult struct { | |||
| 24 | + | Done bool | |||
| 25 | + | Success bool | |||
| 26 | + | Message string | |||
| 27 | + | mutex sync.Mutex | |||
| 28 | + | } | |||
| 29 | + | ||||
| 21 | type MapOperation string | 30 | type MapOperation string | ||
| 22 | 31 | ||||
| 23 | var ( | 32 | var ( | ||
| @@ -107,6 +116,25 @@ func ActionSuccess(a *server.App) string { | |||||
| 107 | 116 | ||||
| 108 | // ImageManagerComponent handles the /image-manager route (HTMX route) | 117 | // ImageManagerComponent handles the /image-manager route (HTMX route) | ||
| 109 | func ImageManagerComponent(c echo.Context, a *server.App) error { | 118 | func ImageManagerComponent(c echo.Context, a *server.App) error { | ||
| 119 | + | // Check last prune status and add trigger header if needed | |||
| 120 | + | lastPruneResult.mutex.Lock() | |||
| 121 | + | if lastPruneResult.Done { | |||
| 122 | + | var level string | |||
| 123 | + | if lastPruneResult.Success { | |||
| 124 | + | level = "success" | |||
| 125 | + | } else { | |||
| 126 | + | level = "error" | |||
| 127 | + | } | |||
| 128 | + | // Prepare JSON payload for HX-Trigger | |||
| 129 | + | payload := map[string]string{"level": level, "message": lastPruneResult.Message} | |||
| 130 | + | jsonPayload, _ := json.Marshal(payload) // Error handling omitted for brevity | |||
| 131 | + | c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload))) | |||
| 132 | + | ||||
| 133 | + | // Reset the flag | |||
| 134 | + | lastPruneResult.Done = false | |||
| 135 | + | } | |||
| 136 | + | lastPruneResult.mutex.Unlock() | |||
| 137 | + | ||||
| 110 | images, err := docker.ListContainerImages() | 138 | images, err := docker.ListContainerImages() | ||
| 111 | if err != nil { | 139 | if err != nil { | ||
| 112 | return sendError(c, err) | 140 | return sendError(c, err) | ||
| @@ -179,11 +207,39 @@ func ImageManagerDelete(c echo.Context, a *server.App) error { | |||||
| 179 | 207 | ||||
| 180 | // ImageManagerPrune handles the /image-manager/prune route | 208 | // ImageManagerPrune handles the /image-manager/prune route | ||
| 181 | func ImageManagerPrune(c echo.Context, a *server.App) error { | 209 | func ImageManagerPrune(c echo.Context, a *server.App) error { | ||
| 182 | - | numPurged, err := docker.PruneImages() | 210 | + | // Run pruning in a background goroutine |
| 183 | - | if err != nil { | 211 | + | go func() { |
| 184 | - | return sendError(c, err) | 212 | + | var success bool |
| 185 | - | } | 213 | + | var message string |
| 186 | - | return c.String(http.StatusOK, fmt.Sprintf("Successfully purged %d images", numPurged)) | 214 | + | logger.Info("Starting background image prune...") |
| 215 | + | numPurged, err := docker.PruneImages() // Remove context argument | |||
| 216 | + | if err != nil { | |||
| 217 | + | logger.Error("Background image prune failed", "error", err) | |||
| 218 | + | success = false | |||
| 219 | + | message = fmt.Sprintf("Image prune failed: %v", err) | |||
| 220 | + | // Optionally, notify the user or admin about the failure (e.g., via websocket, SSE, or a status endpoint) | |||
| 221 | + | return | |||
| 222 | + | } else { | |||
| 223 | + | logger.Info("Background image prune completed", "purged_count", numPurged) | |||
| 224 | + | success = true | |||
| 225 | + | message = fmt.Sprintf("Successfully purged %d images.", numPurged) | |||
| 226 | + | // Optionally, notify the user about completion (e.g., via websocket, SSE, or updating the UI) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // Update shared state | |||
| 230 | + | lastPruneResult.mutex.Lock() | |||
| 231 | + | lastPruneResult.Done = true | |||
| 232 | + | lastPruneResult.Success = success | |||
| 233 | + | lastPruneResult.Message = message | |||
| 234 | + | lastPruneResult.mutex.Unlock() | |||
| 235 | + | }() | |||
| 236 | + | ||||
| 237 | + | // Immediately return 202 Accepted to the client | |||
| 238 | + | c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) // Set appropriate content type if needed for HTMX | |||
| 239 | + | c.Response().WriteHeader(http.StatusAccepted) | |||
| 240 | + | // Optionally, return a message or updated component indicating the process has started | |||
| 241 | + | // For now, just return a simple message in the response body | |||
| 242 | + | return c.String(http.StatusAccepted, "Image pruning started in the background.") | |||
| 187 | } | 243 | } | ||
| 188 | 244 | ||||
| 189 | // ContainerManagerComponent handles the /container-manager route | 245 | // ContainerManagerComponent handles the /container-manager route | ||
M internal/httpserve/handlers/static.go +6 -24
| @@ -3,10 +3,6 @@ package handlers | |||
| 3 | 3 | import ( | |
| 4 | 4 | "io/fs" | |
| 5 | 5 | "net/http" | |
| 6 | - | "os" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | 6 | ) | |
| 11 | 7 | ||
| 12 | 8 | // NoDirFileSys restricts directory listing | |
| @@ -14,6 +10,11 @@ type NoDirFileSys struct { | |||
| 14 | 10 | fs http.FileSystem | |
| 15 | 11 | } | |
| 16 | 12 | ||
| 13 | + | // NewNoDirFileSys creates a new NoDirFileSys | |
| 14 | + | func NewNoDirFileSys(fs http.FileSystem) NoDirFileSys { | |
| 15 | + | return NoDirFileSys{fs: fs} | |
| 16 | + | } | |
| 17 | + | ||
| 17 | 18 | func (nfs NoDirFileSys) Open(name string) (http.File, error) { | |
| 18 | 19 | f, err := nfs.fs.Open(name) | |
| 19 | 20 | if err != nil { | |
| @@ -33,23 +34,4 @@ func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) { | |||
| 33 | 34 | return nil, nil | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | - | // StaticRoute serves static files from the embedded filesystem | |
| 37 | - | func StaticRoute(c echo.Context, a *server.App) error { | |
| 38 | - | // Set the cache-control header if we are not in dev mode | |
| 39 | - | if os.Getenv("RUN_ENV") != "dev" { | |
| 40 | - | c.Response().Header().Set("Cache-Control", "public, max-age=86400") | |
| 41 | - | } else { | |
| 42 | - | c.Response().Header().Set("Cache-Control", "no-cache") | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Convert fs.FS to http.FileSystem | |
| 46 | - | httpFS := http.FS(a.PublicFS) | |
| 47 | - | ||
| 48 | - | // Use custom file system wrapper to disable directory listing | |
| 49 | - | noDirFS := NoDirFileSys{httpFS} | |
| 50 | - | ||
| 51 | - | // Serve the file from the custom filesystem | |
| 52 | - | publicFS := http.FileServer(noDirFS) | |
| 53 | - | publicFS.ServeHTTP(c.Response(), c.Request()) | |
| 54 | - | return nil | |
| 55 | - | } | |
| 37 | + | // StaticRoute serves static files from the embedded filesystem - REMOVED | |
| @@ -3,10 +3,6 @@ package handlers | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io/fs" | 4 | "io/fs" | ||
| 5 | "net/http" | 5 | "net/http" | ||
| 6 | - | "os" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/gordon/internal/server" | |||
| 9 | - | "github.com/labstack/echo/v4" | |||
| 10 | ) | 6 | ) | ||
| 11 | 7 | ||||
| 12 | // NoDirFileSys restricts directory listing | 8 | // NoDirFileSys restricts directory listing | ||
| @@ -14,6 +10,11 @@ type NoDirFileSys struct { | |||||
| 14 | fs http.FileSystem | 10 | fs http.FileSystem | ||
| 15 | } | 11 | } | ||
| 16 | 12 | ||||
| 13 | + | // NewNoDirFileSys creates a new NoDirFileSys | |||
| 14 | + | func NewNoDirFileSys(fs http.FileSystem) NoDirFileSys { | |||
| 15 | + | return NoDirFileSys{fs: fs} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 17 | func (nfs NoDirFileSys) Open(name string) (http.File, error) { | 18 | func (nfs NoDirFileSys) Open(name string) (http.File, error) { | ||
| 18 | f, err := nfs.fs.Open(name) | 19 | f, err := nfs.fs.Open(name) | ||
| 19 | if err != nil { | 20 | if err != nil { | ||
| @@ -33,23 +34,4 @@ func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) { | |||||
| 33 | return nil, nil | 34 | return nil, nil | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | - | // StaticRoute serves static files from the embedded filesystem | 37 | + | // StaticRoute serves static files from the embedded filesystem - REMOVED |
| 37 | - | func StaticRoute(c echo.Context, a *server.App) error { | |||
| 38 | - | // Set the cache-control header if we are not in dev mode | |||
| 39 | - | if os.Getenv("RUN_ENV") != "dev" { | |||
| 40 | - | c.Response().Header().Set("Cache-Control", "public, max-age=86400") | |||
| 41 | - | } else { | |||
| 42 | - | c.Response().Header().Set("Cache-Control", "no-cache") | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Convert fs.FS to http.FileSystem | |||
| 46 | - | httpFS := http.FS(a.PublicFS) | |||
| 47 | - | ||||
| 48 | - | // Use custom file system wrapper to disable directory listing | |||
| 49 | - | noDirFS := NoDirFileSys{httpFS} | |||
| 50 | - | ||||
| 51 | - | // Serve the file from the custom filesystem | |||
| 52 | - | publicFS := http.FileServer(noDirFS) | |||
| 53 | - | publicFS.ServeHTTP(c.Response(), c.Request()) | |||
| 54 | - | return nil | |||
| 55 | - | } | |||
M internal/httpserve/router.go +22 -9
| @@ -2,6 +2,7 @@ package httpserve | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net/http" | |
| 5 | + | "os" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 7 | 8 | "github.com/bnema/gordon/internal/httpserve/middleware" | |
| @@ -53,8 +54,27 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||
| 53 | 54 | log.Debug("Binding admin routes with AdminPath", "path", AdminPath) | |
| 54 | 55 | bindAdminRoute(e, a, AdminPath) | |
| 55 | 56 | ||
| 56 | - | log.Debug("Binding static routes") | |
| 57 | - | bindStaticRoute(e, a, "/*") | |
| 57 | + | log.Debug("Binding static routes under /assets/*") | |
| 58 | + | ||
| 59 | + | // Setup static file server | |
| 60 | + | httpFS := http.FS(a.PublicFS) | |
| 61 | + | noDirFS := handlers.NewNoDirFileSys(httpFS) | |
| 62 | + | fileServer := http.FileServer(noDirFS) | |
| 63 | + | ||
| 64 | + | // Middleware to set cache headers for static files | |
| 65 | + | staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 66 | + | return func(c echo.Context) error { | |
| 67 | + | if os.Getenv("RUN_ENV") != "dev" { | |
| 68 | + | c.Response().Header().Set("Cache-Control", "public, max-age=86400") // 1 day | |
| 69 | + | } else { | |
| 70 | + | c.Response().Header().Set("Cache-Control", "no-cache") | |
| 71 | + | } | |
| 72 | + | return next(c) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Serve static files from /assets path | |
| 77 | + | e.GET("/assets/*", echo.WrapHandler(http.StripPrefix("/assets/", fileServer)), staticCacheMiddleware, echomid.Gzip()) | |
| 58 | 78 | ||
| 59 | 79 | // Only bind HTMX endpoints if Admin WebUI is enabled | |
| 60 | 80 | if a.Config.Admin.IsAdminWebUIEnabled() { | |
| @@ -116,13 +136,6 @@ func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |||
| 116 | 136 | }) | |
| 117 | 137 | } | |
| 118 | 138 | ||
| 119 | - | // bindStaticRoute bind static path | |
| 120 | - | func bindStaticRoute(e *echo.Echo, a *server.App, path string) { | |
| 121 | - | e.GET(path, func(c echo.Context) error { | |
| 122 | - | return handlers.StaticRoute(c, a) | |
| 123 | - | }, echomid.Gzip()) | |
| 124 | - | } | |
| 125 | - | ||
| 126 | 139 | // bindLoginRoute binds all login routes | |
| 127 | 140 | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |
| 128 | 141 | // Skip binding admin login routes if admin webUI is disabled | |
| @@ -2,6 +2,7 @@ package httpserve | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "net/http" | 4 | "net/http" | ||
| 5 | + | "os" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/gordon/internal/httpserve/handlers" | 7 | "github.com/bnema/gordon/internal/httpserve/handlers" | ||
| 7 | "github.com/bnema/gordon/internal/httpserve/middleware" | 8 | "github.com/bnema/gordon/internal/httpserve/middleware" | ||
| @@ -53,8 +54,27 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||||
| 53 | log.Debug("Binding admin routes with AdminPath", "path", AdminPath) | 54 | log.Debug("Binding admin routes with AdminPath", "path", AdminPath) | ||
| 54 | bindAdminRoute(e, a, AdminPath) | 55 | bindAdminRoute(e, a, AdminPath) | ||
| 55 | 56 | ||||
| 56 | - | log.Debug("Binding static routes") | 57 | + | log.Debug("Binding static routes under /assets/*") |
| 57 | - | bindStaticRoute(e, a, "/*") | 58 | + | |
| 59 | + | // Setup static file server | |||
| 60 | + | httpFS := http.FS(a.PublicFS) | |||
| 61 | + | noDirFS := handlers.NewNoDirFileSys(httpFS) | |||
| 62 | + | fileServer := http.FileServer(noDirFS) | |||
| 63 | + | ||||
| 64 | + | // Middleware to set cache headers for static files | |||
| 65 | + | staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 66 | + | return func(c echo.Context) error { | |||
| 67 | + | if os.Getenv("RUN_ENV") != "dev" { | |||
| 68 | + | c.Response().Header().Set("Cache-Control", "public, max-age=86400") // 1 day | |||
| 69 | + | } else { | |||
| 70 | + | c.Response().Header().Set("Cache-Control", "no-cache") | |||
| 71 | + | } | |||
| 72 | + | return next(c) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Serve static files from /assets path | |||
| 77 | + | e.GET("/assets/*", echo.WrapHandler(http.StripPrefix("/assets/", fileServer)), staticCacheMiddleware, echomid.Gzip()) | |||
| 58 | 78 | ||||
| 59 | // Only bind HTMX endpoints if Admin WebUI is enabled | 79 | // Only bind HTMX endpoints if Admin WebUI is enabled | ||
| 60 | if a.Config.Admin.IsAdminWebUIEnabled() { | 80 | if a.Config.Admin.IsAdminWebUIEnabled() { | ||
| @@ -116,13 +136,6 @@ func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |||||
| 116 | }) | 136 | }) | ||
| 117 | } | 137 | } | ||
| 118 | 138 | ||||
| 119 | - | // bindStaticRoute bind static path | |||
| 120 | - | func bindStaticRoute(e *echo.Echo, a *server.App, path string) { | |||
| 121 | - | e.GET(path, func(c echo.Context) error { | |||
| 122 | - | return handlers.StaticRoute(c, a) | |||
| 123 | - | }, echomid.Gzip()) | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | // bindLoginRoute binds all login routes | 139 | // bindLoginRoute binds all login routes | ||
| 127 | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | 140 | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | ||
| 128 | // Skip binding admin login routes if admin webUI is disabled | 141 | // Skip binding admin login routes if admin webUI is disabled | ||
M internal/templating/models/templ/components/imagelist.templ +51 -1
| @@ -43,12 +43,17 @@ templ ImageList(images []HumanReadableImage) { | |||
| 43 | 43 | <span class="iconf"></span> Refresh | |
| 44 | 44 | </button> | |
| 45 | 45 | <button | |
| 46 | + | id="purge-button" | |
| 46 | 47 | class="btn btn-sm btn-warning" | |
| 47 | 48 | hx-post="/htmx/image-manager/prune" | |
| 48 | 49 | hx-target="#image-manager" | |
| 49 | 50 | hx-swap="innerHTML" | |
| 51 | + | hx-indicator="#purge-spinner" | |
| 50 | 52 | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | |
| 51 | - | <span class="iconf"></span> Purge Unused | |
| 53 | + | <span class="btn-content flex items-center"> | |
| 54 | + | <span class="iconf mr-1"></span> Purge Unused | |
| 55 | + | </span> | |
| 56 | + | <span class="loading loading-spinner loading-sm hidden" id="purge-spinner"></span> | |
| 52 | 57 | </button> | |
| 53 | 58 | </div> | |
| 54 | 59 | </div> | |
| @@ -133,7 +138,52 @@ templ ImageList(images []HumanReadableImage) { | |||
| 133 | 138 | </div> | |
| 134 | 139 | </div> | |
| 135 | 140 | ||
| 141 | + | <!-- Notification Area --> | |
| 142 | + | <div id="image-prune-notifications" class="fixed bottom-4 right-4 z-50 space-y-2"></div> | |
| 143 | + | ||
| 144 | + | <style> | |
| 145 | + | #purge-button.htmx-request .btn-content { | |
| 146 | + | display: none; | |
| 147 | + | } | |
| 148 | + | #purge-button.htmx-request #purge-spinner { | |
| 149 | + | display: inline-block; | |
| 150 | + | } | |
| 151 | + | </style> | |
| 152 | + | ||
| 136 | 153 | <script> | |
| 137 | 154 | console.debug('Image list rendered'); | |
| 155 | + | ||
| 156 | + | document.body.addEventListener('showMessage', function(evt) { | |
| 157 | + | console.debug('showMessage event received:', evt.detail); | |
| 158 | + | const detail = evt.detail; | |
| 159 | + | const notificationArea = document.getElementById('image-prune-notifications'); | |
| 160 | + | if (!notificationArea || !detail || !detail.message) return; | |
| 161 | + | ||
| 162 | + | const alertDiv = document.createElement('div'); | |
| 163 | + | let alertClass = 'alert-info'; // Default | |
| 164 | + | let iconClass = 'ℹ️'; // Default icon | |
| 165 | + | ||
| 166 | + | if (detail.level === 'success') { | |
| 167 | + | alertClass = 'alert-success'; | |
| 168 | + | iconClass = ''; // Checkmark icon | |
| 169 | + | } else if (detail.level === 'error') { | |
| 170 | + | alertClass = 'alert-error'; | |
| 171 | + | iconClass = ''; // Error icon | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | alertDiv.className = `alert ${alertClass} shadow-lg flex`; | |
| 175 | + | alertDiv.innerHTML = ` | |
| 176 | + | <span class="iconf text-xl">${iconClass}</span> | |
| 177 | + | <span>${detail.message}</span> | |
| 178 | + | <button class="btn btn-sm btn-ghost ml-auto" onclick="this.parentElement.remove()">✕</button> | |
| 179 | + | `; | |
| 180 | + | ||
| 181 | + | notificationArea.appendChild(alertDiv); | |
| 182 | + | ||
| 183 | + | // Automatically remove the alert after 5 seconds | |
| 184 | + | setTimeout(() => { | |
| 185 | + | alertDiv.remove(); | |
| 186 | + | }, 5000); | |
| 187 | + | }); | |
| 138 | 188 | </script> | |
| 139 | 189 | } ⏎̸ | |
| @@ -43,12 +43,17 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 43 | <span class="iconf"></span> Refresh | 43 | <span class="iconf"></span> Refresh | ||
| 44 | </button> | 44 | </button> | ||
| 45 | <button | 45 | <button | ||
| 46 | + | id="purge-button" | |||
| 46 | class="btn btn-sm btn-warning" | 47 | class="btn btn-sm btn-warning" | ||
| 47 | hx-post="/htmx/image-manager/prune" | 48 | hx-post="/htmx/image-manager/prune" | ||
| 48 | hx-target="#image-manager" | 49 | hx-target="#image-manager" | ||
| 49 | hx-swap="innerHTML" | 50 | hx-swap="innerHTML" | ||
| 51 | + | hx-indicator="#purge-spinner" | |||
| 50 | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | 52 | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | ||
| 51 | - | <span class="iconf"></span> Purge Unused | 53 | + | <span class="btn-content flex items-center"> |
| 54 | + | <span class="iconf mr-1"></span> Purge Unused | |||
| 55 | + | </span> | |||
| 56 | + | <span class="loading loading-spinner loading-sm hidden" id="purge-spinner"></span> | |||
| 52 | </button> | 57 | </button> | ||
| 53 | </div> | 58 | </div> | ||
| 54 | </div> | 59 | </div> | ||
| @@ -133,7 +138,52 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 133 | </div> | 138 | </div> | ||
| 134 | </div> | 139 | </div> | ||
| 135 | 140 | ||||
| 141 | + | <!-- Notification Area --> | |||
| 142 | + | <div id="image-prune-notifications" class="fixed bottom-4 right-4 z-50 space-y-2"></div> | |||
| 143 | + | ||||
| 144 | + | <style> | |||
| 145 | + | #purge-button.htmx-request .btn-content { | |||
| 146 | + | display: none; | |||
| 147 | + | } | |||
| 148 | + | #purge-button.htmx-request #purge-spinner { | |||
| 149 | + | display: inline-block; | |||
| 150 | + | } | |||
| 151 | + | </style> | |||
| 152 | + | ||||
| 136 | <script> | 153 | <script> | ||
| 137 | console.debug('Image list rendered'); | 154 | console.debug('Image list rendered'); | ||
| 155 | + | ||||
| 156 | + | document.body.addEventListener('showMessage', function(evt) { | |||
| 157 | + | console.debug('showMessage event received:', evt.detail); | |||
| 158 | + | const detail = evt.detail; | |||
| 159 | + | const notificationArea = document.getElementById('image-prune-notifications'); | |||
| 160 | + | if (!notificationArea || !detail || !detail.message) return; | |||
| 161 | + | ||||
| 162 | + | const alertDiv = document.createElement('div'); | |||
| 163 | + | let alertClass = 'alert-info'; // Default | |||
| 164 | + | let iconClass = 'ℹ️'; // Default icon | |||
| 165 | + | ||||
| 166 | + | if (detail.level === 'success') { | |||
| 167 | + | alertClass = 'alert-success'; | |||
| 168 | + | iconClass = ''; // Checkmark icon | |||
| 169 | + | } else if (detail.level === 'error') { | |||
| 170 | + | alertClass = 'alert-error'; | |||
| 171 | + | iconClass = ''; // Error icon | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | alertDiv.className = `alert ${alertClass} shadow-lg flex`; | |||
| 175 | + | alertDiv.innerHTML = ` | |||
| 176 | + | <span class="iconf text-xl">${iconClass}</span> | |||
| 177 | + | <span>${detail.message}</span> | |||
| 178 | + | <button class="btn btn-sm btn-ghost ml-auto" onclick="this.parentElement.remove()">✕</button> | |||
| 179 | + | `; | |||
| 180 | + | ||||
| 181 | + | notificationArea.appendChild(alertDiv); | |||
| 182 | + | ||||
| 183 | + | // Automatically remove the alert after 5 seconds | |||
| 184 | + | setTimeout(() => { | |||
| 185 | + | alertDiv.remove(); | |||
| 186 | + | }, 5000); | |||
| 187 | + | }); | |||
| 138 | </script> | 188 | </script> | ||
| 139 | } ⏎̸ | 189 | } ⏎̸ | ||