detached 619fddb

Merge branch 'feat/templ-integration' into dev

commit
619fddb839e76a292aacb94f071cbea709d04689
author
bamen · · 2025-03-25 06:37 UTC
merge of
441dfbb + 8a8ae4c · 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
3 before:3 before:
4 hooks:4 hooks:
5 - go mod tidy5 - go mod tidy
6+ - templ generate
7+ - bun run build:css
6 8
7 release:9 release:
8 draft: true10 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 \
20 ARCHS := amd64 arm6420 ARCHS := amd64 arm64
21 21
22 # Phony targets22 # Phony targets
23-.PHONY: all build build-push clean build-css23+.PHONY: all build build-push clean build-css build-templ
24 24
25 # Default target25 # Default target
26 all: build26 all: build
27 27
28 # Build binaries28 # Build binaries
29-build: build-css29+build: build-css build-templ
30 @echo "Building Go binaries..."30 @echo "Building Go binaries..."
31 @mkdir -p $(DIST_DIR)31 @mkdir -p $(DIST_DIR)
32 @rm -f $(DIST_DIR)/*32 @rm -f $(DIST_DIR)/*
@@ -41,6 +41,12 @@ build-css:
41 @bun run build:css41 @bun run build:css
42 @echo "CSS built successfully"42 @echo "CSS built successfully"
43 43
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+
44 # Build and push Docker images50 # Build and push Docker images
45 build-push: build-css build51 build-push: build-css build
46 @echo "Cleaning up dangling images..."52 @echo "Cleaning up dangling images..."
M docker-compose.yml.example +6 -1
@@ -44,9 +44,11 @@ services:
44 # Reverse Proxy Configuration44 # Reverse Proxy Configuration
45 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy45 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
46 - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy46 - 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)
47 - GORDON_PROXY_CERT_DIR=/certs48 - GORDON_PROXY_CERT_DIR=/certs
48 - GORDON_PROXY_EMAIL=admin@example.com49 - 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)
50 52
51 # Session Secret for security53 # Session Secret for security
52 - SESSION_SECRET=replace_with_secure_random_string54 - SESSION_SECRET=replace_with_secure_random_string
@@ -97,8 +99,11 @@ networks:
97 # # Reverse Proxy Configuration99 # # Reverse Proxy Configuration
98 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy100 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
99 # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy101 # - 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)
100 # - GORDON_PROXY_CERT_DIR=/certs103 # - GORDON_PROXY_CERT_DIR=/certs
101 # - GORDON_PROXY_EMAIL=admin@example.com104 # - 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)
102 # 107 #
103 # # Session Secret for security108 # # Session Secret for security
104 # - SESSION_SECRET=replace_with_secure_random_string109 # - SESSION_SECRET=replace_with_secure_random_string
M go.mod +9 -0
@@ -6,6 +6,7 @@ require (
6 github.com/AlecAivazis/survey/v2 v2.3.76 github.com/AlecAivazis/survey/v2 v2.3.7
7 github.com/Masterminds/semver/v3 v3.3.17 github.com/Masterminds/semver/v3 v3.3.1
8 github.com/PuerkitoBio/goquery v1.10.28 github.com/PuerkitoBio/goquery v1.10.2
9+ github.com/a-h/templ v0.3.833
9 github.com/charmbracelet/bubbles v0.20.010 github.com/charmbracelet/bubbles v0.20.0
10 github.com/charmbracelet/lipgloss v1.0.011 github.com/charmbracelet/lipgloss v1.0.0
11 github.com/charmbracelet/log v0.4.012 github.com/charmbracelet/log v0.4.0
@@ -24,12 +25,20 @@ require (
24 )25 )
25 26
26 require (27 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
27 github.com/cespare/xxhash/v2 v2.3.0 // indirect31 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
28 github.com/klauspost/compress v1.18.0 // indirect34 github.com/klauspost/compress v1.18.0 // indirect
29 github.com/klauspost/cpuid/v2 v2.2.10 // indirect35 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
36+ github.com/natefinch/atomic v1.0.1 // indirect
30 github.com/zeebo/xxh3 v1.0.2 // indirect37 github.com/zeebo/xxh3 v1.0.2 // indirect
31 go.mongodb.org/mongo-driver v1.17.3 // indirect38 go.mongodb.org/mongo-driver v1.17.3 // indirect
32 go.opentelemetry.io/auto/sdk v1.1.0 // indirect39 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
33 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect42 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
34 )43 )
35 44
M go.sum +14 -2 generated file: collapsed
M internal/common/init_config.go +170 -61
@@ -47,14 +47,15 @@ type BuildConfig struct {
47 }47 }
48 48
49 type AdminConfig struct {49 type AdminConfig struct {
50- Path string `yaml:"path"`50+ Path string `yaml:"path"`
51+ AdminWebUI bool `yaml:"-"`
51 }52 }
52 53
53 type GeneralConfig struct {54 type GeneralConfig struct {
54 StorageDir string `yaml:"storageDir"`55 StorageDir string `yaml:"storageDir"`
55 Token string `yaml:"token"`56 Token string `yaml:"token"`
56 LogLevel string `yaml:"logLevel"`57 LogLevel string `yaml:"logLevel"`
57- GordonContainerID string `yaml:"gordonContainerID"`58+ GordonContainerID string `yaml:"-"`
58 }59 }
59 60
60 type HttpConfig struct {61 type HttpConfig struct {
@@ -73,33 +74,39 @@ type ContainerEngineConfig struct {
73 }74 }
74 75
75 type ReverseProxyConfig struct {76 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates81+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"82+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
82- Email string `yaml:"email"` // Email for Let's Encrypt83+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache84+ Email string `yaml:"email"` // Email for Let's Encrypt
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds85+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)86+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
86- DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)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)
87 }91 }
88 92
89 // Default values93 // Default values
90 var (94 var (
91- sock = "/var/run/docker.sock"95+ sock = "/var/run/docker.sock"
92- podmansock = "/run/podman/podman.sock"96+ podmansock = "/run/podman/podman.sock"
93- reverseProxyPort = "443" // Changed to 443 for standard HTTPS97+ reverseProxyPort = "443" // Changed to 443 for standard HTTPS
94- httpPort = "80" // Standard HTTP port98+ httpPort = "80" // Standard HTTP port
95- certDir = "/certs"99+ certDir = "/certs"
96- letsEncryptMode = "staging"100+ letsEncryptMode = "staging"
97- autoRenew = true101+ autoRenew = true
98- renewBefore = 30 // days102+ proxyEnabled = true // Default to enabled for backward compatibility
99- cacheSize = 1000 // entries103+ renewBefore = 30 // days
100- gracePeriod = 30 // seconds104+ cacheSize = 1000 // entries
101- defaultLogLevel = "info" // Default log level105+ gracePeriod = 30 // seconds
102- disableRateLimit = false106+ defaultLogLevel = "info" // Default log level
107+ disableRateLimit = false
108+ detectUpstreamProxy = false // Default to disabled
109+ skipCertificates = false // Default to disabled
103 )110 )
104 111
105 // applyDefaultsToConfig applies default values to any fields that have zero values112 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -154,6 +161,12 @@ func applyDefaultsToConfig(config *Config) bool {
154 }161 }
155 162
156 // Apply defaults to ReverseProxy config163 // 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+ }
157 if config.ReverseProxy.Port == "" {170 if config.ReverseProxy.Port == "" {
158 config.ReverseProxy.Port = reverseProxyPort171 config.ReverseProxy.Port = reverseProxyPort
159 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)172 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
@@ -189,16 +202,30 @@ func applyDefaultsToConfig(config *Config) bool {
189 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)202 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
190 defaultsApplied = true203 defaultsApplied = true
191 }204 }
192- // Set EnableLogs to true by default if not specified205+ // Set EnableHttpLogs to true by default if not specified
193- if !config.ReverseProxy.EnableLogs {206+ if !config.ReverseProxy.EnableHttpLogs {
194- config.ReverseProxy.EnableLogs = true207+ config.ReverseProxy.EnableHttpLogs = true
195- logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", 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)
196 defaultsApplied = true222 defaultsApplied = true
197 }223 }
198- // Set DisableRateLimit to false by default if not specified224+
199- if !config.ReverseProxy.DisableRateLimit {225+ // Set SkipCertificates to false by default if not specified
200- config.ReverseProxy.DisableRateLimit = false226+ if !config.ReverseProxy.SkipCertificates {
201- logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false)227+ config.ReverseProxy.SkipCertificates = skipCertificates
228+ logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
202 defaultsApplied = true229 defaultsApplied = true
203 }230 }
204 231
@@ -552,6 +579,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
552 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)579 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
553 }580 }
554 }581 }
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+ }
555 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {601 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
556 config.ReverseProxy.CertDir = val602 config.ReverseProxy.CertDir = val
557 if printLogs {603 if printLogs {
@@ -600,22 +646,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
600 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)646 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
601 }647 }
602 648
603- // Handle GORDON_PROXY_ENABLE_LOGS environment variable649+ // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
604- if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {650+ if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
605 enableLogs, err := strconv.ParseBool(val)651 enableLogs, err := strconv.ParseBool(val)
606 if err == nil {652 if err == nil {
607- config.ReverseProxy.EnableLogs = enableLogs653+ config.ReverseProxy.EnableHttpLogs = enableLogs
608 }654 }
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)
610 }663 }
611 664
612- // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable665+ // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
613- if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {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
614 disableRateLimit, err := strconv.ParseBool(val)674 disableRateLimit, err := strconv.ParseBool(val)
615 if err == nil {675 if err == nil {
616- config.ReverseProxy.DisableRateLimit = disableRateLimit676+ // 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)
617 }701 }
618- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit)
619 }702 }
620 }703 }
621 704
@@ -663,16 +746,16 @@ func (config *Config) LoadConfig() (*Config, error) {
663 Network: "gordon",746 Network: "gordon",
664 },747 },
665 ReverseProxy: ReverseProxyConfig{748 ReverseProxy: ReverseProxyConfig{
666- Port: reverseProxyPort,749+ Port: reverseProxyPort,
667- HttpPort: httpPort,750+ HttpPort: httpPort,
668- CertDir: certDir,751+ CertDir: certDir,
669- AutoRenew: autoRenew,752+ AutoRenew: autoRenew,
670- RenewBefore: renewBefore,753+ RenewBefore: renewBefore,
671- LetsEncryptMode: letsEncryptMode,754+ LetsEncryptMode: letsEncryptMode,
672- CacheSize: cacheSize,755+ CacheSize: cacheSize,
673- GracePeriod: gracePeriod,756+ GracePeriod: gracePeriod,
674- EnableLogs: true,757+ EnableHttpLogs: true,
675- DisableRateLimit: false,758+ EnableRateLimit: false,
676 },759 },
677 }760 }
678 761
@@ -802,16 +885,16 @@ func (config *Config) LoadConfig() (*Config, error) {
802 Network: "gordon",885 Network: "gordon",
803 },886 },
804 ReverseProxy: ReverseProxyConfig{887 ReverseProxy: ReverseProxyConfig{
805- Port: reverseProxyPort,888+ Port: reverseProxyPort,
806- HttpPort: httpPort,889+ HttpPort: httpPort,
807- CertDir: certDir,890+ CertDir: certDir,
808- AutoRenew: autoRenew,891+ AutoRenew: autoRenew,
809- RenewBefore: renewBefore,892+ RenewBefore: renewBefore,
810- LetsEncryptMode: letsEncryptMode,893+ LetsEncryptMode: letsEncryptMode,
811- CacheSize: cacheSize,894+ CacheSize: cacheSize,
812- GracePeriod: gracePeriod,895+ GracePeriod: gracePeriod,
813- EnableLogs: true,896+ EnableHttpLogs: true,
814- DisableRateLimit: false,897+ EnableRateLimit: false,
815 },898 },
816 }899 }
817 900
@@ -884,6 +967,27 @@ func (config *Config) LoadConfig() (*Config, error) {
884 logger.Info("Loaded configuration from", "path", configFilePath)967 logger.Info("Loaded configuration from", "path", configFilePath)
885 }968 }
886 969
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+
887 // Apply the log level from configuration991 // Apply the log level from configuration
888 if config.General.LogLevel != "" {992 if config.General.LogLevel != "" {
889 // Set the log level using our centralized logger993 // Set the log level using our centralized logger
@@ -1006,3 +1110,8 @@ func (c *HttpConfig) FullDomain() string {
1006 func (c *Config) GetVersion() string {1110 func (c *Config) GetVersion() string {
1007 return c.Build.BuildVersion1111 return c.Build.BuildVersion
1008 }1112 }
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 {
19 cc.PodmanSock = cc.Sock19 cc.PodmanSock = cc.Sock
20 }20 }
21 21
22- config := NewDockerConfig(cc)22+ // If socket is empty, try to detect it automatically
23- err := docker.InitializeClient(config)23+ if cc.Sock == "" {
24- if err != nil {24+ // Try standard paths first
25- // Check if it's a "socket doesn't exist" error25+ standardPaths := []string{
26- if strings.Contains(err.Error(), "no such file or directory") {26+ "/var/run/docker.sock", // Standard Docker socket in containers
27- // Log the error27+ "/run/podman/podman.sock", // Standard Podman path
28- log.Printf("Error initializing Docker client: %s", err)28+ "/var/run/podman/podman.sock", // Alternative standard path
29-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- }
37 30
38- // Add Docker socket path as a fallback31+ // Add home directory based path
39- if !isPodmanSocket {32+ homePath := os.Getenv("HOME")
40- socketPaths = append(socketPaths, "/var/run/docker.sock")33+ if homePath != "" {
41- }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)
42 41
43- for _, path := range socketPaths {42+ // Detect if this is a Podman socket
44- // Skip if it's the path we already tried43+ isPodmanSocket = detectPodmanSocket(path)
45- if path == cc.Sock {44+ if isPodmanSocket {
46- continue45+ cc.Podman = true
46+ cc.PodmanSock = path
47 }47 }
48 48
49- // Check if this socket exists49+ cc.Sock = path
50- if _, err := os.Stat(path); err == nil {50+ break
51- log.Printf("Found alternative socket at %s", path)51+ } else {
52+ log.Printf("Socket not found at: %s", path)
53+ }
54+ }
52 55
53- // Detect if this is a Podman socket56+ // If not found in standard paths, try common user IDs
54- isPodmanSocket = detectPodmanSocket(path)57+ if cc.Sock == "" {
55- if isPodmanSocket {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)
56 cc.Podman = true72 cc.Podman = true
57- cc.PodmanSock = path73+ 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)
58 }78 }
79+ }
59 80
60- cc.Sock = path81+ // Check Docker socket for this UID
61-82+ if _, err := os.Stat(dockerSocketPath); err == nil {
62- // Try again with the new path83+ if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
63- newConfig := NewDockerConfig(cc)84+ file.Close()
64- if err := docker.InitializeClient(newConfig); err == nil {85+ log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
65- return nil86+ cc.Sock = dockerSocketPath
87+ break
66 }88 }
67 }89 }
68 }90 }
91+ }
92+ }
69 93
70- // Return without an error, as we want to continue even if Docker is not available94+ // If we found a socket, try to initialize Docker client
71- log.Printf("Docker/Podman functionality will be limited or unavailable")95+ if cc.Sock != "" {
72- return fmt.Errorf("could not connect to container engine: %w", err)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
73 }101 }
74 102
75- return err103+ // 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+ }
76 }130 }
77- return nil131+
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")
78 }192 }
79 193
80 // detectPodmanSocket checks if a socket path is likely a Podman socket194 // 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 (
7 7
8 "github.com/bnema/gordon/internal/server"8 "github.com/bnema/gordon/internal/server"
9 "github.com/bnema/gordon/internal/templating/cmdparams"9 "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"
10 "github.com/bnema/gordon/internal/templating/render"12 "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"13 "github.com/bnema/gordon/pkg/docker"
12 "github.com/bnema/gordon/pkg/sanitize"14 "github.com/bnema/gordon/pkg/sanitize"
@@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
34 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)36 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
35 if !exists {37 if !exists {
36 log.Debug("No mapping found for short ID: %s", shortID)38 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)
38 }40 }
39 41
40 log.Debug("Found mapping: %s -> %s", shortID, fullID)42 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
49 var imageName string51 var imageName string
50 if len(imageInfo.RepoTags) > 0 {52 if len(imageInfo.RepoTags) > 0 {
51 imageName = imageInfo.RepoTags[0]53 imageName = imageInfo.RepoTags[0]
54+ } else {
55+ imageName = imageInfo.ID
52 }56 }
53 57
54- data := map[string]interface{}{58+ // Setup form data
55- "Title": "Create a new container",59+ formData := templcomponents.ContainerFormData{
56- "ShortID": shortID,60+ ImageName: imageName,
57- "ImageID": fullID,61+ ImageID: fullID,
58- "ImageName": imageName,62+ ShortID: shortID,
63+ ErrorMessage: "",
59 }64 }
60 65
61- // Render the create container page66+ // Get the renderer
62- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a)67+ renderer := render.NewTemplRenderer(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)
75 68
69+ // Render the component
70+ return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
76 }71 }
77 72
78 // CreateContainerFullGET handles the full HTML page for creating a new container73 // CreateContainerFullGET handles the full HTML page for creating a new container
@@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
84 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)79 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
85 if !exists {80 if !exists {
86 log.Debug("No mapping found for short ID: %s", shortID)81 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)
88 }83 }
89 84
90 log.Debug("Found mapping: %s -> %s", shortID, fullID)85 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
99 var imageName string94 var imageName string
100 if len(imageInfo.RepoTags) > 0 {95 if len(imageInfo.RepoTags) > 0 {
101 imageName = imageInfo.RepoTags[0]96 imageName = imageInfo.RepoTags[0]
97+ } else {
98+ imageName = imageInfo.ID
102 }99 }
103 100
104- data := map[string]interface{}{101+ // Setup form data
105- "Title": "Create a new container",102+ formData := templcomponents.ContainerFormData{
106- "ShortID": shortID,103+ ImageName: imageName,
107- "ImageID": fullID,104+ ImageID: fullID,
108- "ImageName": imageName,105+ ShortID: shortID,
109- "AdminPath": a.Config.Admin.Path,106+ ErrorMessage: "",
110 }107 }
111 108
112- // Specify both admin and fragments directories109+ // Get the renderer
113- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")110+ renderer := render.NewTemplRenderer(a)
114- if err != nil {
115- return sendError(c, err)
116- }
117 111
118- renderedHTML, err := rendererData.Render(data, a)112+ // Render the createcontainerfull template
119- if err != nil {113+ pageData := templadmin.CreateContainerFullData{
120- log.Debug("Error rendering template: %v", err)114+ Title: "Create Container | Gordon",
121- return sendError(c, err)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,
122 }120 }
123 121
124- return c.HTML(200, renderedHTML)122+ return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
125 }123 }
126 124
127 // CreateContainerPOST handles the create container form submission125 // CreateContainerPOST handles the create container form submission
M internal/httpserve/handlers/container_management.go +58 -2
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/gordon/internal/templating/render"12 "github.com/bnema/gordon/internal/templating/render"
13 "github.com/bnema/gordon/pkg/docker"13 "github.com/bnema/gordon/pkg/docker"
14 "github.com/bnema/gordon/pkg/humanize"14 "github.com/bnema/gordon/pkg/humanize"
15+ "github.com/bnema/gordon/pkg/logger"
15 "github.com/docker/docker/api/types"16 "github.com/docker/docker/api/types"
16 "github.com/labstack/echo/v4"17 "github.com/labstack/echo/v4"
17 )18 )
@@ -58,6 +59,10 @@ type HumanReadableContainerImage struct {
58 SizeStr string59 SizeStr string
59 RepoDigests []string60 RepoDigests []string
60 RepoTags []string61 RepoTags []string
62+ Repository string
63+ Tag string
64+ Size string
65+ Created string
61 }66 }
62 67
63 type HumanReadableContainer struct {68 type HumanReadableContainer struct {
@@ -85,17 +90,33 @@ type ContainerDisplay struct {
85 90
86 // renderHTML is a generalized function to render HTML91 // renderHTML is a generalized function to render HTML
87 func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {92 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+
88 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)95 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
89 if err != nil {96 if err != nil {
97+ logger.Error("Error getting HTML renderer", "error", err)
90 return err98 return err
91 }99 }
100+
92 renderedHTML, err := rendererData.Render(data, a)101 renderedHTML, err := rendererData.Render(data, a)
93 if err != nil {102 if err != nil {
103+ logger.Error("Error rendering HTML", "error", err)
94 return err104 return err
95 }105 }
106+
107+ logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
96 return c.HTML(http.StatusOK, renderedHTML)108 return c.HTML(http.StatusOK, renderedHTML)
97 }109 }
98 110
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+
99 // ActionSuccess returns the success HTML fragment120 // ActionSuccess returns the success HTML fragment
100 func ActionSuccess(a *server.App) string {121 func ActionSuccess(a *server.App) string {
101 // Create a data structure to pass to the template122 // Create a data structure to pass to the template
@@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string {
114 135
115 // ImageManagerComponent handles the /image-manager route (HTMX route)136 // ImageManagerComponent handles the /image-manager route (HTMX route)
116 func ImageManagerComponent(c echo.Context, a *server.App) error {137 func ImageManagerComponent(c echo.Context, a *server.App) error {
138+ logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139+
117 images, err := docker.ListContainerImages()140 images, err := docker.ListContainerImages()
118 if err != nil {141 if err != nil {
142+ logger.Error("Error listing container images:", "error", err)
119 return sendError(c, err)143 return sendError(c, err)
120 }144 }
121 145
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+
122 var humanReadableImages []HumanReadableContainerImage151 var humanReadableImages []HumanReadableContainerImage
123 152
124 for _, img := range images {153 for _, img := range images {
@@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
129 sizeStr := humanize.BytesToReadableSize(img.Size)158 sizeStr := humanize.BytesToReadableSize(img.Size)
130 159
131 for _, repoTag := range img.RepoTags {160 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+
132 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{169 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
133 ID: img.ID,170 ID: img.ID,
134 ShortID: shortID,171 ShortID: shortID,
@@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
137 Name: repoTag,174 Name: repoTag,
138 RepoDigests: img.RepoDigests,175 RepoDigests: img.RepoDigests,
139 RepoTags: img.RepoTags,176 RepoTags: img.RepoTags,
177+ // Add fields that match template expectations
178+ Repository: repository,
179+ Tag: tag,
180+ Size: sizeStr,
181+ Created: createdStr,
140 })182 })
141 }183 }
142 }184 }
@@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
145 "Images": humanReadableImages,187 "Images": humanReadableImages,
146 }188 }
147 189
190+ logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
148 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)191 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
149 }192 }
150 193
@@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
170 213
171 // ContainerManagerComponent handles the /container-manager route214 // ContainerManagerComponent handles the /container-manager route
172 func ContainerManagerComponent(c echo.Context, a *server.App) error {215 func ContainerManagerComponent(c echo.Context, a *server.App) error {
216+ logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
217+
173 containers, err := docker.ListRunningContainers()218 containers, err := docker.ListRunningContainers()
174 if err != nil {219 if err != nil {
220+ logger.Error("Error listing containers", "error", err)
175 return sendError(c, err)221 return sendError(c, err)
176 }222 }
177 223
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+
178 var humanReadableContainers []HumanReadableContainer229 var humanReadableContainers []HumanReadableContainer
179 230
180 for _, container := range containers {231 for _, container := range containers {
@@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
212 return err263 return err
213 }264 }
214 data := map[string]interface{}{265 data := map[string]interface{}{
215- "Lang": yamlData["Lang"],266+ "Lang": yamlData["Lang"],
216- "containers": humanReadableContainers,267+ "Containers": humanReadableContainers,
268+ "NoContainer": len(humanReadableContainers) == 0,
217 }269 }
218 270
271+ logger.Debug("Rendering containerlist template",
272+ "humanReadableContainers", len(humanReadableContainers),
273+ "noContainer", len(humanReadableContainers) == 0)
274+
219 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)275 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
220 }276 }
221 277
M internal/httpserve/handlers/container_transactions.go +3 -3
@@ -2,13 +2,13 @@ package handlers
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "log"
6 "strings"5 "strings"
7 6
8 "github.com/bnema/gordon/internal/server"7 "github.com/bnema/gordon/internal/server"
9 "github.com/bnema/gordon/internal/templating/cmdparams"8 "github.com/bnema/gordon/internal/templating/cmdparams"
10 "github.com/bnema/gordon/internal/templating/render"9 "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"10 "github.com/bnema/gordon/pkg/docker"
11+ "github.com/bnema/gordon/pkg/logger"
12 "github.com/labstack/echo/v4"12 "github.com/labstack/echo/v4"
13 "gopkg.in/yaml.v3"13 "gopkg.in/yaml.v3"
14 )14 )
@@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error {
132 for i, step := range tq.steps {132 for i, step := range tq.steps {
133 err := step()133 err := step()
134 if err != nil {134 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))
136 transactionErrors = append(transactionErrors, err)136 transactionErrors = append(transactionErrors, err)
137 for j := i - 1; j >= 0; j-- {137 for j := i - 1; j >= 0; j-- {
138 rollback := tq.rollbacks[j]138 rollback := tq.rollbacks[j]
139 if rollback != nil {139 if rollback != nil {
140 if rollbackErr := rollback(); rollbackErr != nil {140 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))
142 rollbackErrors = append(rollbackErrors, rollbackErr)142 rollbackErrors = append(rollbackErrors, rollbackErr)
143 }143 }
144 }144 }
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
@@ -17,7 +17,7 @@ import (
17 "github.com/bnema/gordon/internal/proxy"17 "github.com/bnema/gordon/internal/proxy"
18 "github.com/bnema/gordon/internal/server"18 "github.com/bnema/gordon/internal/server"
19 "github.com/bnema/gordon/pkg/docker"19 "github.com/bnema/gordon/pkg/docker"
20- "github.com/charmbracelet/log"20+ "github.com/bnema/gordon/pkg/logger"
21 "github.com/labstack/echo/v4"21 "github.com/labstack/echo/v4"
22 )22 )
23 23
@@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
26 payload, err := validateAndPrepareDeployPayload(c)26 payload, err := validateAndPrepareDeployPayload(c)
27 if err != nil {27 if err != nil {
28 // Debug28 // Debug
29- log.Error("Failed to validate and prepare payload", "error", err)29+ logger.Error("Failed to validate and prepare payload", "error", err)
30 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{30 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
31 Success: false,31 Success: false,
32 Message: fmt.Sprintf("Invalid payload: %v", err),32 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
37 _, err = saveAndImportImage(c, a, payload)37 _, err = saveAndImportImage(c, a, payload)
38 if err != nil {38 if err != nil {
39 // Debug39 // Debug
40- log.Error("Failed to save and import image", "error", err)40+ logger.Error("Failed to save and import image", "error", err)
41 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{41 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
42 Success: false,42 Success: false,
43 Message: fmt.Sprintf("Failed to save or import image: %v", err),43 Message: fmt.Sprintf("Failed to save or import image: %v", err),
@@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error {
48 containerID, containerName, err := createAndStartContainer(a, payload)48 containerID, containerName, err := createAndStartContainer(a, payload)
49 if err != nil {49 if err != nil {
50 if strings.Contains(err.Error(), "is already in use") {50 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)
52 existingContainerID := extractContainerID(err.Error())52 existingContainerID := extractContainerID(err.Error())
53 existingContainerName, _ := docker.GetContainerName(existingContainerID)53 existingContainerName, _ := docker.GetContainerName(existingContainerID)
54 // Remove the / from the container name54 // Remove the / from the container name
55 existingContainerName = strings.TrimLeft(existingContainerName, "/")55 existingContainerName = strings.TrimLeft(existingContainerName, "/")
56 56
57 if existingContainerID == "" || existingContainerName == "" {57 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")
59 return sendJSONResponse(c, http.StatusConflict, DeployResponse{59 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
60 Success: false,60 Success: false,
61 Message: "A container for this deploy already exists but could not be identified.",61 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 {
71 StatusCode: http.StatusConflict,71 StatusCode: http.StatusConflict,
72 })72 })
73 }73 }
74- log.Error("Failed to create or start container", "error", err)74+ logger.Error("Failed to create or start container", "error", err)
75 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{75 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
76 Success: false,76 Success: false,
77 Message: fmt.Sprintf("Failed to create or start container: %v", err),77 Message: fmt.Sprintf("Failed to create or start container: %v", err),
@@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error {
90 containerIP := GetContainerIP(a, containerID, containerName)90 containerIP := GetContainerIP(a, containerID, containerName)
91 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)91 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
92 if err != nil {92 if err != nil {
93- log.Error("Failed to add proxy route", "error", err)93+ logger.Error("Failed to add proxy route", "error", err)
94 return sendDeployErrorResponse(c, "Failed to add proxy route", err)94 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
95 }95 }
96 } else {96 } 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)
98 }98 }
99 99
100 // Arrived here means deployment was successful100 // Arrived here means deployment was successful
@@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
124 124
125 payload, err := validateAndPrepareDeployPayload(c)125 payload, err := validateAndPrepareDeployPayload(c)
126 if err != nil {126 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)
128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129 Success: false,129 Success: false,
130 Message: fmt.Sprintf("Invalid payload: %v", err),130 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
196 // Chunks validation steps196 // Chunks validation steps
197 //197 //
198 if len(chunkData) == 0 {198 if len(chunkData) == 0 {
199- log.Error("Received empty chunk")199+ logger.Error("Received empty chunk")
200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201 Success: false,201 Success: false,
202 Message: "Received empty chunk",202 Message: "Received empty chunk",
@@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
206 206
207 // Verify chunk size matches metadata207 // Verify chunk size matches metadata
208 if int64(len(chunkData)) != metadata.ChunkSize {208 if int64(len(chunkData)) != metadata.ChunkSize {
209- log.Error("Chunk size mismatch",209+ logger.Error("Chunk size mismatch",
210 "expected", metadata.ChunkSize,210 "expected", metadata.ChunkSize,
211 "received", len(chunkData))211 "received", len(chunkData))
212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
@@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
236 }236 }
237 237
238 func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {238 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",
240 "transferID", transferID)240 "transferID", transferID)
241 241
242 // Get transfer data242 // Get transfer data
@@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
296 // Check for existing container296 // Check for existing container
297 existingContainerID := docker.GetContainerIDByName(containerName)297 existingContainerID := docker.GetContainerIDByName(containerName)
298 if existingContainerID != "" {298 if existingContainerID != "" {
299- log.Warn("Container already exists",299+ logger.Warn("Container already exists",
300 "name", containerName,300 "name", containerName,
301 "id", existingContainerID)301 "id", existingContainerID)
302 return sendDeployErrorResponse(c, "Container already exists", nil)302 return sendDeployErrorResponse(c, "Container already exists", nil)
303 }303 }
304 304
305 // Import the image305 // Import the image
306- log.Info("Importing image from file",306+ logger.Info("Importing image from file",
307 "path", tmpFile.Name(),307 "path", tmpFile.Name(),
308 "imageName", imageName)308 "imageName", imageName)
309 309
310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311 if err != nil {311 if err != nil {
312 if strings.Contains(err.Error(), "platform compatibility check failed") {312 if strings.Contains(err.Error(), "platform compatibility check failed") {
313- log.Error("Platform compatibility check failed",313+ logger.Error("Platform compatibility check failed",
314 "error", err,314 "error", err,
315 "imageName", imageName)315 "imageName", imageName)
316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
@@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
343 // Cleanup transfer data343 // Cleanup transfer data
344 cleanupTransfer(transferID)344 cleanupTransfer(transferID)
345 345
346- log.Info("Deployment completed successfully",346+ logger.Info("Deployment completed successfully",
347 "containerID", containerID,347 "containerID", containerID,
348 "containerName", containerName,348 "containerName", containerName,
349 "domain", targetDomain)349 "domain", targetDomain)
@@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
352 containerIP := GetContainerIP(a, containerID, containerName)352 containerIP := GetContainerIP(a, containerID, containerName)
353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354 if err != nil {354 if err != nil {
355- log.Error("Failed to add proxy route", "error", err)355+ logger.Error("Failed to add proxy route", "error", err)
356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357 }357 }
358 358
@@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
422 422
423 // Double check that we have a valid container IP - critical for recreated containers423 // Double check that we have a valid container IP - critical for recreated containers
424 if containerIP == "" || containerIP == containerID || containerIP == "localhost" {424 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",
426 "containerID", containerID,426 "containerID", containerID,
427 "invalid_ip", containerIP)427 "invalid_ip", containerIP)
428 428
@@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
437 // Get the correct container IP with retry logic437 // Get the correct container IP with retry logic
438 newContainerIP := GetContainerIP(a, containerID, containerName)438 newContainerIP := GetContainerIP(a, containerID, containerName)
439 if newContainerIP != containerIP && newContainerIP != containerName {439 if newContainerIP != containerIP && newContainerIP != containerName {
440- log.Info("Updated container IP for proxy route",440+ logger.Info("Updated container IP for proxy route",
441 "containerID", containerID,441 "containerID", containerID,
442 "old_ip", containerIP,442 "old_ip", containerIP,
443 "new_ip", newContainerIP)443 "new_ip", newContainerIP)
@@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
448 // Get container info to check labels448 // Get container info to check labels
449 containerInfo, err := docker.GetContainerInfo(containerID)449 containerInfo, err := docker.GetContainerInfo(containerID)
450 if err != nil {450 if err != nil {
451- log.Warn("Failed to get container info", "error", err)451+ logger.Warn("Failed to get container info", "error", err)
452 } else {452 } else {
453 // Check if the container has a gordon.proxy.port label that might override containerPort453 // Check if the container has a gordon.proxy.port label that might override containerPort
454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455 if portLabel != containerPort {455 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",
457 "label_port", portLabel,457 "label_port", portLabel,
458 "provided_port", containerPort)458 "provided_port", containerPort)
459 containerPort = portLabel459 containerPort = portLabel
@@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
467 // Check if the target domain specifies HTTPS467 // Check if the target domain specifies HTTPS
468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469 protocol = "https"469 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",
471 "domain", targetDomain,471 "domain", targetDomain,
472 "protocol", protocol)472 "protocol", protocol)
473 } else {473 } else {
@@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479 protocol = "https"479 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",
481 "domain", targetDomain,481 "domain", targetDomain,
482 "containerID", containerID,482 "containerID", containerID,
483 "label_value", sslValue,483 "label_value", sslValue,
@@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497 497
498 // Log that we're adding the domain for Let's Encrypt498 // 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",
500 "domain", cleanDomain)500 "domain", cleanDomain)
501 501
502 // Get container name for better logging502 // Get container name for better logging
503 containerName, err := docker.GetContainerName(containerID)503 containerName, err := docker.GetContainerName(containerID)
504 if err != nil {504 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)
506 } else {506 } else {
507 containerName = strings.TrimPrefix(containerName, "/")507 containerName = strings.TrimPrefix(containerName, "/")
508- log.Debug("Container name for proxy route", "name", containerName)508+ logger.Debug("Container name for proxy route", "name", containerName)
509 }509 }
510 510
511 // Check the database for any existing routes with this domain, regardless of container ID511 // 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
526 526
527 if err == nil {527 if err == nil {
528 // Domain exists in database, check if this is a recreated container528 // 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",
530 "domain", cleanDomain,530 "domain", cleanDomain,
531 "old_container_id", existingContainerID,531 "old_container_id", existingContainerID,
532 "new_container_id", containerID)532 "new_container_id", containerID)
@@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
538 networkName := a.Config.ContainerEngine.Network538 networkName := a.Config.ContainerEngine.Network
539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- log.Info("Updating container IP before database update",541+ logger.Info("Updating container IP before database update",
542 "domain", cleanDomain,542 "domain", cleanDomain,
543 "old_ip", containerIP,543 "old_ip", containerIP,
544 "new_ip", networkSettings.IPAddress)544 "new_ip", networkSettings.IPAddress)
@@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
559 return fmt.Errorf("failed to update proxy route: %w", err)559 return fmt.Errorf("failed to update proxy route: %w", err)
560 }560 }
561 561
562- log.Info("Updated proxy route for recreated container",562+ logger.Info("Updated proxy route for recreated container",
563 "domain", cleanDomain,563 "domain", cleanDomain,
564 "old_container_id", existingContainerID,564 "old_container_id", existingContainerID,
565 "new_container_id", containerID,565 "new_container_id", containerID,
@@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580 580
581 if err == nil && updatedContainerIP != containerIP {581 if err == nil && updatedContainerIP != containerIP {
582- log.Warn("Database verification failed: container IP mismatch",582+ logger.Warn("Database verification failed: container IP mismatch",
583 "expected", containerIP,583 "expected", containerIP,
584 "actual", updatedContainerIP)584 "actual", updatedContainerIP)
585 } else {585 } else {
586- log.Debug("Database verification passed: container IP updated correctly",586+ logger.Debug("Database verification passed: container IP updated correctly",
587 "container_ip", containerIP)587 "container_ip", containerIP)
588 }588 }
589 589
@@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
596 routes := p.GetRoutes()596 routes := p.GetRoutes()
597 if route, exists := routes[cleanDomain]; exists {597 if route, exists := routes[cleanDomain]; exists {
598 if route.ContainerIP != containerIP {598 if route.ContainerIP != containerIP {
599- log.Warn("Route reload verification failed: container IP mismatch",599+ logger.Warn("Route reload verification failed: container IP mismatch",
600 "domain", cleanDomain,600 "domain", cleanDomain,
601 "expected", containerIP,601 "expected", containerIP,
602 "actual", route.ContainerIP)602 "actual", route.ContainerIP)
603 } else {603 } else {
604- log.Debug("Route reload verification passed: container IP loaded correctly",604+ logger.Debug("Route reload verification passed: container IP loaded correctly",
605 "domain", cleanDomain,605 "domain", cleanDomain,
606 "container_ip", containerIP)606 "container_ip", containerIP)
607 }607 }
@@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
623 // and avoid database locking conflicts623 // and avoid database locking conflicts
624 p.RegisterNewlyCreatedContainer(containerID)624 p.RegisterNewlyCreatedContainer(containerID)
625 625
626- log.Debug("Added proxy route",626+ logger.Debug("Added proxy route",
627 "domain", targetDomain,627 "domain", targetDomain,
628 "containerIP", containerIP,628 "containerIP", containerIP,
629 "containerPort", containerPort,629 "containerPort", containerPort,
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
@@ -18,7 +18,7 @@ import (
18 "github.com/bnema/gordon/internal/templating/cmdparams"18 "github.com/bnema/gordon/internal/templating/cmdparams"
19 "github.com/bnema/gordon/pkg/docker"19 "github.com/bnema/gordon/pkg/docker"
20 "github.com/bnema/gordon/pkg/filestore"20 "github.com/bnema/gordon/pkg/filestore"
21- "github.com/charmbracelet/log"21+ "github.com/bnema/gordon/pkg/logger"
22 "github.com/labstack/echo/v4"22 "github.com/labstack/echo/v4"
23 )23 )
24 24
@@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) {
71 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {71 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
72 err := c.JSON(statusCode, response)72 err := c.JSON(statusCode, response)
73 if err != nil {73 if err != nil {
74- log.Error("Failed to send JSON response",74+ logger.Error("Failed to send JSON response",
75 "error", err,75 "error", err,
76 "statusCode", statusCode)76 "statusCode", statusCode)
77 }77 }
@@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
152 imageID, err = docker.ImportImageToEngine(imagePath)152 imageID, err = docker.ImportImageToEngine(imagePath)
153 if err != nil {153 if err != nil {
154 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {154 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",
156 "error", removeErr,156 "error", removeErr,
157 "path", imagePath)157 "path", imagePath)
158 }158 }
@@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
161 161
162 if imageID == "" {162 if imageID == "" {
163 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {163 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",
165 "error", removeErr,165 "error", removeErr,
166 "path", imagePath)166 "path", imagePath)
167 }167 }
@@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
170 170
171 err = filestore.RemoveFromStorage(imagePath)171 err = filestore.RemoveFromStorage(imagePath)
172 if err != nil {172 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",
174 "error", err,174 "error", err,
175 "path", imagePath)175 "path", imagePath)
176 return "", fmt.Errorf("failed to remove temporary image file: %v", err)176 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
181 181
182 // createAndStartContainer creates a container from the image, starts it and returns the container ID182 // createAndStartContainer creates a container from the image, starts it and returns the container ID
183 func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {183 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",
185 "imageName", payload.ImageName,185 "imageName", payload.ImageName,
186 "imageID", payload.ImageID,186 "imageID", payload.ImageID,
187 "port", payload.Port)187 "port", payload.Port)
@@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192 }192 }
193 193
194- log.Debug("Container parameters prepared", "params", params)194+ logger.Debug("Container parameters prepared", "params", params)
195 195
196 containerID, err := docker.CreateContainer(params)196 containerID, err := docker.CreateContainer(params)
197 if err != nil {197 if err != nil {
198 return "", "", fmt.Errorf("failed to create container: %v", err)198 return "", "", fmt.Errorf("failed to create container: %v", err)
199 }199 }
200 200
201- log.Info("Container created successfully", "containerID", containerID)201+ logger.Info("Container created successfully", "containerID", containerID)
202 202
203 containerName, err := docker.GetContainerName(containerID)203 containerName, err := docker.GetContainerName(containerID)
204 if err != nil {204 if err != nil {
205 return "", "", fmt.Errorf("failed to get container name: %v", err)205 return "", "", fmt.Errorf("failed to get container name: %v", err)
206 }206 }
207 207
208- log.Info("Starting container",208+ logger.Info("Starting container",
209 "containerID", containerID,209 "containerID", containerID,
210 "name", containerName)210 "name", containerName)
211 211
@@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
215 // Get container logs if start failed215 // Get container logs if start failed
216 logs, logErr := docker.GetContainerLogs(containerID)216 logs, logErr := docker.GetContainerLogs(containerID)
217 if logErr == nil {217 if logErr == nil {
218- log.Error("Container start failed - container logs",218+ logger.Error("Container start failed - container logs",
219 "logs", logs,219 "logs", logs,
220 "containerID", containerID)220 "containerID", containerID)
221 }221 }
@@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
223 // Cleanup the failed container223 // Cleanup the failed container
224 cleanupErr := docker.RemoveContainer(containerID)224 cleanupErr := docker.RemoveContainer(containerID)
225 if cleanupErr != nil {225 if cleanupErr != nil {
226- log.Error("Failed to cleanup container after start failure",226+ logger.Error("Failed to cleanup container after start failure",
227 "containerID", containerID,227 "containerID", containerID,
228 "error", cleanupErr)228 "error", cleanupErr)
229 }229 }
230 return "", "", fmt.Errorf("failed to start container: %v", err)230 return "", "", fmt.Errorf("failed to start container: %v", err)
231 }231 }
232 232
233- log.Info("Container started successfully",233+ logger.Info("Container started successfully",
234 "containerID", containerID,234 "containerID", containerID,
235 "name", containerName)235 "name", containerName)
236 236
@@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool {
292 292
293 metadata := chunkStore.metadata[transferID]293 metadata := chunkStore.metadata[transferID]
294 if metadata == nil {294 if metadata == nil {
295- log.Warn("No metadata found for transfer", "transferID", transferID)295+ logger.Warn("No metadata found for transfer", "transferID", transferID)
296 return false296 return false
297 }297 }
298 298
299 chunksReceived := len(chunkStore.chunks[transferID])299 chunksReceived := len(chunkStore.chunks[transferID])
300 isComplete := chunksReceived == metadata.TotalChunks300 isComplete := chunksReceived == metadata.TotalChunks
301 301
302- log.Debug("Transfer completion check",302+ logger.Debug("Transfer completion check",
303 "transferID", transferID,303 "transferID", transferID,
304 "chunksReceived", chunksReceived,304 "chunksReceived", chunksReceived,
305 "totalExpected", metadata.TotalChunks,305 "totalExpected", metadata.TotalChunks,
@@ -328,7 +328,7 @@ func cleanupOldTransfers() {
328 chunkStore.mu.Lock()328 chunkStore.mu.Lock()
329 for id, startTime := range chunkStore.started {329 for id, startTime := range chunkStore.started {
330 if now.Sub(startTime) > transferTimeout {330 if now.Sub(startTime) > transferTimeout {
331- log.Debug("Cleaning up stale transfer",331+ logger.Debug("Cleaning up stale transfer",
332 "transferID", id,332 "transferID", id,
333 "age", now.Sub(startTime))333 "age", now.Sub(startTime))
334 cleanupTransfer(id)334 cleanupTransfer(id)
@@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
376 totalSize += int64(len(chunk))376 totalSize += int64(len(chunk))
377 }377 }
378 378
379- log.Debug("Starting chunk assembly",379+ logger.Debug("Starting chunk assembly",
380 "totalChunks", totalChunks,380 "totalChunks", totalChunks,
381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382 382
@@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
435 return fmt.Errorf("failed to reset file pointer: %w", err)435 return fmt.Errorf("failed to reset file pointer: %w", err)
436 }436 }
437 437
438- log.Debug("Chunk assembly completed successfully",438+ logger.Debug("Chunk assembly completed successfully",
439 "totalBytesWritten", written,439 "totalBytesWritten", written,
440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441 441
@@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
464 // If this is a retry, add an increasing delay464 // If this is a retry, add an increasing delay
465 if retry > 0 {465 if retry > 0 {
466 sleepTime := time.Duration(500*retry) * time.Millisecond466 sleepTime := time.Duration(500*retry) * time.Millisecond
467- log.Debug("Retrying to get container IP",467+ logger.Debug("Retrying to get container IP",
468 "attempt", retry+1,468 "attempt", retry+1,
469 "containerID", containerID,469 "containerID", containerID,
470 "delay", sleepTime)470 "delay", sleepTime)
@@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
486 refreshedInfo, err := docker.GetContainerInfo(containerID)486 refreshedInfo, err := docker.GetContainerInfo(containerID)
487 if err == nil {487 if err == nil {
488 containerInfo = refreshedInfo488 containerInfo = refreshedInfo
489- log.Debug("Refreshed container info for IP retrieval",489+ logger.Debug("Refreshed container info for IP retrieval",
490 "containerName", containerName,490 "containerName", containerName,
491 "containerID", containerID)491 "containerID", containerID)
492 } else {492 } else {
@@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
501 networkName := a.Config.ContainerEngine.Network501 networkName := a.Config.ContainerEngine.Network
502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503 containerIP = networkSettings.IPAddress503 containerIP = networkSettings.IPAddress
504- log.Debug("Found container IP",504+ logger.Debug("Found container IP",
505 "containerName", containerName,505 "containerName", containerName,
506 "containerID", containerID,506 "containerID", containerID,
507 "containerIP", containerIP,507 "containerIP", containerIP,
@@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514 if networkSettings.IPAddress != "" {514 if networkSettings.IPAddress != "" {
515 containerIP = networkSettings.IPAddress515 containerIP = networkSettings.IPAddress
516- log.Debug("Found container IP in alternative network",516+ logger.Debug("Found container IP in alternative network",
517 "containerName", containerName,517 "containerName", containerName,
518 "containerID", containerID,518 "containerID", containerID,
519 "containerIP", containerIP,519 "containerIP", containerIP,
@@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
533 // Extract just the IP address from the CIDR format533 // Extract just the IP address from the CIDR format
534 ipCIDR := endpoint.IPv4Address534 ipCIDR := endpoint.IPv4Address
535 containerIP = strings.Split(ipCIDR, "/")[0]535 containerIP = strings.Split(ipCIDR, "/")[0]
536- log.Debug("Found container IP via network inspection",536+ logger.Debug("Found container IP via network inspection",
537 "containerName", containerName,537 "containerName", containerName,
538 "containerID", containerID,538 "containerID", containerID,
539 "containerIP", containerIP,539 "containerIP", containerIP,
@@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
546 546
547 // If we've tried multiple times and couldn't get an IP547 // If we've tried multiple times and couldn't get an IP
548 if lastErr != nil {548 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",
550 "error", lastErr,550 "error", lastErr,
551 "containerID", containerID)551 "containerID", containerID)
552 }552 }
553 553
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",
555 "containerName", containerName,555 "containerName", containerName,
556 "containerID", containerID)556 "containerID", containerID)
557 557
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 (
12 12
13 // RegisterRoutes registers all routes and middlewares13 // RegisterRoutes registers all routes and middlewares
14 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {14 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
15-
16 e.Use(echomid.Recover())15 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+
17 // Initiate the session middleware22 // Initiate the session middleware
18 e.Use(middleware.InitSessionMiddleware(a))23 e.Use(middleware.InitSessionMiddleware(a))
19 24
@@ -21,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
21 e.Use(middleware.LanguageDetection)26 e.Use(middleware.LanguageDetection)
22 27
23 AdminPath := a.Config.Admin.Path28 AdminPath := a.Config.Admin.Path
29+ log.Debug("Binding login routes with AdminPath", "path", AdminPath)
24 bindLoginRoute(e, a, AdminPath)30 bindLoginRoute(e, a, AdminPath)
31+
32+ log.Debug("Binding API endpoints")
25 bindAPIEndpoints(e, a)33 bindAPIEndpoints(e, a)
26 34
27 // SetCommonDataMiddleware will pass data to the renderer35 // SetCommonDataMiddleware will pass data to the renderer
@@ -36,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
36 // Color scheme detection for dark/light mode44 // Color scheme detection for dark/light mode
37 e.Use(middleware.ColorSchemeDetection)45 e.Use(middleware.ColorSchemeDetection)
38 46
47+ // Set custom error handler
48+ e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
49+
50+ log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
39 bindAdminRoute(e, a, AdminPath)51 bindAdminRoute(e, a, AdminPath)
52+
53+ log.Debug("Binding static routes")
40 bindStaticRoute(e, a, "/*")54 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+ }
42 63
43 // Protect the root path with a 40364 // Protect the root path with a 403
44 e.GET("/", func(c echo.Context) error {65 e.GET("/", func(c echo.Context) error {
45- return c.String(403, "403 Forbidden")66+ return handlers.RenderForbiddenPage(c, a)
46 })67 })
47 68
69+ log.Info("All routes registered successfully")
48 return e70 return e
49 }71 }
50 72
@@ -100,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
100 122
101 // bindLoginRoute binds all login routes123 // bindLoginRoute binds all login routes
102 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {124 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+
103 e.GET(adminPath+"/login", func(c echo.Context) error {131 e.GET(adminPath+"/login", func(c echo.Context) error {
104- return handlers.RenderLoginPage(c, a)132+ return handlers.RenderTemplLoginPage(c, a)
105 })133 })
106 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {134 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
107 return handlers.HandleTokenSubmission(c, a)135 return handlers.HandleTokenSubmission(c, a)
@@ -119,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
119 147
120 // bindAdminRoute binds all admin routes148 // bindAdminRoute binds all admin routes
121 func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {149 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+
122 adminGroup := e.Group(adminPath)156 adminGroup := e.Group(adminPath)
123 // Since login is behind the admin path, we cannot use group middleware157 // Since login is behind the admin path, we cannot use group middleware
124 adminGroup.GET("", func(c echo.Context) error {158 adminGroup.GET("", func(c echo.Context) error {
@@ -126,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
126 }, middleware.RequireLogin(a))160 }, middleware.RequireLogin(a))
127 161
128 adminGroup.GET("/manager", func(c echo.Context) error {162 adminGroup.GET("/manager", func(c echo.Context) error {
129- return handlers.AdminManagerRoute(c, a)163+ return handlers.RenderTemplManagerPage(c, a)
130 }, middleware.RequireLogin(a))164 }, middleware.RequireLogin(a))
131 adminGroup.GET("/cc/:ID", func(c echo.Context) error {165 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
132 return handlers.CreateContainerFullGET(c, a)166 return handlers.CreateContainerFullGET(c, a)
@@ -145,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
145 179
146 // bindHTMXEndpoints binds all HTMX endpoints180 // bindHTMXEndpoints binds all HTMX endpoints
147 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {181 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
148- // Create a group for /htmx endpoints182+ // 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
149 htmxGroup := e.Group("/htmx")189 htmxGroup := e.Group("/htmx")
150 190
151 // Apply middleware to the group, so all endpoints are protected191 // Apply middleware to the group, so all endpoints are protected
@@ -162,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
162 202
163 // List all containers203 // List all containers
164 htmxGroup.GET("/container-manager", func(c echo.Context) error {204 htmxGroup.GET("/container-manager", func(c echo.Context) error {
165- return handlers.ContainerManagerComponent(c, a)205+ return handlers.RenderTemplContainerList(c, a)
166 })206 })
167 // Stop a container207 // Stop a container
168 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {208 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
M internal/proxy/certificates.go +200 -74
@@ -31,8 +31,116 @@ const (
31 LetsEncryptProdPattern = "%s_letsencrypt-production"31 LetsEncryptProdPattern = "%s_letsencrypt-production"
32 )32 )
33 33
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+
34 // setupCertManager configures the autocert manager for Let's Encrypt107 // setupCertManager configures the autocert manager for Let's Encrypt
35 func (p *Proxy) setupCertManager() {108 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+
36 // Create a cache directory if it doesn't exist144 // Create a cache directory if it doesn't exist
37 dir := p.config.CertDir145 dir := p.config.CertDir
38 if dir == "" {146 if dir == "" {
@@ -169,11 +277,11 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
169 "expires_in", expiresInDays,277 "expires_in", expiresInDays,
170 "days",278 "days",
171 "type", certType)279 "type", certType)
172- 280+
173 // Check if certificate matches current mode281 // Check if certificate matches current mode
174 if p.config.LetsEncryptMode == "production" && certType == "staging" {282 if p.config.LetsEncryptMode == "production" && certType == "staging" {
175 logger.Info("Found staging certificate but production mode is enabled",283 logger.Info("Found staging certificate but production mode is enabled",
176- "domain", domain, 284+ "domain", domain,
177 "action", "will request new production certificate")285 "action", "will request new production certificate")
178 return false, certType286 return false, certType
179 } else if p.config.LetsEncryptMode == "staging" && certType == "production" {287 } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
@@ -183,7 +291,7 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
183 "domain", domain,291 "domain", domain,
184 "action", "using existing production certificate")292 "action", "using existing production certificate")
185 }293 }
186- 294+
187 return true, certType295 return true, certType
188 }296 }
189 297
@@ -206,27 +314,27 @@ func getCertificateFileName(domain string, certType string) string {
206 func getCertificateType(cert *x509.Certificate) string {314 func getCertificateType(cert *x509.Certificate) string {
207 // Check issuer to determine the certificate type315 // Check issuer to determine the certificate type
208 issuer := cert.Issuer.CommonName316 issuer := cert.Issuer.CommonName
209- 317+
210 // Check for self-signed certificate318 // Check for self-signed certificate
211 if cert.Issuer.CommonName == cert.Subject.CommonName {319 if cert.Issuer.CommonName == cert.Subject.CommonName {
212 return "self-signed"320 return "self-signed"
213 }321 }
214- 322+
215 // Check for Let's Encrypt staging certificate323 // Check for Let's Encrypt staging certificate
216- if strings.Contains(issuer, "STAGING") || 324+ if strings.Contains(issuer, "STAGING") ||
217- strings.Contains(issuer, "Fake LE") || 325+ strings.Contains(issuer, "Fake LE") ||
218- strings.Contains(issuer, "Counterfeit") || 326+ strings.Contains(issuer, "Counterfeit") ||
219- strings.Contains(issuer, "False Fennel") {327+ strings.Contains(issuer, "False Fennel") {
220 return "staging"328 return "staging"
221 }329 }
222- 330+
223 // Check for Let's Encrypt production certificate331 // Check for Let's Encrypt production certificate
224- if strings.Contains(issuer, "Let's Encrypt") || 332+ if strings.Contains(issuer, "Let's Encrypt") ||
225- strings.Contains(issuer, "R3") || 333+ strings.Contains(issuer, "R3") ||
226- strings.Contains(issuer, "E1") {334+ strings.Contains(issuer, "E1") {
227 return "production"335 return "production"
228 }336 }
229- 337+
230 // Unknown issuer338 // Unknown issuer
231 return "unknown"339 return "unknown"
232 }340 }
@@ -252,7 +360,7 @@ func isCertificateValid(certData []byte, domain string) (bool, float64, string,
252 360
253 // Determine the certificate type361 // Determine the certificate type
254 certType := getCertificateType(cert)362 certType := getCertificateType(cert)
255- 363+
256 // Check if the certificate is still valid364 // Check if the certificate is still valid
257 now := time.Now()365 now := time.Now()
258 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {366 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {
@@ -303,21 +411,21 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
303 getCertificateFileName(domain, "staging"),411 getCertificateFileName(domain, "staging"),
304 getCertificateFileName(domain, "production"),412 getCertificateFileName(domain, "production"),
305 }413 }
306- 414+
307 // Check each possible certificate file using known patterns415 // Check each possible certificate file using known patterns
308 for _, baseName := range certFiles {416 for _, baseName := range certFiles {
309 certFile := filepath.Join(certDir, baseName)417 certFile := filepath.Join(certDir, baseName)
310 keyFile := filepath.Join(certDir, baseName+"+rsa")418 keyFile := filepath.Join(certDir, baseName+"+rsa")
311- 419+
312 // Skip if either file doesn't exist420 // Skip if either file doesn't exist
313 if _, err := os.Stat(certFile); os.IsNotExist(err) {421 if _, err := os.Stat(certFile); os.IsNotExist(err) {
314 continue422 continue
315 }423 }
316- 424+
317 if _, err := os.Stat(keyFile); os.IsNotExist(err) {425 if _, err := os.Stat(keyFile); os.IsNotExist(err) {
318 continue426 continue
319 }427 }
320- 428+
321 // If we found both files, validate the certificate429 // If we found both files, validate the certificate
322 certData, err := os.ReadFile(certFile)430 certData, err := os.ReadFile(certFile)
323 if err != nil {431 if err != nil {
@@ -327,24 +435,24 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
327 "error", err)435 "error", err)
328 continue436 continue
329 }437 }
330- 438+
331 // Validate the certificate data439 // Validate the certificate data
332 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)440 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
333 if !validCert {441 if !validCert {
334 continue442 continue
335 }443 }
336- 444+
337 logger.Info("Valid certificate found in filesystem with standard naming pattern",445 logger.Info("Valid certificate found in filesystem with standard naming pattern",
338 "domain", domain,446 "domain", domain,
339 "expires_in", expiresInDays,447 "expires_in", expiresInDays,
340 "days",448 "days",
341 "type", certType,449 "type", certType,
342 "cert_file", certFile)450 "cert_file", certFile)
343- 451+
344 // Check if certificate matches current mode452 // Check if certificate matches current mode
345 if p.config.LetsEncryptMode == "production" && certType == "staging" {453 if p.config.LetsEncryptMode == "production" && certType == "staging" {
346 logger.Info("Found staging certificate but production mode is enabled",454 logger.Info("Found staging certificate but production mode is enabled",
347- "domain", domain, 455+ "domain", domain,
348 "action", "will request new production certificate")456 "action", "will request new production certificate")
349 // Delete the staging certificate457 // Delete the staging certificate
350 deleteCertificateFiles(certDir, baseName)458 deleteCertificateFiles(certDir, baseName)
@@ -356,7 +464,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
356 "domain", domain,464 "domain", domain,
357 "action", "using existing production certificate")465 "action", "using existing production certificate")
358 }466 }
359- 467+
360 // Log some debug information about the certificate files468 // Log some debug information about the certificate files
361 certFileInfo, _ := os.Stat(certFile)469 certFileInfo, _ := os.Stat(certFile)
362 keyFileInfo, _ := os.Stat(keyFile)470 keyFileInfo, _ := os.Stat(keyFile)
@@ -368,30 +476,30 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
368 "key_file", keyFile,476 "key_file", keyFile,
369 "key_size", keyFileInfo.Size(),477 "key_size", keyFileInfo.Size(),
370 "key_modified", keyFileInfo.ModTime())478 "key_modified", keyFileInfo.ModTime())
371- 479+
372 // Attempt to add to autocert cache if it's not already there480 // Attempt to add to autocert cache if it's not already there
373 if certType == "production" || (certType == "staging" && p.config.LetsEncryptMode == "staging") {481 if certType == "production" || (certType == "staging" && p.config.LetsEncryptMode == "staging") {
374 // Add to autocert cache for future use482 // Add to autocert cache for future use
375 p.addCertificateToCache(domain, certFile, keyFile)483 p.addCertificateToCache(domain, certFile, keyFile)
376 }484 }
377- 485+
378 return true, certType486 return true, certType
379 }487 }
380 488
381 // If no matches with standard patterns, try a glob search for any files starting with the domain name489 // 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
383 // and domain_letsencrypt-staging491 // and domain_letsencrypt-staging
384 logger.Debug("No standard certificate files found, searching for any matching certificates",492 logger.Debug("No standard certificate files found, searching for any matching certificates",
385 "domain", domain)493 "domain", domain)
386- 494+
387 files, err := filepath.Glob(filepath.Join(certDir, domain+"*"))495 files, err := filepath.Glob(filepath.Join(certDir, domain+"*"))
388 if err != nil {496 if err != nil {
389- logger.Warn("Error searching for certificate files", 497+ logger.Warn("Error searching for certificate files",
390- "domain", domain, 498+ "domain", domain,
391 "error", err)499 "error", err)
392 return false, ""500 return false, ""
393 }501 }
394- 502+
395 // Extract base names without the path503 // Extract base names without the path
396 var candidates []string504 var candidates []string
397 for _, file := range files {505 for _, file := range files {
@@ -399,34 +507,34 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
399 if strings.HasSuffix(file, "+rsa") {507 if strings.HasSuffix(file, "+rsa") {
400 continue508 continue
401 }509 }
402- 510+
403 baseFile := filepath.Base(file)511 baseFile := filepath.Base(file)
404 // Check if the corresponding key file exists512 // Check if the corresponding key file exists
405 keyFile := file + "+rsa"513 keyFile := file + "+rsa"
406 if _, err := os.Stat(keyFile); os.IsNotExist(err) {514 if _, err := os.Stat(keyFile); os.IsNotExist(err) {
407 continue515 continue
408 }516 }
409- 517+
410 // Add this candidate518 // Add this candidate
411 candidates = append(candidates, baseFile)519 candidates = append(candidates, baseFile)
412 }520 }
413- 521+
414 // No certificate files found522 // No certificate files found
415 if len(candidates) == 0 {523 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",
417 "domain", domain)525 "domain", domain)
418 return false, ""526 return false, ""
419 }527 }
420- 528+
421 logger.Debug("Found certificate candidates in glob search",529 logger.Debug("Found certificate candidates in glob search",
422 "domain", domain,530 "domain", domain,
423 "candidates", candidates)531 "candidates", candidates)
424- 532+
425 // Process each candidate (if there are multiple matches)533 // Process each candidate (if there are multiple matches)
426 for _, baseName := range candidates {534 for _, baseName := range candidates {
427 certFile := filepath.Join(certDir, baseName)535 certFile := filepath.Join(certDir, baseName)
428 keyFile := filepath.Join(certDir, baseName+"+rsa")536 keyFile := filepath.Join(certDir, baseName+"+rsa")
429- 537+
430 // Read and validate the certificate538 // Read and validate the certificate
431 certData, err := os.ReadFile(certFile)539 certData, err := os.ReadFile(certFile)
432 if err != nil {540 if err != nil {
@@ -436,27 +544,27 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
436 "error", err)544 "error", err)
437 continue545 continue
438 }546 }
439- 547+
440 // Validate the certificate data548 // Validate the certificate data
441 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)549 validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
442 if !validCert {550 if !validCert {
443 continue551 continue
444 }552 }
445- 553+
446 logger.Info("Valid certificate found in filesystem with non-standard naming",554 logger.Info("Valid certificate found in filesystem with non-standard naming",
447 "domain", domain,555 "domain", domain,
448 "expires_in", expiresInDays,556 "expires_in", expiresInDays,
449 "days",557 "days",
450 "type", certType,558 "type", certType,
451 "cert_file", certFile)559 "cert_file", certFile)
452- 560+
453 // If we're in production mode and this is a production certificate, use it561 // If we're in production mode and this is a production certificate, use it
454 if p.config.LetsEncryptMode == "production" && certType == "production" {562 if p.config.LetsEncryptMode == "production" && certType == "production" {
455 // Attempt to add to autocert cache563 // Attempt to add to autocert cache
456 p.addCertificateToCache(domain, certFile, keyFile)564 p.addCertificateToCache(domain, certFile, keyFile)
457 return true, certType565 return true, certType
458 }566 }
459- 567+
460 // If we're in staging mode, prefer production certificates if available568 // If we're in staging mode, prefer production certificates if available
461 if p.config.LetsEncryptMode == "staging" {569 if p.config.LetsEncryptMode == "staging" {
462 if certType == "production" {570 if certType == "production" {
@@ -470,7 +578,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
470 return true, certType578 return true, certType
471 }579 }
472 }580 }
473- 581+
474 // If this is a staging certificate but we're in production mode, ignore it582 // If this is a staging certificate but we're in production mode, ignore it
475 // and continue checking other candidates583 // and continue checking other candidates
476 if p.config.LetsEncryptMode == "production" && certType == "staging" {584 if p.config.LetsEncryptMode == "production" && certType == "staging" {
@@ -479,7 +587,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
479 "cert_file", certFile)587 "cert_file", certFile)
480 continue588 continue
481 }589 }
482- 590+
483 // Fallback: use the certificate if it's valid591 // Fallback: use the certificate if it's valid
484 // Attempt to add to autocert cache592 // Attempt to add to autocert cache
485 p.addCertificateToCache(domain, certFile, keyFile)593 p.addCertificateToCache(domain, certFile, keyFile)
@@ -495,7 +603,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
495 func deleteCertificateFiles(certDir string, baseName string) {603 func deleteCertificateFiles(certDir string, baseName string) {
496 certFile := filepath.Join(certDir, baseName)604 certFile := filepath.Join(certDir, baseName)
497 keyFile := filepath.Join(certDir, baseName+"+rsa")605 keyFile := filepath.Join(certDir, baseName+"+rsa")
498- 606+
499 // Delete certificate file607 // Delete certificate file
500 if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) {608 if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) {
501 logger.Warn("Failed to delete certificate file",609 logger.Warn("Failed to delete certificate file",
@@ -504,7 +612,7 @@ func deleteCertificateFiles(certDir string, baseName string) {
504 } else if !os.IsNotExist(err) {612 } else if !os.IsNotExist(err) {
505 logger.Info("Deleted certificate file", "file", certFile)613 logger.Info("Deleted certificate file", "file", certFile)
506 }614 }
507- 615+
508 // Delete key file616 // Delete key file
509 if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) {617 if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) {
510 logger.Warn("Failed to delete certificate key file",618 logger.Warn("Failed to delete certificate key file",
@@ -665,8 +773,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
665 // First check in the cache773 // First check in the cache
666 validInCache, cacheType := p.checkCertificateInCache(domain)774 validInCache, cacheType := p.checkCertificateInCache(domain)
667 if validInCache {775 if validInCache {
668- logger.Debug("Certificate exists in cache", 776+ logger.Debug("Certificate exists in cache",
669- "domain", domain, 777+ "domain", domain,
670 "type", cacheType)778 "type", cacheType)
671 return true, cacheType779 return true, cacheType
672 }780 }
@@ -674,8 +782,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
674 // If not in cache, check the filesystem782 // If not in cache, check the filesystem
675 validInFS, fsType := p.checkCertificateInFilesystem(domain)783 validInFS, fsType := p.checkCertificateInFilesystem(domain)
676 if validInFS {784 if validInFS {
677- logger.Debug("Certificate exists in filesystem but not in cache", 785+ logger.Debug("Certificate exists in filesystem but not in cache",
678- "domain", domain, 786+ "domain", domain,
679 "type", fsType)787 "type", fsType)
680 return true, fsType788 return true, fsType
681 }789 }
@@ -699,6 +807,15 @@ func (p *Proxy) requestAdminCertificate() {
699 return807 return
700 }808 }
701 809
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+
702 // Extract hostname from admin domain, resolving it to see if it's publicly accessible819 // Extract hostname from admin domain, resolving it to see if it's publicly accessible
703 host := adminDomain820 host := adminDomain
704 ips, err := net.LookupIP(host)821 ips, err := net.LookupIP(host)
@@ -721,7 +838,7 @@ func (p *Proxy) requestAdminCertificate() {
721 838
722 // Check if we already have a valid certificate (cache or filesystem)839 // Check if we already have a valid certificate (cache or filesystem)
723 hasValidCert, certType := p.checkCertificateExists(adminDomain)840 hasValidCert, certType := p.checkCertificateExists(adminDomain)
724- 841+
725 // If we have a valid cert that matches our mode, use it842 // If we have a valid cert that matches our mode, use it
726 if hasValidCert {843 if hasValidCert {
727 // Check for mode mismatch844 // Check for mode mismatch
@@ -765,7 +882,7 @@ func (p *Proxy) requestAdminCertificate() {
765 if certDir == "" {882 if certDir == "" {
766 certDir = p.app.GetConfig().General.StorageDir + "/certs"883 certDir = p.app.GetConfig().General.StorageDir + "/certs"
767 }884 }
768- 885+
769 // Ensure certificate directory exists886 // Ensure certificate directory exists
770 if err := os.MkdirAll(certDir, 0755); err != nil {887 if err := os.MkdirAll(certDir, 0755); err != nil {
771 logger.Warn("Failed to create certificate directory",888 logger.Warn("Failed to create certificate directory",
@@ -863,15 +980,15 @@ func (p *Proxy) requestAdminCertificate() {
863 if p.config.LetsEncryptMode == "staging" {980 if p.config.LetsEncryptMode == "staging" {
864 certType = "staging"981 certType = "staging"
865 }982 }
866- 983+
867 // Try to rename the certificate files984 // Try to rename the certificate files
868 oldCertPath := filepath.Join(certDir, adminDomain)985 oldCertPath := filepath.Join(certDir, adminDomain)
869 oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa")986 oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa")
870- 987+
871 newName := getCertificateFileName(adminDomain, certType)988 newName := getCertificateFileName(adminDomain, certType)
872 newCertPath := filepath.Join(certDir, newName)989 newCertPath := filepath.Join(certDir, newName)
873 newKeyPath := filepath.Join(certDir, newName+"+rsa")990 newKeyPath := filepath.Join(certDir, newName+"+rsa")
874- 991+
875 // Only rename if the files exist and the new names are different992 // Only rename if the files exist and the new names are different
876 if oldCertPath != newCertPath {993 if oldCertPath != newCertPath {
877 // Check if old cert exists994 // Check if old cert exists
@@ -890,7 +1007,7 @@ func (p *Proxy) requestAdminCertificate() {
890 "from", oldCertPath,1007 "from", oldCertPath,
891 "to", newCertPath)1008 "to", newCertPath)
892 }1009 }
893- 1010+
894 // Rename key1011 // Rename key
895 if _, err := os.Stat(oldKeyPath); err == nil {1012 if _, err := os.Stat(oldKeyPath); err == nil {
896 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {1013 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -909,7 +1026,7 @@ func (p *Proxy) requestAdminCertificate() {
909 }1026 }
910 }1027 }
911 }1028 }
912- 1029+
913 logger.Info("Successfully obtained Let's Encrypt certificate",1030 logger.Info("Successfully obtained Let's Encrypt certificate",
914 "domain", adminDomain,1031 "domain", adminDomain,
915 "type", certType)1032 "type", certType)
@@ -930,6 +1047,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
930 return1047 return
931 }1048 }
932 1049
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+
933 // Extract hostname from domain, resolving it to see if it's publicly accessible1059 // Extract hostname from domain, resolving it to see if it's publicly accessible
934 ips, err := net.LookupIP(domain)1060 ips, err := net.LookupIP(domain)
935 if err != nil {1061 if err != nil {
@@ -951,7 +1077,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
951 1077
952 // Check if we already have a valid certificate (cache or filesystem)1078 // Check if we already have a valid certificate (cache or filesystem)
953 hasValidCert, certType := p.checkCertificateExists(domain)1079 hasValidCert, certType := p.checkCertificateExists(domain)
954- 1080+
955 // If we have a valid cert that matches our mode, use it1081 // If we have a valid cert that matches our mode, use it
956 if hasValidCert {1082 if hasValidCert {
957 // Check for mode mismatch1083 // Check for mode mismatch
@@ -995,7 +1121,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
995 if certDir == "" {1121 if certDir == "" {
996 certDir = p.app.GetConfig().General.StorageDir + "/certs"1122 certDir = p.app.GetConfig().General.StorageDir + "/certs"
997 }1123 }
998- 1124+
999 // Ensure certificate directory exists1125 // Ensure certificate directory exists
1000 if err := os.MkdirAll(certDir, 0755); err != nil {1126 if err := os.MkdirAll(certDir, 0755); err != nil {
1001 logger.Warn("Failed to create certificate directory",1127 logger.Warn("Failed to create certificate directory",
@@ -1093,15 +1219,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
1093 if p.config.LetsEncryptMode == "staging" {1219 if p.config.LetsEncryptMode == "staging" {
1094 certType = "staging"1220 certType = "staging"
1095 }1221 }
1096- 1222+
1097 // Try to rename the certificate files1223 // Try to rename the certificate files
1098 oldCertPath := filepath.Join(certDir, domain)1224 oldCertPath := filepath.Join(certDir, domain)
1099 oldKeyPath := filepath.Join(certDir, domain+"+rsa")1225 oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1100- 1226+
1101 newName := getCertificateFileName(domain, certType)1227 newName := getCertificateFileName(domain, certType)
1102 newCertPath := filepath.Join(certDir, newName)1228 newCertPath := filepath.Join(certDir, newName)
1103 newKeyPath := filepath.Join(certDir, newName+"+rsa")1229 newKeyPath := filepath.Join(certDir, newName+"+rsa")
1104- 1230+
1105 // Only rename if the files exist and the new names are different1231 // Only rename if the files exist and the new names are different
1106 if oldCertPath != newCertPath {1232 if oldCertPath != newCertPath {
1107 // Check if old cert exists1233 // Check if old cert exists
@@ -1120,7 +1246,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
1120 "from", oldCertPath,1246 "from", oldCertPath,
1121 "to", newCertPath)1247 "to", newCertPath)
1122 }1248 }
1123- 1249+
1124 // Rename key1250 // Rename key
1125 if _, err := os.Stat(oldKeyPath); err == nil {1251 if _, err := os.Stat(oldKeyPath); err == nil {
1126 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {1252 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -1139,7 +1265,7 @@ func (p *Proxy) requestDomainCertificate(domain string) {
1139 }1265 }
1140 }1266 }
1141 }1267 }
1142- 1268+
1143 logger.Info("Successfully obtained Let's Encrypt certificate",1269 logger.Info("Successfully obtained Let's Encrypt certificate",
1144 "domain", domain,1270 "domain", domain,
1145 "type", certType)1271 "type", certType)
@@ -1224,15 +1350,15 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1224 if p.config.LetsEncryptMode == "staging" {1350 if p.config.LetsEncryptMode == "staging" {
1225 certType = "staging"1351 certType = "staging"
1226 }1352 }
1227- 1353+
1228 // Rename the certificate files to include the type1354 // Rename the certificate files to include the type
1229 oldCertPath := filepath.Join(certDir, domain)1355 oldCertPath := filepath.Join(certDir, domain)
1230 oldKeyPath := filepath.Join(certDir, domain+"+rsa")1356 oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1231- 1357+
1232 newName := getCertificateFileName(domain, certType)1358 newName := getCertificateFileName(domain, certType)
1233 newCertPath := filepath.Join(certDir, newName)1359 newCertPath := filepath.Join(certDir, newName)
1234 newKeyPath := filepath.Join(certDir, newName+"+rsa")1360 newKeyPath := filepath.Join(certDir, newName+"+rsa")
1235- 1361+
1236 // Only rename if the files exist and the new names are different1362 // Only rename if the files exist and the new names are different
1237 if oldCertPath != newCertPath {1363 if oldCertPath != newCertPath {
1238 // Check if old cert exists1364 // Check if old cert exists
@@ -1251,7 +1377,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1251 "from", oldCertPath,1377 "from", oldCertPath,
1252 "to", newCertPath)1378 "to", newCertPath)
1253 }1379 }
1254- 1380+
1255 // Rename key1381 // Rename key
1256 if _, err := os.Stat(oldKeyPath); err == nil {1382 if _, err := os.Stat(oldKeyPath); err == nil {
1257 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {1383 if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
@@ -1270,7 +1396,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1270 }1396 }
1271 }1397 }
1272 }1398 }
1273- 1399+
1274 logger.Info("Successfully obtained Let's Encrypt certificate on retry",1400 logger.Info("Successfully obtained Let's Encrypt certificate on retry",
1275 "domain", domain,1401 "domain", domain,
1276 "attempt", attempt,1402 "attempt", attempt,
@@ -1326,7 +1452,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1326 "domain", domain,1452 "domain", domain,
1327 "max_retries", maxRetries,1453 "max_retries", maxRetries,
1328 "fallback", "Generating self-signed certificate")1454 "fallback", "Generating self-signed certificate")
1329- 1455+
1330 // Generate a self-signed certificate for this domain1456 // Generate a self-signed certificate for this domain
1331 fallbackCert, err := generateFallbackCertificates([]string{domain})1457 fallbackCert, err := generateFallbackCertificates([]string{domain})
1332 if err != nil {1458 if err != nil {
@@ -1338,13 +1464,13 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1338 selfSignedName := getCertificateFileName(domain, "self-signed")1464 selfSignedName := getCertificateFileName(domain, "self-signed")
1339 certPath := filepath.Join(certDir, selfSignedName)1465 certPath := filepath.Join(certDir, selfSignedName)
1340 keyPath := filepath.Join(certDir, selfSignedName+"+rsa")1466 keyPath := filepath.Join(certDir, selfSignedName+"+rsa")
1341- 1467+
1342 // Save the certificate and key1468 // Save the certificate and key
1343 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]})1469 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]})
1344 keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey)1470 keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey)
1345 if err == nil {1471 if err == nil {
1346 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})1472 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
1347- 1473+
1348 // Write files1474 // Write files
1349 if err := os.WriteFile(certPath, certPEM, 0644); err != nil {1475 if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
1350 logger.Error("Failed to write self-signed certificate to disk",1476 logger.Error("Failed to write self-signed certificate to disk",
@@ -1352,14 +1478,14 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
1352 "path", certPath,1478 "path", certPath,
1353 "error", err)1479 "error", err)
1354 }1480 }
1355- 1481+
1356 if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {1482 if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
1357 logger.Error("Failed to write self-signed key to disk",1483 logger.Error("Failed to write self-signed key to disk",
1358 "domain", domain,1484 "domain", domain,
1359 "path", keyPath,1485 "path", keyPath,
1360 "error", err)1486 "error", err)
1361 }1487 }
1362- 1488+
1363 logger.Info("Generated and saved self-signed fallback certificate",1489 logger.Info("Generated and saved self-signed fallback certificate",
1364 "domain", domain,1490 "domain", domain,
1365 "cert_path", certPath,1491 "cert_path", certPath,
M internal/proxy/containers.go +7 -5
@@ -1,7 +1,6 @@
1 package proxy1 package proxy
2 2
3 import (3 import (
4- "fmt"
5 "os"4 "os"
6 "strings"5 "strings"
7 6
@@ -12,11 +11,11 @@ import (
12 // scanContainersAndCheckCertificates scans all containers from the Gordon network11 // scanContainersAndCheckCertificates scans all containers from the Gordon network
13 // and checks if we have certificates for their domains and subdomains12 // and checks if we have certificates for their domains and subdomains
14 func (p *Proxy) scanContainersAndCheckCertificates() error {13 func (p *Proxy) scanContainersAndCheckCertificates() error {
15- // Get the network name from app config
16 networkName := p.app.GetConfig().ContainerEngine.Network14 networkName := p.app.GetConfig().ContainerEngine.Network
17-
18 if networkName == "" {15 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
20 }19 }
21 20
22 logger.Info("Scanning containers in network for certificate checks",21 logger.Info("Scanning containers in network for certificate checks",
@@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
25 // Try to get network info using the docker client24 // Try to get network info using the docker client
26 networkInfo, err := docker.GetNetworkInfo(networkName)25 networkInfo, err := docker.GetNetworkInfo(networkName)
27 if err != nil {26 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
29 }31 }
30 32
31 // Skip admin cert check if environment variable is set33 // Skip admin cert check if environment variable is set
M internal/proxy/core.go +34 -6
@@ -22,7 +22,6 @@ import (
22 // - certificates.go - Certificate management functions22 // - certificates.go - Certificate management functions
23 // - routes.go - Route management functions23 // - routes.go - Route management functions
24 // - middleware.go - HTTP middleware functions24 // - middleware.go - HTTP middleware functions
25-// - blacklist.go - IP blacklisting functionality
26 // - server.go - Server startup and shutdown25 // - server.go - Server startup and shutdown
27 // - utils.go - Utility functions26 // - utils.go - Utility functions
28 27
@@ -58,6 +57,10 @@ type Proxy struct {
58 recentContainersMu sync.RWMutex57 recentContainersMu sync.RWMutex
59 containerCooldownPeriod time.Duration58 containerCooldownPeriod time.Duration
60 59
60+ // Upstream proxy detection
61+ upstreamProxyDetected bool
62+ upstreamProxyMu sync.RWMutex
63+
61 // SQL queries64 // SQL queries
62 queries *queries.ProxyQueries65 queries *queries.ProxyQueries
63 }66 }
@@ -66,8 +69,36 @@ type Proxy struct {
66 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {69 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
67 logger.Debug("Initializing reverse proxy")70 logger.Debug("Initializing reverse proxy")
68 71
69- // Log important configuration information72+ // Set up the config
70- logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol")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+ }
71 102
72 // Set up the echo server for HTTPS traffic103 // Set up the echo server for HTTPS traffic
73 httpsServer := echo.New()104 httpsServer := echo.New()
@@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
77 httpServer := echo.New()108 httpServer := echo.New()
78 httpServer.HideBanner = true109 httpServer.HideBanner = true
79 110
80- // Set up the config
81- config := app.GetConfig().ReverseProxy
82-
83 // Detect and store our container ID (if we're running in a container)111 // Detect and store our container ID (if we're running in a container)
84 var ourContainerID string112 var ourContainerID string
85 if docker.IsRunningInContainer() {113 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 @@
2 <html lang="en">2 <html lang="en">
3 {{template "header.gohtml" .}}3 {{template "header.gohtml" .}}
4 4
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">
6 {{template "menu.gohtml" .}}6 {{template "menu.gohtml" .}}
7 <!-- Main content here -->7 <!-- Main content here -->
8 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">8 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
9 <div class="flex flex-col md:flex-row w-full flex-grow">9 <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">10+ <div class="card flex-grow">
11- <h1 class="text-2xl font-bold mb-4">Edit Container</h1>11+ <h1 class="card-header mb-4">Edit Container</h1>
12 <!-- Include the existing edit container fragment here -->12 <!-- Include the existing edit container fragment here -->
13 {{template "createcontainer.gohtml" .}}13 {{template "createcontainer.gohtml" .}}
14 </div>14 </div>
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
@@ -1,6 +1,6 @@
1 <div id="dashboard">1 <div id="dashboard">
2 <h12 <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"
4 >4 >
5 Hello World from dashboard.html5 Hello World from dashboard.html
6 </h1>6 </h1>
M internal/templating/models/html/admin/index.gohtml +9 -9
@@ -2,23 +2,23 @@
2 <html lang="en">2 <html lang="en">
3 {{template "header.gohtml" .}}3 {{template "header.gohtml" .}}
4 4
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">
6 {{template "menu.gohtml" .}}6 {{template "menu.gohtml" .}}
7 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">7 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
8 <div8 <div
9 class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">9 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">10+ <div class="card flex-grow">
11- <h2>{{index .Lang "dashboard" "content" "title"}}</h2>11+ <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
12 </div>12 </div>
13 <div class="w-full md:w-2/6 flex flex-col space-y-4">13 <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">14+ <div class="card">
15- <div class="w-full h-44 rounded-sm bg-gray-300"></div>15+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
16 </div>16 </div>
17- <div class="bg-white p-4 rounded-md shadow mb-4">17+ <div class="card">
18- <div class="w-full h-44 rounded-sm bg-gray-300"></div>18+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
19 </div>19 </div>
20- <div class="bg-white p-4 rounded-md shadow mb-4">20+ <div class="card">
21- <div class="w-full h-44 rounded-sm bg-gray-300"></div>21+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
22 </div>22 </div>
23 </div>23 </div>
24 </div>24 </div>
M internal/templating/models/html/admin/install.gohtml +7 -7
@@ -21,7 +21,7 @@
21 </head>21 </head>
22 22
23 <body23 <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"
25 hx-boost="true"25 hx-boost="true"
26 >26 >
27 <!-- Logo and Title -->27 <!-- Logo and Title -->
@@ -38,28 +38,28 @@
38 </div>38 </div>
39 <div class="flex justify-center mt-4">39 <div class="flex justify-center mt-4">
40 <h140 <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"
42 >42 >
43 Gordon43 Gordon
44 </h1>44 </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"
46 >v0.0.1</span46 >v0.0.1</span
47 >47 >
48 </div>48 </div>
49 </div>49 </div>
50 50
51 <!-- Main Content -->51 <!-- Main Content -->
52- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">52+ <div class="card w-2/3">
53 <!-- Built-in Proxy -->53 <!-- Built-in Proxy -->
54 <div id="built-in-proxy" class="mt-8">54 <div id="built-in-proxy" class="mt-8">
55 <div class="flex items-center mb-4">55 <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">
57 <span class="text-white font-bold">✓</span>57 <span class="text-white font-bold">✓</span>
58 </div>58 </div>
59- <h2 class="text-lg ml-2">Reverse Proxy</h2>59+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
60 </div>60 </div>
61 61
62- <div class="bg-gray-100 p-4 rounded-md">62+ <div class="bg-indigo-50 p-4 rounded-md">
63 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>63 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
64 <p>The proxy automatically:</p>64 <p>The proxy automatically:</p>
65 <ul class="list-disc ml-6 mb-4">65 <ul class="list-disc ml-6 mb-4">
M internal/templating/models/html/admin/manager.gohtml +65 -23
@@ -1,34 +1,76 @@
1 <!-- manager.gohtml -->1 <!-- manager.gohtml -->
2 <!DOCTYPE html>2 <!DOCTYPE html>
3-<html lang="en">3+<html lang="en" data-theme="gordon">
4 {{template "header.gohtml" .}}4 {{template "header.gohtml" .}}
5 5
6- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">6+ <body class="min-h-screen bg-base-200">
7 {{template "menu.gohtml" .}}7 {{template "menu.gohtml" .}}
8- <!-- Main content here -->8+
9- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">9+ <!-- Main content -->
10- <div class="flex flex-col md:flex-row w-full flex-grow">10+ <div class="container mx-auto py-6 px-4 mt-16">
11- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">11+ <div class="text-sm breadcrumbs mb-4">
12- <!-- List all the containers -->12+ <ul>
13- <div13+ <li><a href="{{ .AdminPath }}">Gordon</a></li>
14- id="container-manager"14+ <li>Manager</li>
15- hx-get="/htmx/container-manager"15+ </ul>
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>
28 </div>16 </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+
29 </div>72 </div>
30 73
31- <!-- footer -->
32 {{template "footer.gohtml" .}}74 {{template "footer.gohtml" .}}
33 </body>75 </body>
34 </html>76 </html>
M internal/templating/models/html/fragments/components.gohtml +55 -12
@@ -1,20 +1,24 @@
1 <!-- @format -->1 <!-- @format -->
2 2
3-<div id="header">3+<div id="header" class="hero bg-base-200">
4- <h14+ <div class="hero-content text-center">
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"5+ <div class="max-w-md">
6- >6+ <h1 class="text-5xl font-bold gordon-title">
7- {{ .body.header.helloworld }}7+ {{ .body.header.helloworld }}
8- </h1>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>
9 </div>13 </div>
10 14
11-<div id="hello">15+<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
12- <h116+ <div>
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"17+ <span class="iconfb">󰋽</span>
14- >18+ <span class="IBMPlexSans">{{ .body.div.hello }}</span>
15- {{ .body.div.hello }}19+ </div>
16- </h1>
17 </div>20 </div>
21+
18 <div22 <div
19 id="header"23 id="header"
20 hx-get="/htmx"24 hx-get="/htmx"
@@ -31,3 +35,42 @@
31 hx-trigger="load"35 hx-trigger="load"
32 hx-swap="outerHTML"36 hx-swap="outerHTML"
33 ></div>37 ></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">1+<!-- Container list using daisyUI components -->
2- <div class="flex justify-between items-center mb-1">2+<div class="card w-full bg-base-300">
3- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">3+ <div class="card-body p-4">
4- {{index .Lang "manager" "content" "h1-containers"}}4+
5- </h1>5+ <!-- Action buttons -->
6- <button6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- id="refresh-containers-button"7+ <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"8+ <div class="btn-group">
9- title="Refresh container list"9+ <button
10- >10+ class="btn btn-sm btn-primary"
11- <span class="iconf text-xl align-middle">󰑐 </span>11+ hx-get="/htmx/container-manager"
12- Refresh12+ hx-target="#container-manager"
13- </button>13+ hx-swap="innerHTML">
14- </div>14+ <span class="iconf">󰑓</span> Refresh
15- <table class="p-4 border-t-2 table-auto">15+ </button>
16- <thead>16+ </div>
17- <tr class="text-center text-gray-400">17+ </div>
18- <th>{{index .Lang "manager" "content" "thName"}}</th>18+
19- <th>{{index .Lang "manager" "content" "thPorts"}}</th>19+ <!-- Container list -->
20- <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th>20+ <table class="table">
21- <th>{{index .Lang "manager" "content" "thStatus"}}</th>21+ <!-- head -->
22- <th>{{index .Lang "manager" "content" "thState"}}</th>22+ <thead>
23- <th>{{index .Lang "manager" "content" "thActions"}}</th>23+ <tr>
24- </tr>24+ <th>Name</th>
25- </thead>25+ <th>Image</th>
26- <tbody>26+ <th>Status</th>
27- {{ $Lang := .Lang }} 27+ <th>Created</th>
28- {{28+ <th>Actions</th>
29- range.containers29+ </tr>
30- }}30+ </thead>
31- <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100">31+ <tbody>
32- <td>32+ {{ if or .NoContainer (eq (len .Containers) 0) }}
33- <span33+ <tr>
34- title="{{.Container.ID}}"34+ <td colspan="5" class="text-center py-4">
35- class="font-bold"35+ <div class="alert alert-info">
36- >{{.Name}}</span36+ <span class="iconf">ℹ️</span>
37- >37+ <span>No containers found. Upload an image first, then create a container.</span>
38- </td>38+ </div>
39- <td class="port-container">{{ .Ports }}</td>39+ </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>
92 </tr>40 </tr>
93- <tr>41+ {{ else }}
94- <td colspan="7">42+ {{ range .Containers }}
95- <div id="actionResponse-{{.ID}}"></div>43+ <tr class="hover">
96- <div id="error-message-{{.ID}}" class="text-red-500"></div>44+ <td>
97- </td>45+ <div class="flex items-center gap-3">
98- </tr>46+ <div>
99- {{ else }}47+ <div class="font-bold">{{ .Names }}</div>
100- <tr>48+ <div class="text-sm opacity-50">{{ .ID }}</div>
101- <td colspan="7" class="mt-2">49+ </div>
102- <p class="text-center text-gray-400 text-xl">50+ </div>
103- {{index $Lang "manager" "content" "no-containers"}}51+ </td>
104- </p>52+ <td>{{ .Image }}</td>
105- </td>53+ <td>
106- </tr>54+ {{ if eq .State "running" }}
107- {{55+ <div class="badge badge-success badge-sm">Running</div>
108- end56+ {{ else if eq .State "exited" }}
109- }}57+ <div class="badge badge-warning badge-sm">Stopped</div>
110- </tbody>58+ {{ else }}
111- </table>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>
112 </div>118 </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 @@
1 <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">1 <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">
2 <!-- Left Side: Docker Run Options -->2 <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow bg-slate-100 p-4 rounded-md">3+ <div class="flex-grow card p-6">
4- <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2>4+ <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
5 {{if .ErrorMessage}}5 {{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>
7 {{ end }}10 {{ end }}
8 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post" 11 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
9 hx-post="/htmx/create-container/{{.ShortID}}"12 hx-post="/htmx/create-container/{{.ShortID}}"
@@ -11,83 +14,104 @@
11 hx-target="#create-container-{{.ShortID}}"14 hx-target="#create-container-{{.ShortID}}"
12 hx-target-*="#error-message-create-container-{{.ShortID}}">15 hx-target-*="#error-message-create-container-{{.ShortID}}">
13 <!-- Image Name -->16 <!-- Image Name -->
14- <div class="flex space-x-4 pb-4">17+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
15- <div class="w-1/3">18+ <div class="md:col-span-1">
16- <label class="block">Image name</label>19+ <label class="label">
17- <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"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"
18 value="{{.ImageName}}" readonly />23 value="{{.ImageName}}" readonly />
19 </div>24 </div>
20 25
21- <div class="w-2/3">26+ <div class="md:col-span-2">
22- <label class="block">Image ID</label>27+ <label class="label">
23- <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"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"
24 value="{{.ImageID}}" readonly />31 value="{{.ImageID}}" readonly />
25 </div>32 </div>
26 </div>33 </div>
27 34
28 <!-- Container Name -->35 <!-- Container Name -->
29- <div class="pb-4">36+ <div class="form-control pb-4">
30- <label class="block">Container name</label>37+ <label class="label">
31- <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" />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}}" />
32 </div>41 </div>
42+
33 <!-- Http(s) / Subdomain / Domain -->43 <!-- 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">
35 <!-- Protocol -->45 <!-- Protocol -->
36- <div class="w-1/4">46+ <div class="md:col-span-3">
37- <label class="block">Protocol</label>47+ <label class="label">
38- <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300">48+ <span class="label-text">Protocol</span>
49+ </label>
50+ <select name="container_protocol" class="select select-bordered w-full">
39 <option value="https">https</option>51 <option value="https">https</option>
40 <option value="http">http</option>52 <option value="http">http</option>
41 </select>53 </select>
42 </div>54 </div>
43 55
44 <!-- Subdomain -->56 <!-- Subdomain -->
45- <div class="w-1/3">57+ <div class="md:col-span-4">
46- <label class="block">Subdomain</label>58+ <label class="label">
47- <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" />59+ <span class="label-text">Subdomain</span>
60+ </label>
61+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
48 </div>62 </div>
49 63
50 <!-- Domain -->64 <!-- Domain -->
51- <div class="w-5/12">65+ <div class="md:col-span-5">
52- <label class="block">Domain</label>66+ <label class="label">
53- <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" />67+ <span class="label-text">Domain</span>
68+ </label>
69+ <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
54 </div>70 </div>
55 </div>71 </div>
72+
56 <!-- Exposed Ports -->73 <!-- Exposed Ports -->
57- <div class="pb-4">74+ <div class="form-control pb-4">
58- <label class="block">Exposed Ports (Optional)</label>75+ <label class="label">
59- <span class="text-gray-500">76+ <span class="label-text">Exposed Ports (Optional)</span>
60- eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)77+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
61- </span>78+ </label>
62- <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />79+ <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
63 </div>80 </div>
64 81
65 <!-- Proxy Port -->82 <!-- Proxy Port -->
66- <div class="mb-6">83+ <div class="form-control pb-4">
67- <label class="block">Container Proxy Port (Required)</label>84+ <label class="label">
68- <div class="text-xs text-gray-500 mb-2">85+ <span class="label-text">Container Proxy Port (Required)</span>
69- Specify the internal container port that the proxy will route traffic to86+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
70- </div>87+ </label>
71- <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required />88+ <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
72 </div>89 </div>
73 90
74 <!-- Volumes -->91 <!-- Volumes -->
75- <div class="pb-4">92+ <div class="form-control pb-4">
76- <label class="block">Volumes</label>93+ <label class="label">
77- <span class="text-gray-500">e.g. ./data:/container/data </span>94+ <span class="label-text">Volumes</span>
78- <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" />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}}" />
79 </div>98 </div>
80 99
81- <div class="pb-4">100+ <div class="form-control pb-4">
82- <label class="block">Environment variables</label>101+ <label class="label">
83- <span class="text-gray-500">e.g. KEY=VALUE </span>102+ <span class="label-text">Environment variables</span>
84- <textarea name="environment_variables" class="w-full p-4 rounded-md"103+ <span class="label-text-alt">e.g. KEY=VALUE</span>
104+ </label>
105+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
85 rows="4">{{.EnvironmentVariables}}</textarea>106 rows="4">{{.EnvironmentVariables}}</textarea>
86 </div>107 </div>
108+
87 <!-- Restart: always/on-failure/only when stopped/never -->109 <!-- Restart: always/on-failure/only when stopped/never -->
88- <div class="pb-4">110+ <div class="form-control pb-4">
89- <label class="block">Restart</label>111+ <label class="label">
90- <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300">112+ <span class="label-text">Restart</span>
113+ </label>
114+ <select name="restart" class="select select-bordered w-full">
91 <option value="always">Always</option>115 <option value="always">Always</option>
92 <option value="on-failure">On failure</option>116 <option value="on-failure">On failure</option>
93 <option value="unless-stopped">Unless stopped</option>117 <option value="unless-stopped">Unless stopped</option>
@@ -96,20 +120,21 @@
96 </div>120 </div>
97 121
98 <!-- Skip Proxy Setup Option -->122 <!-- Skip Proxy Setup Option -->
99- <div class="pb-4">123+ <div class="form-control pb-4">
100- <label class="flex items-center space-x-2">124+ <label class="label cursor-pointer justify-start gap-4">
101- <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" />125+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
102 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>126 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
103 </label>127 </label>
104- <p class="text-sm text-gray-500 ml-7">128+ <div class="text-sm opacity-70 ml-10">
105 Use this option if you only want to create the container without triggering proxy setup, 129 Use this option if you only want to create the container without triggering proxy setup,
106 certificate generation, and other related processes.130 certificate generation, and other related processes.
107- </p>131+ </div>
108 </div>132 </div>
109 133
110- <input type="submit" value="Create" id="create-container-submit"134+ <button type="submit" id="create-container-submit" class="btn btn-primary">
111- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />135+ Create Container
136+ </button>
112 </form>137 </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>
114 </div>139 </div>
115 </div>⏎̸140 </div>⏎̸
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
@@ -1,9 +1,9 @@
1 <div1 <div
2 id="edit-container-{{.ID}}"2 id="edit-container-{{.ID}}"
3- class="h-fit-content"3+ class="card bg-base-100 shadow-xl"
4 hx-ext="response-targets"4 hx-ext="response-targets"
5 >5 >
6- <div id="editor">6+ <div id="editor" class="card-body">
7 <form7 <form
8 method="post"8 method="post"
9 action="/htmx/container-manager/edit/{{.ID}}"9 action="/htmx/container-manager/edit/{{.ID}}"
@@ -14,17 +14,19 @@
14 <textarea14 <textarea
15 name="container_config"15 name="container_config"
16 id="container_config"16 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"
18 rows="20"18 rows="20"
19 >{{.ContainerInfo}}</textarea19 >{{.ContainerInfo}}</textarea
20 >20 >
21- <input21+ <div class="card-actions justify-end mt-4">
22- type="submit"22+ <input
23- value="Save"23+ type="submit"
24- id="save-button-{{.ID}}"24+ value="Save"
25- class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer"25+ id="save-button-{{.ID}}"
26- />26+ class="btn btn-primary"
27+ />
28+ </div>
27 </form>29 </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>
29 </div>31 </div>
30 </div>32 </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 @@
11 <script src="/libs/htmx-sse.js"></script>11 <script src="/libs/htmx-sse.js"></script>
12 <script defer src="/libs/hyperscript.min.js"></script>12 <script defer src="/libs/hyperscript.min.js"></script>
13 <script defer src="/assets/js/custom.js"></script>13 <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>
14 </head>24 </head>
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
@@ -1,82 +1,98 @@
1-<div class="flex flex-col p-4">1+<!-- Image list using daisyUI components -->
2- <!-- Upload button aligned right of Images -->2+<div class="card w-full bg-base-300">
3- <div class="flex justify-between items-center mb-1">3+ <div class="card-body p-4">
4- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1>4+
5- <div class="flex space-x-2">5+ <!-- Action buttons -->
6- <button6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- id="refresh-images-button"7+ <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"8+ <div class="flex gap-2">
9- title="Refresh image list"9+ <button
10- >10+ class="btn btn-sm btn-secondary"
11- <span class="iconf text-xl align-middle">󰑐 </span>11+ hx-get="/htmx/upload-image"
12- Refresh12+ hx-target="#image-manager"
13- </button>13+ hx-swap="innerHTML">
14- <button14+ <span class="iconf">󰦒</span> Upload
15- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"15+ </button>
16- id="upload-button"16+ <button
17- hx-get="/htmx/upload-image"17+ class="btn btn-sm btn-primary"
18- hx-target="#upload-image"18+ hx-get="/htmx/image-manager"
19- hx-swap="innerHTML"19+ hx-target="#image-manager"
20- title="Manually upload a .tar container image"20+ hx-swap="innerHTML">
21- >21+ <span class="iconf">󰑓</span> Refresh
22- <span class="iconf text-xl align-middle">󰭾 </span>22+ </button>
23- Upload23+ </div>
24- </button>
25 </div>24 </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>
53 25
54- <button26+ <!-- Image list -->
55- id="remove-button-img-{{.ShortID}}"27+ <table class="table">
56- hx-delete="/htmx/image-manager/delete/{{.ShortID}}"28+ <!-- head -->
57- hx-target="#img-{{.ShortID}}"29+ <thead>
58- hx-target-*="#error-message-div-{{.ShortID}}"30+ <tr>
59- hx-swap="innerHTML"31+ <th>Repository</th>
60- hx-confirm="Are you sure you want to delete this container image?"32+ <th>Tag</th>
61- class="py-2 px-4"33+ <th>Size</th>
62- >34+ <th>Created</th>
63- <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span>35+ <th>Actions</th>
64- </button>36+ </tr>
65- </td>37+ </thead>
66- </tr>38+ <tbody>
67- <t>39+ {{ if .NoImage }}
68- <td colspan="4">40+ <tr>
69- <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div>41+ <td colspan="5" class="text-center py-4">
70- <div class="create-container" id="create-container-{{.ShortID}}"></div>42+ <div class="alert alert-info">
71- </td>43+ <span class="iconf">ℹ️</span>
72- </tr>44+ <span>No images found. Upload an image first.</span>
73- {{ else }}45+ </div>
74- <tr>46+ </td>
75- <td colspan="4" class="mt-2">No images found.</td>47+ </tr>
76- </tr>48+ {{ else }}
77- {{49+ {{ range .Images }}
78- end50+ <tr class="hover">
79- }}51+ <td>
80- </tbody>52+ <div class="flex items-center gap-3">
81- </table>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>
82 </div>94 </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-<div1+<!-- DaisyUI Navbar -->
2- id="menubar"2+<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
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">3+ <div class="navbar-start">
4- <div class="w-full flex justify-start">4+ <div class="flex items-center">
5- <h15+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6- class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter">6+ Gordon
7- Gordon7+ </h1>
8- </h1>8+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9- <span9+ {{ .BuildVersion }}
10- id="actual-version"10+ </span>
11- class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF">11+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12- {{ .BuildVersion }}12+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13- </span>13+ </span>
14- <span14+ </div>
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>
21 </div>15 </div>
22- <div class="w-full">16+ <div class="navbar-center hidden lg:flex">
23- <ul17+ <ul class="menu menu-horizontal px-1">
24- class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center">18+ <li>
25- <li class="">19+ <a href="{{ .AdminPath }}/manager" class="font-semibold">
26- <span class="JetBrainsMonoBoldNF pr-1"> </span>20+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
27- <a href="{{ .AdminPath }}/manager">Manager</a>21+ </a>
28 </li>22 </li>
29 </ul>23 </ul>
30 </div>24 </div>
31- <div class="w-full flex space-x-4 justify-end">25+ <div class="navbar-end">
32- <a26+ <!-- Theme toggle -->
33- href="{{ .UserSettingsURL }}"27+ <div class="dropdown dropdown-end">
34- title="User settings"28+ <label tabindex="0" class="btn btn-ghost btn-circle">
35- class="iconf text-gordonmascot-mblue text-lg md:text-xl"29+ <span class="iconf text-lg md:text-xl">󰔎</span>
36- ></a30+ </label>
37- >31+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
38- <a32+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
39- href="{{ .LogoutURL }}"33+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
40- title="Logout"34+ <li><button onclick="setTheme('light')">Light</button></li>
41- class="iconf text-gordonmascot-mblue text-lg md:text-xl"35+ <li><button onclick="setTheme('dark')">Dark</button></li>
42- >󰍃</a36+ </ul>
43- >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>
44 </div>57 </div>
45 </div>58 </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 @@
1 <div1 <div
2 id="success"2 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">
4 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>4 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
5 </div>5 </div>
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
@@ -1,35 +1,61 @@
1-<!-- Form to upload a container image (.tar format) -->1+<!-- Upload image form using DaisyUI components -->
2-<div2+<div class="card bg-base-100 shadow">
3- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"3+ <div class="card-body">
4- hx-ext="response-targets"4+ <div class="flex justify-between items-center">
5- hx-boost="true">5+ <h2 class="card-title text-primary">Upload Image</h2>
6- <div class="flex-grow bg-slate-100 p-4 rounded-md">6+ <button
7- <h2>Upload Image Containers</h2>7+ class="btn btn-sm btn-circle btn-ghost"
8- <!-- Upload Form -->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+
9 <form16 <form
10- action="/upload"17+ id="upload-form"
11- method="post"18+ hx-encoding="multipart/form-data"
12- enctype="multipart/form-data"
13 hx-post="/htmx/upload-image"19 hx-post="/htmx/upload-image"
14- hx-swap="innerHTML"
15 hx-target="#image-manager"20 hx-target="#image-manager"
16- hx-target-*="#error-message-div">21+ hx-swap="innerHTML"
17- <div22+ class="space-y-4">
18- class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4"23+
19- hx-drop="true">24+ <div class="alert alert-info shadow-lg mb-4">
20- Drag & Drop your file here or25+ <span class="iconf">ℹ️</span>
21- <input26+ <div>
22- type="file"27+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
23- name="file"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"
24 accept=".tar"40 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>
26 </div>58 </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" />
32 </form>59 </form>
33- <div id="error-message-div" class="text-red-500"></div>
34 </div>60 </div>
35 </div>61 </div>
M internal/templating/models/html/login/index.gohtml +79 -63
@@ -1,5 +1,5 @@
1 <!doctype html>1 <!doctype html>
2-<html lang="en">2+<html lang="en" data-theme="corporate">
3 <head>3 <head>
4 <meta charset="UTF-8" />4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,95 +12,111 @@
12 <script src="/libs/htmx.min.js"></script>12 <script src="/libs/htmx.min.js"></script>
13 <!-- HTMX SSE -->13 <!-- HTMX SSE -->
14 <script src="/libs/htmx-sse.js"></script>14 <script src="/libs/htmx-sse.js"></script>
15+ <!-- DaisyUI -->
16+ <script src="/libs/daisyui.js"></script>
15 <!-- Custom JS from assets/js -->17 <!-- Custom JS from assets/js -->
16 <script src="/assets/js/custom.js"></script>18 <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>
17 </head>27 </head>
18 28
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">
20 <!-- Logo and Title -->30 <!-- Logo and Title -->
21 <div31 <div
22 id="logo-mascot"32 id="logo-mascot"
23 class="flex flex-col justify-center items-center p-4 mb-4">33 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">
25 <img35 <img
26 src="/assets/imgs/gordon-mascot-mq-trsp.png"36 src="/assets/imgs/gordon-mascot-mq-trsp.png"
27 alt="logo gordon"37 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" />
29 </div>39 </div>
30 <div class="flex justify-center mt-4">40 <div class="flex justify-center mt-4">
31 <h141 <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">
33 Gordon43 Gordon
34 </h1>44 </h1>
35 <span45 <span
36- class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"46+ class="gordon-v-subtitle text-secondary text-sm ml-2"
37 >{{ .BuildVersion }}</span47 >{{ .BuildVersion }}</span
38 >48 >
39 </div>49 </div>
40 </div>50 </div>
41 51
42 <!-- Main Content -->52 <!-- Main Content -->
43- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">53+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
44- <!-- Login Section -->54+ <div class="card-body">
45- <div id="login-section">55+ <!-- Login Section -->
46- <div class="flex flex-col items-center">56+ <div id="login-section">
47- <h2 class="text-2xl font-semibold text-gray-800">Login</h2>57+ <div class="flex flex-col items-center">
48- 58+ <h2 class="card-title text-center mb-4">Login</h2>
49- <!-- Error Message Display -->59+
50- {{ if .Error }}60+ <!-- Error Message Display -->
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">61+ {{ if .Error }}
52- <span class="block sm:inline">62+ <div class="alert alert-error mb-4">
53- {{ if eq .Error "empty_token" }}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>
54- Please enter a token.64+ <span>
55- {{ else if eq .Error "invalid_token" }}65+ {{ if eq .Error "empty_token" }}
56- Invalid token. Please try again.66+ Please enter a token.
57- {{ else if eq .Error "server_error" }}67+ {{ else if eq .Error "invalid_token" }}
58- A server error occurred. Please try again later.68+ Invalid token. Please try again.
59- {{ else }}69+ {{ else if eq .Error "server_error" }}
60- An error occurred. Please try again.70+ A server error occurred. Please try again later.
61- {{ end }}71+ {{ else }}
62- </span>72+ An error occurred. Please try again.
63- </div>73+ {{ end }}
64- {{ end }}74+ </span>
65- 75+ </div>
66- <!-- Token Input Form -->76+ {{ end }}
67- <div class="mt-4 w-full max-w-md">77+
68- <form action="login/submit-token" method="POST" class="mb-4">78+ <!-- Token Input Form -->
69- <div class="mb-2">79+ <div class="w-full">
70- <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label>80+ <form action="login/submit-token" method="POST" class="mb-4">
71- <input 81+ <div class="form-control mb-4">
72- type="text" 82+ <label for="token" class="label">
73- id="token" 83+ <span class="label-text">Admin Token</span>
74- name="token" 84+ </label>
75- placeholder="Enter your admin token" 85+ <input
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"86+ type="text"
77- required87+ id="token"
78- />88+ name="token"
79- </div>89+ placeholder="Enter your admin token"
80- <button 90+ class="input input-bordered w-full"
81- type="submit" 91+ required
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"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"
83 >109 >
84- Continue with Token110+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
85- </button>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" />
86- </form>112+ </svg>
87- <div class="text-center text-sm text-gray-500 mb-4">- OR -</div>113+ Sign in with GitHub
88- </div>114+ </a>
89- 115+ </div>
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>
100 </div>116 </div>
101 </div>117 </div>
118+ <!-- End Login Section -->
102 </div>119 </div>
103- <!-- End Login Section -->
104 </div>120 </div>
105 </body>121 </body>
106 </html>122 </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 @@
6 6
7 /* Embeded path : assets/css/cutsom.css */7 /* Embeded path : assets/css/cutsom.css */
8 8
9-@tailwind base;9+@import "tailwindcss";
10-@tailwind components;10+@source "../../../templating/models/html/**/*.{gohtml,go}";
11-@tailwind utilities;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+}
12 121
13 /* Import IBM Plex Fonts */122 /* Import IBM Plex Fonts */
14 @font-face {123 @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() {
187 187
188 // Show loading indicator188 // Show loading indicator
189 const containerManager = $('#container-manager');189 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+
190 const originalContent = containerManager.html();198 const originalContent = containerManager.html();
191 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');199 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
192 200
201+ // Ensure the container is visible
202+ containerManager.removeClass('hidden');
203+
193 $.ajax({204 $.ajax({
194 url: '/htmx/container-manager',205 url: '/htmx/container-manager',
195 type: 'GET',206 type: 'GET',
196 success: function(data) {207 success: function(data) {
208+ console.debug("Container data received, length:", data.length);
197 $('#container-manager').html(data);209 $('#container-manager').html(data);
210+
211+ // Force visibility
212+ $('#container-manager').removeClass('hidden');
213+
198 console.debug("Container list refreshed successfully");214 console.debug("Container list refreshed successfully");
199 },215 },
200 error: function(xhr, status, error) {216 error: function(xhr, status, error) {
201 console.error("Error refreshing container list:", error);217 console.error("Error refreshing container list:", error);
218+ console.error("Status:", status);
219+ console.error("Response:", xhr.responseText);
202 // Restore original content on error220 // Restore original content on error
203 containerManager.html(originalContent);221 containerManager.html(originalContent);
204 }222 }
@@ -211,6 +229,14 @@ function refreshImageList() {
211 229
212 // Show loading indicator230 // Show loading indicator
213 const imageManager = $('#image-manager');231 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+
214 const originalContent = imageManager.html();240 const originalContent = imageManager.html();
215 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');241 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
216 242
@@ -218,11 +244,14 @@ function refreshImageList() {
218 url: '/htmx/image-manager',244 url: '/htmx/image-manager',
219 type: 'GET',245 type: 'GET',
220 success: function(data) {246 success: function(data) {
247+ console.debug("Image data received, length:", data.length);
221 $('#image-manager').html(data);248 $('#image-manager').html(data);
222 console.debug("Image list refreshed successfully");249 console.debug("Image list refreshed successfully");
223 },250 },
224 error: function(xhr, status, error) {251 error: function(xhr, status, error) {
225 console.error("Error refreshing image list:", error);252 console.error("Error refreshing image list:", error);
253+ console.error("Status:", status);
254+ console.error("Response:", xhr.responseText);
226 // Restore original content on error255 // Restore original content on error
227 imageManager.html(originalContent);256 imageManager.html(originalContent);
228 }257 }
@@ -236,6 +265,9 @@ function setupHtmxEventListeners() {
236 const targetId = event.detail.target.id;265 const targetId = event.detail.target.id;
237 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);266 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
238 267
268+ // Remove the tab visibility fix which was interfering with DaisyUI
269+ // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
270+
239 // Check if the target is an action response271 // Check if the target is an action response
240 if (targetId && targetId.startsWith('actionResponse-')) {272 if (targetId && targetId.startsWith('actionResponse-')) {
241 console.debug(`Action completed for ${targetId}`);273 console.debug(`Action completed for ${targetId}`);
@@ -257,7 +289,7 @@ function setupHtmxEventListeners() {
257 console.debug('Image manager updated');289 console.debug('Image manager updated');
258 290
259 // Show notification for successful upload291 // 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')) {
261 showNotification('Image uploaded successfully', 'success');293 showNotification('Image uploaded successfully', 'success');
262 }294 }
263 295
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 @@
1 {1 {
2+ "type": "module",
2 "devDependencies": {3 "devDependencies": {
4+ "daisyui": "^5.0.6",
3 "prettier": "^3.5.3",5 "prettier": "^3.5.3",
4 "prettier-plugin-tailwindcss": "^0.5.14",6 "prettier-plugin-tailwindcss": "^0.5.14",
5 "tailwindcss": "^4.0.14"7 "tailwindcss": "^4.0.14"
@@ -9,7 +11,7 @@
9 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"11 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"
10 },12 },
11 "dependencies": {13 "dependencies": {
12- "caniuse-lite": "^1.0.30001705",14+ "@tailwindcss/cli": "^4.0.14",
13 "dotenv": "^16.4.7"15 "dotenv": "^16.4.7"
14 }16 }
15 }17 }
M pkg/docker/client.go +19 -3
@@ -28,14 +28,15 @@ func InitializeClient(config *Config) error {
28 return fmt.Errorf("sock field in Config is empty")28 return fmt.Errorf("sock field in Config is empty")
29 }29 }
30 30
31+ logger.Debug("Checking socket file existence", "socket_path", config.Sock)
31 if _, err := os.Stat(config.Sock); os.IsNotExist(err) {32 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)
33 return err34 return err
34 }35 }
35 36
36 // For Podman compatibility, we need to ensure we're using the right connection approach37 // For Podman compatibility, we need to ensure we're using the right connection approach
37 host := "unix://" + config.Sock38 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)
39 40
40 // Create the Docker client with appropriate options41 // Create the Docker client with appropriate options
41 cli, err := client.NewClientWithOpts(42 cli, err := client.NewClientWithOpts(
@@ -47,7 +48,18 @@ func InitializeClient(config *Config) error {
47 return fmt.Errorf("error initializing Docker client: %s", err)48 return fmt.Errorf("error initializing Docker client: %s", err)
48 }49 }
49 50
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)
51 dockerCli = cli63 dockerCli = cli
52 currentConfig = config64 currentConfig = config
53 return nil65 return nil
@@ -56,17 +68,20 @@ func InitializeClient(config *Config) error {
56 func CheckIfInitialized() error {68 func CheckIfInitialized() error {
57 if currentConfig == nil || currentConfig.Sock == "" {69 if currentConfig == nil || currentConfig.Sock == "" {
58 logger.Error("Docker client check failed", "error", "client not initialized or configuration missing")70 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")
59 return fmt.Errorf("docker client is not initialized or configuration is missing")72 return fmt.Errorf("docker client is not initialized or configuration is missing")
60 }73 }
61 74
62 socketPath := currentConfig.Sock75 socketPath := currentConfig.Sock
63 if _, err := os.Stat(socketPath); os.IsNotExist(err) {76 if _, err := os.Stat(socketPath); os.IsNotExist(err) {
64 logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath)77 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)
65 return fmt.Errorf("sock file does not exist: %s", socketPath)79 return fmt.Errorf("sock file does not exist: %s", socketPath)
66 }80 }
67 81
68 if dockerCli == nil {82 if dockerCli == nil {
69 logger.Error("Docker client check failed", "error", "client is nil")83 logger.Error("Docker client check failed", "error", "client is nil")
84+ logger.Debug("Docker client check failed: client is nil")
70 return fmt.Errorf("docker client is not initialized")85 return fmt.Errorf("docker client is not initialized")
71 }86 }
72 87
@@ -76,6 +91,7 @@ func CheckIfInitialized() error {
76 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})91 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})
77 if err != nil {92 if err != nil {
78 logger.Error("Docker client check failed", "error", err)93 logger.Error("Docker client check failed", "error", err)
94+ logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err)
79 return fmt.Errorf("cannot connect to Docker daemon: %s", err)95 return fmt.Errorf("cannot connect to Docker daemon: %s", err)
80 }96 }
81 97
M pkg/docker/containers.go +7 -5
@@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) {
100 if err != nil {100 if err != nil {
101 return nil, err101 return nil, err
102 }102 }
103- // Check if the list is empty103+ // Just return the containers, even if it's an empty slice
104- if len(containers) == 0 {
105- return nil, fmt.Errorf("no containers found")
106- }
107 return containers, nil104 return containers, nil
108 }105 }
109 106
@@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) {
420 417
421 // GetNetworkInfo returns information about a network418 // GetNetworkInfo returns information about a network
422 func GetNetworkInfo(networkName string) (*network.Inspect, error) {419 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+
423 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})425 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})
424 if err != nil {426 if err != nil {
425 return nil, err427 return nil, err
@@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s
947 logger.Debug("Container stopped",949 logger.Debug("Container stopped",
948 "container_id", event.id,950 "container_id", event.id,
949 "container_name", event.name)951 "container_name", event.name)
950- 952+
951 // Call the stop callback if provided953 // Call the stop callback if provided
952 if stopCallback != nil {954 if stopCallback != nil {
953 logger.Debug("Calling container stop callback",955 logger.Debug("Calling container stop callback",
M tailwind.config.js +39 -0
@@ -14,7 +14,46 @@ module.exports = {
14 lbeige: "#FFDCB3",14 lbeige: "#FFDCB3",
15 mbeige: "#D7B49F",15 mbeige: "#D7B49F",
16 },16 },
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+ },
17 },47 },
18 },48 },
19 },49 },
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+ },
20 };59 };
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