detached 2f01687

Merge branch 'feat/templ-integration' into dev

commit
2f01687ca391d167c1af5a13eefe3d8894eac923
author
bamen · · 2025-03-25 06:37 UTC
merge of
3bc07cb + 60d0f76 · diff against the first parent
signature
unsigned
90 files · +14194 -776

this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.

M .goreleaser.yaml +2 -0
@@ -3,6 +3,8 @@ version: 2
33 before:
44 hooks:
55 - go mod tidy
6+ - templ generate
7+ - bun run build:css
68
79 release:
810 draft: true
A .templ.config.json +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "root": "internal/templating/models/templ",
3+ "paths": ["internal/templating/models/templ"]
4+}⏎̸
M Makefile +8 -2
@@ -20,13 +20,13 @@ LDFLAGS := -s -w \
2020 ARCHS := amd64 arm64
2121
2222 # Phony targets
23-.PHONY: all build build-push clean build-css
23+.PHONY: all build build-push clean build-css build-templ
2424
2525 # Default target
2626 all: build
2727
2828 # Build binaries
29-build: build-css
29+build: build-css build-templ
3030 @echo "Building Go binaries..."
3131 @mkdir -p $(DIST_DIR)
3232 @rm -f $(DIST_DIR)/*
@@ -41,6 +41,12 @@ build-css:
4141 @bun run build:css
4242 @echo "CSS built successfully"
4343
44+# Generate Go code from templ templates
45+build-templ:
46+ @echo "Generating Go code from templ templates..."
47+ @templ generate
48+ @echo "templ generation completed successfully"
49+
4450 # Build and push Docker images
4551 build-push: build-css build
4652 @echo "Cleaning up dangling images..."
M docker-compose.yml.example +6 -1
@@ -44,9 +44,11 @@ services:
4444 # Reverse Proxy Configuration
4545 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
4646 - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
47+ - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
4748 - GORDON_PROXY_CERT_DIR=/certs
4849 - GORDON_PROXY_EMAIL=admin@example.com
49- - GORDON_PROXY_ENABLE_LOGS=true # Enable HTTP request logging (set to false to disable)
50+ - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
51+ - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
5052
5153 # Session Secret for security
5254 - SESSION_SECRET=replace_with_secure_random_string
@@ -97,8 +99,11 @@ networks:
9799 # # Reverse Proxy Configuration
98100 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
99101 # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
102+# - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
100103 # - GORDON_PROXY_CERT_DIR=/certs
101104 # - GORDON_PROXY_EMAIL=admin@example.com
105+# - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
106+# - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
102107 #
103108 # # Session Secret for security
104109 # - SESSION_SECRET=replace_with_secure_random_string
M go.mod +9 -0
@@ -6,6 +6,7 @@ require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/Masterminds/semver/v3 v3.3.1
88 github.com/PuerkitoBio/goquery v1.10.2
9+ github.com/a-h/templ v0.3.833
910 github.com/charmbracelet/bubbles v0.20.0
1011 github.com/charmbracelet/lipgloss v1.0.0
1112 github.com/charmbracelet/log v0.4.0
@@ -24,12 +25,20 @@ require (
2425 )
2526
2627 require (
28+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
29+ github.com/andybalholm/brotli v1.1.0 // indirect
30+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
2731 github.com/cespare/xxhash/v2 v2.3.0 // indirect
32+ github.com/cli/browser v1.3.0 // indirect
33+ github.com/fsnotify/fsnotify v1.7.0 // indirect
2834 github.com/klauspost/compress v1.18.0 // indirect
2935 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
36+ github.com/natefinch/atomic v1.0.1 // indirect
3037 github.com/zeebo/xxh3 v1.0.2 // indirect
3138 go.mongodb.org/mongo-driver v1.17.3 // indirect
3239 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
40+ golang.org/x/mod v0.24.0 // indirect
41+ golang.org/x/tools v0.31.0 // indirect
3342 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
3443 )
3544
M go.sum +14 -2 generated file: collapsed
M internal/common/init_config.go +170 -61
@@ -47,14 +47,15 @@ type BuildConfig struct {
4747 }
4848
4949 type AdminConfig struct {
50- Path string `yaml:"path"`
50+ Path string `yaml:"path"`
51+ AdminWebUI bool `yaml:"-"`
5152 }
5253
5354 type GeneralConfig struct {
5455 StorageDir string `yaml:"storageDir"`
5556 Token string `yaml:"token"`
5657 LogLevel string `yaml:"logLevel"`
57- GordonContainerID string `yaml:"gordonContainerID"`
58+ GordonContainerID string `yaml:"-"`
5859 }
5960
6061 type HttpConfig struct {
@@ -73,33 +74,39 @@ type ContainerEngineConfig struct {
7374 }
7475
7576 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
82- Email string `yaml:"email"` // Email for Let's Encrypt
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)
86- DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)
77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
81+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
82+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
83+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
84+ Email string `yaml:"email"` // Email for Let's Encrypt
85+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
86+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
87+ EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true)
88+ EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false)
89+ DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false)
90+ SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false)
8791 }
8892
8993 // Default values
9094 var (
91- sock = "/var/run/docker.sock"
92- podmansock = "/run/podman/podman.sock"
93- reverseProxyPort = "443" // Changed to 443 for standard HTTPS
94- httpPort = "80" // Standard HTTP port
95- certDir = "/certs"
96- letsEncryptMode = "staging"
97- autoRenew = true
98- renewBefore = 30 // days
99- cacheSize = 1000 // entries
100- gracePeriod = 30 // seconds
101- defaultLogLevel = "info" // Default log level
102- disableRateLimit = false
95+ sock = "/var/run/docker.sock"
96+ podmansock = "/run/podman/podman.sock"
97+ reverseProxyPort = "443" // Changed to 443 for standard HTTPS
98+ httpPort = "80" // Standard HTTP port
99+ certDir = "/certs"
100+ letsEncryptMode = "staging"
101+ autoRenew = true
102+ proxyEnabled = true // Default to enabled for backward compatibility
103+ renewBefore = 30 // days
104+ cacheSize = 1000 // entries
105+ gracePeriod = 30 // seconds
106+ defaultLogLevel = "info" // Default log level
107+ disableRateLimit = false
108+ detectUpstreamProxy = false // Default to disabled
109+ skipCertificates = false // Default to disabled
103110 )
104111
105112 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -154,6 +161,12 @@ func applyDefaultsToConfig(config *Config) bool {
154161 }
155162
156163 // Apply defaults to ReverseProxy config
164+ // Set Enabled to true by default if not specified
165+ if !config.ReverseProxy.Enabled {
166+ config.ReverseProxy.Enabled = proxyEnabled
167+ logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled)
168+ defaultsApplied = true
169+ }
157170 if config.ReverseProxy.Port == "" {
158171 config.ReverseProxy.Port = reverseProxyPort
159172 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
@@ -189,16 +202,30 @@ func applyDefaultsToConfig(config *Config) bool {
189202 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
190203 defaultsApplied = true
191204 }
192- // Set EnableLogs to true by default if not specified
193- if !config.ReverseProxy.EnableLogs {
194- config.ReverseProxy.EnableLogs = true
195- logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true)
205+ // Set EnableHttpLogs to true by default if not specified
206+ if !config.ReverseProxy.EnableHttpLogs {
207+ config.ReverseProxy.EnableHttpLogs = true
208+ logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true)
209+ defaultsApplied = true
210+ }
211+ // Set EnableRateLimit to false by default if not specified
212+ if !config.ReverseProxy.EnableRateLimit {
213+ config.ReverseProxy.EnableRateLimit = false
214+ logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", false)
215+ defaultsApplied = true
216+ }
217+
218+ // Set DetectUpstreamProxy to false by default if not specified
219+ if !config.ReverseProxy.DetectUpstreamProxy {
220+ config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
221+ logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy)
196222 defaultsApplied = true
197223 }
198- // Set DisableRateLimit to false by default if not specified
199- if !config.ReverseProxy.DisableRateLimit {
200- config.ReverseProxy.DisableRateLimit = false
201- logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false)
224+
225+ // Set SkipCertificates to false by default if not specified
226+ if !config.ReverseProxy.SkipCertificates {
227+ config.ReverseProxy.SkipCertificates = skipCertificates
228+ logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
202229 defaultsApplied = true
203230 }
204231
@@ -552,6 +579,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
552579 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
553580 }
554581 }
582+
583+ // New environment variables for reverse proxy port control
584+ if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" {
585+ // This is handled at runtime in the proxy server but we log it here
586+ if printLogs {
587+ logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val)
588+ }
589+ }
590+
591+ // Handle GORDON_PROXY_ENABLED environment variable
592+ if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" {
593+ enabled, err := strconv.ParseBool(val)
594+ if err == nil {
595+ config.ReverseProxy.Enabled = enabled
596+ }
597+ if printLogs {
598+ logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled)
599+ }
600+ }
555601 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
556602 config.ReverseProxy.CertDir = val
557603 if printLogs {
@@ -600,22 +646,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
600646 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
601647 }
602648
603- // Handle GORDON_PROXY_ENABLE_LOGS environment variable
604- if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
649+ // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
650+ if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
605651 enableLogs, err := strconv.ParseBool(val)
606652 if err == nil {
607- config.ReverseProxy.EnableLogs = enableLogs
653+ config.ReverseProxy.EnableHttpLogs = enableLogs
608654 }
609- logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs)
655+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs)
656+ } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
657+ // For backward compatibility
658+ enableLogs, err := strconv.ParseBool(val)
659+ if err == nil {
660+ config.ReverseProxy.EnableHttpLogs = enableLogs
661+ }
662+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs)
610663 }
611664
612- // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable
613- if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
665+ // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
666+ if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" {
667+ enableRateLimit, err := strconv.ParseBool(val)
668+ if err == nil {
669+ config.ReverseProxy.EnableRateLimit = enableRateLimit
670+ }
671+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit)
672+ } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
673+ // For backward compatibility
614674 disableRateLimit, err := strconv.ParseBool(val)
615675 if err == nil {
616- config.ReverseProxy.DisableRateLimit = disableRateLimit
676+ // Invert the logic
677+ config.ReverseProxy.EnableRateLimit = !disableRateLimit
678+ }
679+ logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit)
680+ }
681+
682+ // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable
683+ if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" {
684+ detectUpstream, err := strconv.ParseBool(val)
685+ if err == nil {
686+ config.ReverseProxy.DetectUpstreamProxy = detectUpstream
687+ }
688+ if printLogs {
689+ logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy)
690+ }
691+ }
692+
693+ // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable
694+ if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" {
695+ skipCerts, err := strconv.ParseBool(val)
696+ if err == nil {
697+ config.ReverseProxy.SkipCertificates = skipCerts
698+ }
699+ if printLogs {
700+ logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
617701 }
618- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit)
619702 }
620703 }
621704
@@ -663,16 +746,16 @@ func (config *Config) LoadConfig() (*Config, error) {
663746 Network: "gordon",
664747 },
665748 ReverseProxy: ReverseProxyConfig{
666- Port: reverseProxyPort,
667- HttpPort: httpPort,
668- CertDir: certDir,
669- AutoRenew: autoRenew,
670- RenewBefore: renewBefore,
671- LetsEncryptMode: letsEncryptMode,
672- CacheSize: cacheSize,
673- GracePeriod: gracePeriod,
674- EnableLogs: true,
675- DisableRateLimit: false,
749+ Port: reverseProxyPort,
750+ HttpPort: httpPort,
751+ CertDir: certDir,
752+ AutoRenew: autoRenew,
753+ RenewBefore: renewBefore,
754+ LetsEncryptMode: letsEncryptMode,
755+ CacheSize: cacheSize,
756+ GracePeriod: gracePeriod,
757+ EnableHttpLogs: true,
758+ EnableRateLimit: false,
676759 },
677760 }
678761
@@ -802,16 +885,16 @@ func (config *Config) LoadConfig() (*Config, error) {
802885 Network: "gordon",
803886 },
804887 ReverseProxy: ReverseProxyConfig{
805- Port: reverseProxyPort,
806- HttpPort: httpPort,
807- CertDir: certDir,
808- AutoRenew: autoRenew,
809- RenewBefore: renewBefore,
810- LetsEncryptMode: letsEncryptMode,
811- CacheSize: cacheSize,
812- GracePeriod: gracePeriod,
813- EnableLogs: true,
814- DisableRateLimit: false,
888+ Port: reverseProxyPort,
889+ HttpPort: httpPort,
890+ CertDir: certDir,
891+ AutoRenew: autoRenew,
892+ RenewBefore: renewBefore,
893+ LetsEncryptMode: letsEncryptMode,
894+ CacheSize: cacheSize,
895+ GracePeriod: gracePeriod,
896+ EnableHttpLogs: true,
897+ EnableRateLimit: false,
815898 },
816899 }
817900
@@ -884,6 +967,27 @@ func (config *Config) LoadConfig() (*Config, error) {
884967 logger.Info("Loaded configuration from", "path", configFilePath)
885968 }
886969
970+ // After loading from file or environment, check if Admin.Path is empty
971+ // This should be placed before returning the config
972+ if config.Admin.Path == "" {
973+ logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled")
974+ config.Admin.AdminWebUI = false
975+ } else {
976+ config.Admin.AdminWebUI = true
977+ }
978+
979+ // Save config file if running in container and env vars were applied
980+ // This ensures config.yml always reflects the environment variables
981+ if docker.IsRunningInContainer() && configLogsPrinted {
982+ logger.Info("Environment variables were applied, saving configuration to reflect current values...")
983+ err = config.SaveConfig()
984+ if err != nil {
985+ logger.Error("Failed to save configuration with environment variables", "error", err)
986+ } else {
987+ logger.Info("Successfully updated config.yml with environment variable values")
988+ }
989+ }
990+
887991 // Apply the log level from configuration
888992 if config.General.LogLevel != "" {
889993 // Set the log level using our centralized logger
@@ -1006,3 +1110,8 @@ func (c *HttpConfig) FullDomain() string {
10061110 func (c *Config) GetVersion() string {
10071111 return c.Build.BuildVersion
10081112 }
1113+
1114+// IsAdminWebUIEnabled returns whether the admin dashboard is enabled
1115+func (c *AdminConfig) IsAdminWebUIEnabled() bool {
1116+ return c.AdminWebUI && c.Path != ""
1117+}
M internal/common/init_dockerclient.go +155 -41
@@ -19,62 +19,176 @@ func DockerInit(cc *ContainerEngineConfig) error {
1919 cc.PodmanSock = cc.Sock
2020 }
2121
22- config := NewDockerConfig(cc)
23- err := docker.InitializeClient(config)
24- if err != nil {
25- // Check if it's a "socket doesn't exist" error
26- if strings.Contains(err.Error(), "no such file or directory") {
27- // Log the error
28- log.Printf("Error initializing Docker client: %s", err)
29-
30- // Try alternative socket paths
31- socketPaths := []string{
32- "/run/podman/podman.sock", // Standard path
33- "/var/run/podman/podman.sock", // Alternative standard path
34- os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros
35- "/run/user/1000/podman/podman.sock", // User-specific socket
36- }
22+ // If socket is empty, try to detect it automatically
23+ if cc.Sock == "" {
24+ // Try standard paths first
25+ standardPaths := []string{
26+ "/var/run/docker.sock", // Standard Docker socket in containers
27+ "/run/podman/podman.sock", // Standard Podman path
28+ "/var/run/podman/podman.sock", // Alternative standard path
29+ }
3730
38- // Add Docker socket path as a fallback
39- if !isPodmanSocket {
40- socketPaths = append(socketPaths, "/var/run/docker.sock")
41- }
31+ // Add home directory based path
32+ homePath := os.Getenv("HOME")
33+ if homePath != "" {
34+ standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock")
35+ }
36+
37+ // Check standard paths first
38+ for _, path := range standardPaths {
39+ if _, err := os.Stat(path); err == nil {
40+ log.Printf("Auto-detected container engine socket at %s", path)
4241
43- for _, path := range socketPaths {
44- // Skip if it's the path we already tried
45- if path == cc.Sock {
46- continue
42+ // Detect if this is a Podman socket
43+ isPodmanSocket = detectPodmanSocket(path)
44+ if isPodmanSocket {
45+ cc.Podman = true
46+ cc.PodmanSock = path
4747 }
4848
49- // Check if this socket exists
50- if _, err := os.Stat(path); err == nil {
51- log.Printf("Found alternative socket at %s", path)
49+ cc.Sock = path
50+ break
51+ } else {
52+ log.Printf("Socket not found at: %s", path)
53+ }
54+ }
5255
53- // Detect if this is a Podman socket
54- isPodmanSocket = detectPodmanSocket(path)
55- if isPodmanSocket {
56+ // If not found in standard paths, try common user IDs
57+ if cc.Sock == "" {
58+ log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...")
59+
60+ // Try common user IDs from 1000 to 1100 (typical range for first users on Linux)
61+ for uid := 1000; uid <= 1100; uid++ {
62+ uidStr := fmt.Sprintf("%d", uid)
63+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
64+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
65+
66+ // Check the Podman socket exists
67+ if _, err := os.Stat(podmanSocketPath); err == nil {
68+ // Check if the socket is accessible for reading/writing
69+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
70+ file.Close()
71+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
5672 cc.Podman = true
57- cc.PodmanSock = path
73+ cc.PodmanSock = podmanSocketPath
74+ cc.Sock = podmanSocketPath
75+ break
76+ } else {
77+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
5878 }
79+ }
5980
60- cc.Sock = path
61-
62- // Try again with the new path
63- newConfig := NewDockerConfig(cc)
64- if err := docker.InitializeClient(newConfig); err == nil {
65- return nil
81+ // Check Docker socket for this UID
82+ if _, err := os.Stat(dockerSocketPath); err == nil {
83+ if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
84+ file.Close()
85+ log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
86+ cc.Sock = dockerSocketPath
87+ break
6688 }
6789 }
6890 }
91+ }
92+ }
6993
70- // Return without an error, as we want to continue even if Docker is not available
71- log.Printf("Docker/Podman functionality will be limited or unavailable")
72- return fmt.Errorf("could not connect to container engine: %w", err)
94+ // If we found a socket, try to initialize Docker client
95+ if cc.Sock != "" {
96+ config := NewDockerConfig(cc)
97+ err := docker.InitializeClient(config)
98+ if err == nil {
99+ log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock)
100+ return nil
73101 }
74102
75- return err
103+ // Log the error but continue with fallback strategy
104+ log.Printf("Error initializing Docker client: %s", err)
105+ }
106+
107+ // If we failed to find or initialize a socket, try a comprehensive fallback scan
108+ // across all common socket locations and user IDs
109+ socketPaths := []string{
110+ "/var/run/docker.sock",
111+ "/run/podman/podman.sock",
112+ "/var/run/podman/podman.sock",
113+ }
114+
115+ // Add home directory based path
116+ homePath := os.Getenv("HOME")
117+ if homePath != "" {
118+ socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock")
119+ }
120+
121+ // Skip if we already tried this path
122+ if cc.Sock != "" {
123+ skipPath := cc.Sock
124+ for i, path := range socketPaths {
125+ if path == skipPath {
126+ socketPaths = append(socketPaths[:i], socketPaths[i+1:]...)
127+ break
128+ }
129+ }
76130 }
77- return nil
131+
132+ // Try common user IDs from 1000 to 1100
133+ for uid := 1000; uid <= 1100; uid++ {
134+ uidStr := fmt.Sprintf("%d", uid)
135+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
136+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
137+
138+ // Check the Podman socket exists
139+ if _, err := os.Stat(podmanSocketPath); err == nil {
140+ // Check if the socket is accessible for reading/writing
141+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
142+ file.Close()
143+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
144+ socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path
145+ } else {
146+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
147+ }
148+ }
149+
150+ // Also check Docker socket for this UID
151+ if _, err := os.Stat(dockerSocketPath); err == nil {
152+ socketPaths = append(socketPaths, dockerSocketPath)
153+ }
154+ }
155+
156+ log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths))
157+
158+ for _, path := range socketPaths {
159+ // Skip if it's the path we already tried
160+ if path == cc.Sock {
161+ continue
162+ }
163+
164+ // Check if this socket exists
165+ if _, err := os.Stat(path); err == nil {
166+ log.Printf("Found alternative socket at %s", path)
167+
168+ // Detect if this is a Podman socket
169+ isPodmanSocket = detectPodmanSocket(path)
170+ if isPodmanSocket {
171+ cc.Podman = true
172+ cc.PodmanSock = path
173+ }
174+
175+ cc.Sock = path
176+
177+ // Try again with the new path
178+ newConfig := NewDockerConfig(cc)
179+ if err := docker.InitializeClient(newConfig); err == nil {
180+ log.Printf("Successfully initialized Docker client with socket: %s", path)
181+ return nil
182+ } else {
183+ log.Printf("Socket found but client initialization failed: %s", err)
184+ }
185+ }
186+ }
187+
188+ // If we still haven't found a valid socket, give up but don't fail the application
189+ log.Printf("No working container engine socket found after comprehensive scan")
190+ log.Printf("Docker/Podman functionality will be limited or unavailable")
191+ return fmt.Errorf("could not find or connect to any container engine socket")
78192 }
79193
80194 // detectPodmanSocket checks if a socket path is likely a Podman socket
M internal/httpserve/handlers/container_crud.go +35 -37
@@ -7,6 +7,8 @@ import (
77
88 "github.com/bnema/gordon/internal/server"
99 "github.com/bnema/gordon/internal/templating/cmdparams"
10+ templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components"
11+ templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
1012 "github.com/bnema/gordon/internal/templating/render"
1113 "github.com/bnema/gordon/pkg/docker"
1214 "github.com/bnema/gordon/pkg/sanitize"
@@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
3436 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
3537 if !exists {
3638 log.Debug("No mapping found for short ID: %s", shortID)
37- return c.String(http.StatusNotFound, "Image ID not found")
39+ return RenderNotFoundPage(c, a)
3840 }
3941
4042 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
4951 var imageName string
5052 if len(imageInfo.RepoTags) > 0 {
5153 imageName = imageInfo.RepoTags[0]
54+ } else {
55+ imageName = imageInfo.ID
5256 }
5357
54- data := map[string]interface{}{
55- "Title": "Create a new container",
56- "ShortID": shortID,
57- "ImageID": fullID,
58- "ImageName": imageName,
58+ // Setup form data
59+ formData := templcomponents.ContainerFormData{
60+ ImageName: imageName,
61+ ImageID: fullID,
62+ ShortID: shortID,
63+ ErrorMessage: "",
5964 }
6065
61- // Render the create container page
62- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a)
63-
64- if err != nil {
65- return sendError(c, err)
66- }
67-
68- renderedHTML, err := rendererData.Render(data, a)
69-
70- if err != nil {
71- return sendError(c, err)
72- }
73-
74- return c.HTML(200, renderedHTML)
66+ // Get the renderer
67+ renderer := render.NewTemplRenderer(a)
7568
69+ // Render the component
70+ return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
7671 }
7772
7873 // CreateContainerFullGET handles the full HTML page for creating a new container
@@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
8479 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
8580 if !exists {
8681 log.Debug("No mapping found for short ID: %s", shortID)
87- return c.String(http.StatusNotFound, "Image ID not found")
82+ return RenderNotFoundPage(c, a)
8883 }
8984
9085 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
9994 var imageName string
10095 if len(imageInfo.RepoTags) > 0 {
10196 imageName = imageInfo.RepoTags[0]
97+ } else {
98+ imageName = imageInfo.ID
10299 }
103100
104- data := map[string]interface{}{
105- "Title": "Create a new container",
106- "ShortID": shortID,
107- "ImageID": fullID,
108- "ImageName": imageName,
109- "AdminPath": a.Config.Admin.Path,
101+ // Setup form data
102+ formData := templcomponents.ContainerFormData{
103+ ImageName: imageName,
104+ ImageID: fullID,
105+ ShortID: shortID,
106+ ErrorMessage: "",
110107 }
111108
112- // Specify both admin and fragments directories
113- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")
114- if err != nil {
115- return sendError(c, err)
116- }
109+ // Get the renderer
110+ renderer := render.NewTemplRenderer(a)
117111
118- renderedHTML, err := rendererData.Render(data, a)
119- if err != nil {
120- log.Debug("Error rendering template: %v", err)
121- return sendError(c, err)
112+ // Render the createcontainerfull template
113+ pageData := templadmin.CreateContainerFullData{
114+ Title: "Create Container | Gordon",
115+ BuildVersion: a.Config.Build.BuildVersion,
116+ AdminPath: a.Config.Admin.Path,
117+ UserSettings: "/user",
118+ LogoutURL: a.Config.Admin.Path + "/logout",
119+ ContainerData: formData,
122120 }
123121
124- return c.HTML(200, renderedHTML)
122+ return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
125123 }
126124
127125 // CreateContainerPOST handles the create container form submission
M internal/httpserve/handlers/container_management.go +58 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/gordon/internal/templating/render"
1313 "github.com/bnema/gordon/pkg/docker"
1414 "github.com/bnema/gordon/pkg/humanize"
15+ "github.com/bnema/gordon/pkg/logger"
1516 "github.com/docker/docker/api/types"
1617 "github.com/labstack/echo/v4"
1718 )
@@ -58,6 +59,10 @@ type HumanReadableContainerImage struct {
5859 SizeStr string
5960 RepoDigests []string
6061 RepoTags []string
62+ Repository string
63+ Tag string
64+ Size string
65+ Created string
6166 }
6267
6368 type HumanReadableContainer struct {
@@ -85,17 +90,33 @@ type ContainerDisplay struct {
8590
8691 // renderHTML is a generalized function to render HTML
8792 func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
93+ logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data))
94+
8895 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
8996 if err != nil {
97+ logger.Error("Error getting HTML renderer", "error", err)
9098 return err
9199 }
100+
92101 renderedHTML, err := rendererData.Render(data, a)
93102 if err != nil {
103+ logger.Error("Error rendering HTML", "error", err)
94104 return err
95105 }
106+
107+ logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
96108 return c.HTML(http.StatusOK, renderedHTML)
97109 }
98110
111+// getKeysFromMap returns a slice of keys from a map
112+func getKeysFromMap(data map[string]interface{}) []string {
113+ keys := make([]string, 0, len(data))
114+ for k := range data {
115+ keys = append(keys, k)
116+ }
117+ return keys
118+}
119+
99120 // ActionSuccess returns the success HTML fragment
100121 func ActionSuccess(a *server.App) string {
101122 // Create a data structure to pass to the template
@@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string {
114135
115136 // ImageManagerComponent handles the /image-manager route (HTMX route)
116137 func ImageManagerComponent(c echo.Context, a *server.App) error {
138+ logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139+
117140 images, err := docker.ListContainerImages()
118141 if err != nil {
142+ logger.Error("Error listing container images:", "error", err)
119143 return sendError(c, err)
120144 }
121145
146+ logger.Debug("Images found", "count", len(images))
147+ for i, img := range images {
148+ logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags)
149+ }
150+
122151 var humanReadableImages []HumanReadableContainerImage
123152
124153 for _, img := range images {
@@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
129158 sizeStr := humanize.BytesToReadableSize(img.Size)
130159
131160 for _, repoTag := range img.RepoTags {
161+ // Split the repo tag into repository and tag
162+ parts := strings.Split(repoTag, ":")
163+ repository := parts[0]
164+ tag := "latest"
165+ if len(parts) > 1 {
166+ tag = parts[1]
167+ }
168+
132169 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
133170 ID: img.ID,
134171 ShortID: shortID,
@@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
137174 Name: repoTag,
138175 RepoDigests: img.RepoDigests,
139176 RepoTags: img.RepoTags,
177+ // Add fields that match template expectations
178+ Repository: repository,
179+ Tag: tag,
180+ Size: sizeStr,
181+ Created: createdStr,
140182 })
141183 }
142184 }
@@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
145187 "Images": humanReadableImages,
146188 }
147189
190+ logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
148191 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
149192 }
150193
@@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
170213
171214 // ContainerManagerComponent handles the /container-manager route
172215 func ContainerManagerComponent(c echo.Context, a *server.App) error {
216+ logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
217+
173218 containers, err := docker.ListRunningContainers()
174219 if err != nil {
220+ logger.Error("Error listing containers", "error", err)
175221 return sendError(c, err)
176222 }
177223
224+ logger.Debug("Containers found", "count", len(containers))
225+ for i, container := range containers {
226+ logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
227+ }
228+
178229 var humanReadableContainers []HumanReadableContainer
179230
180231 for _, container := range containers {
@@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
212263 return err
213264 }
214265 data := map[string]interface{}{
215- "Lang": yamlData["Lang"],
216- "containers": humanReadableContainers,
266+ "Lang": yamlData["Lang"],
267+ "Containers": humanReadableContainers,
268+ "NoContainer": len(humanReadableContainers) == 0,
217269 }
218270
271+ logger.Debug("Rendering containerlist template",
272+ "humanReadableContainers", len(humanReadableContainers),
273+ "noContainer", len(humanReadableContainers) == 0)
274+
219275 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
220276 }
221277
M internal/httpserve/handlers/container_transactions.go +3 -3
@@ -2,13 +2,13 @@ package handlers
22
33 import (
44 "fmt"
5- "log"
65 "strings"
76
87 "github.com/bnema/gordon/internal/server"
98 "github.com/bnema/gordon/internal/templating/cmdparams"
109 "github.com/bnema/gordon/internal/templating/render"
1110 "github.com/bnema/gordon/pkg/docker"
11+ "github.com/bnema/gordon/pkg/logger"
1212 "github.com/labstack/echo/v4"
1313 "gopkg.in/yaml.v3"
1414 )
@@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error {
132132 for i, step := range tq.steps {
133133 err := step()
134134 if err != nil {
135- log.Printf("Error occurred at step index %d: %v", i, err)
135+ logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err))
136136 transactionErrors = append(transactionErrors, err)
137137 for j := i - 1; j >= 0; j-- {
138138 rollback := tq.rollbacks[j]
139139 if rollback != nil {
140140 if rollbackErr := rollback(); rollbackErr != nil {
141- log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr)
141+ logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr))
142142 rollbackErrors = append(rollbackErrors, rollbackErr)
143143 }
144144 }
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
@@ -17,7 +17,7 @@ import (
1717 "github.com/bnema/gordon/internal/proxy"
1818 "github.com/bnema/gordon/internal/server"
1919 "github.com/bnema/gordon/pkg/docker"
20- "github.com/charmbracelet/log"
20+ "github.com/bnema/gordon/pkg/logger"
2121 "github.com/labstack/echo/v4"
2222 )
2323
@@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
2626 payload, err := validateAndPrepareDeployPayload(c)
2727 if err != nil {
2828 // Debug
29- log.Error("Failed to validate and prepare payload", "error", err)
29+ logger.Error("Failed to validate and prepare payload", "error", err)
3030 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
3131 Success: false,
3232 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
3737 _, err = saveAndImportImage(c, a, payload)
3838 if err != nil {
3939 // Debug
40- log.Error("Failed to save and import image", "error", err)
40+ logger.Error("Failed to save and import image", "error", err)
4141 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
4242 Success: false,
4343 Message: fmt.Sprintf("Failed to save or import image: %v", err),
@@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error {
4848 containerID, containerName, err := createAndStartContainer(a, payload)
4949 if err != nil {
5050 if strings.Contains(err.Error(), "is already in use") {
51- log.Warn("Container already exists", "error", err)
51+ logger.Warn("Container already exists", "error", err)
5252 existingContainerID := extractContainerID(err.Error())
5353 existingContainerName, _ := docker.GetContainerName(existingContainerID)
5454 // Remove the / from the container name
5555 existingContainerName = strings.TrimLeft(existingContainerName, "/")
5656
5757 if existingContainerID == "" || existingContainerName == "" {
58- log.Error("Failed to extract container ID or Name from error message")
58+ logger.Error("Failed to extract container ID or Name from error message")
5959 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
6060 Success: false,
6161 Message: "A container for this deploy already exists but could not be identified.",
@@ -71,7 +71,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
7171 StatusCode: http.StatusConflict,
7272 })
7373 }
74- log.Error("Failed to create or start container", "error", err)
74+ logger.Error("Failed to create or start container", "error", err)
7575 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
7676 Success: false,
7777 Message: fmt.Sprintf("Failed to create or start container: %v", err),
@@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error {
9090 containerIP := GetContainerIP(a, containerID, containerName)
9191 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
9292 if err != nil {
93- log.Error("Failed to add proxy route", "error", err)
93+ logger.Error("Failed to add proxy route", "error", err)
9494 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
9595 }
9696 } else {
97- log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
97+ logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
9898 }
9999
100100 // Arrived here means deployment was successful
@@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
124124
125125 payload, err := validateAndPrepareDeployPayload(c)
126126 if err != nil {
127- log.Error("Failed to validate and prepare payload", "error", err)
127+ logger.Error("Failed to validate and prepare payload", "error", err)
128128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129129 Success: false,
130130 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
196196 // Chunks validation steps
197197 //
198198 if len(chunkData) == 0 {
199- log.Error("Received empty chunk")
199+ logger.Error("Received empty chunk")
200200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201201 Success: false,
202202 Message: "Received empty chunk",
@@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
206206
207207 // Verify chunk size matches metadata
208208 if int64(len(chunkData)) != metadata.ChunkSize {
209- log.Error("Chunk size mismatch",
209+ logger.Error("Chunk size mismatch",
210210 "expected", metadata.ChunkSize,
211211 "received", len(chunkData))
212212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
@@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
236236 }
237237
238238 func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {
239- log.Info("Starting complete deploy transfer processing",
239+ logger.Info("Starting complete deploy transfer processing",
240240 "transferID", transferID)
241241
242242 // Get transfer data
@@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
296296 // Check for existing container
297297 existingContainerID := docker.GetContainerIDByName(containerName)
298298 if existingContainerID != "" {
299- log.Warn("Container already exists",
299+ logger.Warn("Container already exists",
300300 "name", containerName,
301301 "id", existingContainerID)
302302 return sendDeployErrorResponse(c, "Container already exists", nil)
303303 }
304304
305305 // Import the image
306- log.Info("Importing image from file",
306+ logger.Info("Importing image from file",
307307 "path", tmpFile.Name(),
308308 "imageName", imageName)
309309
310310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311311 if err != nil {
312312 if strings.Contains(err.Error(), "platform compatibility check failed") {
313- log.Error("Platform compatibility check failed",
313+ logger.Error("Platform compatibility check failed",
314314 "error", err,
315315 "imageName", imageName)
316316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
@@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
343343 // Cleanup transfer data
344344 cleanupTransfer(transferID)
345345
346- log.Info("Deployment completed successfully",
346+ logger.Info("Deployment completed successfully",
347347 "containerID", containerID,
348348 "containerName", containerName,
349349 "domain", targetDomain)
@@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
352352 containerIP := GetContainerIP(a, containerID, containerName)
353353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354354 if err != nil {
355- log.Error("Failed to add proxy route", "error", err)
355+ logger.Error("Failed to add proxy route", "error", err)
356356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357357 }
358358
@@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
422422
423423 // Double check that we have a valid container IP - critical for recreated containers
424424 if containerIP == "" || containerIP == containerID || containerIP == "localhost" {
425- log.Warn("Invalid container IP detected, attempting to get correct IP",
425+ logger.Warn("Invalid container IP detected, attempting to get correct IP",
426426 "containerID", containerID,
427427 "invalid_ip", containerIP)
428428
@@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
437437 // Get the correct container IP with retry logic
438438 newContainerIP := GetContainerIP(a, containerID, containerName)
439439 if newContainerIP != containerIP && newContainerIP != containerName {
440- log.Info("Updated container IP for proxy route",
440+ logger.Info("Updated container IP for proxy route",
441441 "containerID", containerID,
442442 "old_ip", containerIP,
443443 "new_ip", newContainerIP)
@@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
448448 // Get container info to check labels
449449 containerInfo, err := docker.GetContainerInfo(containerID)
450450 if err != nil {
451- log.Warn("Failed to get container info", "error", err)
451+ logger.Warn("Failed to get container info", "error", err)
452452 } else {
453453 // Check if the container has a gordon.proxy.port label that might override containerPort
454454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455455 if portLabel != containerPort {
456- log.Info("Using container label port instead of provided port",
456+ logger.Info("Using container label port instead of provided port",
457457 "label_port", portLabel,
458458 "provided_port", containerPort)
459459 containerPort = portLabel
@@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
467467 // Check if the target domain specifies HTTPS
468468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469469 protocol = "https"
470- log.Debug("Using HTTPS protocol for proxy route based on target domain",
470+ logger.Debug("Using HTTPS protocol for proxy route based on target domain",
471471 "domain", targetDomain,
472472 "protocol", protocol)
473473 } else {
@@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
477477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479479 protocol = "https"
480- log.Debug("Using HTTPS protocol for proxy route based on container labels",
480+ logger.Debug("Using HTTPS protocol for proxy route based on container labels",
481481 "domain", targetDomain,
482482 "containerID", containerID,
483483 "label_value", sslValue,
@@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
496496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497497
498498 // Log that we're adding the domain for Let's Encrypt
499- log.Debug("Adding domain for Let's Encrypt verification",
499+ logger.Debug("Adding domain for Let's Encrypt verification",
500500 "domain", cleanDomain)
501501
502502 // Get container name for better logging
503503 containerName, err := docker.GetContainerName(containerID)
504504 if err != nil {
505- log.Warn("Unable to get container name", "containerID", containerID, "error", err)
505+ logger.Warn("Unable to get container name", "containerID", containerID, "error", err)
506506 } else {
507507 containerName = strings.TrimPrefix(containerName, "/")
508- log.Debug("Container name for proxy route", "name", containerName)
508+ logger.Debug("Container name for proxy route", "name", containerName)
509509 }
510510
511511 // Check the database for any existing routes with this domain, regardless of container ID
@@ -526,7 +526,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
526526
527527 if err == nil {
528528 // Domain exists in database, check if this is a recreated container
529- log.Info("Found existing proxy route for domain",
529+ logger.Info("Found existing proxy route for domain",
530530 "domain", cleanDomain,
531531 "old_container_id", existingContainerID,
532532 "new_container_id", containerID)
@@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
538538 networkName := a.Config.ContainerEngine.Network
539539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- log.Info("Updating container IP before database update",
541+ logger.Info("Updating container IP before database update",
542542 "domain", cleanDomain,
543543 "old_ip", containerIP,
544544 "new_ip", networkSettings.IPAddress)
@@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
559559 return fmt.Errorf("failed to update proxy route: %w", err)
560560 }
561561
562- log.Info("Updated proxy route for recreated container",
562+ logger.Info("Updated proxy route for recreated container",
563563 "domain", cleanDomain,
564564 "old_container_id", existingContainerID,
565565 "new_container_id", containerID,
@@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
579579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580580
581581 if err == nil && updatedContainerIP != containerIP {
582- log.Warn("Database verification failed: container IP mismatch",
582+ logger.Warn("Database verification failed: container IP mismatch",
583583 "expected", containerIP,
584584 "actual", updatedContainerIP)
585585 } else {
586- log.Debug("Database verification passed: container IP updated correctly",
586+ logger.Debug("Database verification passed: container IP updated correctly",
587587 "container_ip", containerIP)
588588 }
589589
@@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
596596 routes := p.GetRoutes()
597597 if route, exists := routes[cleanDomain]; exists {
598598 if route.ContainerIP != containerIP {
599- log.Warn("Route reload verification failed: container IP mismatch",
599+ logger.Warn("Route reload verification failed: container IP mismatch",
600600 "domain", cleanDomain,
601601 "expected", containerIP,
602602 "actual", route.ContainerIP)
603603 } else {
604- log.Debug("Route reload verification passed: container IP loaded correctly",
604+ logger.Debug("Route reload verification passed: container IP loaded correctly",
605605 "domain", cleanDomain,
606606 "container_ip", containerIP)
607607 }
@@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
623623 // and avoid database locking conflicts
624624 p.RegisterNewlyCreatedContainer(containerID)
625625
626- log.Debug("Added proxy route",
626+ logger.Debug("Added proxy route",
627627 "domain", targetDomain,
628628 "containerIP", containerIP,
629629 "containerPort", containerPort,
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
@@ -18,7 +18,7 @@ import (
1818 "github.com/bnema/gordon/internal/templating/cmdparams"
1919 "github.com/bnema/gordon/pkg/docker"
2020 "github.com/bnema/gordon/pkg/filestore"
21- "github.com/charmbracelet/log"
21+ "github.com/bnema/gordon/pkg/logger"
2222 "github.com/labstack/echo/v4"
2323 )
2424
@@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) {
7171 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
7272 err := c.JSON(statusCode, response)
7373 if err != nil {
74- log.Error("Failed to send JSON response",
74+ logger.Error("Failed to send JSON response",
7575 "error", err,
7676 "statusCode", statusCode)
7777 }
@@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
152152 imageID, err = docker.ImportImageToEngine(imagePath)
153153 if err != nil {
154154 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
155- log.Error("Failed to remove temporary image file after import failure",
155+ logger.Error("Failed to remove temporary image file after import failure",
156156 "error", removeErr,
157157 "path", imagePath)
158158 }
@@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
161161
162162 if imageID == "" {
163163 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
164- log.Error("Failed to remove temporary image file after empty ID",
164+ logger.Error("Failed to remove temporary image file after empty ID",
165165 "error", removeErr,
166166 "path", imagePath)
167167 }
@@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
170170
171171 err = filestore.RemoveFromStorage(imagePath)
172172 if err != nil {
173- log.Error("Failed to remove temporary image file after successful import",
173+ logger.Error("Failed to remove temporary image file after successful import",
174174 "error", err,
175175 "path", imagePath)
176176 return "", fmt.Errorf("failed to remove temporary image file: %v", err)
@@ -181,7 +181,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
181181
182182 // createAndStartContainer creates a container from the image, starts it and returns the container ID
183183 func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
184- log.Info("Starting container creation process",
184+ logger.Info("Starting container creation process",
185185 "imageName", payload.ImageName,
186186 "imageID", payload.ImageID,
187187 "port", payload.Port)
@@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
191191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192192 }
193193
194- log.Debug("Container parameters prepared", "params", params)
194+ logger.Debug("Container parameters prepared", "params", params)
195195
196196 containerID, err := docker.CreateContainer(params)
197197 if err != nil {
198198 return "", "", fmt.Errorf("failed to create container: %v", err)
199199 }
200200
201- log.Info("Container created successfully", "containerID", containerID)
201+ logger.Info("Container created successfully", "containerID", containerID)
202202
203203 containerName, err := docker.GetContainerName(containerID)
204204 if err != nil {
205205 return "", "", fmt.Errorf("failed to get container name: %v", err)
206206 }
207207
208- log.Info("Starting container",
208+ logger.Info("Starting container",
209209 "containerID", containerID,
210210 "name", containerName)
211211
@@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
215215 // Get container logs if start failed
216216 logs, logErr := docker.GetContainerLogs(containerID)
217217 if logErr == nil {
218- log.Error("Container start failed - container logs",
218+ logger.Error("Container start failed - container logs",
219219 "logs", logs,
220220 "containerID", containerID)
221221 }
@@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
223223 // Cleanup the failed container
224224 cleanupErr := docker.RemoveContainer(containerID)
225225 if cleanupErr != nil {
226- log.Error("Failed to cleanup container after start failure",
226+ logger.Error("Failed to cleanup container after start failure",
227227 "containerID", containerID,
228228 "error", cleanupErr)
229229 }
230230 return "", "", fmt.Errorf("failed to start container: %v", err)
231231 }
232232
233- log.Info("Container started successfully",
233+ logger.Info("Container started successfully",
234234 "containerID", containerID,
235235 "name", containerName)
236236
@@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool {
292292
293293 metadata := chunkStore.metadata[transferID]
294294 if metadata == nil {
295- log.Warn("No metadata found for transfer", "transferID", transferID)
295+ logger.Warn("No metadata found for transfer", "transferID", transferID)
296296 return false
297297 }
298298
299299 chunksReceived := len(chunkStore.chunks[transferID])
300300 isComplete := chunksReceived == metadata.TotalChunks
301301
302- log.Debug("Transfer completion check",
302+ logger.Debug("Transfer completion check",
303303 "transferID", transferID,
304304 "chunksReceived", chunksReceived,
305305 "totalExpected", metadata.TotalChunks,
@@ -328,7 +328,7 @@ func cleanupOldTransfers() {
328328 chunkStore.mu.Lock()
329329 for id, startTime := range chunkStore.started {
330330 if now.Sub(startTime) > transferTimeout {
331- log.Debug("Cleaning up stale transfer",
331+ logger.Debug("Cleaning up stale transfer",
332332 "transferID", id,
333333 "age", now.Sub(startTime))
334334 cleanupTransfer(id)
@@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
376376 totalSize += int64(len(chunk))
377377 }
378378
379- log.Debug("Starting chunk assembly",
379+ logger.Debug("Starting chunk assembly",
380380 "totalChunks", totalChunks,
381381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382382
@@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
435435 return fmt.Errorf("failed to reset file pointer: %w", err)
436436 }
437437
438- log.Debug("Chunk assembly completed successfully",
438+ logger.Debug("Chunk assembly completed successfully",
439439 "totalBytesWritten", written,
440440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441441
@@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
464464 // If this is a retry, add an increasing delay
465465 if retry > 0 {
466466 sleepTime := time.Duration(500*retry) * time.Millisecond
467- log.Debug("Retrying to get container IP",
467+ logger.Debug("Retrying to get container IP",
468468 "attempt", retry+1,
469469 "containerID", containerID,
470470 "delay", sleepTime)
@@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
486486 refreshedInfo, err := docker.GetContainerInfo(containerID)
487487 if err == nil {
488488 containerInfo = refreshedInfo
489- log.Debug("Refreshed container info for IP retrieval",
489+ logger.Debug("Refreshed container info for IP retrieval",
490490 "containerName", containerName,
491491 "containerID", containerID)
492492 } else {
@@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
501501 networkName := a.Config.ContainerEngine.Network
502502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503503 containerIP = networkSettings.IPAddress
504- log.Debug("Found container IP",
504+ logger.Debug("Found container IP",
505505 "containerName", containerName,
506506 "containerID", containerID,
507507 "containerIP", containerIP,
@@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
513513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514514 if networkSettings.IPAddress != "" {
515515 containerIP = networkSettings.IPAddress
516- log.Debug("Found container IP in alternative network",
516+ logger.Debug("Found container IP in alternative network",
517517 "containerName", containerName,
518518 "containerID", containerID,
519519 "containerIP", containerIP,
@@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
533533 // Extract just the IP address from the CIDR format
534534 ipCIDR := endpoint.IPv4Address
535535 containerIP = strings.Split(ipCIDR, "/")[0]
536- log.Debug("Found container IP via network inspection",
536+ logger.Debug("Found container IP via network inspection",
537537 "containerName", containerName,
538538 "containerID", containerID,
539539 "containerIP", containerIP,
@@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
546546
547547 // If we've tried multiple times and couldn't get an IP
548548 if lastErr != nil {
549- log.Warn("Failed to get container info after multiple attempts",
549+ logger.Warn("Failed to get container info after multiple attempts",
550550 "error", lastErr,
551551 "containerID", containerID)
552552 }
553553
554- log.Warn("Could not find container IP, using container name as fallback",
554+ logger.Warn("Could not find container IP, using container name as fallback",
555555 "containerName", containerName,
556556 "containerID", containerID)
557557
A internal/httpserve/handlers/error_handlers.go +36 -0
@@ -0,0 +1,36 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/server"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/errors"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// RenderNotFoundPage renders a 404 error page using templ
13+func RenderNotFoundPage(c echo.Context, a *server.App) error {
14+ data := errors.ErrorPageData{
15+ Title: "Page Not Found | Gordon",
16+ AdminPath: a.Config.Admin.Path,
17+ StatusCode: http.StatusNotFound,
18+ Message: "Sorry, the page you are looking for does not exist.",
19+ }
20+
21+ renderer := render.NewTemplRenderer(a)
22+ return renderer.RenderTempl(c, errors.NotFoundPage(data))
23+}
24+
25+// RenderForbiddenPage renders a 403 error page using templ
26+func RenderForbiddenPage(c echo.Context, a *server.App) error {
27+ data := errors.ErrorPageData{
28+ Title: "Access Forbidden | Gordon",
29+ AdminPath: a.Config.Admin.Path,
30+ StatusCode: http.StatusForbidden,
31+ Message: "You don't have permission to access this resource.",
32+ }
33+
34+ renderer := render.NewTemplRenderer(a)
35+ return renderer.RenderTempl(c, errors.ForbiddenPage(data))
36+} ⏎̸
A internal/httpserve/handlers/templ_handlers.go +212 -0
@@ -0,0 +1,212 @@
1+package handlers
2+
3+import (
4+ "github.com/bnema/gordon/internal/server"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/bnema/gordon/pkg/docker"
10+ "github.com/docker/docker/api/types"
11+ "github.com/labstack/echo/v4"
12+ "strings"
13+ "time"
14+ "github.com/bnema/gordon/pkg/humanize"
15+ "net/http"
16+)
17+
18+// RenderTemplLoginPage renders the login page using templ
19+func RenderTemplLoginPage(c echo.Context, a *server.App) error {
20+ // Check for token in query param for backward compatibility
21+ urlToken := c.QueryParam("token")
22+ _ = urlToken // Not using this yet, but kept for compatibility
23+
24+ // Check for error message
25+ errorMsg := c.QueryParam("error")
26+
27+ // Create a templ renderer
28+ renderer := render.NewTemplRenderer(a)
29+
30+ // Render the login page
31+ return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg))
32+}
33+
34+// RenderTemplDashboardPage renders the dashboard page using templ
35+func RenderTemplDashboardPage(c echo.Context, a *server.App) error {
36+ // Create a templ renderer
37+ renderer := render.NewTemplRenderer(a)
38+
39+ // Get user-specific URLs
40+ adminPath := a.Config.Admin.Path
41+ userSettingsURL := adminPath + "/settings"
42+ logoutURL := adminPath + "/logout"
43+
44+ // Render the dashboard page
45+ return renderer.RenderTempl(c, admin.DashboardPage(
46+ "Dashboard",
47+ a.Config.Build.BuildVersion,
48+ adminPath,
49+ userSettingsURL,
50+ logoutURL,
51+ ))
52+}
53+
54+// RenderTemplManagerPage renders the manager page using templ
55+func RenderTemplManagerPage(c echo.Context, a *server.App) error {
56+ // Create a templ renderer
57+ renderer := render.NewTemplRenderer(a)
58+
59+ // Get container and image counts
60+ containerCount, _ := GetContainerCount(a)
61+ imageCount, _ := GetImageCount(a)
62+
63+ // Get user-specific URLs
64+ adminPath := a.Config.Admin.Path
65+ userSettingsURL := adminPath + "/settings"
66+ logoutURL := adminPath + "/logout"
67+
68+ // Render the manager page
69+ return renderer.RenderTempl(c, admin.ManagerPage(
70+ "Container Manager",
71+ a.Config.Build.BuildVersion,
72+ adminPath,
73+ userSettingsURL,
74+ logoutURL,
75+ containerCount,
76+ imageCount,
77+ ))
78+}
79+
80+// RenderTemplContainerList renders the container list component
81+func RenderTemplContainerList(c echo.Context, a *server.App) error {
82+ // Create a templ renderer
83+ renderer := render.NewTemplRenderer(a)
84+
85+ // Get the containers
86+ containers, noContainer, err := GetContainers(a)
87+ if err != nil {
88+ return err
89+ }
90+
91+ // Render the container list component
92+ return renderer.RenderTempl(c, components.ContainerList(containers, noContainer))
93+}
94+
95+// RenderTemplImageList renders the image list component
96+func RenderTemplImageList(c echo.Context, a *server.App) error {
97+ // Create a templ renderer
98+ renderer := render.NewTemplRenderer(a)
99+
100+ // Get the images
101+ images, err := docker.ListContainerImages()
102+ if err != nil {
103+ return err
104+ }
105+
106+ // Convert to human-readable format
107+ var humanReadableImages []components.HumanReadableImage
108+ for _, img := range images {
109+ shortID := img.ID[7:19]
110+
111+ for _, repoTag := range img.RepoTags {
112+ // Split the repo tag into repository and tag
113+ parts := strings.Split(repoTag, ":")
114+ repository := parts[0]
115+ tag := "latest"
116+ if len(parts) > 1 {
117+ tag = parts[1]
118+ }
119+
120+ humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
121+ ID: img.ID,
122+ ShortID: shortID,
123+ Name: repoTag,
124+ Repository: repository,
125+ Tag: tag,
126+ Size: humanize.BytesToReadableSize(img.Size),
127+ Created: humanize.TimeAgo(time.Unix(img.Created, 0)),
128+ RepoDigests: img.RepoDigests,
129+ RepoTags: img.RepoTags,
130+ })
131+ }
132+ }
133+
134+ // Render the image list component
135+ return renderer.RenderTempl(c, components.ImageList(humanReadableImages))
136+}
137+
138+// RenderTemplUploadImageForm renders the upload image form component
139+func RenderTemplUploadImageForm(c echo.Context, a *server.App) error {
140+ // Create a templ renderer
141+ renderer := render.NewTemplRenderer(a)
142+
143+ // Render the upload image form component
144+ return renderer.RenderTempl(c, components.UploadImageForm())
145+}
146+
147+// RenderTemplCreateContainerForm renders the create container form component
148+func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error {
149+ // Create a templ renderer
150+ renderer := render.NewTemplRenderer(a)
151+
152+ // Get the image ID from the URL parameter
153+ shortID := c.Param("ID")
154+
155+ // Try to fetch the image info
156+ imageID, exists := safelyInteractWithIDMap(Fetch, shortID)
157+ if !exists {
158+ return c.String(http.StatusBadRequest, "Invalid image ID")
159+ }
160+
161+ // Get image info
162+ image, err := docker.GetImageInfo(imageID)
163+ if err != nil {
164+ return err
165+ }
166+
167+ // Prepare image name from repo tags
168+ imageName := "unknown"
169+ if len(image.RepoTags) > 0 {
170+ imageName = image.RepoTags[0]
171+ }
172+
173+ // Prepare form data
174+ formData := components.ContainerFormData{
175+ ImageName: imageName,
176+ ImageID: imageID,
177+ ShortID: shortID,
178+ // Default values can be set here if needed
179+ ProxyPort: "80", // Default proxy port
180+ }
181+
182+ // Render the create container form component
183+ return renderer.RenderTempl(c, components.CreateContainerForm(formData))
184+}
185+
186+// GetContainerCount returns the count of containers
187+func GetContainerCount(a *server.App) (int, error) {
188+ containers, err := docker.ListRunningContainers()
189+ if err != nil {
190+ return 0, err
191+ }
192+ return len(containers), nil
193+}
194+
195+// GetImageCount returns the count of images
196+func GetImageCount(a *server.App) (int, error) {
197+ images, err := docker.ListContainerImages()
198+ if err != nil {
199+ return 0, err
200+ }
201+ return len(images), nil
202+}
203+
204+// GetContainers returns the list of containers
205+func GetContainers(a *server.App) ([]types.Container, bool, error) {
206+ containers, err := docker.ListRunningContainers()
207+ if err != nil {
208+ return nil, false, err
209+ }
210+ noContainer := len(containers) == 0
211+ return containers, noContainer, nil
212+}⏎̸
A internal/httpserve/middleware/error_middleware.go +36 -0
@@ -0,0 +1,36 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/httpserve/handlers"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/labstack/echo/v4"
9+)
10+
11+// CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages
12+func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler {
13+ return func(err error, c echo.Context) {
14+ code := http.StatusInternalServerError
15+ if he, ok := err.(*echo.HTTPError); ok {
16+ code = he.Code
17+ }
18+
19+ // Handle specific error codes with our templates
20+ switch code {
21+ case http.StatusNotFound:
22+ if err := handlers.RenderNotFoundPage(c, a); err != nil {
23+ c.Logger().Error(err)
24+ }
25+ return
26+ case http.StatusForbidden:
27+ if err := handlers.RenderForbiddenPage(c, a); err != nil {
28+ c.Logger().Error(err)
29+ }
30+ return
31+ default:
32+ // For other errors, use Echo's default error handler
33+ c.Echo().DefaultHTTPErrorHandler(err, c)
34+ }
35+ }
36+} ⏎̸
M internal/httpserve/router.go +47 -7
@@ -12,8 +12,13 @@ import (
1212
1313 // RegisterRoutes registers all routes and middlewares
1414 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
15-
1615 e.Use(echomid.Recover())
16+
17+ // Log admin status
18+ log.Info("Admin WebUI status",
19+ "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
20+ "path", a.Config.Admin.Path)
21+
1722 // Initiate the session middleware
1823 e.Use(middleware.InitSessionMiddleware(a))
1924
@@ -21,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
2126 e.Use(middleware.LanguageDetection)
2227
2328 AdminPath := a.Config.Admin.Path
29+ log.Debug("Binding login routes with AdminPath", "path", AdminPath)
2430 bindLoginRoute(e, a, AdminPath)
31+
32+ log.Debug("Binding API endpoints")
2533 bindAPIEndpoints(e, a)
2634
2735 // SetCommonDataMiddleware will pass data to the renderer
@@ -36,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
3644 // Color scheme detection for dark/light mode
3745 e.Use(middleware.ColorSchemeDetection)
3846
47+ // Set custom error handler
48+ e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
49+
50+ log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
3951 bindAdminRoute(e, a, AdminPath)
52+
53+ log.Debug("Binding static routes")
4054 bindStaticRoute(e, a, "/*")
41- bindHTMXEndpoints(e, a)
55+
56+ // Only bind HTMX endpoints if Admin WebUI is enabled
57+ if a.Config.Admin.IsAdminWebUIEnabled() {
58+ log.Info("Admin WebUI is enabled, binding HTMX endpoints")
59+ bindHTMXEndpoints(e, a)
60+ } else {
61+ log.Info("Admin WebUI is disabled, skipping HTMX endpoints")
62+ }
4263
4364 // Protect the root path with a 403
4465 e.GET("/", func(c echo.Context) error {
45- return c.String(403, "403 Forbidden")
66+ return handlers.RenderForbiddenPage(c, a)
4667 })
4768
69+ log.Info("All routes registered successfully")
4870 return e
4971 }
5072
@@ -100,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
100122
101123 // bindLoginRoute binds all login routes
102124 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
125+ // Skip binding admin login routes if admin webUI is disabled
126+ if !a.Config.Admin.IsAdminWebUIEnabled() {
127+ log.Debug("Admin WebUI is disabled, skipping admin login routes")
128+ return
129+ }
130+
103131 e.GET(adminPath+"/login", func(c echo.Context) error {
104- return handlers.RenderLoginPage(c, a)
132+ return handlers.RenderTemplLoginPage(c, a)
105133 })
106134 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
107135 return handlers.HandleTokenSubmission(c, a)
@@ -119,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
119147
120148 // bindAdminRoute binds all admin routes
121149 func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
150+ // Skip binding admin routes if admin webUI is disabled
151+ if !a.Config.Admin.IsAdminWebUIEnabled() {
152+ log.Debug("Admin WebUI is disabled, skipping admin routes")
153+ return
154+ }
155+
122156 adminGroup := e.Group(adminPath)
123157 // Since login is behind the admin path, we cannot use group middleware
124158 adminGroup.GET("", func(c echo.Context) error {
@@ -126,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
126160 }, middleware.RequireLogin(a))
127161
128162 adminGroup.GET("/manager", func(c echo.Context) error {
129- return handlers.AdminManagerRoute(c, a)
163+ return handlers.RenderTemplManagerPage(c, a)
130164 }, middleware.RequireLogin(a))
131165 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
132166 return handlers.CreateContainerFullGET(c, a)
@@ -145,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
145179
146180 // bindHTMXEndpoints binds all HTMX endpoints
147181 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
148- // Create a group for /htmx endpoints
182+ // Skip if AdminWebUI is disabled
183+ if !a.Config.Admin.IsAdminWebUIEnabled() {
184+ log.Debug("Admin WebUI is disabled, skipping HTMX endpoints")
185+ return
186+ }
187+
188+ // Create a group for /htmx endpoints
149189 htmxGroup := e.Group("/htmx")
150190
151191 // Apply middleware to the group, so all endpoints are protected
@@ -162,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
162202
163203 // List all containers
164204 htmxGroup.GET("/container-manager", func(c echo.Context) error {
165- return handlers.ContainerManagerComponent(c, a)
205+ return handlers.RenderTemplContainerList(c, a)
166206 })
167207 // Stop a container
168208 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
M internal/proxy/certificates.go +200 -74
@@ -31,8 +31,116 @@ const (
3131 LetsEncryptProdPattern = "%s_letsencrypt-production"
3232 )
3333
34+// detectUpstreamProxy checks for signs of an upstream TLS-terminating proxy
35+func (p *Proxy) detectUpstreamProxy() bool {
36+ // Check if detection is explicitly disabled
37+ if !p.config.DetectUpstreamProxy {
38+ return false
39+ }
40+
41+ // First check our cached detection status - if we already detected a proxy previously
42+ p.upstreamProxyMu.RLock()
43+ alreadyDetected := p.upstreamProxyDetected
44+ p.upstreamProxyMu.RUnlock()
45+
46+ if alreadyDetected {
47+ return true
48+ }
49+
50+ // Check for known HTTP headers that indicate a TLS termination proxy
51+ // This is a best-effort approach since we're analyzing HTTP headers from
52+ // previous requests stored in our context (if available)
53+
54+ // Look for common proxy headers in our recent requests
55+ // This is a static check at startup; ideally we would check incoming
56+ // requests dynamically, but for this implementation we're using
57+ // known proxy environment indicators
58+
59+ // Most common proxy headers: X-Forwarded-Proto, X-Forwarded-SSL, X-Forwarded-For
60+ // Environment variables: HTTPS=on, HTTP_X_FORWARDED_PROTO=https
61+
62+ // Check for common environment variables set by upstream proxies
63+ if os.Getenv("HTTPS") == "on" ||
64+ os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
65+ os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
66+ logger.Info("Upstream TLS-terminating proxy detected via environment variables",
67+ "detection_method", "environment_variables",
68+ "https", os.Getenv("HTTPS"),
69+ "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
70+ "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
71+
72+ // Cache this result
73+ p.upstreamProxyMu.Lock()
74+ p.upstreamProxyDetected = true
75+ p.upstreamProxyMu.Unlock()
76+
77+ return true
78+ }
79+
80+ // Check if Cloudflare headers are present in environment
81+ // Cloudflare sets environment variables like HTTP_CF_VISITOR={"scheme":"https"}
82+ if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
83+ if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
84+ logger.Info("Cloudflare proxy detected via environment variables",
85+ "detection_method", "cloudflare_headers",
86+ "cf_visitor", cfVisitor)
87+
88+ // Cache this result
89+ p.upstreamProxyMu.Lock()
90+ p.upstreamProxyDetected = true
91+ p.upstreamProxyMu.Unlock()
92+
93+ return true
94+ }
95+ }
96+
97+ // Not detected, but log if explicit configuration is set
98+ if p.config.SkipCertificates {
99+ logger.Info("No upstream proxy detected automatically, but certificate acquisition will be skipped due to configuration",
100+ "detection_result", "false",
101+ "skip_certificates", "true (forced via config)")
102+ }
103+
104+ return false
105+}
106+
34107 // setupCertManager configures the autocert manager for Let's Encrypt
35108 func (p *Proxy) setupCertManager() {
109+ // First check for upstream TLS-terminating proxies if detection is enabled
110+ isProxied := p.detectUpstreamProxy()
111+
112+ // Skip certificate acquisition if we're behind a TLS-terminating proxy
113+ // and the skip certificates option is enabled
114+ if isProxied && p.config.SkipCertificates {
115+ logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition",
116+ "upstream_proxy", "detected",
117+ "certificate_acquisition", "disabled")
118+ // We still setup a fallback cert for the admin domain
119+ adminDomain := p.app.GetConfig().Http.FullDomain()
120+ rootDomain := p.app.GetConfig().Http.Domain
121+
122+ var fallbackDomains []string
123+ fallbackDomains = append(fallbackDomains, adminDomain)
124+
125+ // Include root domain in fallback certificate if it's different from admin domain
126+ if rootDomain != "" && rootDomain != adminDomain {
127+ fallbackDomains = append(fallbackDomains, rootDomain)
128+ }
129+
130+ fallbackCert, err := generateFallbackCertificates(fallbackDomains)
131+ if err != nil {
132+ logger.Warn("Failed to generate fallback certificate", "error", err)
133+ } else {
134+ logger.Info("Generated fallback self-signed certificate for admin domain behind proxy",
135+ "domain", adminDomain,
136+ "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05"))
137+ // Store the fallback certificate
138+ p.fallbackCert = fallbackCert
139+ }
140+
141+ return
142+ }
143+
36144 // Create a cache directory if it doesn't exist
37145 dir := p.config.CertDir
38146 if dir == "" {
@@ -169,11 +277,11 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
169277 "expires_in", expiresInDays,
170278 "days",
171279 "type", certType)
172-
280+
173281 // Check if certificate matches current mode
174282 if p.config.LetsEncryptMode == "production" && certType == "staging" {
175283 logger.Info("Found staging certificate but production mode is enabled",
176- "domain", domain,
284+ "domain", domain,
177285 "action", "will request new production certificate")
178286 return false, certType
179287 } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
@@ -183,7 +291,7 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
183291 "domain", domain,
184292 "action", "using existing production certificate")
185293 }
186-
294+
187295 return true, certType
188296 }
189297
@@ -206,27 +314,27 @@ func getCertificateFileName(domain string, certType string) string {
206314 func getCertificateType(cert *x509.Certificate) string {
207315 // Check issuer to determine the certificate type
208316 issuer := cert.Issuer.CommonName
209-
317+
210318 // Check for self-signed certificate
211319 if cert.Issuer.CommonName == cert.Subject.CommonName {
212320 return "self-signed"
213321 }
214-
322+
215323 // Check for Let's Encrypt staging certificate
216- if strings.Contains(issuer, "STAGING") ||
217- strings.Contains(issuer, "Fake LE") ||
218- strings.Contains(issuer, "Counterfeit") ||
219- strings.Contains(issuer, "False Fennel") {
324+ if strings.Contains(issuer, "STAGING") ||
325+ strings.Contains(issuer, "Fake LE") ||
326+ strings.Contains(issuer, "Counterfeit") ||
327+ strings.Contains(issuer, "False Fennel") {
220328 return "staging"
221329 }
222-
330+
223331 // Check for Let's Encrypt production certificate
224- if strings.Contains(issuer, "Let's Encrypt") ||
225- strings.Contains(issuer, "R3") ||
226- strings.Contains(issuer, "E1") {
332+ if strings.Contains(issuer, "Let's Encrypt") ||
333+ strings.Contains(issuer, "R3") ||
334+ strings.Contains(issuer, "E1") {
227335 return "production"
228336 }
229-
337+
230338 // Unknown issuer
231339 return "unknown"
232340 }
@@ -252,7 +360,7 @@ func isCertificateValid(certData []byte, domain string) (bool, float64, string,
252360
253361 // Determine the certificate type
254362 certType := getCertificateType(cert)
255-
363+
256364 // Check if the certificate is still valid
257365 now := time.Now()
258366 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {
@@ -303,21 +411,21 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
303411 getCertificateFileName(domain, "staging"),
304412 getCertificateFileName(domain, "production"),
305413 }
306-
414+
307415 // Check each possible certificate file using known patterns
308416 for _, baseName := range certFiles {
309417 certFile := filepath.Join(certDir, baseName)
310418 keyFile := filepath.Join(certDir, baseName+"+rsa")
311-
419+
312420 // Skip if either file doesn't exist
313421 if _, err := os.Stat(certFile); os.IsNotExist(err) {
314422 continue
315423 }
316-
424+
317425 if _, err := os.Stat(keyFile); os.IsNotExist(err) {
318426 continue
319427 }
320-
428+
321429 // If we found both files, validate the certificate
322430 certData, err := os.ReadFile(certFile)
323431 if err != nil {
@@ -327,24 +435,24 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
327435 "error", err)
328436 continue
329437 }
330-
438+
331439 // Validate the certificate data
332440 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
333441 if !validCert {
334442 continue
335443 }
336-
444+
337445 logger.Info("Valid certificate found in filesystem with standard naming pattern",
338446 "domain", domain,
339447 "expires_in", expiresInDays,
340448 "days",
341449 "type", certType,
342450 "cert_file", certFile)
343-
451+
344452 // Check if certificate matches current mode
345453 if p.config.LetsEncryptMode == "production" && certType == "staging" {
346454 logger.Info("Found staging certificate but production mode is enabled",
347- "domain", domain,
455+ "domain", domain,
348456 "action", "will request new production certificate")
349457 // Delete the staging certificate
350458 deleteCertificateFiles(certDir, baseName)
@@ -356,7 +464,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
356464 "domain", domain,
357465 "action", "using existing production certificate")
358466 }
359-
467+
360468 // Log some debug information about the certificate files
361469 certFileInfo, _ := os.Stat(certFile)
362470 keyFileInfo, _ := os.Stat(keyFile)
@@ -368,30 +476,30 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
368476 "key_file", keyFile,
369477 "key_size", keyFileInfo.Size(),
370478 "key_modified", keyFileInfo.ModTime())
371-
479+
372480 // Attempt to add to autocert cache if it's not already there
373481 if certType == "production" || (certType == "staging" && p.config.LetsEncryptMode == "staging") {
374482 // Add to autocert cache for future use
375483 p.addCertificateToCache(domain, certFile, keyFile)
376484 }
377-
485+
378486 return true, certType
379487 }
380488
381489 // If no matches with standard patterns, try a glob search for any files starting with the domain name
382- // This will catch files with any suffix like domain_letsencrypt-production
490+ // This will catch files with any suffix like domain_letsencrypt-production
383491 // and domain_letsencrypt-staging
384492 logger.Debug("No standard certificate files found, searching for any matching certificates",
385493 "domain", domain)
386-
494+
387495 files, err := filepath.Glob(filepath.Join(certDir, domain+"*"))
388496 if err != nil {
389- logger.Warn("Error searching for certificate files",
390- "domain", domain,
497+ logger.Warn("Error searching for certificate files",
498+ "domain", domain,
391499 "error", err)
392500 return false, ""
393501 }
394-
502+
395503 // Extract base names without the path
396504 var candidates []string
397505 for _, file := range files {
@@ -399,34 +507,34 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
399507 if strings.HasSuffix(file, "+rsa") {
400508 continue
401509 }
402-
510+
403511 baseFile := filepath.Base(file)
404512 // Check if the corresponding key file exists
405513 keyFile := file + "+rsa"
406514 if _, err := os.Stat(keyFile); os.IsNotExist(err) {
407515 continue
408516 }
409-
517+
410518 // Add this candidate
411519 candidates = append(candidates, baseFile)
412520 }
413-
521+
414522 // No certificate files found
415523 if len(candidates) == 0 {
416- logger.Debug("No certificate files found in filesystem glob search",
524+ logger.Debug("No certificate files found in filesystem glob search",
417525 "domain", domain)
418526 return false, ""
419527 }
420-
528+
421529 logger.Debug("Found certificate candidates in glob search",
422530 "domain", domain,
423531 "candidates", candidates)
424-
532+
425533 // Process each candidate (if there are multiple matches)
426534 for _, baseName := range candidates {
427535 certFile := filepath.Join(certDir, baseName)
428536 keyFile := filepath.Join(certDir, baseName+"+rsa")
429-
537+
430538 // Read and validate the certificate
431539 certData, err := os.ReadFile(certFile)
432540 if err != nil {
@@ -436,27 +544,27 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
436544 "error", err)
437545 continue
438546 }
439-
547+
440548 // Validate the certificate data
441549 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
442550 if !validCert {
443551 continue
444552 }
445-
553+
446554 logger.Info("Valid certificate found in filesystem with non-standard naming",
447555 "domain", domain,
448556 "expires_in", expiresInDays,
449557 "days",
450558 "type", certType,
451559 "cert_file", certFile)
452-
560+
453561 // If we're in production mode and this is a production certificate, use it
454562 if p.config.LetsEncryptMode == "production" && certType == "production" {
455563 // Attempt to add to autocert cache
456564 p.addCertificateToCache(domain, certFile, keyFile)
457565 return true, certType
458566 }
459-
567+
460568 // If we're in staging mode, prefer production certificates if available
461569 if p.config.LetsEncryptMode == "staging" {
462570 if certType == "production" {
@@ -470,7 +578,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
470578 return true, certType
471579 }
472580 }
473-
581+
474582 // If this is a staging certificate but we're in production mode, ignore it
475583 // and continue checking other candidates
476584 if p.config.LetsEncryptMode == "production" && certType == "staging" {
@@ -479,7 +587,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
479587 "cert_file", certFile)
480588 continue
481589 }
482-
590+
483591 // Fallback: use the certificate if it's valid
484592 // Attempt to add to autocert cache
485593 p.addCertificateToCache(domain, certFile, keyFile)
@@ -495,7 +603,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
495603 func deleteCertificateFiles(certDir string, baseName string) {
496604 certFile := filepath.Join(certDir, baseName)
497605 keyFile := filepath.Join(certDir, baseName+"+rsa")
498-
606+
499607 // Delete certificate file
500608 if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) {
501609 logger.Warn("Failed to delete certificate file",
@@ -504,7 +612,7 @@ func deleteCertificateFiles(certDir string, baseName string) {
504612 } else if !os.IsNotExist(err) {
505613 logger.Info("Deleted certificate file", "file", certFile)
506614 }
507-
615+
508616 // Delete key file
509617 if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) {
510618 logger.Warn("Failed to delete certificate key file",
@@ -665,8 +773,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
665773 // First check in the cache
666774 validInCache, cacheType := p.checkCertificateInCache(domain)
667775 if validInCache {
668- logger.Debug("Certificate exists in cache",
669- "domain", domain,
776+ logger.Debug("Certificate exists in cache",
777+ "domain", domain,
670778 "type", cacheType)
671779 return true, cacheType
672780 }
@@ -674,8 +782,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
674782 // If not in cache, check the filesystem
675783 validInFS, fsType := p.checkCertificateInFilesystem(domain)
676784 if validInFS {
677- logger.Debug("Certificate exists in filesystem but not in cache",
678- "domain", domain,
785+ logger.Debug("Certificate exists in filesystem but not in cache",
786+ "domain", domain,
679787 "type", fsType)
680788 return true, fsType
681789 }
@@ -699,6 +807,15 @@ func (p *Proxy) requestAdminCertificate() {
699807 return
700808 }
701809
810+ // Check for upstream proxy with certificate skipping enabled
811+ isProxied := p.detectUpstreamProxy()
812+ if isProxied && p.config.SkipCertificates {
813+ logger.Info("Upstream TLS-terminating proxy detected, skipping admin certificate request",
814+ "domain", adminDomain,
815+ "skip_reason", "behind TLS-terminating proxy")
816+ return
817+ }
818+
702819 // Extract hostname from admin domain, resolving it to see if it's publicly accessible
703820 host := adminDomain
704821 ips, err := net.LookupIP(host)
@@ -721,7 +838,7 @@ func (p *Proxy) requestAdminCertificate() {
721838
722839 // Check if we already have a valid certificate (cache or filesystem)
723840 hasValidCert, certType := p.checkCertificateExists(adminDomain)
724-
841+
725842 // If we have a valid cert that matches our mode, use it
726843 if hasValidCert {
727844 // Check for mode mismatch
@@ -765,7 +882,7 @@ func (p *Proxy) requestAdminCertificate() {
765882 if certDir == "" {
766883 certDir = p.app.GetConfig().General.StorageDir + "/certs"
767884 }
768-
885+
769886 // Ensure certificate directory exists
770887 if err := os.MkdirAll(certDir, 0755); err != nil {
771888 logger.Warn("Failed to create certificate directory",
@@ -863,15 +980,15 @@ func (p *Proxy) requestAdminCertificate() {
863980 if p.config.LetsEncryptMode == "staging" {
864981 certType = "staging"
865982 }
866-
983+
867984 // Try to rename the certificate files
868985 oldCertPath := filepath.Join(certDir, adminDomain)
869986 oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa")
870-
987+
871988 newName := getCertificateFileName(adminDomain, certType)
872989 newCertPath := filepath.Join(certDir, newName)
873990 newKeyPath := filepath.Join(certDir, newName+"+rsa")
874-
991+
875992 // Only rename if the files exist and the new names are different
876993 if oldCertPath != newCertPath {
877994 // Check if old cert exists
@@ -890,7 +1007,7 @@ func (p *Proxy) requestAdminCertificate() {
8901007 "from", oldCertPath,
8911008 "to", newCertPath)
8921009 }
893-
1010+
8941011 // Rename key
8951012 if _, err := os.Stat(oldKeyPath); err == nil {
8961013 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -909,7 +1026,7 @@ func (p *Proxy) requestAdminCertificate() {
9091026 }
9101027 }
9111028 }
912-
1029+
9131030 logger.Info("Successfully obtained Let's Encrypt certificate",
9141031 "domain", adminDomain,
9151032 "type", certType)
@@ -930,6 +1047,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
9301047 return
9311048 }
9321049
1050+ // Check for upstream proxy with certificate skipping enabled
1051+ isProxied := p.detectUpstreamProxy()
1052+ if isProxied && p.config.SkipCertificates {
1053+ logger.Info("Upstream TLS-terminating proxy detected, skipping domain certificate request",
1054+ "domain", domain,
1055+ "skip_reason", "behind TLS-terminating proxy")
1056+ return
1057+ }
1058+
9331059 // Extract hostname from domain, resolving it to see if it's publicly accessible
9341060 ips, err := net.LookupIP(domain)
9351061 if err != nil {
@@ -951,7 +1077,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
9511077
9521078 // Check if we already have a valid certificate (cache or filesystem)
9531079 hasValidCert, certType := p.checkCertificateExists(domain)
954-
1080+
9551081 // If we have a valid cert that matches our mode, use it
9561082 if hasValidCert {
9571083 // Check for mode mismatch
@@ -995,7 +1121,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
9951121 if certDir == "" {
9961122 certDir = p.app.GetConfig().General.StorageDir + "/certs"
9971123 }
998-
1124+
9991125 // Ensure certificate directory exists
10001126 if err := os.MkdirAll(certDir, 0755); err != nil {
10011127 logger.Warn("Failed to create certificate directory",
@@ -1093,15 +1219,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
10931219 if p.config.LetsEncryptMode == "staging" {
10941220 certType = "staging"
10951221 }
1096-
1222+
10971223 // Try to rename the certificate files
10981224 oldCertPath := filepath.Join(certDir, domain)
10991225 oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1100-
1226+
11011227 newName := getCertificateFileName(domain, certType)
11021228 newCertPath := filepath.Join(certDir, newName)
11031229 newKeyPath := filepath.Join(certDir, newName+"+rsa")
1104-
1230+
11051231 // Only rename if the files exist and the new names are different
11061232 if oldCertPath != newCertPath {
11071233 // Check if old cert exists
@@ -1120,7 +1246,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
11201246 "from", oldCertPath,
11211247 "to", newCertPath)
11221248 }
1123-
1249+
11241250 // Rename key
11251251 if _, err := os.Stat(oldKeyPath); err == nil {
11261252 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -1139,7 +1265,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
11391265 }
11401266 }
11411267 }
1142-
1268+
11431269 logger.Info("Successfully obtained Let's Encrypt certificate",
11441270 "domain", domain,
11451271 "type", certType)
@@ -1224,15 +1350,15 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
12241350 if p.config.LetsEncryptMode == "staging" {
12251351 certType = "staging"
12261352 }
1227-
1353+
12281354 // Rename the certificate files to include the type
12291355 oldCertPath := filepath.Join(certDir, domain)
12301356 oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1231-
1357+
12321358 newName := getCertificateFileName(domain, certType)
12331359 newCertPath := filepath.Join(certDir, newName)
12341360 newKeyPath := filepath.Join(certDir, newName+"+rsa")
1235-
1361+
12361362 // Only rename if the files exist and the new names are different
12371363 if oldCertPath != newCertPath {
12381364 // Check if old cert exists
@@ -1251,7 +1377,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
12511377 "from", oldCertPath,
12521378 "to", newCertPath)
12531379 }
1254-
1380+
12551381 // Rename key
12561382 if _, err := os.Stat(oldKeyPath); err == nil {
12571383 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -1270,7 +1396,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
12701396 }
12711397 }
12721398 }
1273-
1399+
12741400 logger.Info("Successfully obtained Let's Encrypt certificate on retry",
12751401 "domain", domain,
12761402 "attempt", attempt,
@@ -1326,7 +1452,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
13261452 "domain", domain,
13271453 "max_retries", maxRetries,
13281454 "fallback", "Generating self-signed certificate")
1329-
1455+
13301456 // Generate a self-signed certificate for this domain
13311457 fallbackCert, err := generateFallbackCertificates([]string{domain})
13321458 if err != nil {
@@ -1338,13 +1464,13 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
13381464 selfSignedName := getCertificateFileName(domain, "self-signed")
13391465 certPath := filepath.Join(certDir, selfSignedName)
13401466 keyPath := filepath.Join(certDir, selfSignedName+"+rsa")
1341-
1467+
13421468 // Save the certificate and key
13431469 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]})
13441470 keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey)
13451471 if err == nil {
13461472 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
1347-
1473+
13481474 // Write files
13491475 if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
13501476 logger.Error("Failed to write self-signed certificate to disk",
@@ -1352,14 +1478,14 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
13521478 "path", certPath,
13531479 "error", err)
13541480 }
1355-
1481+
13561482 if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
13571483 logger.Error("Failed to write self-signed key to disk",
13581484 "domain", domain,
13591485 "path", keyPath,
13601486 "error", err)
13611487 }
1362-
1488+
13631489 logger.Info("Generated and saved self-signed fallback certificate",
13641490 "domain", domain,
13651491 "cert_path", certPath,
M internal/proxy/containers.go +7 -5
@@ -1,7 +1,6 @@
11 package proxy
22
33 import (
4- "fmt"
54 "os"
65 "strings"
76
@@ -12,11 +11,11 @@ import (
1211 // scanContainersAndCheckCertificates scans all containers from the Gordon network
1312 // and checks if we have certificates for their domains and subdomains
1413 func (p *Proxy) scanContainersAndCheckCertificates() error {
15- // Get the network name from app config
1614 networkName := p.app.GetConfig().ContainerEngine.Network
17-
1815 if networkName == "" {
19- return fmt.Errorf("container network name is not configured")
16+ // If no network is defined, skip container scanning
17+ logger.Info("No container network defined, skipping certificate checks")
18+ return nil
2019 }
2120
2221 logger.Info("Scanning containers in network for certificate checks",
@@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
2524 // Try to get network info using the docker client
2625 networkInfo, err := docker.GetNetworkInfo(networkName)
2726 if err != nil {
28- return fmt.Errorf("failed to get network info: %w", err)
27+ logger.Warn("Failed to get network info, skipping certificate checks",
28+ "error", err,
29+ "network", networkName)
30+ return nil // Return nil instead of error to continue execution
2931 }
3032
3133 // Skip admin cert check if environment variable is set
M internal/proxy/core.go +34 -6
@@ -22,7 +22,6 @@ import (
2222 // - certificates.go - Certificate management functions
2323 // - routes.go - Route management functions
2424 // - middleware.go - HTTP middleware functions
25-// - blacklist.go - IP blacklisting functionality
2625 // - server.go - Server startup and shutdown
2726 // - utils.go - Utility functions
2827
@@ -58,6 +57,10 @@ type Proxy struct {
5857 recentContainersMu sync.RWMutex
5958 containerCooldownPeriod time.Duration
6059
60+ // Upstream proxy detection
61+ upstreamProxyDetected bool
62+ upstreamProxyMu sync.RWMutex
63+
6164 // SQL queries
6265 queries *queries.ProxyQueries
6366 }
@@ -66,8 +69,36 @@ type Proxy struct {
6669 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
6770 logger.Debug("Initializing reverse proxy")
6871
69- // Log important configuration information
70- logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol")
72+ // Set up the config
73+ config := app.GetConfig().ReverseProxy
74+
75+ // Check if proxy is disabled through configuration
76+ if !config.Enabled {
77+ logger.Info("Reverse proxy is disabled via configuration")
78+ // Return a non-active proxy instance
79+ return &Proxy{
80+ config: config,
81+ app: app,
82+ serverStarted: false,
83+ shutdown: make(chan struct{}),
84+ }, nil
85+ }
86+
87+ // Check if running inside a container
88+ isContainer := docker.IsRunningInContainer()
89+ logger.Debug("Container environment check", "is_running_in_container", isContainer)
90+
91+ if !isContainer {
92+ logger.Info("Gordon is not running in a container, proxy will be disabled automatically")
93+ // Override the enabled setting to false
94+ config.Enabled = false
95+ return &Proxy{
96+ config: config,
97+ app: app,
98+ serverStarted: false,
99+ shutdown: make(chan struct{}),
100+ }, nil
101+ }
71102
72103 // Set up the echo server for HTTPS traffic
73104 httpsServer := echo.New()
@@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
77108 httpServer := echo.New()
78109 httpServer.HideBanner = true
79110
80- // Set up the config
81- config := app.GetConfig().ReverseProxy
82-
83111 // Detect and store our container ID (if we're running in a container)
84112 var ourContainerID string
85113 if docker.IsRunningInContainer() {
D internal/proxy/utils.go +0 -26
@@ -1,26 +0,0 @@
1-package proxy
2-
3-import (
4- "crypto/rand"
5- "fmt"
6- "time"
7-
8- "github.com/google/uuid"
9-)
10-
11-// proxyGenerateUUID generates a random UUID for use in various identifiers
12-// This is a renamed version to avoid conflicts until we clean up proxy.go
13-func proxyGenerateUUID() string {
14- id, err := uuid.NewRandom()
15- if err != nil {
16- // If we can't generate a UUID using the standard library,
17- // fall back to our own implementation
18- b := make([]byte, 16)
19- _, err := rand.Read(b)
20- if err != nil {
21- return fmt.Sprintf("%d", time.Now().UnixNano())
22- }
23- return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
24- }
25- return id.String()
26-}
M internal/templating/models/html/admin/createcontainerfull.gohtml +3 -3
@@ -2,13 +2,13 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
5- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">
5+ <body class="bg-app flex flex-col lg:flex-row p-0">
66 {{template "menu.gohtml" .}}
77 <!-- Main content here -->
88 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
99 <div class="flex flex-col md:flex-row w-full flex-grow">
10- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">
11- <h1 class="text-2xl font-bold mb-4">Edit Container</h1>
10+ <div class="card flex-grow">
11+ <h1 class="card-header mb-4">Edit Container</h1>
1212 <!-- Include the existing edit container fragment here -->
1313 {{template "createcontainer.gohtml" .}}
1414 </div>
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
@@ -1,6 +1,6 @@
11 <div id="dashboard">
22 <h1
3- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
3+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
44 >
55 Hello World from dashboard.html
66 </h1>
M internal/templating/models/html/admin/index.gohtml +9 -9
@@ -2,23 +2,23 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
5- <body class="bg-zinc-200 flex flex-col md:flex-row p-4 md:p-0">
5+ <body class="bg-app flex flex-col md:flex-row p-4 md:p-0">
66 {{template "menu.gohtml" .}}
77 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
88 <div
99 class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
10- <div class="flex-grow bg-white p-4 rounded-md shadow mb-4">
11- <h2>{{index .Lang "dashboard" "content" "title"}}</h2>
10+ <div class="card flex-grow">
11+ <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
1212 </div>
1313 <div class="w-full md:w-2/6 flex flex-col space-y-4">
14- <div class="bg-white p-4 rounded-md shadow mb-4">
15- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
14+ <div class="card">
15+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1616 </div>
17- <div class="bg-white p-4 rounded-md shadow mb-4">
18- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
17+ <div class="card">
18+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1919 </div>
20- <div class="bg-white p-4 rounded-md shadow mb-4">
21- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
20+ <div class="card">
21+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
2222 </div>
2323 </div>
2424 </div>
M internal/templating/models/html/admin/install.gohtml +7 -7
@@ -21,7 +21,7 @@
2121 </head>
2222
2323 <body
24- class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center"
24+ class="IBMPlexSans bg-app md:p-8 flex flex-col items-center"
2525 hx-boost="true"
2626 >
2727 <!-- Logo and Title -->
@@ -38,28 +38,28 @@
3838 </div>
3939 <div class="flex justify-center mt-4">
4040 <h1
41- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter"
41+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
4242 >
4343 Gordon
4444 </h1>
45- <span class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"
45+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"
4646 >v0.0.1</span
4747 >
4848 </div>
4949 </div>
5050
5151 <!-- Main Content -->
52- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
52+ <div class="card w-2/3">
5353 <!-- Built-in Proxy -->
5454 <div id="built-in-proxy" class="mt-8">
5555 <div class="flex items-center mb-4">
56- <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
56+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
5757 <span class="text-white font-bold">✓</span>
5858 </div>
59- <h2 class="text-lg ml-2">Reverse Proxy</h2>
59+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
6060 </div>
6161
62- <div class="bg-gray-100 p-4 rounded-md">
62+ <div class="bg-indigo-50 p-4 rounded-md">
6363 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
6464 <p>The proxy automatically:</p>
6565 <ul class="list-disc ml-6 mb-4">
M internal/templating/models/html/admin/manager.gohtml +65 -23
@@ -1,34 +1,76 @@
11 <!-- manager.gohtml -->
22 <!DOCTYPE html>
3-<html lang="en">
3+<html lang="en" data-theme="gordon">
44 {{template "header.gohtml" .}}
55
6- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">
6+ <body class="min-h-screen bg-base-200">
77 {{template "menu.gohtml" .}}
8- <!-- Main content here -->
9- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
10- <div class="flex flex-col md:flex-row w-full flex-grow">
11- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">
12- <!-- List all the containers -->
13- <div
14- id="container-manager"
15- hx-get="/htmx/container-manager"
16- hx-trigger="load"
17- hx-swap="innerHTML"
18- hx-swap-oob="true"
19- ></div>
20- <!-- List all the images -->
21- <div
22- id="image-manager"
23- hx-get="/htmx/image-manager"
24- hx-trigger="load"
25- hx-swap="innerHTML"
26- hx-swap-oob="true"
27- ></div>
8+
9+ <!-- Main content -->
10+ <div class="container mx-auto py-6 px-4 mt-16">
11+ <div class="text-sm breadcrumbs mb-4">
12+ <ul>
13+ <li><a href="{{ .AdminPath }}">Gordon</a></li>
14+ <li>Manager</li>
15+ </ul>
2816 </div>
17+
18+ <!-- Stats overview -->
19+ <div class="stats shadow mb-6 w-full">
20+ <div class="stat">
21+ <div class="stat-figure text-primary">
22+ <span class="iconf text-3xl">󰡨</span>
23+ </div>
24+ <div class="stat-title">Containers</div>
25+ <div class="stat-value" id="container-count">{{.ContainerCount}}</div>
26+ <div class="stat-desc">Docker containers managed by Gordon</div>
27+ </div>
28+
29+ <div class="stat">
30+ <div class="stat-figure text-secondary">
31+ <span class="iconf text-3xl">󰋊</span>
32+ </div>
33+ <div class="stat-title">Images</div>
34+ <div class="stat-value" id="image-count">{{.ImageCount}}</div>
35+ <div class="stat-desc">Docker images available</div>
36+ </div>
37+ </div>
38+
39+ <!-- Tabs for containers and images -->
40+ <div role="tablist" class="tabs tabs-boxed mb-4">
41+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
42+ <div role="tabpanel" class="tab-content p-0">
43+ <!-- Container manager section -->
44+ <div id="container-manager"
45+ hx-get="/htmx/container-manager"
46+ hx-trigger="load"
47+ hx-swap="innerHTML">
48+ <!-- Container content loaded via HTMX -->
49+ <div class="text-center p-4">
50+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
51+ Loading containers...
52+ </div>
53+ </div>
54+ </div>
55+
56+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
57+ <div role="tabpanel" class="tab-content p-0">
58+ <!-- Image manager section -->
59+ <div id="image-manager"
60+ hx-get="/htmx/image-manager"
61+ hx-trigger="load"
62+ hx-swap="innerHTML">
63+ <!-- Image content loaded via HTMX -->
64+ <div class="text-center p-4">
65+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
66+ Loading images...
67+ </div>
68+ </div>
69+ </div>
70+ </div>
71+
2972 </div>
3073
31- <!-- footer -->
3274 {{template "footer.gohtml" .}}
3375 </body>
3476 </html>
M internal/templating/models/html/fragments/components.gohtml +55 -12
@@ -1,20 +1,24 @@
11 <!-- @format -->
22
3-<div id="header">
4- <h1
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
6- >
7- {{ .body.header.helloworld }}
8- </h1>
3+<div id="header" class="hero bg-base-200">
4+ <div class="hero-content text-center">
5+ <div class="max-w-md">
6+ <h1 class="text-5xl font-bold gordon-title">
7+ {{ .body.header.helloworld }}
8+ </h1>
9+ <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p>
10+ <button class="btn btn-primary">Get Started</button>
11+ </div>
12+ </div>
913 </div>
1014
11-<div id="hello">
12- <h1
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
14- >
15- {{ .body.div.hello }}
16- </h1>
15+<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
16+ <div>
17+ <span class="iconfb">󰋽</span>
18+ <span class="IBMPlexSans">{{ .body.div.hello }}</span>
19+ </div>
1720 </div>
21+
1822 <div
1923 id="header"
2024 hx-get="/htmx"
@@ -31,3 +35,42 @@
3135 hx-trigger="load"
3236 hx-swap="outerHTML"
3337 ></div>
38+
39+<!-- Stats Cards -->
40+<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
41+ <div class="stat bg-base-100 shadow">
42+ <div class="stat-figure text-primary">
43+ <span class="iconfb text-3xl">󰡨</span>
44+ </div>
45+ <div class="stat-title">Containers</div>
46+ <div class="stat-value">31</div>
47+ <div class="stat-desc">Running containers</div>
48+ </div>
49+
50+ <div class="stat bg-base-100 shadow">
51+ <div class="stat-figure text-secondary">
52+ <span class="iconfb text-3xl">󱧕</span>
53+ </div>
54+ <div class="stat-title">Images</div>
55+ <div class="stat-value">42</div>
56+ <div class="stat-desc">Available images</div>
57+ </div>
58+
59+ <div class="stat bg-base-100 shadow">
60+ <div class="stat-figure text-accent">
61+ <span class="iconfb text-3xl">󰙅</span>
62+ </div>
63+ <div class="stat-title">Networks</div>
64+ <div class="stat-value">12</div>
65+ <div class="stat-desc">Container networks</div>
66+ </div>
67+</div>
68+
69+<!-- Example of tabs -->
70+<div class="tabs tabs-boxed my-4">
71+ <a class="tab tab-active">Overview</a>
72+ <a class="tab">Containers</a>
73+ <a class="tab">Images</a>
74+ <a class="tab">Networks</a>
75+ <a class="tab">Volumes</a>
76+</div>
M internal/templating/models/html/fragments/containerlist.gohtml +120 -110
@@ -1,112 +1,122 @@
1-<div class="flex flex-col p-4" hx-ext="response-targets">
2- <div class="flex justify-between items-center mb-1">
3- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">
4- {{index .Lang "manager" "content" "h1-containers"}}
5- </h1>
6- <button
7- id="refresh-containers-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh container list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- </div>
15- <table class="p-4 border-t-2 table-auto">
16- <thead>
17- <tr class="text-center text-gray-400">
18- <th>{{index .Lang "manager" "content" "thName"}}</th>
19- <th>{{index .Lang "manager" "content" "thPorts"}}</th>
20- <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th>
21- <th>{{index .Lang "manager" "content" "thStatus"}}</th>
22- <th>{{index .Lang "manager" "content" "thState"}}</th>
23- <th>{{index .Lang "manager" "content" "thActions"}}</th>
24- </tr>
25- </thead>
26- <tbody>
27- {{ $Lang := .Lang }}
28- {{
29- range.containers
30- }}
31- <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100">
32- <td>
33- <span
34- title="{{.Container.ID}}"
35- class="font-bold"
36- >{{.Name}}</span
37- >
38- </td>
39- <td class="port-container">{{ .Ports }}</td>
40- <td class="entrypoint-container">{{.ProxyPort}}</td>
41- <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td>
42- <td style="color: {{.StateColor}};">{{.State}}</td>
43- <td>
44- <button
45- id="start-button-{{.ID}}"
46- hx-post="/htmx/container-manager/start/{{.ID}}"
47- hx-target="#actionResponse-{{.ID}}"
48- hx-target-*="#error-message-{{.ID}}"
49- hx-swap="outerHTML"
50- class="py-2 px-4"
51- >
52- <span title="Start the container" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span>
53- </button>
54- <!-- EDIT -->
55- <button
56- id="edit-button-{{.ID}}"
57- hx-get="/htmx/container-manager/edit/{{.ID}}"
58- hx-target="#actionResponse-{{.ID}}"
59- hx-target-*="#error-message-{{.ID}}"
60- hx-swap="innerHTML"
61- class="py-2 px-4"
62- data-active="false"
63- >
64- <span title="Edit the container" class="iconf text-2xl text-blue-600 hover:text-gordonmascot-hblue">󱩽</span>
65- </button>
66- <!-- stop -->
67- <button
68- id="stop-button-{{.ID}}"
69- hx-post="/htmx/container-manager/stop/{{.ID}}"
70- hx-target="#actionResponse-{{.ID}}"
71- hx-target-*="#error-message-{{.ID}}"
72- hx-swap="innerHTML"
73- hx-confirm="{{index $Lang "manager" "content" "confirm-container-stop"}}"
74- class="py-2 px-4"
75- >
76- <span title="Stop the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
77- </button>
78- <!-- REMOVE -->
79- <button
80- id="remove-button-img-{{.ID}}"
81- hx-delete="/htmx/container-manager/delete/{{.ID}}"
82- hx-target="#actionResponse-{{.ID}}"
83- hx-target-*="#error-message-{{.ID}}"
84- hx-swap="innerHTML"
85- hx-confirm="{{index $Lang "manager" "content" "confirm-delete"}}"
86- class="py-2 px-4"
87- >
88- <!-- iconoir trash -->
89- <span title="Remove the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
90- </button>
91- </td>
1+<!-- Container list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8+ <div class="btn-group">
9+ <button
10+ class="btn btn-sm btn-primary"
11+ hx-get="/htmx/container-manager"
12+ hx-target="#container-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰑓</span> Refresh
15+ </button>
16+ </div>
17+ </div>
18+
19+ <!-- Container list -->
20+ <table class="table">
21+ <!-- head -->
22+ <thead>
23+ <tr>
24+ <th>Name</th>
25+ <th>Image</th>
26+ <th>Status</th>
27+ <th>Created</th>
28+ <th>Actions</th>
29+ </tr>
30+ </thead>
31+ <tbody>
32+ {{ if or .NoContainer (eq (len .Containers) 0) }}
33+ <tr>
34+ <td colspan="5" class="text-center py-4">
35+ <div class="alert alert-info">
36+ <span class="iconf">ℹ️</span>
37+ <span>No containers found. Upload an image first, then create a container.</span>
38+ </div>
39+ </td>
9240 </tr>
93- <tr>
94- <td colspan="7">
95- <div id="actionResponse-{{.ID}}"></div>
96- <div id="error-message-{{.ID}}" class="text-red-500"></div>
97- </td>
98- </tr>
99- {{ else }}
100- <tr>
101- <td colspan="7" class="mt-2">
102- <p class="text-center text-gray-400 text-xl">
103- {{index $Lang "manager" "content" "no-containers"}}
104- </p>
105- </td>
106- </tr>
107- {{
108- end
109- }}
110- </tbody>
111- </table>
41+ {{ else }}
42+ {{ range .Containers }}
43+ <tr class="hover">
44+ <td>
45+ <div class="flex items-center gap-3">
46+ <div>
47+ <div class="font-bold">{{ .Names }}</div>
48+ <div class="text-sm opacity-50">{{ .ID }}</div>
49+ </div>
50+ </div>
51+ </td>
52+ <td>{{ .Image }}</td>
53+ <td>
54+ {{ if eq .State "running" }}
55+ <div class="badge badge-success badge-sm">Running</div>
56+ {{ else if eq .State "exited" }}
57+ <div class="badge badge-warning badge-sm">Stopped</div>
58+ {{ else }}
59+ <div class="badge badge-ghost badge-sm">{{ .State }}</div>
60+ {{ end }}
61+ </td>
62+ <td>{{ .Created }}</td>
63+ <th>
64+ <div class="dropdown dropdown-end">
65+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
66+ <span class="iconf">󰅀</span>
67+ </div>
68+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
69+ {{ if eq .State "running" }}
70+ <li>
71+ <button
72+ class="text-warning flex items-center"
73+ hx-post="/htmx/container-manager/stop/{{ .ID }}"
74+ hx-target="#container-manager"
75+ hx-swap="innerHTML">
76+ <span class="iconf mr-2">󰓛</span> Stop
77+ </button>
78+ </li>
79+ {{ else if eq .State "exited" }}
80+ <li>
81+ <button
82+ class="text-success flex items-center"
83+ hx-post="/htmx/container-manager/start/{{ .ID }}"
84+ hx-target="#container-manager"
85+ hx-swap="innerHTML">
86+ <span class="iconf mr-2">󰐊</span> Start
87+ </button>
88+ </li>
89+ {{ end }}
90+ <li>
91+ <button
92+ class="text-info flex items-center"
93+ hx-get="/htmx/container-manager/edit/{{ .ID }}"
94+ hx-target="#container-manager"
95+ hx-swap="innerHTML">
96+ <span class="iconf mr-2">󰏫</span> Edit
97+ </button>
98+ </li>
99+ <li>
100+ <button
101+ class="text-error flex items-center"
102+ hx-delete="/htmx/container-manager/delete/{{ .ID }}"
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML"
105+ hx-confirm="Are you sure you want to delete this container?">
106+ <span class="iconf mr-2">󰆴</span> Delete
107+ </button>
108+ </li>
109+ </ul>
110+ </div>
111+ </th>
112+ </tr>
113+ {{ end }}
114+ {{ end }}
115+ </tbody>
116+ </table>
117+ </div>
112118 </div>
119+
120+<script>
121+console.debug('Container list rendered');
122+</script>
M internal/templating/models/html/fragments/createcontainer.gohtml +79 -54
@@ -1,9 +1,12 @@
11 <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 <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow bg-slate-100 p-4 rounded-md">
4- <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2>
3+ <div class="flex-grow card p-6">
4+ <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
55 {{if .ErrorMessage}}
6- <div class="text-red-500">{{.ErrorMessage}}</div>
6+ <div class="alert alert-error">
7+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
8+ <span>{{.ErrorMessage}}</span>
9+ </div>
710 {{ end }}
811 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
912 hx-post="/htmx/create-container/{{.ShortID}}"
@@ -11,83 +14,104 @@
1114 hx-target="#create-container-{{.ShortID}}"
1215 hx-target-*="#error-message-create-container-{{.ShortID}}">
1316 <!-- Image Name -->
14- <div class="flex space-x-4 pb-4">
15- <div class="w-1/3">
16- <label class="block">Image name</label>
17- <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
17+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
18+ <div class="md:col-span-1">
19+ <label class="label">
20+ <span class="label-text">Image name</span>
21+ </label>
22+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
1823 value="{{.ImageName}}" readonly />
1924 </div>
2025
21- <div class="w-2/3">
22- <label class="block">Image ID</label>
23- <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
26+ <div class="md:col-span-2">
27+ <label class="label">
28+ <span class="label-text">Image ID</span>
29+ </label>
30+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
2431 value="{{.ImageID}}" readonly />
2532 </div>
2633 </div>
2734
2835 <!-- Container Name -->
29- <div class="pb-4">
30- <label class="block">Container name</label>
31- <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" />
36+ <div class="form-control pb-4">
37+ <label class="label">
38+ <span class="label-text">Container name</span>
39+ </label>
40+ <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" />
3241 </div>
42+
3343 <!-- Http(s) / Subdomain / Domain -->
34- <div class="flex pb-4 space-x-4">
44+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
3545 <!-- Protocol -->
36- <div class="w-1/4">
37- <label class="block">Protocol</label>
38- <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300">
46+ <div class="md:col-span-3">
47+ <label class="label">
48+ <span class="label-text">Protocol</span>
49+ </label>
50+ <select name="container_protocol" class="select select-bordered w-full">
3951 <option value="https">https</option>
4052 <option value="http">http</option>
4153 </select>
4254 </div>
4355
4456 <!-- Subdomain -->
45- <div class="w-1/3">
46- <label class="block">Subdomain</label>
47- <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" />
57+ <div class="md:col-span-4">
58+ <label class="label">
59+ <span class="label-text">Subdomain</span>
60+ </label>
61+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
4862 </div>
4963
5064 <!-- Domain -->
51- <div class="w-5/12">
52- <label class="block">Domain</label>
53- <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" />
65+ <div class="md:col-span-5">
66+ <label class="label">
67+ <span class="label-text">Domain</span>
68+ </label>
69+ <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
5470 </div>
5571 </div>
72+
5673 <!-- Exposed Ports -->
57- <div class="pb-4">
58- <label class="block">Exposed Ports (Optional)</label>
59- <span class="text-gray-500">
60- eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)
61- </span>
62- <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />
74+ <div class="form-control pb-4">
75+ <label class="label">
76+ <span class="label-text">Exposed Ports (Optional)</span>
77+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
78+ </label>
79+ <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
6380 </div>
6481
6582 <!-- Proxy Port -->
66- <div class="mb-6">
67- <label class="block">Container Proxy Port (Required)</label>
68- <div class="text-xs text-gray-500 mb-2">
69- Specify the internal container port that the proxy will route traffic to
70- </div>
71- <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required />
83+ <div class="form-control pb-4">
84+ <label class="label">
85+ <span class="label-text">Container Proxy Port (Required)</span>
86+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
87+ </label>
88+ <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
7289 </div>
7390
7491 <!-- Volumes -->
75- <div class="pb-4">
76- <label class="block">Volumes</label>
77- <span class="text-gray-500">e.g. ./data:/container/data </span>
78- <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" />
92+ <div class="form-control pb-4">
93+ <label class="label">
94+ <span class="label-text">Volumes</span>
95+ <span class="label-text-alt">e.g. ./data:/container/data</span>
96+ </label>
97+ <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" />
7998 </div>
8099
81- <div class="pb-4">
82- <label class="block">Environment variables</label>
83- <span class="text-gray-500">e.g. KEY=VALUE </span>
84- <textarea name="environment_variables" class="w-full p-4 rounded-md"
100+ <div class="form-control pb-4">
101+ <label class="label">
102+ <span class="label-text">Environment variables</span>
103+ <span class="label-text-alt">e.g. KEY=VALUE</span>
104+ </label>
105+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
85106 rows="4">{{.EnvironmentVariables}}</textarea>
86107 </div>
108+
87109 <!-- Restart: always/on-failure/only when stopped/never -->
88- <div class="pb-4">
89- <label class="block">Restart</label>
90- <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300">
110+ <div class="form-control pb-4">
111+ <label class="label">
112+ <span class="label-text">Restart</span>
113+ </label>
114+ <select name="restart" class="select select-bordered w-full">
91115 <option value="always">Always</option>
92116 <option value="on-failure">On failure</option>
93117 <option value="unless-stopped">Unless stopped</option>
@@ -96,20 +120,21 @@
96120 </div>
97121
98122 <!-- Skip Proxy Setup Option -->
99- <div class="pb-4">
100- <label class="flex items-center space-x-2">
101- <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" />
123+ <div class="form-control pb-4">
124+ <label class="label cursor-pointer justify-start gap-4">
125+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
102126 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
103127 </label>
104- <p class="text-sm text-gray-500 ml-7">
128+ <div class="text-sm opacity-70 ml-10">
105129 Use this option if you only want to create the container without triggering proxy setup,
106130 certificate generation, and other related processes.
107- </p>
131+ </div>
108132 </div>
109133
110- <input type="submit" value="Create" id="create-container-submit"
111- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
134+ <button type="submit" id="create-container-submit" class="btn btn-primary">
135+ Create Container
136+ </button>
112137 </form>
113- <div id="error-message-create-container-{{.ShortID}}" class="text-red-500"></div>
138+ <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div>
114139 </div>
115140 </div>⏎̸
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
@@ -1,9 +1,9 @@
11 <div
22 id="edit-container-{{.ID}}"
3- class="h-fit-content"
3+ class="card bg-base-100 shadow-xl"
44 hx-ext="response-targets"
55 >
6- <div id="editor">
6+ <div id="editor" class="card-body">
77 <form
88 method="post"
99 action="/htmx/container-manager/edit/{{.ID}}"
@@ -14,17 +14,19 @@
1414 <textarea
1515 name="container_config"
1616 id="container_config"
17- class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2"
17+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
1818 rows="20"
1919 >{{.ContainerInfo}}</textarea
2020 >
21- <input
22- type="submit"
23- value="Save"
24- id="save-button-{{.ID}}"
25- class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer"
26- />
21+ <div class="card-actions justify-end mt-4">
22+ <input
23+ type="submit"
24+ value="Save"
25+ id="save-button-{{.ID}}"
26+ class="btn btn-primary"
27+ />
28+ </div>
2729 </form>
28- <div id="error-message-edit-container-{{.ID}}" class="text-red-500"></div>
30+ <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div>
2931 </div>
3032 </div>
M internal/templating/models/html/fragments/footer.gohtml +33 -0
@@ -0,0 +1,33 @@
1+<footer class="footer p-10 bg-base-200 text-base-content">
2+ <div>
3+ <span class="footer-title">Gordon</span>
4+ <a class="link link-hover">About us</a>
5+ <a class="link link-hover">Documentation</a>
6+ <a class="link link-hover">Github</a>
7+ </div>
8+ <div>
9+ <span class="footer-title">Container Management</span>
10+ <a class="link link-hover">Dashboard</a>
11+ <a class="link link-hover">Settings</a>
12+ <a class="link link-hover">Stats</a>
13+ </div>
14+ <div>
15+ <span class="footer-title">Legal</span>
16+ <a class="link link-hover">Terms of use</a>
17+ <a class="link link-hover">Privacy policy</a>
18+ <a class="link link-hover">License</a>
19+ </div>
20+</footer>
21+<footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
22+ <div class="items-center grid-flow-col">
23+ <span class="iconfb text-2xl">󰊢</span>
24+ <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
25+ </div>
26+ <div class="md:place-self-center md:justify-self-end">
27+ <div class="grid grid-flow-col gap-4">
28+ <a class="iconfb text-2xl">󰊤</a>
29+ <a class="iconfb text-2xl">󰗃</a>
30+ <a class="iconfb text-2xl">󰖟</a>
31+ </div>
32+ </div>
33+</footer>
M internal/templating/models/html/fragments/header.gohtml +10 -0
@@ -11,4 +11,14 @@
1111 <script src="/libs/htmx-sse.js"></script>
1212 <script defer src="/libs/hyperscript.min.js"></script>
1313 <script defer src="/assets/js/custom.js"></script>
14+
15+ <!-- daisyUI theme configuration -->
16+ <script>
17+ // Check for saved theme preference or use preferred color scheme
18+ const savedTheme = localStorage.getItem('theme') || 'gordon';
19+ document.documentElement.setAttribute('data-theme', savedTheme);
20+
21+ // Log theme loading for debugging
22+ console.debug('Theme loaded:', savedTheme);
23+ </script>
1424 </head>
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
@@ -1,82 +1,98 @@
1-<div class="flex flex-col p-4">
2- <!-- Upload button aligned right of Images -->
3- <div class="flex justify-between items-center mb-1">
4- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1>
5- <div class="flex space-x-2">
6- <button
7- id="refresh-images-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh image list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- <button
15- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
16- id="upload-button"
17- hx-get="/htmx/upload-image"
18- hx-target="#upload-image"
19- hx-swap="innerHTML"
20- title="Manually upload a .tar container image"
21- >
22- <span class="iconf text-xl align-middle">󰭾 </span>
23- Upload
24- </button>
1+<!-- Image list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8+ <div class="flex gap-2">
9+ <button
10+ class="btn btn-sm btn-secondary"
11+ hx-get="/htmx/upload-image"
12+ hx-target="#image-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰦒</span> Upload
15+ </button>
16+ <button
17+ class="btn btn-sm btn-primary"
18+ hx-get="/htmx/image-manager"
19+ hx-target="#image-manager"
20+ hx-swap="innerHTML">
21+ <span class="iconf">󰑓</span> Refresh
22+ </button>
23+ </div>
2524 </div>
26- </div>
27- <div id="upload-image"></div>
28- <table class="p-4 border-t-2">
29- <tbody>
30- {{
31- range.Images
32- }}
33- <tr id="img-{{.ShortID}}" hx-ext="response-targets" class=" hover:bg-slate-100">
34- <td>
35- <span title="{{.ShortID}}">{{.Name}}</span>
36- </td>
37- <td>
38- <span title="Image creation date">{{.CreatedStr}}</span>
39- </td>
40- <td>{{.SizeStr}}</td>
41- <td align="right">
42-
43- <button
44- id="add-button-img-{{.ShortID}}"
45- hx-get="/htmx/create-container/{{.ShortID}}"
46- hx-target="#create-container-{{.ShortID}}"
47- hx-target-*="#error-message-div-{{.ShortID}}"
48- hx-swap="innerHTML"
49- class="py-2 px-4"
50- >
51- <span title="Deploy a container using this image" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue">󰆍</span>
52- </button>
5325
54- <button
55- id="remove-button-img-{{.ShortID}}"
56- hx-delete="/htmx/image-manager/delete/{{.ShortID}}"
57- hx-target="#img-{{.ShortID}}"
58- hx-target-*="#error-message-div-{{.ShortID}}"
59- hx-swap="innerHTML"
60- hx-confirm="Are you sure you want to delete this container image?"
61- class="py-2 px-4"
62- >
63- <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span>
64- </button>
65- </td>
66- </tr>
67- <t>
68- <td colspan="4">
69- <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div>
70- <div class="create-container" id="create-container-{{.ShortID}}"></div>
71- </td>
72- </tr>
73- {{ else }}
74- <tr>
75- <td colspan="4" class="mt-2">No images found.</td>
76- </tr>
77- {{
78- end
79- }}
80- </tbody>
81- </table>
26+ <!-- Image list -->
27+ <table class="table">
28+ <!-- head -->
29+ <thead>
30+ <tr>
31+ <th>Repository</th>
32+ <th>Tag</th>
33+ <th>Size</th>
34+ <th>Created</th>
35+ <th>Actions</th>
36+ </tr>
37+ </thead>
38+ <tbody>
39+ {{ if .NoImage }}
40+ <tr>
41+ <td colspan="5" class="text-center py-4">
42+ <div class="alert alert-info">
43+ <span class="iconf">ℹ️</span>
44+ <span>No images found. Upload an image first.</span>
45+ </div>
46+ </td>
47+ </tr>
48+ {{ else }}
49+ {{ range .Images }}
50+ <tr class="hover">
51+ <td>
52+ <div class="flex items-center gap-3">
53+ <div>
54+ <div class="font-bold">{{ .Repository }}</div>
55+ <div class="text-sm opacity-50">{{ .ID }}</div>
56+ </div>
57+ </div>
58+ </td>
59+ <td>{{ .Tag }}</td>
60+ <td>{{ .Size }}</td>
61+ <td>{{ .Created }}</td>
62+ <th>
63+ <div class="dropdown dropdown-end">
64+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
65+ <span class="iconf">󰅀</span>
66+ </div>
67+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
68+ <li>
69+ <a
70+ href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}"
71+ class="text-success flex items-center">
72+ <span class="iconf mr-2">󰐕</span> Create Container
73+ </a>
74+ </li>
75+ <li>
76+ <button
77+ class="text-error flex items-center"
78+ hx-delete="/htmx/image-manager/delete/{{ .ID }}"
79+ hx-target="#image-manager"
80+ hx-swap="innerHTML"
81+ hx-confirm="Are you sure you want to delete this image?">
82+ <span class="iconf mr-2">󰆴</span> Delete
83+ </button>
84+ </li>
85+ </ul>
86+ </div>
87+ </th>
88+ </tr>
89+ {{ end }}
90+ {{ end }}
91+ </tbody>
92+ </table>
93+ </div>
8294 </div>
95+
96+<script>
97+console.debug('Image list rendered');
98+</script>
M internal/templating/models/html/fragments/menu.gohtml +61 -39
@@ -1,45 +1,67 @@
1-<div
2- id="menubar"
3- class="w-full h-12 md:h-16 fixed top-0 left-0 bg-gordonmascot-lblue flex flex-row justify-evenly items-center px-4 IBMPlexSans">
4- <div class="w-full flex justify-start">
5- <h1
6- class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter">
7- Gordon
8- </h1>
9- <span
10- id="actual-version"
11- class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF">
12- {{ .BuildVersion }}
13- </span>
14- <span
15- hidden
16- id="update-available"
17- class="text-green-300 text-xs md:text-sm JetBrainsMonoNF hidden"
18- title=""
19- ><a href="https://github.com/bnema/gordon/releases">󰨦</a>
20- </span>
1+<!-- DaisyUI Navbar -->
2+<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3+ <div class="navbar-start">
4+ <div class="flex items-center">
5+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6+ Gordon
7+ </h1>
8+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9+ {{ .BuildVersion }}
10+ </span>
11+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13+ </span>
14+ </div>
2115 </div>
22- <div class="w-full">
23- <ul
24- class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center">
25- <li class="">
26- <span class="JetBrainsMonoBoldNF pr-1"> </span>
27- <a href="{{ .AdminPath }}/manager">Manager</a>
16+ <div class="navbar-center hidden lg:flex">
17+ <ul class="menu menu-horizontal px-1">
18+ <li>
19+ <a href="{{ .AdminPath }}/manager" class="font-semibold">
20+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
21+ </a>
2822 </li>
2923 </ul>
3024 </div>
31- <div class="w-full flex space-x-4 justify-end">
32- <a
33- href="{{ .UserSettingsURL }}"
34- title="User settings"
35- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
36- ></a
37- >
38- <a
39- href="{{ .LogoutURL }}"
40- title="Logout"
41- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
42- >󰍃</a
43- >
25+ <div class="navbar-end">
26+ <!-- Theme toggle -->
27+ <div class="dropdown dropdown-end">
28+ <label tabindex="0" class="btn btn-ghost btn-circle">
29+ <span class="iconf text-lg md:text-xl">󰔎</span>
30+ </label>
31+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
32+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
33+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
34+ <li><button onclick="setTheme('light')">Light</button></li>
35+ <li><button onclick="setTheme('dark')">Dark</button></li>
36+ </ul>
37+ </div>
38+
39+ <!-- User menu -->
40+ <div class="dropdown dropdown-end">
41+ <label tabindex="0" class="btn btn-ghost btn-circle">
42+ <span class="iconf text-lg md:text-xl">󰀉</span>
43+ </label>
44+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
45+ <li>
46+ <a href="{{ .UserSettingsURL }}" title="User settings">
47+ <span class="iconf">󰒓</span> Settings
48+ </a>
49+ </li>
50+ <li>
51+ <a href="{{ .LogoutURL }}" title="Logout">
52+ <span class="iconf">󰍃</span> Logout
53+ </a>
54+ </li>
55+ </ul>
56+ </div>
4457 </div>
4558 </div>
59+
60+<!-- Theme toggle script -->
61+<script>
62+ function setTheme(theme) {
63+ document.documentElement.setAttribute('data-theme', theme);
64+ localStorage.setItem('theme', theme);
65+ console.debug('Theme changed to:', theme);
66+ }
67+</script>
M internal/templating/models/html/fragments/success.gohtml +1 -1
@@ -1,5 +1,5 @@
11 <div
22 id="success"
3- class="flex text-blue-800 content-center justify-center items-center w-full h-full">
3+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
44 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
55 </div>
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
@@ -1,35 +1,61 @@
1-<!-- Form to upload a container image (.tar format) -->
2-<div
3- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"
4- hx-ext="response-targets"
5- hx-boost="true">
6- <div class="flex-grow bg-slate-100 p-4 rounded-md">
7- <h2>Upload Image Containers</h2>
8- <!-- Upload Form -->
1+<!-- Upload image form using DaisyUI components -->
2+<div class="card bg-base-100 shadow">
3+ <div class="card-body">
4+ <div class="flex justify-between items-center">
5+ <h2 class="card-title text-primary">Upload Image</h2>
6+ <button
7+ class="btn btn-sm btn-circle btn-ghost"
8+ hx-get="/htmx/image-manager"
9+ hx-target="#image-manager"
10+ hx-swap="innerHTML">
11+ <span class="iconf text-lg">✕</span>
12+ </button>
13+ </div>
14+ <div class="divider"></div>
15+
916 <form
10- action="/upload"
11- method="post"
12- enctype="multipart/form-data"
17+ id="upload-form"
18+ hx-encoding="multipart/form-data"
1319 hx-post="/htmx/upload-image"
14- hx-swap="innerHTML"
1520 hx-target="#image-manager"
16- hx-target-*="#error-message-div">
17- <div
18- class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4"
19- hx-drop="true">
20- Drag & Drop your file here or
21- <input
22- type="file"
23- name="file"
21+ hx-swap="innerHTML"
22+ class="space-y-4">
23+
24+ <div class="alert alert-info shadow-lg mb-4">
25+ <span class="iconf">ℹ️</span>
26+ <div>
27+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
28+ <p>After uploading, you can create and manage containers from this image.</p>
29+ </div>
30+ </div>
31+
32+ <div class="form-control w-full">
33+ <label class="label">
34+ <span class="label-text">Select a container image file (.tar)</span>
35+ </label>
36+ <input
37+ type="file"
38+ name="imageFile"
39+ class="file-input file-input-bordered w-full"
2440 accept=".tar"
25- class="bg-transparent outline-none" />
41+ required />
42+ </div>
43+
44+ <div class="card-actions justify-end mt-4">
45+ <button
46+ class="btn btn-ghost"
47+ hx-get="/htmx/image-manager"
48+ hx-target="#image-manager"
49+ hx-swap="innerHTML"
50+ type="button">
51+ Cancel
52+ </button>
53+ <button
54+ class="btn btn-primary"
55+ type="submit">
56+ <span class="iconf mr-1">󰦒</span> Upload
57+ </button>
2658 </div>
27- <input
28- type="submit"
29- value="Upload"
30- id="upload-submit-button"
31- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
3259 </form>
33- <div id="error-message-div" class="text-red-500"></div>
3460 </div>
3561 </div>
M internal/templating/models/html/login/index.gohtml +79 -63
@@ -1,5 +1,5 @@
11 <!doctype html>
2-<html lang="en">
2+<html lang="en" data-theme="corporate">
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,95 +12,111 @@
1212 <script src="/libs/htmx.min.js"></script>
1313 <!-- HTMX SSE -->
1414 <script src="/libs/htmx-sse.js"></script>
15+ <!-- DaisyUI -->
16+ <script src="/libs/daisyui.js"></script>
1517 <!-- Custom JS from assets/js -->
1618 <script src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'corporate';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+ console.debug('Theme loaded:', savedTheme);
26+ </script>
1727 </head>
1828
19- <body class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center">
29+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
2030 <!-- Logo and Title -->
2131 <div
2232 id="logo-mascot"
2333 class="flex flex-col justify-center items-center p-4 mb-4">
24- <div class="circle-shadow flex justify-center w-100">
34+ <div class="flex justify-center w-100">
2535 <img
2636 src="/assets/imgs/gordon-mascot-mq-trsp.png"
2737 alt="logo gordon"
28- class="sm:h-24 h-32 xl:h-48" />
38+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
2939 </div>
3040 <div class="flex justify-center mt-4">
3141 <h1
32- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter">
42+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
3343 Gordon
3444 </h1>
3545 <span
36- class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"
46+ class="gordon-v-subtitle text-secondary text-sm ml-2"
3747 >{{ .BuildVersion }}</span
3848 >
3949 </div>
4050 </div>
4151
4252 <!-- Main Content -->
43- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
44- <!-- Login Section -->
45- <div id="login-section">
46- <div class="flex flex-col items-center">
47- <h2 class="text-2xl font-semibold text-gray-800">Login</h2>
48-
49- <!-- Error Message Display -->
50- {{ if .Error }}
51- <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
52- <span class="block sm:inline">
53- {{ if eq .Error "empty_token" }}
54- Please enter a token.
55- {{ else if eq .Error "invalid_token" }}
56- Invalid token. Please try again.
57- {{ else if eq .Error "server_error" }}
58- A server error occurred. Please try again later.
59- {{ else }}
60- An error occurred. Please try again.
61- {{ end }}
62- </span>
63- </div>
64- {{ end }}
65-
66- <!-- Token Input Form -->
67- <div class="mt-4 w-full max-w-md">
68- <form action="login/submit-token" method="POST" class="mb-4">
69- <div class="mb-2">
70- <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label>
71- <input
72- type="text"
73- id="token"
74- name="token"
75- placeholder="Enter your admin token"
76- class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue"
77- required
78- />
79- </div>
80- <button
81- type="submit"
82- class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
53+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
54+ <div class="card-body">
55+ <!-- Login Section -->
56+ <div id="login-section">
57+ <div class="flex flex-col items-center">
58+ <h2 class="card-title text-center mb-4">Login</h2>
59+
60+ <!-- Error Message Display -->
61+ {{ if .Error }}
62+ <div class="alert alert-error mb-4">
63+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
64+ <span>
65+ {{ if eq .Error "empty_token" }}
66+ Please enter a token.
67+ {{ else if eq .Error "invalid_token" }}
68+ Invalid token. Please try again.
69+ {{ else if eq .Error "server_error" }}
70+ A server error occurred. Please try again later.
71+ {{ else }}
72+ An error occurred. Please try again.
73+ {{ end }}
74+ </span>
75+ </div>
76+ {{ end }}
77+
78+ <!-- Token Input Form -->
79+ <div class="w-full">
80+ <form action="login/submit-token" method="POST" class="mb-4">
81+ <div class="form-control mb-4">
82+ <label for="token" class="label">
83+ <span class="label-text">Admin Token</span>
84+ </label>
85+ <input
86+ type="text"
87+ id="token"
88+ name="token"
89+ placeholder="Enter your admin token"
90+ class="input input-bordered w-full"
91+ required
92+ />
93+ </div>
94+ <button
95+ type="submit"
96+ class="btn btn-primary w-full"
97+ >
98+ Continue with Token
99+ </button>
100+ </form>
101+ <div class="divider">OR</div>
102+ </div>
103+
104+ <!-- GitHub OAuth -->
105+ <div class="w-full">
106+ <a
107+ href="login/oauth/github"
108+ class="btn btn-secondary w-full"
83109 >
84- Continue with Token
85- </button>
86- </form>
87- <div class="text-center text-sm text-gray-500 mb-4">- OR -</div>
88- </div>
89-
90- <!-- GitHub OAuth -->
91- <div
92- class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left">
93- <a href="login/oauth/github" class="flex items-center">
94- <img
95- src="/assets/imgs/github/github-mark-white.png"
96- alt="logo github"
97- class="h-8 xl:h-12" />
98- <span class="w-full p-2 text-white">Login with GitHub</span>
99- </a>
110+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
111+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
112+ </svg>
113+ Sign in with GitHub
114+ </a>
115+ </div>
100116 </div>
101117 </div>
118+ <!-- End Login Section -->
102119 </div>
103- <!-- End Login Section -->
104120 </div>
105121 </body>
106122 </html>
A internal/templating/models/templ/README.md +48 -0
@@ -0,0 +1,48 @@
1+# Templ Templates for Gordon
2+
3+This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability.
4+
5+## Directory Structure
6+
7+- `components/` - Reusable UI components
8+- `layouts/` - Page layouts and base templates
9+- `pages/` - Full page templates
10+ - `admin/` - Admin pages
11+ - `login/` - Login pages
12+
13+## Working with Templates
14+
15+To create or modify templates:
16+
17+1. Edit the `.templ` files in the appropriate directory
18+2. Run `templ generate` or `make build-templ` to generate Go code
19+3. Use the templates in your handlers with the `TemplRenderer`
20+
21+## Converting from gohtml
22+
23+When converting from gohtml:
24+
25+1. Create a new `.templ` file
26+2. Copy HTML structure from gohtml
27+3. Replace `{{ .varName }}` with `{ varName }`
28+4. Use templ's control flow syntax like `if`, `for`, etc.
29+5. Use `@` to invoke other components
30+
31+## Example:
32+
33+```templ
34+package components
35+
36+templ Button(label string, classes string) {
37+ <button class={classes}>{ label }</button>
38+}
39+```
40+
41+## Usage in Handlers
42+
43+```go
44+func RenderMyPage(c echo.Context, a *server.App) error {
45+ renderer := render.NewTemplRenderer(a)
46+ return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData))
47+}
48+```⏎̸
A internal/templating/models/templ/components/containerlist.templ +132 -0
@@ -0,0 +1,132 @@
1+package components
2+
3+import (
4+ "github.com/docker/docker/api/types"
5+ "strconv"
6+)
7+
8+// ContainerList displays a list of containers
9+templ ContainerList(containers []types.Container, noContainer bool) {
10+ <!-- Container list using daisyUI components -->
11+ <div class="card w-full bg-base-300">
12+ <div class="card-body p-4">
13+
14+ <!-- Action buttons -->
15+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
16+ <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3>
17+ <div class="btn-group">
18+ <button
19+ class="btn btn-sm btn-primary"
20+ hx-get="/htmx/container-manager"
21+ hx-target="#container-manager"
22+ hx-swap="innerHTML">
23+ <span class="iconf">󰑓</span> Refresh
24+ </button>
25+ </div>
26+ </div>
27+
28+ <!-- Container list -->
29+ <table class="table">
30+ <!-- head -->
31+ <thead>
32+ <tr>
33+ <th>Name</th>
34+ <th>Image</th>
35+ <th>Status</th>
36+ <th>Created</th>
37+ <th>Actions</th>
38+ </tr>
39+ </thead>
40+ <tbody>
41+ if noContainer || len(containers) == 0 {
42+ <tr>
43+ <td colspan="5" class="text-center py-4">
44+ <div class="alert alert-info">
45+ <span class="iconf">ℹ️</span>
46+ <span>No containers found. Upload an image first, then create a container.</span>
47+ </div>
48+ </td>
49+ </tr>
50+ } else {
51+ for _, container := range containers {
52+ <tr class="hover">
53+ <td>
54+ <div class="flex items-center gap-3">
55+ <div>
56+ <div class="font-bold">{ container.Names[0] }</div>
57+ <div class="text-sm opacity-50">{ container.ID }</div>
58+ </div>
59+ </div>
60+ </td>
61+ <td>{ container.Image }</td>
62+ <td>
63+ if container.State == "running" {
64+ <div class="badge badge-success badge-sm">Running</div>
65+ } else if container.State == "exited" {
66+ <div class="badge badge-warning badge-sm">Stopped</div>
67+ } else {
68+ <div class="badge badge-ghost badge-sm">{ container.State }</div>
69+ }
70+ </td>
71+ <td>{ strconv.FormatInt(container.Created, 10) }</td>
72+ <th>
73+ <div class="dropdown dropdown-end">
74+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
75+ <span class="iconf">󰅀</span>
76+ </div>
77+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
78+ if container.State == "running" {
79+ <li>
80+ <button
81+ class="text-warning flex items-center"
82+ hx-post={ "/htmx/container-manager/stop/" + container.ID }
83+ hx-target="#container-manager"
84+ hx-swap="innerHTML">
85+ <span class="iconf mr-2">󰓛</span> Stop
86+ </button>
87+ </li>
88+ } else if container.State == "exited" {
89+ <li>
90+ <button
91+ class="text-success flex items-center"
92+ hx-post={ "/htmx/container-manager/start/" + container.ID }
93+ hx-target="#container-manager"
94+ hx-swap="innerHTML">
95+ <span class="iconf mr-2">󰐊</span> Start
96+ </button>
97+ </li>
98+ }
99+ <li>
100+ <button
101+ class="text-info flex items-center"
102+ hx-get={ "/htmx/container-manager/edit/" + container.ID }
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML">
105+ <span class="iconf mr-2">󰏫</span> Edit
106+ </button>
107+ </li>
108+ <li>
109+ <button
110+ class="text-error flex items-center"
111+ hx-delete={ "/htmx/container-manager/delete/" + container.ID }
112+ hx-target="#container-manager"
113+ hx-swap="innerHTML"
114+ hx-confirm="Are you sure you want to delete this container?">
115+ <span class="iconf mr-2">󰆴</span> Delete
116+ </button>
117+ </li>
118+ </ul>
119+ </div>
120+ </th>
121+ </tr>
122+ }
123+ }
124+ </tbody>
125+ </table>
126+ </div>
127+ </div>
128+
129+ <script>
130+ console.debug('Container list rendered');
131+ </script>
132+}⏎̸
A internal/templating/models/templ/components/containerlist_templ.go +228 -0 generated file: collapsed
A internal/templating/models/templ/components/createcontainer.templ +160 -0
@@ -0,0 +1,160 @@
1+package components
2+
3+// ContainerFormData holds the data needed for the container creation form
4+type ContainerFormData struct {
5+ ImageName string
6+ ImageID string
7+ ShortID string
8+ ContainerName string
9+ ContainerSubdomain string
10+ ContainerDomain string
11+ Ports string
12+ ProxyPort string
13+ Volumes string
14+ EnvironmentVariables string
15+ ErrorMessage string
16+}
17+
18+// CreateContainerForm displays a form for creating a new container
19+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">
21+ <!-- Left Side: Docker Run Options -->
22+ <div class="flex-grow card p-6">
23+ <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
24+ if data.ErrorMessage != "" {
25+ <div class="alert alert-error">
26+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
27+ <span>{ data.ErrorMessage }</span>
28+ </div>
29+ }
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+ hx-swap="innerHTML"
33+ hx-target={ "#create-container-" + data.ShortID }
34+ hx-target-*={ "#error-message-create-container-" + data.ShortID }>
35+ <!-- Image Name -->
36+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
37+ <div class="md:col-span-1">
38+ <label class="label">
39+ <span class="label-text">Image name</span>
40+ </label>
41+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
42+ value={ data.ImageName } readonly />
43+ </div>
44+
45+ <div class="md:col-span-2">
46+ <label class="label">
47+ <span class="label-text">Image ID</span>
48+ </label>
49+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
50+ value={ data.ImageID } readonly />
51+ </div>
52+ </div>
53+
54+ <!-- Container Name -->
55+ <div class="form-control pb-4">
56+ <label class="label">
57+ <span class="label-text">Container name</span>
58+ </label>
59+ <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } />
60+ </div>
61+
62+ <!-- Http(s) / Subdomain / Domain -->
63+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
64+ <!-- Protocol -->
65+ <div class="md:col-span-3">
66+ <label class="label">
67+ <span class="label-text">Protocol</span>
68+ </label>
69+ <select name="container_protocol" class="select select-bordered w-full">
70+ <option value="https">https</option>
71+ <option value="http">http</option>
72+ </select>
73+ </div>
74+
75+ <!-- Subdomain -->
76+ <div class="md:col-span-4">
77+ <label class="label">
78+ <span class="label-text">Subdomain</span>
79+ </label>
80+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } />
81+ </div>
82+
83+ <!-- Domain -->
84+ <div class="md:col-span-5">
85+ <label class="label">
86+ <span class="label-text">Domain</span>
87+ </label>
88+ <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } />
89+ </div>
90+ </div>
91+
92+ <!-- Exposed Ports -->
93+ <div class="form-control pb-4">
94+ <label class="label">
95+ <span class="label-text">Exposed Ports (Optional)</span>
96+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
97+ </label>
98+ <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } />
99+ </div>
100+
101+ <!-- Proxy Port -->
102+ <div class="form-control pb-4">
103+ <label class="label">
104+ <span class="label-text">Container Proxy Port (Required)</span>
105+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
106+ </label>
107+ <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required />
108+ </div>
109+
110+ <!-- Volumes -->
111+ <div class="form-control pb-4">
112+ <label class="label">
113+ <span class="label-text">Volumes</span>
114+ <span class="label-text-alt">e.g. ./data:/container/data</span>
115+ </label>
116+ <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } />
117+ </div>
118+
119+ <div class="form-control pb-4">
120+ <label class="label">
121+ <span class="label-text">Environment variables</span>
122+ <span class="label-text-alt">e.g. KEY=VALUE</span>
123+ </label>
124+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
125+ rows="4">{ data.EnvironmentVariables }</textarea>
126+ </div>
127+
128+ <!-- Restart: always/on-failure/only when stopped/never -->
129+ <div class="form-control pb-4">
130+ <label class="label">
131+ <span class="label-text">Restart</span>
132+ </label>
133+ <select name="restart" class="select select-bordered w-full">
134+ <option value="always">Always</option>
135+ <option value="on-failure">On failure</option>
136+ <option value="unless-stopped">Unless stopped</option>
137+ <option value="never">Never</option>
138+ </select>
139+ </div>
140+
141+ <!-- Skip Proxy Setup Option -->
142+ <div class="form-control pb-4">
143+ <label class="label cursor-pointer justify-start gap-4">
144+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
145+ <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
146+ </label>
147+ <div class="text-sm opacity-70 ml-10">
148+ Use this option if you only want to create the container without triggering proxy setup,
149+ certificate generation, and other related processes.
150+ </div>
151+ </div>
152+
153+ <button type="submit" id="create-container-submit" class="btn btn-primary">
154+ Create Container
155+ </button>
156+ </form>
157+ <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div>
158+ </div>
159+ </div>
160+} ⏎̸
A internal/templating/models/templ/components/createcontainer_templ.go +283 -0 generated file: collapsed
A internal/templating/models/templ/components/editcontainer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+import t "github.com/a-h/templ"
4+
5+// EditContainer replaces editcontainer.gohtml
6+// It renders a form to edit a container's configuration
7+templ EditContainer(id string, containerInfo string) {
8+ <div
9+ id={ "edit-container-" + id }
10+ class="card bg-base-100 shadow-xl"
11+ hx-ext="response-targets"
12+ >
13+ <div id="editor" class="card-body">
14+ <form
15+ method="post"
16+ action={ t.URL("/htmx/container-manager/edit/" + id) }
17+ hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) }
18+ hx-swap="innerHTML"
19+ hx-target-*={ "#error-message-edit-container-" + id }
20+ >
21+ <textarea
22+ name="container_config"
23+ id="container_config"
24+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
25+ rows="20"
26+ >{ containerInfo }</textarea>
27+ <div class="card-actions justify-end mt-4">
28+ <input
29+ type="submit"
30+ value="Save"
31+ id={ "save-button-" + id }
32+ class="btn btn-primary"
33+ />
34+ </div>
35+ </form>
36+ <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div>
37+ </div>
38+ </div>
39+} ⏎̸
A internal/templating/models/templ/components/editcontainer_templ.go +131 -0 generated file: collapsed
A internal/templating/models/templ/components/footer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+// Footer is a templ component for the page footer
4+// It replaces footer.gohtml
5+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">
27+ <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>
30+ </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>
37+ </div>
38+ </footer>
39+}⏎̸
A internal/templating/models/templ/components/footer_templ.go +42 -0 generated file: collapsed
A internal/templating/models/templ/components/header.templ +30 -0
@@ -0,0 +1,30 @@
1+package components
2+
3+// Header is a templ component for the page header
4+// It replaces header.gohtml
5+templ Header(title string) {
6+ <head>
7+ <meta charset="UTF-8" />
8+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+ <title>{ title }</title>
10+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
11+
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 defer src="/libs/hyperscript.min.js"></script>
18+ <script defer src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+
26+ // Log theme loading for debugging
27+ console.debug('Theme loaded:', savedTheme);
28+ </script>
29+ </head>
30+}⏎̸
A internal/templating/models/templ/components/header_templ.go +55 -0 generated file: collapsed
A internal/templating/models/templ/components/htmx_components.templ +80 -0
@@ -0,0 +1,80 @@
1+package components
2+
3+// HTMXHeader creates the hero header with HTMX attributes
4+templ HTMXHeader(title string, subtitle string) {
5+ <div id="header" class="hero bg-base-200">
6+ <div class="hero-content text-center">
7+ <div class="max-w-md">
8+ <h1 class="text-5xl font-bold gordon-title">
9+ { title }
10+ </h1>
11+ <p class="py-6 IBMPlexSans">{ subtitle }</p>
12+ <button class="btn btn-primary">Get Started</button>
13+ </div>
14+ </div>
15+ </div>
16+}
17+
18+// HTMXAlert creates an alert box that can be loaded with HTMX
19+templ HTMXAlert(message string) {
20+ <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
21+ <div>
22+ <span class="iconfb">󰋽</span>
23+ <span class="IBMPlexSans">{ message }</span>
24+ </div>
25+ </div>
26+}
27+
28+// HTMXHeaderContainer creates a container to load the HTMX header
29+templ HTMXHeaderContainer() {
30+ <div
31+ id="header"
32+ hx-get="/htmx"
33+ hx-headers='{"HX-Fragment": "header"}'
34+ hx-trigger="load"
35+ hx-swap="outerHTML"
36+ ></div>
37+}
38+
39+// HTMXAppContainer creates a container to load the HTMX app
40+templ HTMXAppContainer() {
41+ <div
42+ id="app"
43+ hx-get="/htmx"
44+ hx-headers='{"HX-Fragment": "hello"}'
45+ hx-trigger="load"
46+ hx-swap="outerHTML"
47+ ></div>
48+}
49+
50+// StatsCard creates individual stat cards for the dashboard
51+templ StatsCard(title string, value string, description string, icon string) {
52+ <div class="stat bg-base-100 shadow">
53+ <div class="stat-figure text-primary">
54+ <span class="iconfb text-3xl">{ icon }</span>
55+ </div>
56+ <div class="stat-title">{ title }</div>
57+ <div class="stat-value">{ value }</div>
58+ <div class="stat-desc">{ description }</div>
59+ </div>
60+}
61+
62+// StatsCards creates a set of stats cards
63+templ StatsCards() {
64+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
65+ @StatsCard("Containers", "31", "Running containers", "󰡨")
66+ @StatsCard("Images", "42", "Available images", "󱧕")
67+ @StatsCard("Networks", "12", "Container networks", "󰙅")
68+ </div>
69+}
70+
71+// TabsNav creates a navigation tabs component
72+templ TabsNav(activeTab string) {
73+ <div class="tabs tabs-boxed my-4">
74+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a>
75+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a>
76+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a>
77+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a>
78+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a>
79+ </div>
80+}⏎̸
A internal/templating/models/templ/components/htmx_components_templ.go +438 -0 generated file: collapsed
A internal/templating/models/templ/components/imagelist.templ +122 -0
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strconv"
5+)
6+
7+// HumanReadableImage represents an image with human-readable attributes
8+type HumanReadableImage struct {
9+ ID string
10+ ShortID string
11+ Name string
12+ Repository string
13+ Tag string
14+ Size string
15+ Created string
16+ RepoDigests []string
17+ RepoTags []string
18+}
19+
20+// ImageList displays a list of images
21+templ ImageList(images []HumanReadableImage) {
22+ <!-- Image list using daisyUI components -->
23+ <div class="card w-full bg-base-300">
24+ <div class="card-body p-4">
25+
26+ <!-- Action buttons -->
27+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
28+ <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
29+ <div class="flex gap-2">
30+ <button
31+ class="btn btn-sm btn-secondary"
32+ hx-get="/htmx/upload-image"
33+ hx-target="#image-manager"
34+ hx-swap="innerHTML">
35+ <span class="iconf">󰦒</span> Upload
36+ </button>
37+ <button
38+ class="btn btn-sm btn-primary"
39+ hx-get="/htmx/image-manager"
40+ hx-target="#image-manager"
41+ hx-swap="innerHTML">
42+ <span class="iconf">󰑓</span> Refresh
43+ </button>
44+ </div>
45+ </div>
46+
47+ <!-- Image list -->
48+ <table class="table">
49+ <!-- head -->
50+ <thead>
51+ <tr>
52+ <th>Repository</th>
53+ <th>Tag</th>
54+ <th>Size</th>
55+ <th>Created</th>
56+ <th>Actions</th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ /* Check if there are no images */
61+ if len(images) == 0 {
62+ <tr>
63+ <td colspan="5" class="text-center py-4">
64+ <div class="alert alert-info">
65+ <span class="iconf">ℹ️</span>
66+ <span>No images found. Upload an image first.</span>
67+ </div>
68+ </td>
69+ </tr>
70+ } else {
71+ /* List all available images */
72+ for _, img := range images {
73+ <tr class="hover">
74+ <td>
75+ <div class="flex items-center gap-3">
76+ <div>
77+ <div class="font-bold">{ img.Repository }</div>
78+ <div class="text-sm opacity-50">{ img.ID }</div>
79+ </div>
80+ </div>
81+ </td>
82+ <td>{ img.Tag }</td>
83+ <td>{ img.Size }</td>
84+ <td>{ img.Created }</td>
85+ <th>
86+ <div class="dropdown dropdown-end">
87+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
88+ <span class="iconf">󰅀</span>
89+ </div>
90+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
91+ <li>
92+ <a
93+ href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) }
94+ class="text-success flex items-center">
95+ <span class="iconf mr-2">󰐕</span> Create Container
96+ </a>
97+ </li>
98+ <li>
99+ <button
100+ class="text-error flex items-center"
101+ hx-delete={ "/htmx/image-manager/delete/" + img.ShortID }
102+ hx-target="#image-manager"
103+ hx-swap="innerHTML"
104+ hx-confirm="Are you sure you want to delete this image?">
105+ <span class="iconf mr-2">󰆴</span> Delete
106+ </button>
107+ </li>
108+ </ul>
109+ </div>
110+ </th>
111+ </tr>
112+ }
113+ }
114+ </tbody>
115+ </table>
116+ </div>
117+ </div>
118+
119+ <script>
120+ console.debug('Image list rendered');
121+ </script>
122+} ⏎̸
A internal/templating/models/templ/components/imagelist_templ.go +175 -0 generated file: collapsed
A internal/templating/models/templ/components/navbar.templ +72 -0
@@ -0,0 +1,72 @@
1+package components
2+
3+// Navbar is a menu component for navigation
4+templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
5+ <!-- DaisyUI Navbar -->
6+ <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
7+ <div class="navbar-start">
8+ <div class="flex items-center">
9+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
10+ Gordon
11+ </h1>
12+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
13+ { buildVersion }
14+ </span>
15+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
16+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
17+ </span>
18+ </div>
19+ </div>
20+ <div class="navbar-center hidden lg:flex">
21+ <ul class="menu menu-horizontal px-1">
22+ <li>
23+ <a href={ templ.URL(adminPath + "/manager") } class="font-semibold">
24+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
25+ </a>
26+ </li>
27+ </ul>
28+ </div>
29+ <div class="navbar-end">
30+ <!-- Theme toggle -->
31+ <div class="dropdown dropdown-end">
32+ <label tabindex="0" class="btn btn-ghost btn-circle">
33+ <span class="iconf text-lg md:text-xl">󰔎</span>
34+ </label>
35+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
36+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
37+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
38+ <li><button onclick="setTheme('light')">Light</button></li>
39+ <li><button onclick="setTheme('dark')">Dark</button></li>
40+ </ul>
41+ </div>
42+
43+ <!-- User menu -->
44+ <div class="dropdown dropdown-end">
45+ <label tabindex="0" class="btn btn-ghost btn-circle">
46+ <span class="iconf text-lg md:text-xl">󰀉</span>
47+ </label>
48+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
49+ <li>
50+ <a href={ templ.URL(userSettingsURL) } title="User settings">
51+ <span class="iconf">󰒓</span> Settings
52+ </a>
53+ </li>
54+ <li>
55+ <a href={ templ.URL(logoutURL) } title="Logout">
56+ <span class="iconf">󰍃</span> Logout
57+ </a>
58+ </li>
59+ </ul>
60+ </div>
61+ </div>
62+ </div>
63+
64+ <!-- Theme toggle script -->
65+ <script>
66+ function setTheme(theme) {
67+ document.documentElement.setAttribute('data-theme', theme);
68+ localStorage.setItem('theme', theme);
69+ console.debug('Theme changed to:', theme);
70+ }
71+ </script>
72+}⏎̸
A internal/templating/models/templ/components/navbar_templ.go +81 -0 generated file: collapsed
A internal/templating/models/templ/components/success.templ +16 -0
@@ -0,0 +1,16 @@
1+package components
2+
3+// Success displays a success message
4+templ Success(message string) {
5+ <div
6+ id="success"
7+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
8+ <p class="text-2xl text-center">
9+ if message != "" {
10+ { message }
11+ } else {
12+ Success!
13+ }
14+ </p>
15+ </div>
16+}⏎̸
A internal/templating/models/templ/components/success_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/components/uploadimage.templ +66 -0
@@ -0,0 +1,66 @@
1+package components
2+
3+// UploadImageForm displays a form for uploading Docker images
4+templ UploadImageForm() {
5+ <!-- Upload image form using DaisyUI components -->
6+ <div class="card bg-base-100 shadow">
7+ <div class="card-body">
8+ <div class="flex justify-between items-center">
9+ <h2 class="card-title text-primary">Upload Image</h2>
10+ <button
11+ class="btn btn-sm btn-circle btn-ghost"
12+ hx-get="/htmx/image-manager"
13+ hx-target="#image-manager"
14+ hx-swap="innerHTML">
15+ <span class="iconf text-lg">✕</span>
16+ </button>
17+ </div>
18+ <div class="divider"></div>
19+
20+ <form
21+ id="upload-form"
22+ hx-encoding="multipart/form-data"
23+ hx-post="/htmx/upload-image"
24+ hx-target="#image-manager"
25+ hx-swap="innerHTML"
26+ class="space-y-4">
27+
28+ <div class="alert alert-info shadow-lg mb-4">
29+ <span class="iconf">ℹ️</span>
30+ <div>
31+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
32+ <p>After uploading, you can create and manage containers from this image.</p>
33+ </div>
34+ </div>
35+
36+ <div class="form-control w-full">
37+ <label class="label">
38+ <span class="label-text">Select a container image file (.tar)</span>
39+ </label>
40+ <input
41+ type="file"
42+ name="imageFile"
43+ class="file-input file-input-bordered w-full"
44+ accept=".tar"
45+ required />
46+ </div>
47+
48+ <div class="card-actions justify-end mt-4">
49+ <button
50+ class="btn btn-ghost"
51+ hx-get="/htmx/image-manager"
52+ hx-target="#image-manager"
53+ hx-swap="innerHTML"
54+ type="button">
55+ Cancel
56+ </button>
57+ <button
58+ class="btn btn-primary"
59+ type="submit">
60+ <span class="iconf mr-1">󰦒</span> Upload
61+ </button>
62+ </div>
63+ </form>
64+ </div>
65+ </div>
66+} ⏎̸
A internal/templating/models/templ/components/uploadimage_templ.go +41 -0 generated file: collapsed
A internal/templating/models/templ/layouts/admin_layout.templ +21 -0
@@ -0,0 +1,21 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// AdminLayout provides the base layout for admin pages
6+templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ <!DOCTYPE html>
8+ <html lang="en" data-theme="gordon-dark">
9+ @components.Header(title)
10+ <body class="min-h-screen bg-base-100">
11+ @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL)
12+
13+ <!-- Main content starts after navbar height -->
14+ <div class="container mx-auto pt-20 px-4">
15+ { children... }
16+ </div>
17+
18+ @components.Footer()
19+ </body>
20+ </html>
21+}⏎̸
A internal/templating/models/templ/layouts/admin_layout_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/layouts/base.templ +16 -0
@@ -0,0 +1,16 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// Base layout template that provides the HTML structure
6+// All pages will use this layout template
7+templ Base(title string) {
8+ <!DOCTYPE html>
9+ <html lang="en">
10+ @components.Header(title)
11+ <body class="min-h-screen bg-base-100">
12+ { children... }
13+ @components.Footer()
14+ </body>
15+ </html>
16+}⏎̸
A internal/templating/models/templ/layouts/base_templ.go +64 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/createcontainerfull.templ +30 -0
@@ -0,0 +1,30 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+)
7+
8+// CreateContainerFullData represents the data needed for the create container full page
9+type CreateContainerFullData struct {
10+ Title string
11+ BuildVersion string
12+ AdminPath string
13+ UserSettings string
14+ LogoutURL string
15+ ContainerData components.ContainerFormData
16+}
17+
18+// CreateContainerFullPage replaces createcontainerfull.gohtml
19+// It renders the full page for creating a container
20+templ CreateContainerFullPage(data CreateContainerFullData) {
21+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
22+ <div class="flex flex-col md:flex-row w-full flex-grow">
23+ <div class="card flex-grow">
24+ <h1 class="card-header mb-4">Create Container</h1>
25+ <!-- Include the container creation form component -->
26+ @components.CreateContainerForm(data.ContainerData)
27+ </div>
28+ </div>
29+ }
30+} ⏎̸
A internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +83 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/dashboard.templ +18 -0
@@ -0,0 +1,18 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// DashboardPage implements the admin dashboard
6+templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
8+ <div id="dashboard">
9+ <h1
10+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
11+ >
12+ Hello World from dashboard.html
13+ </h1>
14+ <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
15+ <script src="libs/chart.js"></script>
16+ </div>
17+ }
18+}⏎̸
A internal/templating/models/templ/pages/admin/dashboard_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/index.templ +36 -0
@@ -0,0 +1,36 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// IndexPageData represents data needed for the index page
6+type IndexPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ LangContent map[string]string // For storing language translations
13+}
14+
15+// IndexPage replaces admin/index.gohtml
16+// It renders the admin dashboard index page
17+templ IndexPage(data IndexPageData) {
18+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
19+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
20+ <div class="card flex-grow">
21+ <h2 class="card-header">{ data.LangContent["title"] }</h2>
22+ </div>
23+ <div class="w-full md:w-2/6 flex flex-col space-y-4">
24+ <div class="card">
25+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
26+ </div>
27+ <div class="card">
28+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
29+ </div>
30+ <div class="card">
31+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
32+ </div>
33+ </div>
34+ </div>
35+ }
36+} ⏎̸
A internal/templating/models/templ/pages/admin/index_templ.go +85 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/install.templ +59 -0
@@ -0,0 +1,59 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// InstallPage replaces install.gohtml
6+// It renders the installation page
7+templ InstallPage(title string, version string) {
8+ @layouts.Base(title) {
9+ <!-- Logo and Title -->
10+ <div
11+ id="logo-mascot"
12+ class="flex flex-col justify-center items-center p-4 mb-4"
13+ >
14+ <div class="circle-shadow flex justify-center w-100">
15+ <img
16+ src="/assets/imgs/gordon-mascot-hq-trsp.png"
17+ alt="logo"
18+ class="sm:h-24 h-32 xl:h-48"
19+ />
20+ </div>
21+ <div class="flex justify-center mt-4">
22+ <h1
23+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
24+ >
25+ Gordon
26+ </h1>
27+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2">
28+ v{ version }
29+ </span>
30+ </div>
31+ </div>
32+
33+ <!-- Main Content -->
34+ <div class="card w-2/3">
35+ <!-- Built-in Proxy -->
36+ <div id="built-in-proxy" class="mt-8">
37+ <div class="flex items-center mb-4">
38+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
39+ <span class="text-white font-bold">✓</span>
40+ </div>
41+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
42+ </div>
43+
44+ <div class="bg-indigo-50 p-4 rounded-md">
45+ <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
46+ <p>The proxy automatically:</p>
47+ <ul class="list-disc ml-6 mb-4">
48+ <li>Manages Let's Encrypt certificates</li>
49+ <li>Routes domains to the appropriate containers</li>
50+ <li>Handles HTTP to HTTPS redirects</li>
51+ <li>Provides proper headers for forwarded requests</li>
52+ </ul>
53+ <p>No additional configuration is required.</p>
54+ </div>
55+ </div>
56+ <!-- End Built-in Proxy -->
57+ </div>
58+ }
59+} ⏎̸
A internal/templating/models/templ/pages/admin/install_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/manager.templ +73 -0
@@ -0,0 +1,73 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "strconv"
6+)
7+
8+// ManagerPage implements the container and image manager page
9+templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) {
10+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
11+ <!-- Main content -->
12+ <div class="text-sm breadcrumbs mb-4">
13+ <ul>
14+ <li><a href={ templ.URL(adminPath) }>Gordon</a></li>
15+ <li>Manager</li>
16+ </ul>
17+ </div>
18+
19+ <!-- Stats overview -->
20+ <div class="stats shadow mb-6 w-full">
21+ <div class="stat">
22+ <div class="stat-figure text-primary">
23+ <span class="iconf text-3xl">󰡨</span>
24+ </div>
25+ <div class="stat-title">Containers</div>
26+ <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div>
27+ <div class="stat-desc">Docker containers managed by Gordon</div>
28+ </div>
29+
30+ <div class="stat">
31+ <div class="stat-figure text-secondary">
32+ <span class="iconf text-3xl">󰋊</span>
33+ </div>
34+ <div class="stat-title">Images</div>
35+ <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div>
36+ <div class="stat-desc">Docker images available</div>
37+ </div>
38+ </div>
39+
40+ <!-- Tabs for containers and images -->
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 />
43+ <div role="tabpanel" class="tab-content p-0">
44+ <!-- Container manager section -->
45+ <div id="container-manager"
46+ hx-get="/htmx/container-manager"
47+ hx-trigger="load"
48+ hx-swap="innerHTML">
49+ <!-- Container content loaded via HTMX -->
50+ <div class="text-center p-4">
51+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
52+ Loading containers...
53+ </div>
54+ </div>
55+ </div>
56+
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">
59+ <!-- Image manager section -->
60+ <div id="image-manager"
61+ hx-get="/htmx/image-manager"
62+ hx-trigger="load"
63+ hx-swap="innerHTML">
64+ <!-- Image content loaded via HTMX -->
65+ <div class="text-center p-4">
66+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
67+ Loading images...
68+ </div>
69+ </div>
70+ </div>
71+ </div>
72+ }
73+}⏎̸
A internal/templating/models/templ/pages/admin/manager_templ.go +99 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/user.templ +58 -0
@@ -0,0 +1,58 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// UserPageData represents data needed for the user page
6+type UserPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ Username string
13+ Email string
14+ Role string
15+}
16+
17+// UserPage replaces admin/user.gohtml
18+// It renders the user profile page
19+templ UserPage(data UserPageData) {
20+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
21+ <div class="text-sm breadcrumbs mb-4">
22+ <ul>
23+ <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li>
24+ <li>User Profile</li>
25+ </ul>
26+ </div>
27+
28+ <div class="card bg-base-100 shadow-xl">
29+ <div class="card-body">
30+ <h2 class="card-title">User Profile</h2>
31+ <div class="divider"></div>
32+
33+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34+ <div class="form-control">
35+ <label class="label">
36+ <span class="label-text">Username</span>
37+ </label>
38+ <input type="text" class="input input-bordered" value={ data.Username } readonly />
39+ </div>
40+
41+ <div class="form-control">
42+ <label class="label">
43+ <span class="label-text">Email</span>
44+ </label>
45+ <input type="email" class="input input-bordered" value={ data.Email } readonly />
46+ </div>
47+
48+ <div class="form-control">
49+ <label class="label">
50+ <span class="label-text">Role</span>
51+ </label>
52+ <input type="text" class="input input-bordered" value={ data.Role } readonly />
53+ </div>
54+ </div>
55+ </div>
56+ </div>
57+ }
58+} ⏎̸
A internal/templating/models/templ/pages/admin/user_templ.go +122 -0 generated file: collapsed
A internal/templating/models/templ/pages/errors/errors.templ +46 -0
@@ -0,0 +1,46 @@
1+package errors
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// ErrorPageData represents data needed for error pages
6+type ErrorPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ StatusCode int
11+ Message string
12+}
13+
14+// NotFoundPage renders a 404 error page
15+templ NotFoundPage(data ErrorPageData) {
16+ @layouts.Base(data.Title) {
17+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
18+ <div class="flex flex-col items-center mb-8">
19+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
20+ <h1 class="text-4xl font-bold text-error">404</h1>
21+ <h2 class="text-2xl mb-2">Page Not Found</h2>
22+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
23+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
24+ <span class="iconf mr-2">🏠</span> Go Home
25+ </a>
26+ </div>
27+ </div>
28+ }
29+}
30+
31+// ForbiddenPage renders a 403 error page
32+templ ForbiddenPage(data ErrorPageData) {
33+ @layouts.Base(data.Title) {
34+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
35+ <div class="flex flex-col items-center mb-8">
36+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
37+ <h1 class="text-4xl font-bold text-error">403</h1>
38+ <h2 class="text-2xl mb-2">Access Forbidden</h2>
39+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
40+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
41+ <span class="iconf mr-2">🏠</span> Go Home
42+ </a>
43+ </div>
44+ </div>
45+ }
46+} ⏎̸
A internal/templating/models/templ/pages/errors/errors_templ.go +162 -0 generated file: collapsed
A internal/templating/models/templ/pages/login/index.templ +126 -0
@@ -0,0 +1,126 @@
1+package login
2+
3+// LoginPage implements the login page with token and GitHub authentication
4+templ LoginPage(title string, buildVersion string, errorMsg string) {
5+ <!doctype html>
6+ <html lang="en" data-theme="gordon-dark">
7+ <head>
8+ <meta charset="UTF-8" />
9+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10+ <title>{ title }</title>
11+ <meta name="description" content="Gordon Container Management System" />
12+ <meta name="keywords" content="container, management, docker, podman" />
13+ <!-- Custom CSS + Tailwind -->
14+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
15+ <!-- HTMX min js -->
16+ <script src="/libs/htmx.min.js"></script>
17+ <!-- HTMX SSE -->
18+ <script src="/libs/htmx-sse.js"></script>
19+ <!-- DaisyUI -->
20+ <script src="/libs/daisyui.js"></script>
21+ <!-- Custom JS from assets/js -->
22+ <script src="/assets/js/custom.js"></script>
23+
24+ <!-- daisyUI theme configuration -->
25+ <script>
26+ // Check for saved theme preference or use preferred color scheme
27+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
28+ document.documentElement.setAttribute('data-theme', savedTheme);
29+ console.debug('Theme loaded:', savedTheme);
30+ </script>
31+ </head>
32+
33+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
34+ <!-- Logo and Title -->
35+ <div
36+ id="logo-mascot"
37+ class="flex flex-col justify-center items-center p-4 mb-4">
38+ <div class="flex justify-center w-100">
39+ <img
40+ src="/assets/imgs/gordon-mascot-mq-trsp.png"
41+ alt="logo gordon"
42+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
43+ </div>
44+ <div class="flex justify-center mt-4">
45+ <h1
46+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
47+ Gordon
48+ </h1>
49+ <span
50+ class="gordon-v-subtitle text-secondary text-sm ml-2"
51+ >{ buildVersion }</span>
52+ </div>
53+ </div>
54+
55+ <!-- Main Content -->
56+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
57+ <div class="card-body">
58+ <!-- Login Section -->
59+ <div id="login-section">
60+ <div class="flex flex-col items-center">
61+ <h2 class="card-title text-center mb-4">Login</h2>
62+
63+ <!-- Error Message Display -->
64+ if errorMsg != "" {
65+ <div class="alert alert-error mb-4">
66+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67+ <span>
68+ if errorMsg == "empty_token" {
69+ Please enter a token.
70+ } else if errorMsg == "invalid_token" {
71+ Invalid token. Please try again.
72+ } else if errorMsg == "server_error" {
73+ A server error occurred. Please try again later.
74+ } else {
75+ An error occurred. Please try again.
76+ }
77+ </span>
78+ </div>
79+ }
80+
81+ <!-- Token Input Form -->
82+ <div class="w-full">
83+ <form action="login/submit-token" method="POST" class="mb-4">
84+ <div class="form-control mb-4">
85+ <label for="token" class="label">
86+ <span class="label-text">Admin Token</span>
87+ </label>
88+ <input
89+ type="text"
90+ id="token"
91+ name="token"
92+ placeholder="Enter your admin token"
93+ class="input input-bordered w-full"
94+ required
95+ />
96+ </div>
97+ <button
98+ type="submit"
99+ class="btn btn-primary w-full"
100+ >
101+ Continue with Token
102+ </button>
103+ </form>
104+ <div class="divider">OR</div>
105+ </div>
106+
107+ <!-- GitHub OAuth -->
108+ <div class="w-full">
109+ <a
110+ href="login/oauth/github"
111+ class="btn btn-secondary w-full"
112+ >
113+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
114+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
115+ </svg>
116+ Sign in with GitHub
117+ </a>
118+ </div>
119+ </div>
120+ </div>
121+ <!-- End Login Section -->
122+ </div>
123+ </div>
124+ </body>
125+ </html>
126+}⏎̸
A internal/templating/models/templ/pages/login/index_templ.go +102 -0 generated file: collapsed
A internal/templating/render/templ.go +42 -0
@@ -0,0 +1,42 @@
1+package render
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+
8+ "github.com/a-h/templ"
9+ "github.com/bnema/gordon/internal/server"
10+ "github.com/labstack/echo/v4"
11+)
12+
13+// Renderer for templ templates
14+type TemplRenderer struct {
15+ BuildVersion string
16+ LatestVersion string
17+}
18+
19+// RenderTempl renders a templ component
20+func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error {
21+ // Set the content type and status code
22+ ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
23+ ctx.Response().WriteHeader(http.StatusOK)
24+
25+ // Create a render context
26+ renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion)
27+
28+ // Render the component
29+ err := component.Render(renderCtx, ctx.Response().Writer)
30+ if err != nil {
31+ return fmt.Errorf("failed to render templ component: %w", err)
32+ }
33+
34+ return nil
35+}
36+
37+// NewTemplRenderer creates a new templ renderer
38+func NewTemplRenderer(a *server.App) *TemplRenderer {
39+ return &TemplRenderer{
40+ BuildVersion: a.Config.Build.BuildVersion,
41+ }
42+}⏎̸
A internal/templating/templ_embed.go +13 -0
@@ -0,0 +1,13 @@
1+package templating
2+
3+// This file contains templ components embeddings
4+// As we migrate from traditional Go templates to templ,
5+// we'll add the appropriate code here to handle templ templates.
6+
7+import (
8+ // Import templ generated templates, which will make them available to the application
9+ _ "github.com/bnema/gordon/internal/templating/models/templ/components"
10+ _ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
11+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
12+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
13+)⏎̸
M internal/webui/public/assets/css/custom.css +112 -3
@@ -6,9 +6,118 @@
66
77 /* Embeded path : assets/css/cutsom.css */
88
9-@tailwind base;
10-@tailwind components;
11-@tailwind utilities;
9+@import "tailwindcss";
10+@source "../../../templating/models/html/**/*.{gohtml,go}";
11+@plugin "daisyui";
12+
13+/* Custom DaisyUI theme */
14+@plugin "daisyui/theme" {
15+ name: "gordon-dark";
16+ default: true;
17+ prefersdark: true;
18+ color-scheme: "dark";
19+ --color-base-100: oklch(28% 0.091 267.935);
20+ --color-base-200: oklch(13% 0.028 261.692);
21+ --color-base-300: oklch(20% 0.042 265.755);
22+ --color-base-content: oklch(93% 0.034 272.788);
23+ --color-primary: oklch(87% 0 0);
24+ --color-primary-content: oklch(14% 0 0);
25+ --color-secondary: oklch(83% 0.145 321.434);
26+ --color-secondary-content: oklch(29% 0.136 325.661);
27+ --color-accent: oklch(80% 0.105 251.813);
28+ --color-accent-content: oklch(28% 0.091 267.935);
29+ --color-neutral: oklch(37% 0.034 259.733);
30+ --color-neutral-content: oklch(98% 0.002 247.839);
31+ --color-info: oklch(68% 0.169 237.323);
32+ --color-info-content: oklch(97% 0.013 236.62);
33+ --color-success: oklch(70% 0.14 182.503);
34+ --color-success-content: oklch(98% 0.014 180.72);
35+ --color-warning: oklch(76% 0.188 70.08);
36+ --color-warning-content: oklch(98% 0.022 95.277);
37+ --color-error: oklch(64% 0.246 16.439);
38+ --color-error-content: oklch(96% 0.015 12.422);
39+ --radius-selector: 1rem;
40+ --radius-field: 0rem;
41+ --radius-box: 0.25rem;
42+ --size-selector: 0.25rem;
43+ --size-field: 0.25rem;
44+ --border: 1px;
45+ --depth: 0;
46+ --noise: 0;
47+}
48+
49+
50+/* Custom component classes */
51+@layer components {
52+ /* Button styles */
53+ .btn-primary {
54+ @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
55+ }
56+
57+ .btn-secondary {
58+ @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
59+ }
60+
61+ .btn-danger {
62+ @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
63+ }
64+
65+ /* Alert styles */
66+ .alert-error {
67+ @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20;
68+ }
69+
70+ .alert-error-icon {
71+ @apply h-5 w-5 text-red-400;
72+ }
73+
74+ .alert-error-text {
75+ @apply text-sm font-medium text-red-800 dark:text-red-300;
76+ }
77+
78+ .alert-success {
79+ @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20;
80+ }
81+
82+ .alert-success-icon {
83+ @apply h-5 w-5 text-green-400;
84+ }
85+
86+ .alert-success-text {
87+ @apply text-sm font-medium text-green-800 dark:text-green-300;
88+ }
89+
90+ /* Card styles */
91+ .card {
92+ @apply bg-white p-4 rounded-md shadow mb-4;
93+ }
94+
95+ .card-header {
96+ @apply text-lg font-semibold text-indigo-600 mb-2;
97+ }
98+
99+ /* Background styles */
100+ .bg-app {
101+ @apply bg-indigo-50;
102+ }
103+
104+ .bg-sidebar {
105+ @apply bg-indigo-900;
106+ }
107+
108+ .bg-card {
109+ @apply bg-white;
110+ }
111+
112+ /* Text styles */
113+ .text-title {
114+ @apply text-indigo-600 font-semibold;
115+ }
116+
117+ .text-subtitle {
118+ @apply text-indigo-500;
119+ }
120+}
12121
13122 /* Import IBM Plex Fonts */
14123 @font-face {
M internal/webui/public/assets/css/tailwind.css +8364 -0 large diff (8364 lines changed): collapsed
M internal/webui/public/assets/js/custom.js +33 -1
@@ -187,18 +187,36 @@ function refreshContainerList() {
187187
188188 // Show loading indicator
189189 const containerManager = $('#container-manager');
190+ if (containerManager.length === 0) {
191+ console.error("Container manager element not found in DOM");
192+ return;
193+ }
194+
195+ console.debug("Container manager element found:", containerManager[0]);
196+ console.debug("Container manager classes:", containerManager.attr('class'));
197+
190198 const originalContent = containerManager.html();
191199 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
192200
201+ // Ensure the container is visible
202+ containerManager.removeClass('hidden');
203+
193204 $.ajax({
194205 url: '/htmx/container-manager',
195206 type: 'GET',
196207 success: function(data) {
208+ console.debug("Container data received, length:", data.length);
197209 $('#container-manager').html(data);
210+
211+ // Force visibility
212+ $('#container-manager').removeClass('hidden');
213+
198214 console.debug("Container list refreshed successfully");
199215 },
200216 error: function(xhr, status, error) {
201217 console.error("Error refreshing container list:", error);
218+ console.error("Status:", status);
219+ console.error("Response:", xhr.responseText);
202220 // Restore original content on error
203221 containerManager.html(originalContent);
204222 }
@@ -211,6 +229,14 @@ function refreshImageList() {
211229
212230 // Show loading indicator
213231 const imageManager = $('#image-manager');
232+ if (imageManager.length === 0) {
233+ console.error("Image manager element not found in DOM");
234+ return;
235+ }
236+
237+ console.debug("Image manager element found:", imageManager[0]);
238+ console.debug("Image manager classes:", imageManager.attr('class'));
239+
214240 const originalContent = imageManager.html();
215241 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
216242
@@ -218,11 +244,14 @@ function refreshImageList() {
218244 url: '/htmx/image-manager',
219245 type: 'GET',
220246 success: function(data) {
247+ console.debug("Image data received, length:", data.length);
221248 $('#image-manager').html(data);
222249 console.debug("Image list refreshed successfully");
223250 },
224251 error: function(xhr, status, error) {
225252 console.error("Error refreshing image list:", error);
253+ console.error("Status:", status);
254+ console.error("Response:", xhr.responseText);
226255 // Restore original content on error
227256 imageManager.html(originalContent);
228257 }
@@ -236,6 +265,9 @@ function setupHtmxEventListeners() {
236265 const targetId = event.detail.target.id;
237266 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
238267
268+ // Remove the tab visibility fix which was interfering with DaisyUI
269+ // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
270+
239271 // Check if the target is an action response
240272 if (targetId && targetId.startsWith('actionResponse-')) {
241273 console.debug(`Action completed for ${targetId}`);
@@ -257,7 +289,7 @@ function setupHtmxEventListeners() {
257289 console.debug('Image manager updated');
258290
259291 // Show notification for successful upload
260- if (event.detail.xhr.responseText.includes('Success')) {
292+ if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) {
261293 showNotification('Image uploaded successfully', 'success');
262294 }
263295
A internal/webui/public/libs/chart.js +20 -0 too large to show in the browser

too large to show in the browser view raw

A internal/webui/public/libs/daisyui.js +8 -0 too large to show in the browser

too large to show in the browser view raw

M package.json +3 -1
@@ -1,5 +1,7 @@
11 {
2+ "type": "module",
23 "devDependencies": {
4+ "daisyui": "^5.0.6",
35 "prettier": "^3.5.3",
46 "prettier-plugin-tailwindcss": "^0.5.14",
57 "tailwindcss": "^4.0.14"
@@ -9,7 +11,7 @@
911 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"
1012 },
1113 "dependencies": {
12- "caniuse-lite": "^1.0.30001705",
14+ "@tailwindcss/cli": "^4.0.14",
1315 "dotenv": "^16.4.7"
1416 }
1517 }
M pkg/docker/client.go +19 -3
@@ -28,14 +28,15 @@ func InitializeClient(config *Config) error {
2828 return fmt.Errorf("sock field in Config is empty")
2929 }
3030
31+ logger.Debug("Checking socket file existence", "socket_path", config.Sock)
3132 if _, err := os.Stat(config.Sock); os.IsNotExist(err) {
32- logger.Error("Failed to initialize Docker client", "error", err)
33+ logger.Error("Failed to initialize Docker client", "error", err, "socket_path", config.Sock)
3334 return err
3435 }
3536
3637 // For Podman compatibility, we need to ensure we're using the right connection approach
3738 host := "unix://" + config.Sock
38- logger.Debug("Setting up Docker client", "host", host)
39+ logger.Debug("Setting up Docker client", "host", host, "podman_enabled", config.PodmanEnable)
3940
4041 // Create the Docker client with appropriate options
4142 cli, err := client.NewClientWithOpts(
@@ -47,7 +48,18 @@ func InitializeClient(config *Config) error {
4748 return fmt.Errorf("error initializing Docker client: %s", err)
4849 }
4950
50- logger.Debug("Docker client initialized", "socket", config.Sock, "podman_enabled", config.PodmanEnable)
51+ // Test the connection to make sure it works
52+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
53+ defer cancel()
54+
55+ logger.Debug("Testing Docker client connection", "socket", config.Sock)
56+ _, err = cli.Ping(ctx)
57+ if err != nil {
58+ logger.Error("Docker client ping failed", "error", err, "socket", config.Sock)
59+ return fmt.Errorf("docker daemon not responding: %s", err)
60+ }
61+
62+ logger.Debug("Docker client initialized successfully", "socket", config.Sock, "podman_enabled", config.PodmanEnable)
5163 dockerCli = cli
5264 currentConfig = config
5365 return nil
@@ -56,17 +68,20 @@ func InitializeClient(config *Config) error {
5668 func CheckIfInitialized() error {
5769 if currentConfig == nil || currentConfig.Sock == "" {
5870 logger.Error("Docker client check failed", "error", "client not initialized or configuration missing")
71+ logger.Debug("Docker client initialization failed: configuration is missing or empty")
5972 return fmt.Errorf("docker client is not initialized or configuration is missing")
6073 }
6174
6275 socketPath := currentConfig.Sock
6376 if _, err := os.Stat(socketPath); os.IsNotExist(err) {
6477 logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath)
78+ logger.Debug("Docker client check failed: socket file not found", "path", socketPath)
6579 return fmt.Errorf("sock file does not exist: %s", socketPath)
6680 }
6781
6882 if dockerCli == nil {
6983 logger.Error("Docker client check failed", "error", "client is nil")
84+ logger.Debug("Docker client check failed: client is nil")
7085 return fmt.Errorf("docker client is not initialized")
7186 }
7287
@@ -76,6 +91,7 @@ func CheckIfInitialized() error {
7691 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})
7792 if err != nil {
7893 logger.Error("Docker client check failed", "error", err)
94+ logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err)
7995 return fmt.Errorf("cannot connect to Docker daemon: %s", err)
8096 }
8197
M pkg/docker/containers.go +7 -5
@@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) {
100100 if err != nil {
101101 return nil, err
102102 }
103- // Check if the list is empty
104- if len(containers) == 0 {
105- return nil, fmt.Errorf("no containers found")
106- }
103+ // Just return the containers, even if it's an empty slice
107104 return containers, nil
108105 }
109106
@@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) {
420417
421418 // GetNetworkInfo returns information about a network
422419 func GetNetworkInfo(networkName string) (*network.Inspect, error) {
420+ // Check if Docker client is initialized
421+ if err := CheckIfInitialized(); err != nil {
422+ return nil, fmt.Errorf("cannot get network info: %w", err)
423+ }
424+
423425 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})
424426 if err != nil {
425427 return nil, err
@@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s
947949 logger.Debug("Container stopped",
948950 "container_id", event.id,
949951 "container_name", event.name)
950-
952+
951953 // Call the stop callback if provided
952954 if stopCallback != nil {
953955 logger.Debug("Calling container stop callback",
M tailwind.config.js +39 -0
@@ -14,7 +14,46 @@ module.exports = {
1414 lbeige: "#FFDCB3",
1515 mbeige: "#D7B49F",
1616 },
17+ // Custom color scheme
18+ indigo: {
19+ 50: "#eef2ff",
20+ 100: "#e0e7ff",
21+ 200: "#c7d2fe",
22+ 300: "#a5b4fc",
23+ 400: "#818cf8",
24+ 500: "#6366f1",
25+ 600: "#4f46e5",
26+ 700: "#4338ca",
27+ 800: "#3730a3",
28+ 900: "#312e81",
29+ 950: "#1e1b4b",
30+ },
31+ red: {
32+ 50: "#fef2f2",
33+ 100: "#fee2e2",
34+ 200: "#fecaca",
35+ 300: "#fca5a5",
36+ 400: "#f87171",
37+ 500: "#ef4444",
38+ 600: "#dc2626",
39+ 700: "#b91c1c",
40+ 800: "#991b1b",
41+ 900: {
42+ DEFAULT: "#7f1d1d",
43+ 20: "rgba(127, 29, 29, 0.2)",
44+ },
45+ 950: "#450a0a",
46+ },
1747 },
1848 },
1949 },
50+ plugins: [require("daisyui")],
51+ daisyui: {
52+ themes: ["light", "dark", "gordon", "gordon-dark"],
53+ darkTheme: "gordon-dark",
54+ base: true,
55+ styled: true,
56+ utils: true,
57+ logs: true,
58+ },
2059 };
A tools.go +7 -0
@@ -0,0 +1,7 @@
1+// +build tools
2+
3+package tools
4+
5+import (
6+ _ "github.com/a-h/templ/cmd/templ"
7+)⏎̸

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