detached db1fafa

refactor: Enhance static file serving and logging mechanisms

- Updated static file serving to conditionally use OS filesystem or embedded filesystem based on GORDON_LIVE_UI environment variable.
- Improved logging for static file serving to provide clarity on the source of served files.
- Refactored session middleware application to ensure it runs after static file handling.
- Implemented a log cooldown mechanism for blocking direct IP access attempts, reducing log noise while maintaining security.
- Updated template components to reference assets from the new serving path for consistency.
commit
db1fafa779eaf0cab20d49e48f6122c6214b3dc3
author
bamen · · 2025-03-30 18:40 UTC
parent
8791634
signature
unsigned
7 files · +95 -38
M internal/httpserve/router.go +35 -21
@@ -1,13 +1,15 @@
11 package httpserve
22
33 import (
4+ "io/fs"
45 "net/http"
56 "os"
67
78 "github.com/bnema/gordon/internal/httpserve/handlers"
89 "github.com/bnema/gordon/internal/httpserve/middleware"
910 "github.com/bnema/gordon/internal/server"
10- "github.com/charmbracelet/log"
11+ "github.com/bnema/gordon/internal/webui" // Import the webui package
12+ log "github.com/bnema/gordon/pkg/logger"
1113 _ "github.com/joho/godotenv/autoload"
1214 "github.com/labstack/echo-contrib/session"
1315 "github.com/labstack/echo/v4"
@@ -18,36 +20,31 @@ import (
1820 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
1921 e.Use(echomid.Recover())
2022
23+ // Determine if running in live UI mode
24+ useOS := os.Getenv("GORDON_LIVE_UI") == "true"
25+ staticFileSystem := getStaticFileSystem(useOS)
26+ assetHandler := http.FileServer(staticFileSystem)
27+
28+ // Serve static files using the asset handler
29+ // We wrap the handler to ensure it works correctly with Echo
30+ // We also need to strip the prefix if accessing assets directly (e.g. /assets/css/...)
31+ // The root path "/" will serve index.html or equivalent by default if present.
32+ e.GET("/*", echo.WrapHandler(assetHandler))
33+
2134 // Log admin status
2235 log.Info("Admin WebUI status",
2336 "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
2437 "path", a.Config.Admin.Path)
2538
2639 // Initiate the session middleware
27- e.Use(middleware.InitSessionMiddleware(a))
40+ sessionMiddleware := middleware.InitSessionMiddleware(a)
41+ // Apply session middleware *after* the static file handler setup
42+ // It will only run for routes not handled by the static file server
43+ e.Use(sessionMiddleware)
2844
2945 // Language detection
3046 e.Use(middleware.LanguageDetection)
3147
32- log.Debug("Binding static routes under /assets/*")
33- // Middleware to set cache headers for static files
34- staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {
35- return func(c echo.Context) error {
36- // Determine caching based on environment
37- if os.Getenv("RUN_ENV") != "dev" {
38- c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day in production
39- } else {
40- c.Response().Header().Set("Cache-Control", "no-cache") // No caching in development
41- }
42- return next(c)
43- }
44- }
45- // Use Echo's static file middleware to serve assets directly from the filesystem.
46- // The path is relative to the project root.
47- staticGroup := e.Group("/assets")
48- staticGroup.Use(staticCacheMiddleware)
49- staticGroup.Static("/", "internal/webui/public/assets")
50-
5148 AdminPath := a.Config.Admin.Path
5249 log.Debug("Binding login routes with AdminPath", "path", AdminPath)
5350 bindLoginRoute(e, a, AdminPath)
@@ -278,3 +275,20 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
278275 return handlers.CreateContainerPOST(c, a)
279276 })
280277 }
278+
279+// getStaticFileSystem returns the appropriate http.FileSystem based on the GORDON_LIVE_UI env var.
280+func getStaticFileSystem(useOS bool) http.FileSystem {
281+ if useOS {
282+ log.Info("Serving static files from OS filesystem (live mode)")
283+ return http.FS(os.DirFS("internal/webui/public"))
284+ }
285+
286+ log.Info("Serving static files from embedded filesystem")
287+ // Use the PublicFS from the webui package
288+ fsys, err := fs.Sub(webui.PublicFS, "public")
289+ if err != nil {
290+ panic(err) // Should not happen with embedded files
291+ }
292+
293+ return http.FS(fsys)
294+}
@@ -1,13 +1,15 @@
1 package httpserve1 package httpserve
2 2
3 import (3 import (
4+ "io/fs"
4 "net/http"5 "net/http"
5 "os"6 "os"
6 7
7 "github.com/bnema/gordon/internal/httpserve/handlers"8 "github.com/bnema/gordon/internal/httpserve/handlers"
8 "github.com/bnema/gordon/internal/httpserve/middleware"9 "github.com/bnema/gordon/internal/httpserve/middleware"
9 "github.com/bnema/gordon/internal/server"10 "github.com/bnema/gordon/internal/server"
10- "github.com/charmbracelet/log"11+ "github.com/bnema/gordon/internal/webui" // Import the webui package
12+ log "github.com/bnema/gordon/pkg/logger"
11 _ "github.com/joho/godotenv/autoload"13 _ "github.com/joho/godotenv/autoload"
12 "github.com/labstack/echo-contrib/session"14 "github.com/labstack/echo-contrib/session"
13 "github.com/labstack/echo/v4"15 "github.com/labstack/echo/v4"
@@ -18,36 +20,31 @@ import (
18 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {20 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
19 e.Use(echomid.Recover())21 e.Use(echomid.Recover())
20 22
23+ // Determine if running in live UI mode
24+ useOS := os.Getenv("GORDON_LIVE_UI") == "true"
25+ staticFileSystem := getStaticFileSystem(useOS)
26+ assetHandler := http.FileServer(staticFileSystem)
27+
28+ // Serve static files using the asset handler
29+ // We wrap the handler to ensure it works correctly with Echo
30+ // We also need to strip the prefix if accessing assets directly (e.g. /assets/css/...)
31+ // The root path "/" will serve index.html or equivalent by default if present.
32+ e.GET("/*", echo.WrapHandler(assetHandler))
33+
21 // Log admin status34 // Log admin status
22 log.Info("Admin WebUI status",35 log.Info("Admin WebUI status",
23 "enabled", a.Config.Admin.IsAdminWebUIEnabled(),36 "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
24 "path", a.Config.Admin.Path)37 "path", a.Config.Admin.Path)
25 38
26 // Initiate the session middleware39 // Initiate the session middleware
27- e.Use(middleware.InitSessionMiddleware(a))40+ sessionMiddleware := middleware.InitSessionMiddleware(a)
41+ // Apply session middleware *after* the static file handler setup
42+ // It will only run for routes not handled by the static file server
43+ e.Use(sessionMiddleware)
28 44
29 // Language detection45 // Language detection
30 e.Use(middleware.LanguageDetection)46 e.Use(middleware.LanguageDetection)
31 47
32- log.Debug("Binding static routes under /assets/*")
33- // Middleware to set cache headers for static files
34- staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {
35- return func(c echo.Context) error {
36- // Determine caching based on environment
37- if os.Getenv("RUN_ENV") != "dev" {
38- c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day in production
39- } else {
40- c.Response().Header().Set("Cache-Control", "no-cache") // No caching in development
41- }
42- return next(c)
43- }
44- }
45- // Use Echo's static file middleware to serve assets directly from the filesystem.
46- // The path is relative to the project root.
47- staticGroup := e.Group("/assets")
48- staticGroup.Use(staticCacheMiddleware)
49- staticGroup.Static("/", "internal/webui/public/assets")
50-
51 AdminPath := a.Config.Admin.Path48 AdminPath := a.Config.Admin.Path
52 log.Debug("Binding login routes with AdminPath", "path", AdminPath)49 log.Debug("Binding login routes with AdminPath", "path", AdminPath)
53 bindLoginRoute(e, a, AdminPath)50 bindLoginRoute(e, a, AdminPath)
@@ -278,3 +275,20 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
278 return handlers.CreateContainerPOST(c, a)275 return handlers.CreateContainerPOST(c, a)
279 })276 })
280 }277 }
278+
279+// getStaticFileSystem returns the appropriate http.FileSystem based on the GORDON_LIVE_UI env var.
280+func getStaticFileSystem(useOS bool) http.FileSystem {
281+ if useOS {
282+ log.Info("Serving static files from OS filesystem (live mode)")
283+ return http.FS(os.DirFS("internal/webui/public"))
284+ }
285+
286+ log.Info("Serving static files from embedded filesystem")
287+ // Use the PublicFS from the webui package
288+ fsys, err := fs.Sub(webui.PublicFS, "public")
289+ if err != nil {
290+ panic(err) // Should not happen with embedded files
291+ }
292+
293+ return http.FS(fsys)
294+}
M internal/proxy/middleware.go +38 -4
@@ -5,6 +5,7 @@ import (
55 "net"
66 "net/http"
77 "strings"
8+ "sync"
89 "time"
910
1011 "github.com/bnema/gordon/pkg/logger"
@@ -22,6 +23,13 @@ var rateLimiterCleanup []func()
2223 // RequestIDKey is the key used to store the request ID in the context
2324 const RequestIDKey = "request_id"
2425
26+// Cooldown period for logging direct IP access warnings
27+const logCooldown = 1 * time.Minute
28+
29+// Map to store the last log time for each IP address attempting direct access
30+var ipLogTimes = make(map[string]time.Time)
31+var ipLogMutex sync.Mutex // Mutex to protect access to ipLogTimes
32+
2533 // isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.
2634 func isHostIPAddress(host string) bool {
2735 // Split host and port
@@ -36,22 +44,48 @@ func isHostIPAddress(host string) bool {
3644 }
3745
3846 // blockDirectIPMiddleware rejects requests where the Host header is an IP address,
39-// unless it's an ACME challenge request.
47+// unless it's an ACME challenge request. It includes a log cooldown mechanism.
4048 func blockDirectIPMiddleware() echo.MiddlewareFunc {
4149 return func(next echo.HandlerFunc) echo.HandlerFunc {
4250 return func(c echo.Context) error {
4351 host := c.Request().Host
4452 path := c.Request().URL.Path
53+ realIP := c.RealIP() // Get the real IP once
4554
4655 if isHostIPAddress(host) {
4756 // Allow ACME HTTP-01 challenges to proceed even via IP
4857 if strings.HasPrefix(path, "/.well-known/acme-challenge/") {
49- logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", c.RealIP())
58+ logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", realIP)
5059 return next(c)
5160 }
5261
53- // Block other direct IP access attempts
54- logger.Warn("Blocking direct IP access attempt", "host", host, "path", path, "ip", c.RealIP(), "user_agent", c.Request().UserAgent())
62+ // --- Log Cooldown Logic ---
63+ now := time.Now()
64+ logAllowed := false
65+
66+ ipLogMutex.Lock()
67+ lastLogTime, exists := ipLogTimes[realIP]
68+ if !exists || now.Sub(lastLogTime) > logCooldown {
69+ // It's a new IP or the cooldown has passed
70+ ipLogTimes[realIP] = now // Update the last log time
71+ logAllowed = true
72+ }
73+ ipLogMutex.Unlock()
74+
75+ // Log only if allowed by the cooldown
76+ if logAllowed {
77+ logger.Warn("Blocking direct IP access attempt (log cooldown active)",
78+ "host", host,
79+ "path", path,
80+ "ip", realIP,
81+ "user_agent", c.Request().UserAgent())
82+ } else {
83+ // Optionally log at DEBUG level that we suppressed a log, useful for verifying
84+ // logger.Debug("Suppressed duplicate direct IP access log due to cooldown", "ip", realIP, "host", host)
85+ }
86+ // --- End Log Cooldown Logic ---
87+
88+ // Block other direct IP access attempts regardless of logging
5589 return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")
5690 }
5791
@@ -5,6 +5,7 @@ import (
5 "net"5 "net"
6 "net/http"6 "net/http"
7 "strings"7 "strings"
8+ "sync"
8 "time"9 "time"
9 10
10 "github.com/bnema/gordon/pkg/logger"11 "github.com/bnema/gordon/pkg/logger"
@@ -22,6 +23,13 @@ var rateLimiterCleanup []func()
22 // RequestIDKey is the key used to store the request ID in the context23 // RequestIDKey is the key used to store the request ID in the context
23 const RequestIDKey = "request_id"24 const RequestIDKey = "request_id"
24 25
26+// Cooldown period for logging direct IP access warnings
27+const logCooldown = 1 * time.Minute
28+
29+// Map to store the last log time for each IP address attempting direct access
30+var ipLogTimes = make(map[string]time.Time)
31+var ipLogMutex sync.Mutex // Mutex to protect access to ipLogTimes
32+
25 // isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.33 // isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.
26 func isHostIPAddress(host string) bool {34 func isHostIPAddress(host string) bool {
27 // Split host and port35 // Split host and port
@@ -36,22 +44,48 @@ func isHostIPAddress(host string) bool {
36 }44 }
37 45
38 // blockDirectIPMiddleware rejects requests where the Host header is an IP address,46 // blockDirectIPMiddleware rejects requests where the Host header is an IP address,
39-// unless it's an ACME challenge request.47+// unless it's an ACME challenge request. It includes a log cooldown mechanism.
40 func blockDirectIPMiddleware() echo.MiddlewareFunc {48 func blockDirectIPMiddleware() echo.MiddlewareFunc {
41 return func(next echo.HandlerFunc) echo.HandlerFunc {49 return func(next echo.HandlerFunc) echo.HandlerFunc {
42 return func(c echo.Context) error {50 return func(c echo.Context) error {
43 host := c.Request().Host51 host := c.Request().Host
44 path := c.Request().URL.Path52 path := c.Request().URL.Path
53+ realIP := c.RealIP() // Get the real IP once
45 54
46 if isHostIPAddress(host) {55 if isHostIPAddress(host) {
47 // Allow ACME HTTP-01 challenges to proceed even via IP56 // Allow ACME HTTP-01 challenges to proceed even via IP
48 if strings.HasPrefix(path, "/.well-known/acme-challenge/") {57 if strings.HasPrefix(path, "/.well-known/acme-challenge/") {
49- logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", c.RealIP())58+ logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", realIP)
50 return next(c)59 return next(c)
51 }60 }
52 61
53- // Block other direct IP access attempts62+ // --- Log Cooldown Logic ---
54- logger.Warn("Blocking direct IP access attempt", "host", host, "path", path, "ip", c.RealIP(), "user_agent", c.Request().UserAgent())63+ now := time.Now()
64+ logAllowed := false
65+
66+ ipLogMutex.Lock()
67+ lastLogTime, exists := ipLogTimes[realIP]
68+ if !exists || now.Sub(lastLogTime) > logCooldown {
69+ // It's a new IP or the cooldown has passed
70+ ipLogTimes[realIP] = now // Update the last log time
71+ logAllowed = true
72+ }
73+ ipLogMutex.Unlock()
74+
75+ // Log only if allowed by the cooldown
76+ if logAllowed {
77+ logger.Warn("Blocking direct IP access attempt (log cooldown active)",
78+ "host", host,
79+ "path", path,
80+ "ip", realIP,
81+ "user_agent", c.Request().UserAgent())
82+ } else {
83+ // Optionally log at DEBUG level that we suppressed a log, useful for verifying
84+ // logger.Debug("Suppressed duplicate direct IP access log due to cooldown", "ip", realIP, "host", host)
85+ }
86+ // --- End Log Cooldown Logic ---
87+
88+ // Block other direct IP access attempts regardless of logging
55 return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")89 return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")
56 }90 }
57 91
M internal/templating/models/templ/components/header.templ +7 -7
@@ -9,13 +9,13 @@ templ Header(title string) {
99 <title>{ title }</title>
1010 <link rel="stylesheet" href="/assets/css/tailwind.css" />
1111
12- <script src="/assets/js/jquery.min.js"></script>
13- <script src="/assets/js/htmx.min.js"></script>
14- <script src="/assets/js/htmx-response-targets.js"></script>
15- <script src="/assets/js/htmx-debug.js"></script>
16- <script src="/assets/js/htmx-sse.js"></script>
17- <script src="/assets/js/daisyui.js" type="module"></script>
18- <script defer src="/assets/js/hyperscript.min.js"></script>
12+ <script src="/libs/jquery.min.js"></script>
13+ <script src="/libs/htmx.min.js"></script>
14+ <script src="/libs/htmx-response-targets.js"></script>
15+ <script src="/libs/htmx-debug.js"></script>
16+ <script src="/libs/htmx-sse.js"></script>
17+ <script src="/libs/daisyui.js" type="module"></script>
18+ <script defer src="/libs/hyperscript.min.js"></script>
1919 <script defer src="/assets/js/custom.js"></script>
2020
2121 <!-- daisyUI theme configuration -->
@@ -9,13 +9,13 @@ templ Header(title string) {
9 <title>{ title }</title>9 <title>{ title }</title>
10 <link rel="stylesheet" href="/assets/css/tailwind.css" />10 <link rel="stylesheet" href="/assets/css/tailwind.css" />
11 11
12- <script src="/assets/js/jquery.min.js"></script>12+ <script src="/libs/jquery.min.js"></script>
13- <script src="/assets/js/htmx.min.js"></script>13+ <script src="/libs/htmx.min.js"></script>
14- <script src="/assets/js/htmx-response-targets.js"></script>14+ <script src="/libs/htmx-response-targets.js"></script>
15- <script src="/assets/js/htmx-debug.js"></script>15+ <script src="/libs/htmx-debug.js"></script>
16- <script src="/assets/js/htmx-sse.js"></script>16+ <script src="/libs/htmx-sse.js"></script>
17- <script src="/assets/js/daisyui.js" type="module"></script>17+ <script src="/libs/daisyui.js" type="module"></script>
18- <script defer src="/assets/js/hyperscript.min.js"></script>18+ <script defer src="/libs/hyperscript.min.js"></script>
19 <script defer src="/assets/js/custom.js"></script>19 <script defer src="/assets/js/custom.js"></script>
20 20
21 <!-- daisyUI theme configuration -->21 <!-- daisyUI theme configuration -->
M internal/templating/models/templ/components/header_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/pages/login/index.templ +4 -4
@@ -13,13 +13,13 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
1313 <!-- Custom CSS + Tailwind -->
1414 <link rel="stylesheet" href="/assets/css/tailwind.css" />
1515 <!-- HTMX min js -->
16- <script src="/assets/js/htmx.min.js"></script>
16+ <script src="/libs/htmx.min.js"></script>
1717 <!-- HTMX SSE -->
18- <script src="/assets/js/htmx-sse.js"></script>
18+ <script src="/libs/htmx-sse.js"></script>
1919 <!-- DaisyUI -->
20- <script src="/assets/js/daisyui.js" type="module"></script>
20+ <script src="/libs/daisyui.js" type="module"></script>
2121 <!-- jQuery -->
22- <script src="/assets/js/jquery.min.js"></script>
22+ <script src="/libs/jquery.min.js"></script>
2323 <!-- Custom JS from assets/js -->
2424 <script src="/assets/js/custom.js" defer></script>
2525
@@ -13,13 +13,13 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
13 <!-- Custom CSS + Tailwind -->13 <!-- Custom CSS + Tailwind -->
14 <link rel="stylesheet" href="/assets/css/tailwind.css" />14 <link rel="stylesheet" href="/assets/css/tailwind.css" />
15 <!-- HTMX min js -->15 <!-- HTMX min js -->
16- <script src="/assets/js/htmx.min.js"></script>16+ <script src="/libs/htmx.min.js"></script>
17 <!-- HTMX SSE -->17 <!-- HTMX SSE -->
18- <script src="/assets/js/htmx-sse.js"></script>18+ <script src="/libs/htmx-sse.js"></script>
19 <!-- DaisyUI -->19 <!-- DaisyUI -->
20- <script src="/assets/js/daisyui.js" type="module"></script>20+ <script src="/libs/daisyui.js" type="module"></script>
21 <!-- jQuery -->21 <!-- jQuery -->
22- <script src="/assets/js/jquery.min.js"></script>22+ <script src="/libs/jquery.min.js"></script>
23 <!-- Custom JS from assets/js -->23 <!-- Custom JS from assets/js -->
24 <script src="/assets/js/custom.js" defer></script>24 <script src="/assets/js/custom.js" defer></script>
25 25
M internal/templating/models/templ/pages/login/index_templ.go +1 -1
@@ -43,7 +43,7 @@ func LoginPage(title string, buildVersion string, errorMsg string) templ.Compone
4343 if templ_7745c5c3_Err != nil {
4444 return templ_7745c5c3_Err
4545 }
46- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><meta name=\"description\" content=\"Gordon Container Management System\"><meta name=\"keywords\" content=\"container, management, docker, podman\"><!-- Custom CSS + Tailwind --><link rel=\"stylesheet\" href=\"/assets/css/tailwind.css\"><!-- HTMX min js --><script src=\"/assets/js/htmx.min.js\"></script><!-- HTMX SSE --><script src=\"/assets/js/htmx-sse.js\"></script><!-- DaisyUI --><script src=\"/assets/js/daisyui.js\" type=\"module\"></script><!-- jQuery --><script src=\"/assets/js/jquery.min.js\"></script><!-- Custom JS from assets/js --><script src=\"/assets/js/custom.js\" defer></script><!-- daisyUI theme configuration --><script>\n\t // Check for saved theme preference or use preferred color scheme\n\t const savedTheme = localStorage.getItem('theme') || 'gordon-dark';\n\t document.documentElement.setAttribute('data-theme', savedTheme);\n\t console.debug('Theme loaded:', savedTheme);\n\t </script></head><body class=\"IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4\"><!-- Logo and Title --><div id=\"logo-mascot\" class=\"flex flex-col justify-center items-center p-4 mb-4\"><div class=\"flex justify-center w-100\"><img src=\"/assets/imgs/gordon-mascot-mq-trsp.png\" alt=\"logo gordon\" class=\"sm:h-24 h-32 xl:h-48 drop-shadow-xl\"></div><div class=\"flex justify-center mt-4\"><h1 class=\"gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter\">Gordon</h1><span class=\"gordon-v-subtitle text-secondary text-sm ml-2\">")
46+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><meta name=\"description\" content=\"Gordon Container Management System\"><meta name=\"keywords\" content=\"container, management, docker, podman\"><!-- Custom CSS + Tailwind --><link rel=\"stylesheet\" href=\"/assets/css/tailwind.css\"><!-- HTMX min js --><script src=\"/libs/htmx.min.js\"></script><!-- HTMX SSE --><script src=\"/libs/htmx-sse.js\"></script><!-- DaisyUI --><script src=\"/libs/daisyui.js\" type=\"module\"></script><!-- jQuery --><script src=\"/libs/jquery.min.js\"></script><!-- Custom JS from assets/js --><script src=\"/assets/js/custom.js\" defer></script><!-- daisyUI theme configuration --><script>\n\t // Check for saved theme preference or use preferred color scheme\n\t const savedTheme = localStorage.getItem('theme') || 'gordon-dark';\n\t document.documentElement.setAttribute('data-theme', savedTheme);\n\t console.debug('Theme loaded:', savedTheme);\n\t </script></head><body class=\"IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4\"><!-- Logo and Title --><div id=\"logo-mascot\" class=\"flex flex-col justify-center items-center p-4 mb-4\"><div class=\"flex justify-center w-100\"><img src=\"/assets/imgs/gordon-mascot-mq-trsp.png\" alt=\"logo gordon\" class=\"sm:h-24 h-32 xl:h-48 drop-shadow-xl\"></div><div class=\"flex justify-center mt-4\"><h1 class=\"gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter\">Gordon</h1><span class=\"gordon-v-subtitle text-secondary text-sm ml-2\">")
4747 if templ_7745c5c3_Err != nil {
4848 return templ_7745c5c3_Err
4949 }
@@ -43,7 +43,7 @@ func LoginPage(title string, buildVersion string, errorMsg string) templ.Compone
43 if templ_7745c5c3_Err != nil {43 if templ_7745c5c3_Err != nil {
44 return templ_7745c5c3_Err44 return templ_7745c5c3_Err
45 }45 }
46- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><meta name=\"description\" content=\"Gordon Container Management System\"><meta name=\"keywords\" content=\"container, management, docker, podman\"><!-- Custom CSS + Tailwind --><link rel=\"stylesheet\" href=\"/assets/css/tailwind.css\"><!-- HTMX min js --><script src=\"/assets/js/htmx.min.js\"></script><!-- HTMX SSE --><script src=\"/assets/js/htmx-sse.js\"></script><!-- DaisyUI --><script src=\"/assets/js/daisyui.js\" type=\"module\"></script><!-- jQuery --><script src=\"/assets/js/jquery.min.js\"></script><!-- Custom JS from assets/js --><script src=\"/assets/js/custom.js\" defer></script><!-- daisyUI theme configuration --><script>\n\t // Check for saved theme preference or use preferred color scheme\n\t const savedTheme = localStorage.getItem('theme') || 'gordon-dark';\n\t document.documentElement.setAttribute('data-theme', savedTheme);\n\t console.debug('Theme loaded:', savedTheme);\n\t </script></head><body class=\"IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4\"><!-- Logo and Title --><div id=\"logo-mascot\" class=\"flex flex-col justify-center items-center p-4 mb-4\"><div class=\"flex justify-center w-100\"><img src=\"/assets/imgs/gordon-mascot-mq-trsp.png\" alt=\"logo gordon\" class=\"sm:h-24 h-32 xl:h-48 drop-shadow-xl\"></div><div class=\"flex justify-center mt-4\"><h1 class=\"gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter\">Gordon</h1><span class=\"gordon-v-subtitle text-secondary text-sm ml-2\">")46+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><meta name=\"description\" content=\"Gordon Container Management System\"><meta name=\"keywords\" content=\"container, management, docker, podman\"><!-- Custom CSS + Tailwind --><link rel=\"stylesheet\" href=\"/assets/css/tailwind.css\"><!-- HTMX min js --><script src=\"/libs/htmx.min.js\"></script><!-- HTMX SSE --><script src=\"/libs/htmx-sse.js\"></script><!-- DaisyUI --><script src=\"/libs/daisyui.js\" type=\"module\"></script><!-- jQuery --><script src=\"/libs/jquery.min.js\"></script><!-- Custom JS from assets/js --><script src=\"/assets/js/custom.js\" defer></script><!-- daisyUI theme configuration --><script>\n\t // Check for saved theme preference or use preferred color scheme\n\t const savedTheme = localStorage.getItem('theme') || 'gordon-dark';\n\t document.documentElement.setAttribute('data-theme', savedTheme);\n\t console.debug('Theme loaded:', savedTheme);\n\t </script></head><body class=\"IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4\"><!-- Logo and Title --><div id=\"logo-mascot\" class=\"flex flex-col justify-center items-center p-4 mb-4\"><div class=\"flex justify-center w-100\"><img src=\"/assets/imgs/gordon-mascot-mq-trsp.png\" alt=\"logo gordon\" class=\"sm:h-24 h-32 xl:h-48 drop-shadow-xl\"></div><div class=\"flex justify-center mt-4\"><h1 class=\"gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter\">Gordon</h1><span class=\"gordon-v-subtitle text-secondary text-sm ml-2\">")
47 if templ_7745c5c3_Err != nil {47 if templ_7745c5c3_Err != nil {
48 return templ_7745c5c3_Err48 return templ_7745c5c3_Err
49 }49 }
A internal/webui/embed.go +9 -0
@@ -0,0 +1,9 @@
1+package webui
2+
3+import "embed"
4+
5+// PublicFS holds the embedded static file system for the web UI.
6+// The path "public" is relative to this package directory (internal/webui).
7+//
8+//go:embed public
9+var PublicFS embed.FS
@@ -0,0 +1,9 @@
1+package webui
2+
3+import "embed"
4+
5+// PublicFS holds the embedded static file system for the web UI.
6+// The path "public" is relative to this package directory (internal/webui).
7+//
8+//go:embed public
9+var PublicFS embed.FS

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