detached ad19124

feat: Add image pruning functionality and enhance image management UI

- Introduced a new endpoint for purging unused Docker images via the `/image-manager/prune` route.
- Implemented the `ImageManagerPrune` handler to execute the pruning operation and return the number of images purged.
- Updated the image management UI to include a "Purge Unused" button, allowing users to easily remove dangling images.
- Refactored template components to utilize the new URL generation method for improved maintainability.
- Enhanced logging for image management operations to provide better traceability.
commit
ad19124f7cd5c3f53163249c974741ef956e5b97
author
bamen · · 2025-03-30 12:46 UTC
parent
10909c5
signature
unsigned
20 files · +347 -336
M go.mod +1 -1
@@ -249,7 +249,7 @@ require (
249249 github.com/Microsoft/go-winio v0.6.2 // indirect
250250 github.com/andybalholm/cascadia v1.3.3 // indirect
251251 github.com/aymerick/douceur v0.2.0 // indirect
252- github.com/docker/go-units v0.5.0 // indirect
252+ github.com/docker/go-units v0.5.0
253253 github.com/gogo/protobuf v1.3.2 // indirect
254254 github.com/google/uuid v1.6.0 // direct
255255 github.com/gorilla/context v1.1.2 // indirect
@@ -249,7 +249,7 @@ require (
249 github.com/Microsoft/go-winio v0.6.2 // indirect249 github.com/Microsoft/go-winio v0.6.2 // indirect
250 github.com/andybalholm/cascadia v1.3.3 // indirect250 github.com/andybalholm/cascadia v1.3.3 // indirect
251 github.com/aymerick/douceur v0.2.0 // indirect251 github.com/aymerick/douceur v0.2.0 // indirect
252- github.com/docker/go-units v0.5.0 // indirect252+ github.com/docker/go-units v0.5.0
253 github.com/gogo/protobuf v1.3.2 // indirect253 github.com/gogo/protobuf v1.3.2 // indirect
254 github.com/google/uuid v1.6.0 // direct254 github.com/google/uuid v1.6.0 // direct
255 github.com/gorilla/context v1.1.2 // indirect255 github.com/gorilla/context v1.1.2 // indirect
M internal/httpserve/handlers/container_management.go +9 -0
@@ -177,6 +177,15 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
177177 return c.String(http.StatusOK, ActionSuccess(a))
178178 }
179179
180+// ImageManagerPrune handles the /image-manager/prune route
181+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))
187+}
188+
180189 // ContainerManagerComponent handles the /container-manager route
181190 func ContainerManagerComponent(c echo.Context, a *server.App) error {
182191 logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
@@ -177,6 +177,15 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
177 return c.String(http.StatusOK, ActionSuccess(a))177 return c.String(http.StatusOK, ActionSuccess(a))
178 }178 }
179 179
180+// ImageManagerPrune handles the /image-manager/prune route
181+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))
187+}
188+
180 // ContainerManagerComponent handles the /container-manager route189 // ContainerManagerComponent handles the /container-manager route
181 func ContainerManagerComponent(c echo.Context, a *server.App) error {190 func ContainerManagerComponent(c echo.Context, a *server.App) error {
182 logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())191 logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
M internal/httpserve/router.go +4 -0
@@ -221,6 +221,10 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
221221 htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error {
222222 return handlers.ImageManagerDelete(c, a)
223223 })
224+ // Purge unused images
225+ htmxGroup.POST("/image-manager/prune", func(c echo.Context) error {
226+ return handlers.ImageManagerPrune(c, a)
227+ })
224228
225229 // List all containers
226230 htmxGroup.GET("/container-manager", func(c echo.Context) error {
@@ -221,6 +221,10 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
221 htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error {221 htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error {
222 return handlers.ImageManagerDelete(c, a)222 return handlers.ImageManagerDelete(c, a)
223 })223 })
224+ // Purge unused images
225+ htmxGroup.POST("/image-manager/prune", func(c echo.Context) error {
226+ return handlers.ImageManagerPrune(c, a)
227+ })
224 228
225 // List all containers229 // List all containers
226 htmxGroup.GET("/container-manager", func(c echo.Context) error {230 htmxGroup.GET("/container-manager", func(c echo.Context) error {
M internal/proxy/acme.go +0 -1
@@ -27,7 +27,6 @@ type CertManagerConfig struct {
2727 Email string
2828 Mode string
2929 SkipCertificates bool
30- BehindTLSProxy bool
3130 AdminDomain string
3231 RootDomain string
3332 HttpPort string
@@ -27,7 +27,6 @@ type CertManagerConfig struct {
27 Email string27 Email string
28 Mode string28 Mode string
29 SkipCertificates bool29 SkipCertificates bool
30- BehindTLSProxy bool
31 AdminDomain string30 AdminDomain string
32 RootDomain string31 RootDomain string
33 HttpPort string32 HttpPort string
M internal/proxy/core.go +19 -55
@@ -4,8 +4,6 @@ import (
44 "context"
55 "fmt"
66 "net"
7- "os"
8- "strings"
97 "sync"
108 "time"
119
@@ -162,37 +160,38 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
162160 certDir = app.GetConfig().General.StorageDir + "/certs"
163161 }
164162
165- // Initial upstream proxy detection (before creating CertManager)
166- initialBehindTLSProxy := detectInitialUpstreamProxy(&config)
167-
168163 // Create Certificate Manager Config
169164 certManagerConfig := CertManagerConfig{
170165 CertDir: certDir,
171166 Email: config.Email,
172167 Mode: config.LetsEncryptMode, // staging or production
173168 SkipCertificates: config.SkipCertificates,
174- BehindTLSProxy: initialBehindTLSProxy, // Pass initial detection result
175169 AdminDomain: app.GetConfig().Http.FullDomain(),
176170 RootDomain: app.GetConfig().Http.Domain,
177171 HttpPort: config.DefaultHttpChallengePort,
178172 // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy
179173 }
180174
181- // Create the Certificate Manager
182- certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)
183- if err != nil {
184- logger.Error("Failed to initialize Certificate Manager", "error", err)
185- // Return an error to prevent starting a broken proxy
186- return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)
187- } else {
188- logger.Debug("Certificate manager successfully initialized")
189- p.certificateManager = certMgr // Store the new manager
190- }
175+ // Only initialize Certificate Manager if SkipCertificates is false
176+ if !config.SkipCertificates {
177+ // Create the Certificate Manager
178+ certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)
179+ if err != nil {
180+ logger.Error("Failed to initialize Certificate Manager", "error", err)
181+ // Return an error to prevent starting a broken proxy
182+ return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)
183+ } else {
184+ logger.Debug("Certificate manager successfully initialized")
185+ p.certificateManager = certMgr // Store the new manager
186+ }
191187
192- // Ensure admin domain has proper ACME configuration
193- if err := p.ensureAdminDomainConfig(); err != nil {
194- logger.Error("Failed to ensure admin domain configuration", "error", err)
195- return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)
188+ // Ensure admin domain has proper ACME configuration only if manager was created
189+ if err := p.ensureAdminDomainConfig(); err != nil {
190+ logger.Error("Failed to ensure admin domain configuration", "error", err)
191+ return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)
192+ }
193+ } else {
194+ logger.Info("Skipping certificate manager initialization because SkipCertificates is true")
196195 }
197196
198197 // --- End Certificate Manager Setup ---
@@ -217,41 +216,6 @@ func (p *Proxy) Close() {
217216 logger.Info("Proxy resources cleaned up")
218217 }
219218
220-// detectInitialUpstreamProxy checks environment variables for signs of an upstream proxy
221-// This is used during initialization before the CertificateManager is created.
222-func detectInitialUpstreamProxy(config *common.ReverseProxyConfig) bool {
223- // Check if detection is explicitly disabled
224- if !config.DetectUpstreamProxy {
225- logger.Debug("Upstream proxy detection disabled by config")
226- return false
227- }
228-
229- // Check for common environment variables set by upstream proxies
230- if os.Getenv("HTTPS") == "on" ||
231- os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
232- os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
233- logger.Info("Initial upstream TLS-terminating proxy detected via environment variables",
234- "detection_method", "environment_variables",
235- "https", os.Getenv("HTTPS"),
236- "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
237- "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
238- return true
239- }
240-
241- // Check Cloudflare headers
242- if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
243- if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
244- logger.Info("Initial Cloudflare proxy detected via environment variables",
245- "detection_method", "cloudflare_headers",
246- "cf_visitor", cfVisitor)
247- return true
248- }
249- }
250-
251- logger.Debug("No initial upstream proxy detected via environment variables")
252- return false
253-}
254-
255219 // requestDomainCertificate requests a certificate for a domain
256220 func (p *Proxy) requestDomainCertificate(domainName string) error {
257221 logger.Debug("Requesting certificate for domain", "domain", domainName)
@@ -4,8 +4,6 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "net"6 "net"
7- "os"
8- "strings"
9 "sync"7 "sync"
10 "time"8 "time"
11 9
@@ -162,37 +160,38 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
162 certDir = app.GetConfig().General.StorageDir + "/certs"160 certDir = app.GetConfig().General.StorageDir + "/certs"
163 }161 }
164 162
165- // Initial upstream proxy detection (before creating CertManager)
166- initialBehindTLSProxy := detectInitialUpstreamProxy(&config)
167-
168 // Create Certificate Manager Config163 // Create Certificate Manager Config
169 certManagerConfig := CertManagerConfig{164 certManagerConfig := CertManagerConfig{
170 CertDir: certDir,165 CertDir: certDir,
171 Email: config.Email,166 Email: config.Email,
172 Mode: config.LetsEncryptMode, // staging or production167 Mode: config.LetsEncryptMode, // staging or production
173 SkipCertificates: config.SkipCertificates,168 SkipCertificates: config.SkipCertificates,
174- BehindTLSProxy: initialBehindTLSProxy, // Pass initial detection result
175 AdminDomain: app.GetConfig().Http.FullDomain(),169 AdminDomain: app.GetConfig().Http.FullDomain(),
176 RootDomain: app.GetConfig().Http.Domain,170 RootDomain: app.GetConfig().Http.Domain,
177 HttpPort: config.DefaultHttpChallengePort,171 HttpPort: config.DefaultHttpChallengePort,
178 // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy172 // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy
179 }173 }
180 174
181- // Create the Certificate Manager175+ // Only initialize Certificate Manager if SkipCertificates is false
182- certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)176+ if !config.SkipCertificates {
183- if err != nil {177+ // Create the Certificate Manager
184- logger.Error("Failed to initialize Certificate Manager", "error", err)178+ certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)
185- // Return an error to prevent starting a broken proxy179+ if err != nil {
186- return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)180+ logger.Error("Failed to initialize Certificate Manager", "error", err)
187- } else {181+ // Return an error to prevent starting a broken proxy
188- logger.Debug("Certificate manager successfully initialized")182+ return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)
189- p.certificateManager = certMgr // Store the new manager183+ } else {
190- }184+ logger.Debug("Certificate manager successfully initialized")
185+ p.certificateManager = certMgr // Store the new manager
186+ }
191 187
192- // Ensure admin domain has proper ACME configuration188+ // Ensure admin domain has proper ACME configuration only if manager was created
193- if err := p.ensureAdminDomainConfig(); err != nil {189+ if err := p.ensureAdminDomainConfig(); err != nil {
194- logger.Error("Failed to ensure admin domain configuration", "error", err)190+ logger.Error("Failed to ensure admin domain configuration", "error", err)
195- return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)191+ return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)
192+ }
193+ } else {
194+ logger.Info("Skipping certificate manager initialization because SkipCertificates is true")
196 }195 }
197 196
198 // --- End Certificate Manager Setup ---197 // --- End Certificate Manager Setup ---
@@ -217,41 +216,6 @@ func (p *Proxy) Close() {
217 logger.Info("Proxy resources cleaned up")216 logger.Info("Proxy resources cleaned up")
218 }217 }
219 218
220-// detectInitialUpstreamProxy checks environment variables for signs of an upstream proxy
221-// This is used during initialization before the CertificateManager is created.
222-func detectInitialUpstreamProxy(config *common.ReverseProxyConfig) bool {
223- // Check if detection is explicitly disabled
224- if !config.DetectUpstreamProxy {
225- logger.Debug("Upstream proxy detection disabled by config")
226- return false
227- }
228-
229- // Check for common environment variables set by upstream proxies
230- if os.Getenv("HTTPS") == "on" ||
231- os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
232- os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
233- logger.Info("Initial upstream TLS-terminating proxy detected via environment variables",
234- "detection_method", "environment_variables",
235- "https", os.Getenv("HTTPS"),
236- "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
237- "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
238- return true
239- }
240-
241- // Check Cloudflare headers
242- if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
243- if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
244- logger.Info("Initial Cloudflare proxy detected via environment variables",
245- "detection_method", "cloudflare_headers",
246- "cf_visitor", cfVisitor)
247- return true
248- }
249- }
250-
251- logger.Debug("No initial upstream proxy detected via environment variables")
252- return false
253-}
254-
255 // requestDomainCertificate requests a certificate for a domain219 // requestDomainCertificate requests a certificate for a domain
256 func (p *Proxy) requestDomainCertificate(domainName string) error {220 func (p *Proxy) requestDomainCertificate(domainName string) error {
257 logger.Debug("Requesting certificate for domain", "domain", domainName)221 logger.Debug("Requesting certificate for domain", "domain", domainName)
M internal/proxy/middleware.go +21 -0
@@ -1,7 +1,9 @@
11 package proxy
22
33 import (
4+ "fmt"
45 "net/http"
6+ "strings"
57 "time"
68
79 "github.com/bnema/gordon/pkg/logger"
@@ -28,6 +30,9 @@ func (p *Proxy) setupMiddleware() {
2830 // Add UUID generator middleware for both HTTP and HTTPS servers
2931 p.httpsServer.Use(p.createRequestIDMiddleware())
3032
33+ // Add middleware to log incoming headers (for debugging Cloudflare detection)
34+ p.httpsServer.Use(logHeadersMiddleware())
35+
3136 // Check if rate limiting is disabled via configuration
3237 if p.config.EnableRateLimit {
3338 // Create a new Starskey rate limiter store for HTTPS server
@@ -215,3 +220,19 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
215220 }
216221 }
217222 }
223+
224+// logHeadersMiddleware creates a middleware function to log request headers
225+func logHeadersMiddleware() echo.MiddlewareFunc {
226+ return func(next echo.HandlerFunc) echo.HandlerFunc {
227+ return func(c echo.Context) error {
228+ // Log all headers at DEBUG level
229+ headerStrings := []string{}
230+ for k, v := range c.Request().Header {
231+ headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ",")))
232+ }
233+ logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | "))
234+
235+ return next(c)
236+ }
237+ }
238+}
@@ -1,7 +1,9 @@
1 package proxy1 package proxy
2 2
3 import (3 import (
4+ "fmt"
4 "net/http"5 "net/http"
6+ "strings"
5 "time"7 "time"
6 8
7 "github.com/bnema/gordon/pkg/logger"9 "github.com/bnema/gordon/pkg/logger"
@@ -28,6 +30,9 @@ func (p *Proxy) setupMiddleware() {
28 // Add UUID generator middleware for both HTTP and HTTPS servers30 // Add UUID generator middleware for both HTTP and HTTPS servers
29 p.httpsServer.Use(p.createRequestIDMiddleware())31 p.httpsServer.Use(p.createRequestIDMiddleware())
30 32
33+ // Add middleware to log incoming headers (for debugging Cloudflare detection)
34+ p.httpsServer.Use(logHeadersMiddleware())
35+
31 // Check if rate limiting is disabled via configuration36 // Check if rate limiting is disabled via configuration
32 if p.config.EnableRateLimit {37 if p.config.EnableRateLimit {
33 // Create a new Starskey rate limiter store for HTTPS server38 // Create a new Starskey rate limiter store for HTTPS server
@@ -215,3 +220,19 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
215 }220 }
216 }221 }
217 }222 }
223+
224+// logHeadersMiddleware creates a middleware function to log request headers
225+func logHeadersMiddleware() echo.MiddlewareFunc {
226+ return func(next echo.HandlerFunc) echo.HandlerFunc {
227+ return func(c echo.Context) error {
228+ // Log all headers at DEBUG level
229+ headerStrings := []string{}
230+ for k, v := range c.Request().Header {
231+ headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ",")))
232+ }
233+ logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | "))
234+
235+ return next(c)
236+ }
237+ }
238+}
M internal/proxy/routes.go +0 -38
@@ -1971,44 +1971,6 @@ func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error {
19711971 c.Response().Write([]byte("Bad Gateway: Container is unreachable"))
19721972 }
19731973
1974- // For admin domain, ensure the connection succeeds
1975- if isAdminDomain {
1976- // Do a pre-check to make sure the admin service is reachable
1977- testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort)
1978- client := &http.Client{
1979- Timeout: 2 * time.Second,
1980- Transport: &http.Transport{
1981- DialContext: (&net.Dialer{
1982- Timeout: 1 * time.Second,
1983- }).DialContext,
1984- },
1985- }
1986-
1987- logger.Debug("Testing admin connection before proxying",
1988- "test_url", testURL)
1989-
1990- _, err := client.Get(testURL)
1991- if err != nil {
1992- logger.Warn("Admin service pre-check failed, trying alternative connection methods",
1993- "error", err)
1994-
1995- // Try alternative IPs for the admin domain if the container IP isn't working
1996- altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain}
1997-
1998- for _, altIP := range altIPs {
1999- testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort)
2000- _, err := client.Get(testURL)
2001- if err == nil {
2002- logger.Info("Found working admin connection with alternative IP",
2003- "ip", altIP)
2004- containerIP = altIP
2005- targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort)
2006- break
2007- }
2008- }
2009- }
2010- }
2011-
20121974 // Serve the request using the reverse proxy
20131975 logger.Debug("Serving proxy request",
20141976 "target", targetURL.String())
@@ -1971,44 +1971,6 @@ func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error {
1971 c.Response().Write([]byte("Bad Gateway: Container is unreachable"))1971 c.Response().Write([]byte("Bad Gateway: Container is unreachable"))
1972 }1972 }
1973 1973
1974- // For admin domain, ensure the connection succeeds
1975- if isAdminDomain {
1976- // Do a pre-check to make sure the admin service is reachable
1977- testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort)
1978- client := &http.Client{
1979- Timeout: 2 * time.Second,
1980- Transport: &http.Transport{
1981- DialContext: (&net.Dialer{
1982- Timeout: 1 * time.Second,
1983- }).DialContext,
1984- },
1985- }
1986-
1987- logger.Debug("Testing admin connection before proxying",
1988- "test_url", testURL)
1989-
1990- _, err := client.Get(testURL)
1991- if err != nil {
1992- logger.Warn("Admin service pre-check failed, trying alternative connection methods",
1993- "error", err)
1994-
1995- // Try alternative IPs for the admin domain if the container IP isn't working
1996- altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain}
1997-
1998- for _, altIP := range altIPs {
1999- testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort)
2000- _, err := client.Get(testURL)
2001- if err == nil {
2002- logger.Info("Found working admin connection with alternative IP",
2003- "ip", altIP)
2004- containerIP = altIP
2005- targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort)
2006- break
2007- }
2008- }
2009- }
2010- }
2011-
2012 // Serve the request using the reverse proxy1974 // Serve the request using the reverse proxy
2013 logger.Debug("Serving proxy request",1975 logger.Debug("Serving proxy request",
2014 "target", targetURL.String())1976 "target", targetURL.String())
M internal/proxy/server.go +121 -102
@@ -3,6 +3,7 @@ package proxy
33 import (
44 "context"
55 "crypto/tls"
6+ "errors"
67 "fmt"
78 "net/http"
89 "strings"
@@ -209,12 +210,13 @@ func (p *Proxy) Stop() error {
209210 return nil
210211 }
211212
212-// testAdminConnection attempts to find a working connection to the Gordon admin
213+// testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint
213214 func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
214- adminPath := p.app.GetConfig().Admin.Path
215- url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, adminPath)
215+ adminPath := p.app.GetConfig().Admin.Path // Get the base admin path
216+ pingPath := adminPath + "/ping" // Construct the full ping path
217+ url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath)
216218
217- logger.Debug("Testing admin connection", "url", url)
219+ logger.Debug("Testing admin ping connection", "url", url) // Update log message
218220
219221 // Try connecting using the provided default host
220222 client := &http.Client{
@@ -230,11 +232,18 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
230232 resp, err := client.Get(url)
231233 if err == nil {
232234 resp.Body.Close()
233- logger.Info("Admin connection successful", "host", defaultHost)
234- return defaultHost
235+ // Check if the response status is OK
236+ if resp.StatusCode == http.StatusOK {
237+ logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message
238+ return defaultHost
239+ } else {
240+ logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode)
241+ }
242+ } else {
243+ logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err)
235244 }
236245
237- // If connection failed, try other common hostnames
246+ // If connection failed or status not OK, try other common hostnames
238247 hosts := []string{
239248 "localhost",
240249 "127.0.0.1",
@@ -247,18 +256,24 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
247256 continue // Already tried this one
248257 }
249258
250- url = fmt.Sprintf("http://%s:%s%s", host, port, adminPath)
251- logger.Debug("Trying alternative host", "url", url)
259+ url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too
260+ logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message
252261
253262 resp, err := client.Get(url)
254263 if err == nil {
255264 resp.Body.Close()
256- logger.Info("Found working admin connection", "host", host)
257- return host
265+ if resp.StatusCode == http.StatusOK {
266+ logger.Info("Found working admin ping connection", "host", host) // Update log message
267+ return host
268+ } else {
269+ logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode)
270+ }
271+ } else {
272+ logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err)
258273 }
259274 }
260275
261- logger.Warn("Could not establish admin connection on any tested host", "fallback", defaultHost)
276+ logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message
262277 return defaultHost
263278 }
264279
@@ -473,105 +488,109 @@ func (p *Proxy) checkExternalPortAccess() {
473488
474489 // StartHTTPSServer starts the HTTPS server
475490 func (p *Proxy) StartHTTPSServer() error {
476- logger.Info("Starting HTTPS server",
477- "port", p.config.Port,
478- "mode", p.config.LetsEncryptMode)
479-
480- // Verify the certificate manager is initialized (it's done in NewProxy now)
481- if p.certificateManager == nil {
482- // This should ideally not happen if NewProxy succeeded
483- logger.Error("Certificate manager is nil in StartHTTPSServer, cannot start HTTPS")
484- return fmt.Errorf("certificate manager not initialized")
485- }
486- logger.Info("Using new certificate management system")
487-
488- // Setup TLS configuration
489- tlsConfig := &tls.Config{
490- // Use the helper function that gets the GetCertificate func from the manager
491- GetCertificate: p.getTLSCertificateFunc(),
492- // Ensure we use TLS 1.2 or higher
493- MinVersion: tls.VersionTLS12,
494- // Optimize cipher suites for security and performance
495- CipherSuites: []uint16{
496- tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
497- tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
498- tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
499- tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
500- tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
501- tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
502- },
503- // Use server's preferred ciphersuite
504- PreferServerCipherSuites: true,
505- // Enable HTTP/2
506- NextProtos: []string{"h2", "http/1.1"},
507- // Add server name to use when client doesn't send SNI
508- ServerName: p.app.GetConfig().Http.FullDomain(),
509- }
510-
511- // Removed setup for ACME challenge handler on the persistent HTTP server,
512- // as it's no longer running. Lego's temporary server will handle challenges.
513- // p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.getACMEHTTPHandler()))
514-
515- // Create a custom HTTPS server with proper TLS config
516- httpsServer := &http.Server{
517- Addr: fmt.Sprintf(":%s", p.config.Port),
518- Handler: p.httpsServer,
519- TLSConfig: tlsConfig,
520- // Add increased timeouts to prevent hanging connections
521- ReadTimeout: 60 * time.Second,
522- WriteTimeout: 60 * time.Second,
523- IdleTimeout: 120 * time.Second,
524- ReadHeaderTimeout: 30 * time.Second, // Add header timeout
525- }
491+ // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers)
492+ e := p.httpsServer // Use the existing Echo instance
493+
494+ // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS
495+ if p.config.SkipCertificates {
496+ logger.Info("Starting plain HTTP listener because SkipCertificates is true",
497+ "port", p.config.DefaultHttpChallengePort)
498+
499+ // Create a standard http.Server for the plain HTTP listener
500+ httpServer := &http.Server{
501+ Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix
502+ Handler: e, // Use the main Echo instance
503+ ReadTimeout: 5 * time.Second,
504+ WriteTimeout: 10 * time.Second,
505+ IdleTimeout: 120 * time.Second,
506+ }
526507
527- // Store the HTTPS server for later use in Stop()
528- p.httpsServer.Server = httpsServer
508+ // Start plain HTTP listener using http.Server's ListenAndServe
509+ go func() {
510+ logger.Info("Plain HTTP listener starting", "address", httpServer.Addr)
511+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
512+ // Log specific errors if possible
513+ if strings.Contains(err.Error(), "bind: address already in use") {
514+ logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err)
515+ } else if strings.Contains(err.Error(), "permission denied") {
516+ logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err)
517+ } else {
518+ logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr)
519+ }
520+ }
521+ }()
522+ p.serverStarted = true // Mark server as started
529523
530- // // Create a custom HTTP server
531- // httpServer := &http.Server{
532- // Addr: fmt.Sprintf(":%s", p.config.HttpPort),
533- // Handler: p.httpServer,
534- // ReadTimeout: 30 * time.Second,
535- // WriteTimeout: 30 * time.Second,
536- // IdleTimeout: 120 * time.Second,
537- // }
524+ } else {
525+ // --- Start HTTPS Server (only if SkipCertificates is false) ---
526+ logger.Info("Starting HTTPS server",
527+ "port", p.config.Port,
528+ "mode", p.config.LetsEncryptMode)
538529
539- p.serverStarted = true
530+ // Verify certificate manager is initialized *only* if we need it for HTTPS
531+ if p.certificateManager == nil {
532+ logger.Error("Certificate manager is nil, cannot start HTTPS server")
533+ return fmt.Errorf("certificate manager not initialized for HTTPS")
534+ }
535+ logger.Info("Using certificate management system for HTTPS")
536+
537+ // Setup TLS configuration
538+ tlsConfig := &tls.Config{
539+ GetCertificate: p.getTLSCertificateFunc(),
540+ MinVersion: tls.VersionTLS12,
541+ CipherSuites: []uint16{
542+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
543+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
544+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
545+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
546+ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
547+ tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
548+ },
549+ PreferServerCipherSuites: true,
550+ NextProtos: []string{"h2", "http/1.1"},
551+ ServerName: p.app.GetConfig().Http.FullDomain(),
552+ }
540553
541- // httpServer is no longer needed as ACME HTTP-01 uses its own temporary server
542- // httpServer := &http.Server{
543- // Addr: fmt.Sprintf(":%s", p.config.HttpPort),
544- // Handler: p.httpServer,
545- // ReadTimeout: 30 * time.Second,
546- // WriteTimeout: 30 * time.Second,
547- // IdleTimeout: 120 * time.Second,
548- // }
554+ // Create a custom HTTPS server with proper TLS config
555+ httpsServer := &http.Server{
556+ Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443)
557+ Handler: e, // Use the main Echo instance
558+ TLSConfig: tlsConfig,
559+ ReadTimeout: 60 * time.Second,
560+ WriteTimeout: 60 * time.Second,
561+ IdleTimeout: 120 * time.Second,
562+ ReadHeaderTimeout: 30 * time.Second,
563+ }
549564
550- // Start HTTPS server with more error details
551- go func() {
552- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)
553- // Using empty strings for cert and key files since we're using GetCertificate
554- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
555- logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
556-
557- // Print more diagnostic information
558- if strings.Contains(err.Error(), "bind: address already in use") {
559- logger.Error("Port 443 is already in use by another process. Check if another web server is running.",
560- "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port")
561- } else if strings.Contains(err.Error(), "permission denied") {
562- logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.",
563- "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping")
564- } else if strings.Contains(err.Error(), "certificate") {
565- logger.Error("Certificate-related error when starting HTTPS server",
566- "solution", "Check certificate files and permissions in the certificate directory")
565+ // Store the HTTPS server for later use in Stop()
566+ p.httpsServer.Server = httpsServer // Store the http.Server within Echo
567+
568+ // Start HTTPS server in a goroutine
569+ go func() {
570+ logger.Info("HTTPS server starting", "address", httpsServer.Addr)
571+ // Using empty strings for cert and key files since GetCertificate is used
572+ if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
573+ // Log specific errors if possible
574+ if strings.Contains(err.Error(), "bind: address already in use") {
575+ logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err)
576+ } else if strings.Contains(err.Error(), "permission denied") {
577+ logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err)
578+ } else if strings.Contains(err.Error(), "certificate") {
579+ logger.Error("Certificate-related error starting HTTPS server", "error", err)
580+ } else {
581+ logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
582+ }
567583 }
568- }
569- }()
584+ }()
585+ p.serverStarted = true // Mark server as started
586+ }
570587
571- // Test admin connection after server starts
588+ // Test admin connection after attempting to start the appropriate server
572589 go p.TestAdminConnectionLater()
573590
574- return nil
591+ // Remove the old/duplicate HTTP listener logic that was here
592+
593+ return nil // Return nil indicating server start was initiated
575594 }
576595
577596 // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes
@@ -3,6 +3,7 @@ package proxy
3 import (3 import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6+ "errors"
6 "fmt"7 "fmt"
7 "net/http"8 "net/http"
8 "strings"9 "strings"
@@ -209,12 +210,13 @@ func (p *Proxy) Stop() error {
209 return nil210 return nil
210 }211 }
211 212
212-// testAdminConnection attempts to find a working connection to the Gordon admin213+// testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint
213 func (p *Proxy) testAdminConnection(defaultHost string, port string) string {214 func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
214- adminPath := p.app.GetConfig().Admin.Path215+ adminPath := p.app.GetConfig().Admin.Path // Get the base admin path
215- url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, adminPath)216+ pingPath := adminPath + "/ping" // Construct the full ping path
217+ url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath)
216 218
217- logger.Debug("Testing admin connection", "url", url)219+ logger.Debug("Testing admin ping connection", "url", url) // Update log message
218 220
219 // Try connecting using the provided default host221 // Try connecting using the provided default host
220 client := &http.Client{222 client := &http.Client{
@@ -230,11 +232,18 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
230 resp, err := client.Get(url)232 resp, err := client.Get(url)
231 if err == nil {233 if err == nil {
232 resp.Body.Close()234 resp.Body.Close()
233- logger.Info("Admin connection successful", "host", defaultHost)235+ // Check if the response status is OK
234- return defaultHost236+ if resp.StatusCode == http.StatusOK {
237+ logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message
238+ return defaultHost
239+ } else {
240+ logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode)
241+ }
242+ } else {
243+ logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err)
235 }244 }
236 245
237- // If connection failed, try other common hostnames246+ // If connection failed or status not OK, try other common hostnames
238 hosts := []string{247 hosts := []string{
239 "localhost",248 "localhost",
240 "127.0.0.1",249 "127.0.0.1",
@@ -247,18 +256,24 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
247 continue // Already tried this one256 continue // Already tried this one
248 }257 }
249 258
250- url = fmt.Sprintf("http://%s:%s%s", host, port, adminPath)259+ url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too
251- logger.Debug("Trying alternative host", "url", url)260+ logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message
252 261
253 resp, err := client.Get(url)262 resp, err := client.Get(url)
254 if err == nil {263 if err == nil {
255 resp.Body.Close()264 resp.Body.Close()
256- logger.Info("Found working admin connection", "host", host)265+ if resp.StatusCode == http.StatusOK {
257- return host266+ logger.Info("Found working admin ping connection", "host", host) // Update log message
267+ return host
268+ } else {
269+ logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode)
270+ }
271+ } else {
272+ logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err)
258 }273 }
259 }274 }
260 275
261- logger.Warn("Could not establish admin connection on any tested host", "fallback", defaultHost)276+ logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message
262 return defaultHost277 return defaultHost
263 }278 }
264 279
@@ -473,105 +488,109 @@ func (p *Proxy) checkExternalPortAccess() {
473 488
474 // StartHTTPSServer starts the HTTPS server489 // StartHTTPSServer starts the HTTPS server
475 func (p *Proxy) StartHTTPSServer() error {490 func (p *Proxy) StartHTTPSServer() error {
476- logger.Info("Starting HTTPS server",491+ // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers)
477- "port", p.config.Port,492+ e := p.httpsServer // Use the existing Echo instance
478- "mode", p.config.LetsEncryptMode)493+
479-494+ // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS
480- // Verify the certificate manager is initialized (it's done in NewProxy now)495+ if p.config.SkipCertificates {
481- if p.certificateManager == nil {496+ logger.Info("Starting plain HTTP listener because SkipCertificates is true",
482- // This should ideally not happen if NewProxy succeeded497+ "port", p.config.DefaultHttpChallengePort)
483- logger.Error("Certificate manager is nil in StartHTTPSServer, cannot start HTTPS")498+
484- return fmt.Errorf("certificate manager not initialized")499+ // Create a standard http.Server for the plain HTTP listener
485- }500+ httpServer := &http.Server{
486- logger.Info("Using new certificate management system")501+ Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix
487-502+ Handler: e, // Use the main Echo instance
488- // Setup TLS configuration503+ ReadTimeout: 5 * time.Second,
489- tlsConfig := &tls.Config{504+ WriteTimeout: 10 * time.Second,
490- // Use the helper function that gets the GetCertificate func from the manager505+ IdleTimeout: 120 * time.Second,
491- GetCertificate: p.getTLSCertificateFunc(),506+ }
492- // Ensure we use TLS 1.2 or higher
493- MinVersion: tls.VersionTLS12,
494- // Optimize cipher suites for security and performance
495- CipherSuites: []uint16{
496- tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
497- tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
498- tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
499- tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
500- tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
501- tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
502- },
503- // Use server's preferred ciphersuite
504- PreferServerCipherSuites: true,
505- // Enable HTTP/2
506- NextProtos: []string{"h2", "http/1.1"},
507- // Add server name to use when client doesn't send SNI
508- ServerName: p.app.GetConfig().Http.FullDomain(),
509- }
510-
511- // Removed setup for ACME challenge handler on the persistent HTTP server,
512- // as it's no longer running. Lego's temporary server will handle challenges.
513- // p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.getACMEHTTPHandler()))
514-
515- // Create a custom HTTPS server with proper TLS config
516- httpsServer := &http.Server{
517- Addr: fmt.Sprintf(":%s", p.config.Port),
518- Handler: p.httpsServer,
519- TLSConfig: tlsConfig,
520- // Add increased timeouts to prevent hanging connections
521- ReadTimeout: 60 * time.Second,
522- WriteTimeout: 60 * time.Second,
523- IdleTimeout: 120 * time.Second,
524- ReadHeaderTimeout: 30 * time.Second, // Add header timeout
525- }
526 507
527- // Store the HTTPS server for later use in Stop()508+ // Start plain HTTP listener using http.Server's ListenAndServe
528- p.httpsServer.Server = httpsServer509+ go func() {
510+ logger.Info("Plain HTTP listener starting", "address", httpServer.Addr)
511+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
512+ // Log specific errors if possible
513+ if strings.Contains(err.Error(), "bind: address already in use") {
514+ logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err)
515+ } else if strings.Contains(err.Error(), "permission denied") {
516+ logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err)
517+ } else {
518+ logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr)
519+ }
520+ }
521+ }()
522+ p.serverStarted = true // Mark server as started
529 523
530- // // Create a custom HTTP server524+ } else {
531- // httpServer := &http.Server{525+ // --- Start HTTPS Server (only if SkipCertificates is false) ---
532- // Addr: fmt.Sprintf(":%s", p.config.HttpPort),526+ logger.Info("Starting HTTPS server",
533- // Handler: p.httpServer,527+ "port", p.config.Port,
534- // ReadTimeout: 30 * time.Second,528+ "mode", p.config.LetsEncryptMode)
535- // WriteTimeout: 30 * time.Second,
536- // IdleTimeout: 120 * time.Second,
537- // }
538 529
539- p.serverStarted = true530+ // Verify certificate manager is initialized *only* if we need it for HTTPS
531+ if p.certificateManager == nil {
532+ logger.Error("Certificate manager is nil, cannot start HTTPS server")
533+ return fmt.Errorf("certificate manager not initialized for HTTPS")
534+ }
535+ logger.Info("Using certificate management system for HTTPS")
536+
537+ // Setup TLS configuration
538+ tlsConfig := &tls.Config{
539+ GetCertificate: p.getTLSCertificateFunc(),
540+ MinVersion: tls.VersionTLS12,
541+ CipherSuites: []uint16{
542+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
543+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
544+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
545+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
546+ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
547+ tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
548+ },
549+ PreferServerCipherSuites: true,
550+ NextProtos: []string{"h2", "http/1.1"},
551+ ServerName: p.app.GetConfig().Http.FullDomain(),
552+ }
540 553
541- // httpServer is no longer needed as ACME HTTP-01 uses its own temporary server554+ // Create a custom HTTPS server with proper TLS config
542- // httpServer := &http.Server{555+ httpsServer := &http.Server{
543- // Addr: fmt.Sprintf(":%s", p.config.HttpPort),556+ Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443)
544- // Handler: p.httpServer,557+ Handler: e, // Use the main Echo instance
545- // ReadTimeout: 30 * time.Second,558+ TLSConfig: tlsConfig,
546- // WriteTimeout: 30 * time.Second,559+ ReadTimeout: 60 * time.Second,
547- // IdleTimeout: 120 * time.Second,560+ WriteTimeout: 60 * time.Second,
548- // }561+ IdleTimeout: 120 * time.Second,
562+ ReadHeaderTimeout: 30 * time.Second,
563+ }
549 564
550- // Start HTTPS server with more error details565+ // Store the HTTPS server for later use in Stop()
551- go func() {566+ p.httpsServer.Server = httpsServer // Store the http.Server within Echo
552- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)567+
553- // Using empty strings for cert and key files since we're using GetCertificate568+ // Start HTTPS server in a goroutine
554- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {569+ go func() {
555- logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)570+ logger.Info("HTTPS server starting", "address", httpsServer.Addr)
556-571+ // Using empty strings for cert and key files since GetCertificate is used
557- // Print more diagnostic information572+ if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
558- if strings.Contains(err.Error(), "bind: address already in use") {573+ // Log specific errors if possible
559- logger.Error("Port 443 is already in use by another process. Check if another web server is running.",574+ if strings.Contains(err.Error(), "bind: address already in use") {
560- "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port")575+ logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err)
561- } else if strings.Contains(err.Error(), "permission denied") {576+ } else if strings.Contains(err.Error(), "permission denied") {
562- logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.",577+ logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err)
563- "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping")578+ } else if strings.Contains(err.Error(), "certificate") {
564- } else if strings.Contains(err.Error(), "certificate") {579+ logger.Error("Certificate-related error starting HTTPS server", "error", err)
565- logger.Error("Certificate-related error when starting HTTPS server",580+ } else {
566- "solution", "Check certificate files and permissions in the certificate directory")581+ logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
582+ }
567 }583 }
568- }584+ }()
569- }()585+ p.serverStarted = true // Mark server as started
586+ }
570 587
571- // Test admin connection after server starts588+ // Test admin connection after attempting to start the appropriate server
572 go p.TestAdminConnectionLater()589 go p.TestAdminConnectionLater()
573 590
574- return nil591+ // Remove the old/duplicate HTTP listener logic that was here
592+
593+ return nil // Return nil indicating server start was initiated
575 }594 }
576 595
577 // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes596 // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes
M internal/templating/models/templ/components/createcontainer.templ +8 -6
@@ -1,5 +1,7 @@
11 package components
22
3+import "fmt"
4+
35 // ContainerFormData holds the data needed for the container creation form
46 type ContainerFormData struct {
57 ImageName string
@@ -17,7 +19,7 @@ type ContainerFormData struct {
1719
1820 // CreateContainerForm displays a form for creating a new container
1921 templ CreateContainerForm(data ContainerFormData) {
20- <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
22+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
2123 <!-- Left Side: Docker Run Options -->
2224 <div class="flex-grow card p-6">
2325 <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
@@ -27,11 +29,12 @@ templ CreateContainerForm(data ContainerFormData) {
2729 <span>{ data.ErrorMessage }</span>
2830 </div>
2931 }
30- <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post"
31- hx-post={ "/htmx/create-container/" + data.ShortID }
32+ <form id={ "create-container-form-" + data.ShortID }
33+ action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) }
34+ method="post"
35+ hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) }
3236 hx-swap="innerHTML"
33- hx-target={ "#create-container-" + data.ShortID }
34- hx-target-*={ "#error-message-create-container-" + data.ShortID }>
37+ hx-target={ "#create-form-target-" + data.ShortID }>
3538 <!-- Image Name -->
3639 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
3740 <div class="md:col-span-1">
@@ -154,7 +157,6 @@ templ CreateContainerForm(data ContainerFormData) {
154157 Create Container
155158 </button>
156159 </form>
157- <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div>
158160 </div>
159161 </div>
160162 } ⏎̸
@@ -1,5 +1,7 @@
1 package components1 package components
2 2
3+import "fmt"
4+
3 // ContainerFormData holds the data needed for the container creation form5 // ContainerFormData holds the data needed for the container creation form
4 type ContainerFormData struct {6 type ContainerFormData struct {
5 ImageName string7 ImageName string
@@ -17,7 +19,7 @@ type ContainerFormData struct {
17 19
18 // CreateContainerForm displays a form for creating a new container20 // CreateContainerForm displays a form for creating a new container
19 templ CreateContainerForm(data ContainerFormData) {21 templ CreateContainerForm(data ContainerFormData) {
20- <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">22+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
21 <!-- Left Side: Docker Run Options -->23 <!-- Left Side: Docker Run Options -->
22 <div class="flex-grow card p-6">24 <div class="flex-grow card p-6">
23 <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>25 <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
@@ -27,11 +29,12 @@ templ CreateContainerForm(data ContainerFormData) {
27 <span>{ data.ErrorMessage }</span>29 <span>{ data.ErrorMessage }</span>
28 </div>30 </div>
29 }31 }
30- <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post" 32+ <form id={ "create-container-form-" + data.ShortID }
31- hx-post={ "/htmx/create-container/" + data.ShortID }33+ action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) }
34+ method="post"
35+ hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) }
32 hx-swap="innerHTML" 36 hx-swap="innerHTML"
33- hx-target={ "#create-container-" + data.ShortID }37+ hx-target={ "#create-form-target-" + data.ShortID }>
34- hx-target-*={ "#error-message-create-container-" + data.ShortID }>
35 <!-- Image Name -->38 <!-- Image Name -->
36 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">39 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
37 <div class="md:col-span-1">40 <div class="md:col-span-1">
@@ -154,7 +157,6 @@ templ CreateContainerForm(data ContainerFormData) {
154 Create Container157 Create Container
155 </button>158 </button>
156 </form>159 </form>
157- <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div>
158 </div>160 </div>
159 </div>161 </div>
160 } ⏎̸162 } ⏎̸
M internal/templating/models/templ/components/createcontainer_templ.go +39 -63 generated file: collapsed
M internal/templating/models/templ/components/footer.templ +7 -29
@@ -3,37 +3,15 @@ package components
33 // Footer is a templ component for the page footer
44 // It replaces footer.gohtml
55 templ Footer() {
6- <footer class="footer p-10 bg-base-200 text-base-content">
7- <div>
8- <span class="footer-title">Gordon</span>
9- <a class="link link-hover">About us</a>
10- <a class="link link-hover">Documentation</a>
11- <a class="link link-hover">Github</a>
12- </div>
13- <div>
14- <span class="footer-title">Container Management</span>
15- <a class="link link-hover">Dashboard</a>
16- <a class="link link-hover">Settings</a>
17- <a class="link link-hover">Stats</a>
18- </div>
19- <div>
20- <span class="footer-title">Legal</span>
21- <a class="link link-hover">Terms of use</a>
22- <a class="link link-hover">Privacy policy</a>
23- <a class="link link-hover">License</a>
24- </div>
25- </footer>
26- <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
6+ <footer class="footer items-center p-4 bg-base-300 text-base-content">
277 <div class="items-center grid-flow-col">
28- <span class="iconfb text-2xl">󰊢</span>
29- <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
8+ <span class="iconfb text-xl">󰊢</span>
9+ <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p>
3010 </div>
31- <div class="md:place-self-center md:justify-self-end">
32- <div class="grid grid-flow-col gap-4">
33- <a class="iconfb text-2xl">󰊤</a>
34- <a class="iconfb text-2xl">󰗃</a>
35- <a class="iconfb text-2xl">󰖟</a>
36- </div>
11+ <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
12+ <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a>
13+ // Add other essential links here if needed, e.g., Documentation
14+ // <a class="link link-hover text-sm">Documentation</a>
3715 </div>
3816 </footer>
3917 }⏎̸
@@ -3,37 +3,15 @@ package components
3 // Footer is a templ component for the page footer3 // Footer is a templ component for the page footer
4 // It replaces footer.gohtml4 // It replaces footer.gohtml
5 templ Footer() {5 templ Footer() {
6- <footer class="footer p-10 bg-base-200 text-base-content">6+ <footer class="footer items-center p-4 bg-base-300 text-base-content">
7- <div>
8- <span class="footer-title">Gordon</span>
9- <a class="link link-hover">About us</a>
10- <a class="link link-hover">Documentation</a>
11- <a class="link link-hover">Github</a>
12- </div>
13- <div>
14- <span class="footer-title">Container Management</span>
15- <a class="link link-hover">Dashboard</a>
16- <a class="link link-hover">Settings</a>
17- <a class="link link-hover">Stats</a>
18- </div>
19- <div>
20- <span class="footer-title">Legal</span>
21- <a class="link link-hover">Terms of use</a>
22- <a class="link link-hover">Privacy policy</a>
23- <a class="link link-hover">License</a>
24- </div>
25- </footer>
26- <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
27 <div class="items-center grid-flow-col">7 <div class="items-center grid-flow-col">
28- <span class="iconfb text-2xl">󰊢</span> 8+ <span class="iconfb text-xl">󰊢</span>
29- <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>9+ <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p>
30 </div> 10 </div>
31- <div class="md:place-self-center md:justify-self-end">11+ <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
32- <div class="grid grid-flow-col gap-4">12+ <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a>
33- <a class="iconfb text-2xl">󰊤</a> 13+ // Add other essential links here if needed, e.g., Documentation
34- <a class="iconfb text-2xl">󰗃</a>14+ // <a class="link link-hover text-sm">Documentation</a>
35- <a class="iconfb text-2xl">󰖟</a>
36- </div>
37 </div>15 </div>
38 </footer>16 </footer>
39 }⏎̸17 }⏎̸
M internal/templating/models/templ/components/footer_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/components/imagelist.templ +20 -3
@@ -2,6 +2,7 @@ package components
22
33 import (
44 "strconv"
5+ "fmt"
56 )
67
78 // HumanReadableImage represents an image with human-readable attributes
@@ -24,7 +25,7 @@ templ ImageList(images []HumanReadableImage) {
2425 <div class="card-body p-4">
2526
2627 <!-- Action buttons -->
27- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
28+ <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap">
2829 <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
2930 <div class="flex gap-2">
3031 <button
@@ -41,6 +42,14 @@ templ ImageList(images []HumanReadableImage) {
4142 hx-swap="innerHTML">
4243 <span class="iconf">󰑓</span> Refresh
4344 </button>
45+ <button
46+ class="btn btn-sm btn-warning"
47+ hx-post="/htmx/image-manager/prune"
48+ hx-target="#image-manager"
49+ hx-swap="innerHTML"
50+ 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
52+ </button>
4453 </div>
4554 </div>
4655
@@ -90,7 +99,10 @@ templ ImageList(images []HumanReadableImage) {
9099 <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
91100 <li>
92101 <a
93- href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) }
102+ href="#"
103+ hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) }
104+ hx-target={ "#create-form-target-" + img.ShortID }
105+ hx-swap="innerHTML"
94106 class="text-success flex items-center">
95107 <span class="iconf mr-2">󰐕</span> Create Container
96108 </a>
@@ -98,7 +110,7 @@ templ ImageList(images []HumanReadableImage) {
98110 <li>
99111 <button
100112 class="text-error flex items-center"
101- hx-delete={ "/htmx/image-manager/delete/" + img.ShortID }
113+ hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) }
102114 hx-target="#image-manager"
103115 hx-swap="innerHTML"
104116 hx-confirm="Are you sure you want to delete this image?">
@@ -109,6 +121,11 @@ templ ImageList(images []HumanReadableImage) {
109121 </div>
110122 </th>
111123 </tr>
124+ <tr>
125+ <td colspan="5" id={ "create-form-target-" + img.ShortID }>
126+ <!-- Form will be loaded here by HTMX -->
127+ </td>
128+ </tr>
112129 }
113130 }
114131 </tbody>
@@ -2,6 +2,7 @@ package components
2 2
3 import (3 import (
4 "strconv"4 "strconv"
5+ "fmt"
5 )6 )
6 7
7 // HumanReadableImage represents an image with human-readable attributes8 // HumanReadableImage represents an image with human-readable attributes
@@ -24,7 +25,7 @@ templ ImageList(images []HumanReadableImage) {
24 <div class="card-body p-4">25 <div class="card-body p-4">
25 26
26 <!-- Action buttons -->27 <!-- Action buttons -->
27- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">28+ <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap">
28 <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>29 <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
29 <div class="flex gap-2">30 <div class="flex gap-2">
30 <button 31 <button
@@ -41,6 +42,14 @@ templ ImageList(images []HumanReadableImage) {
41 hx-swap="innerHTML">42 hx-swap="innerHTML">
42 <span class="iconf">󰑓</span> Refresh43 <span class="iconf">󰑓</span> Refresh
43 </button>44 </button>
45+ <button
46+ class="btn btn-sm btn-warning"
47+ hx-post="/htmx/image-manager/prune"
48+ hx-target="#image-manager"
49+ hx-swap="innerHTML"
50+ 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
52+ </button>
44 </div>53 </div>
45 </div>54 </div>
46 55
@@ -90,7 +99,10 @@ templ ImageList(images []HumanReadableImage) {
90 <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">99 <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
91 <li>100 <li>
92 <a 101 <a
93- href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) }102+ href="#"
103+ hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) }
104+ hx-target={ "#create-form-target-" + img.ShortID }
105+ hx-swap="innerHTML"
94 class="text-success flex items-center">106 class="text-success flex items-center">
95 <span class="iconf mr-2">󰐕</span> Create Container107 <span class="iconf mr-2">󰐕</span> Create Container
96 </a>108 </a>
@@ -98,7 +110,7 @@ templ ImageList(images []HumanReadableImage) {
98 <li>110 <li>
99 <button 111 <button
100 class="text-error flex items-center"112 class="text-error flex items-center"
101- hx-delete={ "/htmx/image-manager/delete/" + img.ShortID }113+ hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) }
102 hx-target="#image-manager"114 hx-target="#image-manager"
103 hx-swap="innerHTML"115 hx-swap="innerHTML"
104 hx-confirm="Are you sure you want to delete this image?">116 hx-confirm="Are you sure you want to delete this image?">
@@ -109,6 +121,11 @@ templ ImageList(images []HumanReadableImage) {
109 </div>121 </div>
110 </th>122 </th>
111 </tr>123 </tr>
124+ <tr>
125+ <td colspan="5" id={ "create-form-target-" + img.ShortID }>
126+ <!-- Form will be loaded here by HTMX -->
127+ </td>
128+ </tr>
112 }129 }
113 }130 }
114 </tbody>131 </tbody>
M internal/templating/models/templ/components/imagelist_templ.go +47 -16 generated file: collapsed
M internal/templating/models/templ/components/navbar.templ +1 -1
@@ -3,7 +3,7 @@ package components
33 // Navbar is a menu component for navigation
44 templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
55 <!-- DaisyUI Navbar -->
6- <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
6+ <div class="navbar bg-base-200 shadow-lg fixed top-0 z-50">
77 <div class="navbar-start">
88 <div class="flex items-center">
99 <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
@@ -3,7 +3,7 @@ package components
3 // Navbar is a menu component for navigation3 // Navbar is a menu component for navigation
4 templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {4 templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
5 <!-- DaisyUI Navbar -->5 <!-- DaisyUI Navbar -->
6- <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">6+ <div class="navbar bg-base-200 shadow-lg fixed top-0 z-50">
7 <div class="navbar-start">7 <div class="navbar-start">
8 <div class="flex items-center">8 <div class="flex items-center">
9 <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">9 <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
M internal/templating/models/templ/components/navbar_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/pages/admin/manager.templ +3 -3
@@ -17,7 +17,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
1717 </div>
1818
1919 <!-- Stats overview -->
20- <div class="stats shadow mb-6 w-full">
20+ <div class="stats shadow mb-6 w-full bg-base-300">
2121 <div class="stat">
2222 <div class="stat-figure text-primary">
2323 <span class="iconf text-3xl">󰡨</span>
@@ -40,7 +40,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
4040 <!-- Tabs for containers and images -->
4141 <div role="tablist" class="tabs tabs-boxed mb-4">
4242 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
43- <div role="tabpanel" class="tab-content p-0">
43+ <div role="tabpanel" class="tab-content p-0 bg-base-300">
4444 <!-- Container manager section -->
4545 <div id="container-manager"
4646 hx-get="/htmx/container-manager"
@@ -55,7 +55,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
5555 </div>
5656
5757 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
58- <div role="tabpanel" class="tab-content p-0">
58+ <div role="tabpanel" class="tab-content p-0 bg-base-300">
5959 <!-- Image manager section -->
6060 <div id="image-manager"
6161 hx-get="/htmx/image-manager"
@@ -17,7 +17,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
17 </div>17 </div>
18 18
19 <!-- Stats overview -->19 <!-- Stats overview -->
20- <div class="stats shadow mb-6 w-full">20+ <div class="stats shadow mb-6 w-full bg-base-300">
21 <div class="stat">21 <div class="stat">
22 <div class="stat-figure text-primary">22 <div class="stat-figure text-primary">
23 <span class="iconf text-3xl">󰡨</span>23 <span class="iconf text-3xl">󰡨</span>
@@ -40,7 +40,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
40 <!-- Tabs for containers and images -->40 <!-- Tabs for containers and images -->
41 <div role="tablist" class="tabs tabs-boxed mb-4">41 <div role="tablist" class="tabs tabs-boxed mb-4">
42 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />42 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
43- <div role="tabpanel" class="tab-content p-0">43+ <div role="tabpanel" class="tab-content p-0 bg-base-300">
44 <!-- Container manager section -->44 <!-- Container manager section -->
45 <div id="container-manager"45 <div id="container-manager"
46 hx-get="/htmx/container-manager"46 hx-get="/htmx/container-manager"
@@ -55,7 +55,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti
55 </div>55 </div>
56 56
57 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />57 <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
58- <div role="tabpanel" class="tab-content p-0">58+ <div role="tabpanel" class="tab-content p-0 bg-base-300">
59 <!-- Image manager section -->59 <!-- Image manager section -->
60 <div id="image-manager"60 <div id="image-manager"
61 hx-get="/htmx/image-manager"61 hx-get="/htmx/image-manager"
M internal/templating/models/templ/pages/admin/manager_templ.go +2 -2 generated file: collapsed
M internal/webui/public/assets/css/tailwind.css +9 -13
@@ -5886,10 +5886,6 @@
58865886 border-style: var(--tw-border-style);
58875887 border-width: 1px;
58885888 }
5889- .border-t {
5890- border-top-style: var(--tw-border-style);
5891- border-top-width: 1px;
5892- }
58935889 .btn-dash {
58945890 &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) {
58955891 --btn-shadow: "";
@@ -5960,9 +5956,6 @@
59605956 color: var(--color-warning-content);
59615957 --alert-color: var(--color-warning);
59625958 }
5963- .border-base-300 {
5964- border-color: var(--color-base-300);
5965- }
59665959 .glass {
59675960 border: none;
59685961 backdrop-filter: blur(var(--glass-blur, 40px));
@@ -6201,9 +6194,6 @@
62016194 .p-6 {
62026195 padding: calc(var(--spacing) * 6);
62036196 }
6204- .p-10 {
6205- padding: calc(var(--spacing) * 10);
6206- }
62076197 .menu-title {
62086198 padding-inline: calc(0.25rem * 3);
62096199 padding-block: calc(0.25rem * 2);
@@ -6287,9 +6277,6 @@
62876277 .px-4 {
62886278 padding-inline: calc(var(--spacing) * 4);
62896279 }
6290- .px-10 {
6291- padding-inline: calc(var(--spacing) * 10);
6292- }
62936280 .py-4 {
62946281 padding-block: calc(var(--spacing) * 4);
62956282 }
@@ -6376,6 +6363,10 @@
63766363 font-size: var(--text-sm);
63776364 line-height: var(--tw-leading, var(--text-sm--line-height));
63786365 }
6366+ .text-xl {
6367+ font-size: var(--text-xl);
6368+ line-height: var(--tw-leading, var(--text-xl--line-height));
6369+ }
63796370 .text-xs {
63806371 font-size: var(--text-xs);
63816372 line-height: var(--tw-leading, var(--text-xs--line-height));
@@ -7511,6 +7502,11 @@
75117502 align-items: center;
75127503 }
75137504 }
7505+ .sm\:justify-start {
7506+ @media (width >= 40rem) {
7507+ justify-content: flex-start;
7508+ }
7509+ }
75147510 .md\:col-span-1 {
75157511 @media (width >= 48rem) {
75167512 grid-column: span 1 / span 1;
@@ -5886,10 +5886,6 @@
5886 border-style: var(--tw-border-style);5886 border-style: var(--tw-border-style);
5887 border-width: 1px;5887 border-width: 1px;
5888 }5888 }
5889- .border-t {
5890- border-top-style: var(--tw-border-style);
5891- border-top-width: 1px;
5892- }
5893 .btn-dash {5889 .btn-dash {
5894 &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) {5890 &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) {
5895 --btn-shadow: "";5891 --btn-shadow: "";
@@ -5960,9 +5956,6 @@
5960 color: var(--color-warning-content);5956 color: var(--color-warning-content);
5961 --alert-color: var(--color-warning);5957 --alert-color: var(--color-warning);
5962 }5958 }
5963- .border-base-300 {
5964- border-color: var(--color-base-300);
5965- }
5966 .glass {5959 .glass {
5967 border: none;5960 border: none;
5968 backdrop-filter: blur(var(--glass-blur, 40px));5961 backdrop-filter: blur(var(--glass-blur, 40px));
@@ -6201,9 +6194,6 @@
6201 .p-6 {6194 .p-6 {
6202 padding: calc(var(--spacing) * 6);6195 padding: calc(var(--spacing) * 6);
6203 }6196 }
6204- .p-10 {
6205- padding: calc(var(--spacing) * 10);
6206- }
6207 .menu-title {6197 .menu-title {
6208 padding-inline: calc(0.25rem * 3);6198 padding-inline: calc(0.25rem * 3);
6209 padding-block: calc(0.25rem * 2);6199 padding-block: calc(0.25rem * 2);
@@ -6287,9 +6277,6 @@
6287 .px-4 {6277 .px-4 {
6288 padding-inline: calc(var(--spacing) * 4);6278 padding-inline: calc(var(--spacing) * 4);
6289 }6279 }
6290- .px-10 {
6291- padding-inline: calc(var(--spacing) * 10);
6292- }
6293 .py-4 {6280 .py-4 {
6294 padding-block: calc(var(--spacing) * 4);6281 padding-block: calc(var(--spacing) * 4);
6295 }6282 }
@@ -6376,6 +6363,10 @@
6376 font-size: var(--text-sm);6363 font-size: var(--text-sm);
6377 line-height: var(--tw-leading, var(--text-sm--line-height));6364 line-height: var(--tw-leading, var(--text-sm--line-height));
6378 }6365 }
6366+ .text-xl {
6367+ font-size: var(--text-xl);
6368+ line-height: var(--tw-leading, var(--text-xl--line-height));
6369+ }
6379 .text-xs {6370 .text-xs {
6380 font-size: var(--text-xs);6371 font-size: var(--text-xs);
6381 line-height: var(--tw-leading, var(--text-xs--line-height));6372 line-height: var(--tw-leading, var(--text-xs--line-height));
@@ -7511,6 +7502,11 @@
7511 align-items: center;7502 align-items: center;
7512 }7503 }
7513 }7504 }
7505+ .sm\:justify-start {
7506+ @media (width >= 40rem) {
7507+ justify-content: flex-start;
7508+ }
7509+ }
7514 .md\:col-span-1 {7510 .md\:col-span-1 {
7515 @media (width >= 48rem) {7511 @media (width >= 48rem) {
7516 grid-column: span 1 / span 1;7512 grid-column: span 1 / span 1;
M pkg/docker/images.go +34 -1
@@ -13,8 +13,10 @@ import (
1313 "github.com/bnema/gordon/pkg/verify"
1414 "github.com/charmbracelet/log"
1515 "github.com/docker/docker/api/types"
16- "github.com/docker/docker/api/types/image"
16+ "github.com/docker/docker/api/types/filters"
17+ image "github.com/docker/docker/api/types/image"
1718 "github.com/docker/docker/client"
19+ "github.com/docker/go-units"
1820 )
1921
2022 func ListContainerImages() ([]image.Summary, error) {
@@ -325,3 +327,34 @@ func GetAllImages() ([]image.Summary, error) {
325327
326328 return images, nil
327329 }
330+
331+// PruneImages purges all unused (dangling) images from the Docker engine
332+func PruneImages() (uint64, error) {
333+ // Ensure the Docker client is initialized
334+ if err := CheckIfInitialized(); err != nil {
335+ return 0, fmt.Errorf("docker client not initialized: %w", err)
336+ }
337+
338+ ctx := context.Background()
339+ cli := dockerCli // Use the global client instance
340+
341+ // Define the filter to prune only dangling images (those without tags)
342+ pruneFilters := filters.NewArgs()
343+ pruneFilters.Add("dangling", "true")
344+
345+ // Prune the images
346+ report, err := cli.ImagesPrune(ctx, pruneFilters)
347+ if err != nil {
348+ log.Error("Failed to prune images", "error", err)
349+ return 0, fmt.Errorf("failed to prune images: %w", err)
350+ }
351+
352+ // Log the results
353+ log.Infof("Purged %d images, reclaimed %s space", len(report.ImagesDeleted), units.HumanSize(float64(report.SpaceReclaimed)))
354+ for _, img := range report.ImagesDeleted {
355+ log.Debugf("- Deleted: %s", img.Deleted)
356+ log.Debugf("- Untagged: %s", img.Untagged)
357+ }
358+
359+ return report.SpaceReclaimed, nil
360+}
@@ -13,8 +13,10 @@ import (
13 "github.com/bnema/gordon/pkg/verify"13 "github.com/bnema/gordon/pkg/verify"
14 "github.com/charmbracelet/log"14 "github.com/charmbracelet/log"
15 "github.com/docker/docker/api/types"15 "github.com/docker/docker/api/types"
16- "github.com/docker/docker/api/types/image"16+ "github.com/docker/docker/api/types/filters"
17+ image "github.com/docker/docker/api/types/image"
17 "github.com/docker/docker/client"18 "github.com/docker/docker/client"
19+ "github.com/docker/go-units"
18 )20 )
19 21
20 func ListContainerImages() ([]image.Summary, error) {22 func ListContainerImages() ([]image.Summary, error) {
@@ -325,3 +327,34 @@ func GetAllImages() ([]image.Summary, error) {
325 327
326 return images, nil328 return images, nil
327 }329 }
330+
331+// PruneImages purges all unused (dangling) images from the Docker engine
332+func PruneImages() (uint64, error) {
333+ // Ensure the Docker client is initialized
334+ if err := CheckIfInitialized(); err != nil {
335+ return 0, fmt.Errorf("docker client not initialized: %w", err)
336+ }
337+
338+ ctx := context.Background()
339+ cli := dockerCli // Use the global client instance
340+
341+ // Define the filter to prune only dangling images (those without tags)
342+ pruneFilters := filters.NewArgs()
343+ pruneFilters.Add("dangling", "true")
344+
345+ // Prune the images
346+ report, err := cli.ImagesPrune(ctx, pruneFilters)
347+ if err != nil {
348+ log.Error("Failed to prune images", "error", err)
349+ return 0, fmt.Errorf("failed to prune images: %w", err)
350+ }
351+
352+ // Log the results
353+ log.Infof("Purged %d images, reclaimed %s space", len(report.ImagesDeleted), units.HumanSize(float64(report.SpaceReclaimed)))
354+ for _, img := range report.ImagesDeleted {
355+ log.Debugf("- Deleted: %s", img.Deleted)
356+ log.Debugf("- Untagged: %s", img.Untagged)
357+ }
358+
359+ return report.SpaceReclaimed, nil
360+}

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