detached e39fdc8

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 @@
11 package handlers
22
33 import (
4+ "encoding/json"
45 "fmt"
56 "net/http"
67 "strings"
@@ -18,6 +19,14 @@ import (
1819 "github.com/labstack/echo/v4"
1920 )
2021
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+
2130 type MapOperation string
2231
2332 var (
@@ -107,6 +116,25 @@ func ActionSuccess(a *server.App) string {
107116
108117 // ImageManagerComponent handles the /image-manager route (HTMX route)
109118 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+
110138 images, err := docker.ListContainerImages()
111139 if err != nil {
112140 return sendError(c, err)
@@ -179,11 +207,39 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
179207
180208 // ImageManagerPrune handles the /image-manager/prune route
181209 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.")
187243 }
188244
189245 // ContainerManagerComponent handles the /container-manager route
@@ -1,6 +1,7 @@
1 package handlers1 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 string30 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 route208 // 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 route245 // ContainerManagerComponent handles the /container-manager route
M internal/httpserve/handlers/static.go +6 -24
@@ -3,10 +3,6 @@ package handlers
33 import (
44 "io/fs"
55 "net/http"
6- "os"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/labstack/echo/v4"
106 )
117
128 // NoDirFileSys restricts directory listing
@@ -14,6 +10,11 @@ type NoDirFileSys struct {
1410 fs http.FileSystem
1511 }
1612
13+// NewNoDirFileSys creates a new NoDirFileSys
14+func NewNoDirFileSys(fs http.FileSystem) NoDirFileSys {
15+ return NoDirFileSys{fs: fs}
16+}
17+
1718 func (nfs NoDirFileSys) Open(name string) (http.File, error) {
1819 f, err := nfs.fs.Open(name)
1920 if err != nil {
@@ -33,23 +34,4 @@ func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) {
3334 return nil, nil
3435 }
3536
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 listing8 // NoDirFileSys restricts directory listing
@@ -14,6 +10,11 @@ type NoDirFileSys struct {
14 fs http.FileSystem10 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, nil34 return nil, nil
34 }35 }
35 36
36-// StaticRoute serves static files from the embedded filesystem37+// 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
22
33 import (
44 "net/http"
5+ "os"
56
67 "github.com/bnema/gordon/internal/httpserve/handlers"
78 "github.com/bnema/gordon/internal/httpserve/middleware"
@@ -53,8 +54,27 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
5354 log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
5455 bindAdminRoute(e, a, AdminPath)
5556
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())
5878
5979 // Only bind HTMX endpoints if Admin WebUI is enabled
6080 if a.Config.Admin.IsAdminWebUIEnabled() {
@@ -116,13 +136,6 @@ func bindAPIEndpoints(e *echo.Echo, a *server.App) {
116136 })
117137 }
118138
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-
126139 // bindLoginRoute binds all login routes
127140 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
128141 // 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 enabled79 // 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 routes139 // 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 disabled141 // 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) {
4343 <span class="iconf">󰑓</span> Refresh
4444 </button>
4545 <button
46+ id="purge-button"
4647 class="btn btn-sm btn-warning"
4748 hx-post="/htmx/image-manager/prune"
4849 hx-target="#image-manager"
4950 hx-swap="innerHTML"
51+ hx-indicator="#purge-spinner"
5052 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>
5257 </button>
5358 </div>
5459 </div>
@@ -133,7 +138,52 @@ templ ImageList(images []HumanReadableImage) {
133138 </div>
134139 </div>
135140
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+
136153 <script>
137154 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+ });
138188 </script>
139189 } ⏎̸
@@ -43,12 +43,17 @@ templ ImageList(images []HumanReadableImage) {
43 <span class="iconf">󰑓</span> Refresh43 <span class="iconf">󰑓</span> Refresh
44 </button>44 </button>
45 <button45 <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 Unused53+ <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 } ⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close