Merge branch 'feat/templ-integration' into dev
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 | 3 | before: | |
| 4 | 4 | hooks: | |
| 5 | 5 | - go mod tidy | |
| 6 | + | - templ generate | |
| 7 | + | - bun run build:css | |
| 6 | 8 | ||
| 7 | 9 | release: | |
| 8 | 10 | 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 | 20 | ARCHS := amd64 arm64 | |
| 21 | 21 | ||
| 22 | 22 | # Phony targets | |
| 23 | - | .PHONY: all build build-push clean build-css | |
| 23 | + | .PHONY: all build build-push clean build-css build-templ | |
| 24 | 24 | ||
| 25 | 25 | # Default target | |
| 26 | 26 | all: build | |
| 27 | 27 | ||
| 28 | 28 | # Build binaries | |
| 29 | - | build: build-css | |
| 29 | + | build: build-css build-templ | |
| 30 | 30 | @echo "Building Go binaries..." | |
| 31 | 31 | @mkdir -p $(DIST_DIR) | |
| 32 | 32 | @rm -f $(DIST_DIR)/* | |
| @@ -41,6 +41,12 @@ build-css: | |||
| 41 | 41 | @bun run build:css | |
| 42 | 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 | 50 | # Build and push Docker images | |
| 45 | 51 | build-push: build-css build | |
| 46 | 52 | @echo "Cleaning up dangling images..." | |
M docker-compose.yml.example +6 -1
| @@ -44,9 +44,11 @@ services: | |||
| 44 | 44 | # Reverse Proxy Configuration | |
| 45 | 45 | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 46 | 46 | - 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 | 48 | - GORDON_PROXY_CERT_DIR=/certs | |
| 48 | 49 | - 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 | 53 | # Session Secret for security | |
| 52 | 54 | - SESSION_SECRET=replace_with_secure_random_string | |
| @@ -97,8 +99,11 @@ networks: | |||
| 97 | 99 | # # Reverse Proxy Configuration | |
| 98 | 100 | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 99 | 101 | # - 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 | 103 | # - GORDON_PROXY_CERT_DIR=/certs | |
| 101 | 104 | # - 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 | 108 | # # Session Secret for security | |
| 104 | 109 | # - SESSION_SECRET=replace_with_secure_random_string | |
M go.mod +9 -0
| @@ -6,6 +6,7 @@ require ( | |||
| 6 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| 7 | 7 | github.com/Masterminds/semver/v3 v3.3.1 | |
| 8 | 8 | github.com/PuerkitoBio/goquery v1.10.2 | |
| 9 | + | github.com/a-h/templ v0.3.833 | |
| 9 | 10 | github.com/charmbracelet/bubbles v0.20.0 | |
| 10 | 11 | github.com/charmbracelet/lipgloss v1.0.0 | |
| 11 | 12 | github.com/charmbracelet/log v0.4.0 | |
| @@ -24,12 +25,20 @@ require ( | |||
| 24 | 25 | ) | |
| 25 | 26 | ||
| 26 | 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 | 31 | 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 | 34 | github.com/klauspost/compress v1.18.0 // indirect | |
| 29 | 35 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect | |
| 36 | + | github.com/natefinch/atomic v1.0.1 // indirect | |
| 30 | 37 | github.com/zeebo/xxh3 v1.0.2 // indirect | |
| 31 | 38 | go.mongodb.org/mongo-driver v1.17.3 // indirect | |
| 32 | 39 | 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 | 42 | 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 | 49 | type AdminConfig struct { | |
| 50 | - | Path string `yaml:"path"` | |
| 50 | + | Path string `yaml:"path"` | |
| 51 | + | AdminWebUI bool `yaml:"-"` | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | 54 | type GeneralConfig struct { | |
| 54 | 55 | StorageDir string `yaml:"storageDir"` | |
| 55 | 56 | Token string `yaml:"token"` | |
| 56 | 57 | LogLevel string `yaml:"logLevel"` | |
| 57 | - | GordonContainerID string `yaml:"gordonContainerID"` | |
| 58 | + | GordonContainerID string `yaml:"-"` | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | type HttpConfig struct { | |
| @@ -73,33 +74,39 @@ type ContainerEngineConfig struct { | |||
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | type ReverseProxyConfig struct { | |
| 76 | - | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 77 | - | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 78 | - | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 79 | - | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |
| 80 | - | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 81 | - | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |
| 82 | - | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 83 | - | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 84 | - | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 85 | - | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | |
| 86 | - | DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false) | |
| 77 | + | Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled | |
| 78 | + | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 79 | + | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 80 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 81 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |
| 82 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 83 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |
| 84 | + | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 85 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 86 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 87 | + | EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true) | |
| 88 | + | EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false) | |
| 89 | + | DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false) | |
| 90 | + | SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false) | |
| 87 | 91 | } | |
| 88 | 92 | ||
| 89 | 93 | // Default values | |
| 90 | 94 | var ( | |
| 91 | - | sock = "/var/run/docker.sock" | |
| 92 | - | podmansock = "/run/podman/podman.sock" | |
| 93 | - | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | |
| 94 | - | httpPort = "80" // Standard HTTP port | |
| 95 | - | certDir = "/certs" | |
| 96 | - | letsEncryptMode = "staging" | |
| 97 | - | autoRenew = true | |
| 98 | - | renewBefore = 30 // days | |
| 99 | - | cacheSize = 1000 // entries | |
| 100 | - | gracePeriod = 30 // seconds | |
| 101 | - | defaultLogLevel = "info" // Default log level | |
| 102 | - | disableRateLimit = false | |
| 95 | + | sock = "/var/run/docker.sock" | |
| 96 | + | podmansock = "/run/podman/podman.sock" | |
| 97 | + | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | |
| 98 | + | httpPort = "80" // Standard HTTP port | |
| 99 | + | certDir = "/certs" | |
| 100 | + | letsEncryptMode = "staging" | |
| 101 | + | autoRenew = true | |
| 102 | + | proxyEnabled = true // Default to enabled for backward compatibility | |
| 103 | + | renewBefore = 30 // days | |
| 104 | + | cacheSize = 1000 // entries | |
| 105 | + | gracePeriod = 30 // seconds | |
| 106 | + | defaultLogLevel = "info" // Default log level | |
| 107 | + | disableRateLimit = false | |
| 108 | + | detectUpstreamProxy = false // Default to disabled | |
| 109 | + | skipCertificates = false // Default to disabled | |
| 103 | 110 | ) | |
| 104 | 111 | ||
| 105 | 112 | // 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 | 163 | // 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 | 170 | if config.ReverseProxy.Port == "" { | |
| 158 | 171 | config.ReverseProxy.Port = reverseProxyPort | |
| 159 | 172 | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | |
| @@ -189,16 +202,30 @@ func applyDefaultsToConfig(config *Config) bool { | |||
| 189 | 202 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |
| 190 | 203 | defaultsApplied = true | |
| 191 | 204 | } | |
| 192 | - | // Set EnableLogs to true by default if not specified | |
| 193 | - | if !config.ReverseProxy.EnableLogs { | |
| 194 | - | config.ReverseProxy.EnableLogs = true | |
| 195 | - | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | |
| 205 | + | // Set EnableHttpLogs to true by default if not specified | |
| 206 | + | if !config.ReverseProxy.EnableHttpLogs { | |
| 207 | + | config.ReverseProxy.EnableHttpLogs = true | |
| 208 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |
| 209 | + | defaultsApplied = true | |
| 210 | + | } | |
| 211 | + | // Set EnableRateLimit to false by default if not specified | |
| 212 | + | if !config.ReverseProxy.EnableRateLimit { | |
| 213 | + | config.ReverseProxy.EnableRateLimit = false | |
| 214 | + | logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", false) | |
| 215 | + | defaultsApplied = true | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Set DetectUpstreamProxy to false by default if not specified | |
| 219 | + | if !config.ReverseProxy.DetectUpstreamProxy { | |
| 220 | + | config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy | |
| 221 | + | logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy) | |
| 196 | 222 | defaultsApplied = true | |
| 197 | 223 | } | |
| 198 | - | // Set DisableRateLimit to false by default if not specified | |
| 199 | - | if !config.ReverseProxy.DisableRateLimit { | |
| 200 | - | config.ReverseProxy.DisableRateLimit = false | |
| 201 | - | logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false) | |
| 224 | + | ||
| 225 | + | // Set SkipCertificates to false by default if not specified | |
| 226 | + | if !config.ReverseProxy.SkipCertificates { | |
| 227 | + | config.ReverseProxy.SkipCertificates = skipCertificates | |
| 228 | + | logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates) | |
| 202 | 229 | defaultsApplied = true | |
| 203 | 230 | } | |
| 204 | 231 | ||
| @@ -552,6 +579,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 552 | 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 | 601 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | |
| 556 | 602 | config.ReverseProxy.CertDir = val | |
| 557 | 603 | if printLogs { | |
| @@ -600,22 +646,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 600 | 646 | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | |
| 601 | 647 | } | |
| 602 | 648 | ||
| 603 | - | // Handle GORDON_PROXY_ENABLE_LOGS environment variable | |
| 604 | - | if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" { | |
| 649 | + | // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable | |
| 650 | + | if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" { | |
| 605 | 651 | enableLogs, err := strconv.ParseBool(val) | |
| 606 | 652 | if err == nil { | |
| 607 | - | config.ReverseProxy.EnableLogs = enableLogs | |
| 653 | + | 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 variable | |
| 613 | - | if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | |
| 665 | + | // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable | |
| 666 | + | if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" { | |
| 667 | + | enableRateLimit, err := strconv.ParseBool(val) | |
| 668 | + | if err == nil { | |
| 669 | + | config.ReverseProxy.EnableRateLimit = enableRateLimit | |
| 670 | + | } | |
| 671 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit) | |
| 672 | + | } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | |
| 673 | + | // For backward compatibility | |
| 614 | 674 | disableRateLimit, err := strconv.ParseBool(val) | |
| 615 | 675 | if err == nil { | |
| 616 | - | config.ReverseProxy.DisableRateLimit = disableRateLimit | |
| 676 | + | // Invert the logic | |
| 677 | + | config.ReverseProxy.EnableRateLimit = !disableRateLimit | |
| 678 | + | } | |
| 679 | + | logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit) | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable | |
| 683 | + | if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" { | |
| 684 | + | detectUpstream, err := strconv.ParseBool(val) | |
| 685 | + | if err == nil { | |
| 686 | + | config.ReverseProxy.DetectUpstreamProxy = detectUpstream | |
| 687 | + | } | |
| 688 | + | if printLogs { | |
| 689 | + | logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy) | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable | |
| 694 | + | if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" { | |
| 695 | + | skipCerts, err := strconv.ParseBool(val) | |
| 696 | + | if err == nil { | |
| 697 | + | config.ReverseProxy.SkipCertificates = skipCerts | |
| 698 | + | } | |
| 699 | + | if printLogs { | |
| 700 | + | logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates) | |
| 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 | 746 | Network: "gordon", | |
| 664 | 747 | }, | |
| 665 | 748 | ReverseProxy: ReverseProxyConfig{ | |
| 666 | - | Port: reverseProxyPort, | |
| 667 | - | HttpPort: httpPort, | |
| 668 | - | CertDir: certDir, | |
| 669 | - | AutoRenew: autoRenew, | |
| 670 | - | RenewBefore: renewBefore, | |
| 671 | - | LetsEncryptMode: letsEncryptMode, | |
| 672 | - | CacheSize: cacheSize, | |
| 673 | - | GracePeriod: gracePeriod, | |
| 674 | - | EnableLogs: true, | |
| 675 | - | DisableRateLimit: false, | |
| 749 | + | Port: reverseProxyPort, | |
| 750 | + | HttpPort: httpPort, | |
| 751 | + | CertDir: certDir, | |
| 752 | + | AutoRenew: autoRenew, | |
| 753 | + | RenewBefore: renewBefore, | |
| 754 | + | LetsEncryptMode: letsEncryptMode, | |
| 755 | + | CacheSize: cacheSize, | |
| 756 | + | GracePeriod: gracePeriod, | |
| 757 | + | EnableHttpLogs: true, | |
| 758 | + | EnableRateLimit: false, | |
| 676 | 759 | }, | |
| 677 | 760 | } | |
| 678 | 761 | ||
| @@ -802,16 +885,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 802 | 885 | Network: "gordon", | |
| 803 | 886 | }, | |
| 804 | 887 | ReverseProxy: ReverseProxyConfig{ | |
| 805 | - | Port: reverseProxyPort, | |
| 806 | - | HttpPort: httpPort, | |
| 807 | - | CertDir: certDir, | |
| 808 | - | AutoRenew: autoRenew, | |
| 809 | - | RenewBefore: renewBefore, | |
| 810 | - | LetsEncryptMode: letsEncryptMode, | |
| 811 | - | CacheSize: cacheSize, | |
| 812 | - | GracePeriod: gracePeriod, | |
| 813 | - | EnableLogs: true, | |
| 814 | - | DisableRateLimit: false, | |
| 888 | + | Port: reverseProxyPort, | |
| 889 | + | HttpPort: httpPort, | |
| 890 | + | CertDir: certDir, | |
| 891 | + | AutoRenew: autoRenew, | |
| 892 | + | RenewBefore: renewBefore, | |
| 893 | + | LetsEncryptMode: letsEncryptMode, | |
| 894 | + | CacheSize: cacheSize, | |
| 895 | + | GracePeriod: gracePeriod, | |
| 896 | + | EnableHttpLogs: true, | |
| 897 | + | EnableRateLimit: false, | |
| 815 | 898 | }, | |
| 816 | 899 | } | |
| 817 | 900 | ||
| @@ -884,6 +967,27 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 884 | 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 | 991 | // Apply the log level from configuration | |
| 888 | 992 | if config.General.LogLevel != "" { | |
| 889 | 993 | // Set the log level using our centralized logger | |
| @@ -1006,3 +1110,8 @@ func (c *HttpConfig) FullDomain() string { | |||
| 1006 | 1110 | func (c *Config) GetVersion() string { | |
| 1007 | 1111 | 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 | 19 | cc.PodmanSock = cc.Sock | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | config := NewDockerConfig(cc) | |
| 23 | - | err := docker.InitializeClient(config) | |
| 24 | - | if err != nil { | |
| 25 | - | // Check if it's a "socket doesn't exist" error | |
| 26 | - | if strings.Contains(err.Error(), "no such file or directory") { | |
| 27 | - | // Log the error | |
| 28 | - | log.Printf("Error initializing Docker client: %s", err) | |
| 29 | - | ||
| 30 | - | // Try alternative socket paths | |
| 31 | - | socketPaths := []string{ | |
| 32 | - | "/run/podman/podman.sock", // Standard path | |
| 33 | - | "/var/run/podman/podman.sock", // Alternative standard path | |
| 34 | - | os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros | |
| 35 | - | "/run/user/1000/podman/podman.sock", // User-specific socket | |
| 36 | - | } | |
| 22 | + | // If socket is empty, try to detect it automatically | |
| 23 | + | if cc.Sock == "" { | |
| 24 | + | // Try standard paths first | |
| 25 | + | standardPaths := []string{ | |
| 26 | + | "/var/run/docker.sock", // Standard Docker socket in containers | |
| 27 | + | "/run/podman/podman.sock", // Standard Podman path | |
| 28 | + | "/var/run/podman/podman.sock", // Alternative standard path | |
| 29 | + | } | |
| 37 | 30 | ||
| 38 | - | // Add Docker socket path as a fallback | |
| 39 | - | if !isPodmanSocket { | |
| 40 | - | socketPaths = append(socketPaths, "/var/run/docker.sock") | |
| 41 | - | } | |
| 31 | + | // Add home directory based path | |
| 32 | + | homePath := os.Getenv("HOME") | |
| 33 | + | if homePath != "" { | |
| 34 | + | standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Check standard paths first | |
| 38 | + | for _, path := range standardPaths { | |
| 39 | + | if _, err := os.Stat(path); err == nil { | |
| 40 | + | log.Printf("Auto-detected container engine socket at %s", path) | |
| 42 | 41 | ||
| 43 | - | for _, path := range socketPaths { | |
| 44 | - | // Skip if it's the path we already tried | |
| 45 | - | if path == cc.Sock { | |
| 46 | - | continue | |
| 42 | + | // Detect if this is a Podman socket | |
| 43 | + | isPodmanSocket = detectPodmanSocket(path) | |
| 44 | + | if isPodmanSocket { | |
| 45 | + | cc.Podman = true | |
| 46 | + | cc.PodmanSock = path | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | // Check if this socket exists | |
| 50 | - | if _, err := os.Stat(path); err == nil { | |
| 51 | - | log.Printf("Found alternative socket at %s", path) | |
| 49 | + | cc.Sock = path | |
| 50 | + | break | |
| 51 | + | } else { | |
| 52 | + | log.Printf("Socket not found at: %s", path) | |
| 53 | + | } | |
| 54 | + | } | |
| 52 | 55 | ||
| 53 | - | // Detect if this is a Podman socket | |
| 54 | - | isPodmanSocket = detectPodmanSocket(path) | |
| 55 | - | if isPodmanSocket { | |
| 56 | + | // If not found in standard paths, try common user IDs | |
| 57 | + | if cc.Sock == "" { | |
| 58 | + | log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...") | |
| 59 | + | ||
| 60 | + | // Try common user IDs from 1000 to 1100 (typical range for first users on Linux) | |
| 61 | + | for uid := 1000; uid <= 1100; uid++ { | |
| 62 | + | uidStr := fmt.Sprintf("%d", uid) | |
| 63 | + | podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr) | |
| 64 | + | dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr) | |
| 65 | + | ||
| 66 | + | // Check the Podman socket exists | |
| 67 | + | if _, err := os.Stat(podmanSocketPath); err == nil { | |
| 68 | + | // Check if the socket is accessible for reading/writing | |
| 69 | + | if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil { | |
| 70 | + | file.Close() | |
| 71 | + | log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath) | |
| 56 | 72 | cc.Podman = true | |
| 57 | - | cc.PodmanSock = path | |
| 73 | + | cc.PodmanSock = podmanSocketPath | |
| 74 | + | cc.Sock = podmanSocketPath | |
| 75 | + | break | |
| 76 | + | } else { | |
| 77 | + | log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err) | |
| 58 | 78 | } | |
| 79 | + | } | |
| 59 | 80 | ||
| 60 | - | cc.Sock = path | |
| 61 | - | ||
| 62 | - | // Try again with the new path | |
| 63 | - | newConfig := NewDockerConfig(cc) | |
| 64 | - | if err := docker.InitializeClient(newConfig); err == nil { | |
| 65 | - | return nil | |
| 81 | + | // Check Docker socket for this UID | |
| 82 | + | if _, err := os.Stat(dockerSocketPath); err == nil { | |
| 83 | + | if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil { | |
| 84 | + | file.Close() | |
| 85 | + | log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath) | |
| 86 | + | cc.Sock = dockerSocketPath | |
| 87 | + | break | |
| 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 available | |
| 71 | - | log.Printf("Docker/Podman functionality will be limited or unavailable") | |
| 72 | - | return fmt.Errorf("could not connect to container engine: %w", err) | |
| 94 | + | // If we found a socket, try to initialize Docker client | |
| 95 | + | if cc.Sock != "" { | |
| 96 | + | config := NewDockerConfig(cc) | |
| 97 | + | err := docker.InitializeClient(config) | |
| 98 | + | if err == nil { | |
| 99 | + | log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock) | |
| 100 | + | return nil | |
| 73 | 101 | } | |
| 74 | 102 | ||
| 75 | - | return err | |
| 103 | + | // Log the error but continue with fallback strategy | |
| 104 | + | log.Printf("Error initializing Docker client: %s", err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // If we failed to find or initialize a socket, try a comprehensive fallback scan | |
| 108 | + | // across all common socket locations and user IDs | |
| 109 | + | socketPaths := []string{ | |
| 110 | + | "/var/run/docker.sock", | |
| 111 | + | "/run/podman/podman.sock", | |
| 112 | + | "/var/run/podman/podman.sock", | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Add home directory based path | |
| 116 | + | homePath := os.Getenv("HOME") | |
| 117 | + | if homePath != "" { | |
| 118 | + | socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Skip if we already tried this path | |
| 122 | + | if cc.Sock != "" { | |
| 123 | + | skipPath := cc.Sock | |
| 124 | + | for i, path := range socketPaths { | |
| 125 | + | if path == skipPath { | |
| 126 | + | socketPaths = append(socketPaths[:i], socketPaths[i+1:]...) | |
| 127 | + | break | |
| 128 | + | } | |
| 129 | + | } | |
| 76 | 130 | } | |
| 77 | - | return nil | |
| 131 | + | ||
| 132 | + | // Try common user IDs from 1000 to 1100 | |
| 133 | + | for uid := 1000; uid <= 1100; uid++ { | |
| 134 | + | uidStr := fmt.Sprintf("%d", uid) | |
| 135 | + | podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr) | |
| 136 | + | dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr) | |
| 137 | + | ||
| 138 | + | // Check the Podman socket exists | |
| 139 | + | if _, err := os.Stat(podmanSocketPath); err == nil { | |
| 140 | + | // Check if the socket is accessible for reading/writing | |
| 141 | + | if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil { | |
| 142 | + | file.Close() | |
| 143 | + | log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath) | |
| 144 | + | socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path | |
| 145 | + | } else { | |
| 146 | + | log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Also check Docker socket for this UID | |
| 151 | + | if _, err := os.Stat(dockerSocketPath); err == nil { | |
| 152 | + | socketPaths = append(socketPaths, dockerSocketPath) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths)) | |
| 157 | + | ||
| 158 | + | for _, path := range socketPaths { | |
| 159 | + | // Skip if it's the path we already tried | |
| 160 | + | if path == cc.Sock { | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Check if this socket exists | |
| 165 | + | if _, err := os.Stat(path); err == nil { | |
| 166 | + | log.Printf("Found alternative socket at %s", path) | |
| 167 | + | ||
| 168 | + | // Detect if this is a Podman socket | |
| 169 | + | isPodmanSocket = detectPodmanSocket(path) | |
| 170 | + | if isPodmanSocket { | |
| 171 | + | cc.Podman = true | |
| 172 | + | cc.PodmanSock = path | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | cc.Sock = path | |
| 176 | + | ||
| 177 | + | // Try again with the new path | |
| 178 | + | newConfig := NewDockerConfig(cc) | |
| 179 | + | if err := docker.InitializeClient(newConfig); err == nil { | |
| 180 | + | log.Printf("Successfully initialized Docker client with socket: %s", path) | |
| 181 | + | return nil | |
| 182 | + | } else { | |
| 183 | + | log.Printf("Socket found but client initialization failed: %s", err) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // If we still haven't found a valid socket, give up but don't fail the application | |
| 189 | + | log.Printf("No working container engine socket found after comprehensive scan") | |
| 190 | + | log.Printf("Docker/Podman functionality will be limited or unavailable") | |
| 191 | + | return fmt.Errorf("could not find or connect to any container engine socket") | |
| 78 | 192 | } | |
| 79 | 193 | ||
| 80 | 194 | // 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 | 8 | "github.com/bnema/gordon/internal/server" | |
| 9 | 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 | 12 | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | 13 | "github.com/bnema/gordon/pkg/docker" | |
| 12 | 14 | "github.com/bnema/gordon/pkg/sanitize" | |
| @@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error { | |||
| 34 | 36 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 35 | 37 | if !exists { | |
| 36 | 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 | 42 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | |
| @@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error { | |||
| 49 | 51 | var imageName string | |
| 50 | 52 | if len(imageInfo.RepoTags) > 0 { | |
| 51 | 53 | imageName = imageInfo.RepoTags[0] | |
| 54 | + | } else { | |
| 55 | + | imageName = imageInfo.ID | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | - | data := map[string]interface{}{ | |
| 55 | - | "Title": "Create a new container", | |
| 56 | - | "ShortID": shortID, | |
| 57 | - | "ImageID": fullID, | |
| 58 | - | "ImageName": imageName, | |
| 58 | + | // Setup form data | |
| 59 | + | formData := templcomponents.ContainerFormData{ | |
| 60 | + | ImageName: imageName, | |
| 61 | + | ImageID: fullID, | |
| 62 | + | ShortID: shortID, | |
| 63 | + | ErrorMessage: "", | |
| 59 | 64 | } | |
| 60 | 65 | ||
| 61 | - | // Render the create container page | |
| 62 | - | rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a) | |
| 63 | - | ||
| 64 | - | if err != nil { | |
| 65 | - | return sendError(c, err) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 69 | - | ||
| 70 | - | if err != nil { | |
| 71 | - | return sendError(c, err) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | return c.HTML(200, renderedHTML) | |
| 66 | + | // Get the renderer | |
| 67 | + | renderer := render.NewTemplRenderer(a) | |
| 75 | 68 | ||
| 69 | + | // Render the component | |
| 70 | + | return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData)) | |
| 76 | 71 | } | |
| 77 | 72 | ||
| 78 | 73 | // 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 | 79 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 85 | 80 | if !exists { | |
| 86 | 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 | 85 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | |
| @@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error { | |||
| 99 | 94 | var imageName string | |
| 100 | 95 | if len(imageInfo.RepoTags) > 0 { | |
| 101 | 96 | imageName = imageInfo.RepoTags[0] | |
| 97 | + | } else { | |
| 98 | + | imageName = imageInfo.ID | |
| 102 | 99 | } | |
| 103 | 100 | ||
| 104 | - | data := map[string]interface{}{ | |
| 105 | - | "Title": "Create a new container", | |
| 106 | - | "ShortID": shortID, | |
| 107 | - | "ImageID": fullID, | |
| 108 | - | "ImageName": imageName, | |
| 109 | - | "AdminPath": a.Config.Admin.Path, | |
| 101 | + | // Setup form data | |
| 102 | + | formData := templcomponents.ContainerFormData{ | |
| 103 | + | ImageName: imageName, | |
| 104 | + | ImageID: fullID, | |
| 105 | + | ShortID: shortID, | |
| 106 | + | ErrorMessage: "", | |
| 110 | 107 | } | |
| 111 | 108 | ||
| 112 | - | // Specify both admin and fragments directories | |
| 113 | - | rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments") | |
| 114 | - | if err != nil { | |
| 115 | - | return sendError(c, err) | |
| 116 | - | } | |
| 109 | + | // Get the renderer | |
| 110 | + | renderer := render.NewTemplRenderer(a) | |
| 117 | 111 | ||
| 118 | - | renderedHTML, err := rendererData.Render(data, a) | |
| 119 | - | if err != nil { | |
| 120 | - | log.Debug("Error rendering template: %v", err) | |
| 121 | - | return sendError(c, err) | |
| 112 | + | // Render the createcontainerfull template | |
| 113 | + | pageData := templadmin.CreateContainerFullData{ | |
| 114 | + | Title: "Create Container | Gordon", | |
| 115 | + | BuildVersion: a.Config.Build.BuildVersion, | |
| 116 | + | AdminPath: a.Config.Admin.Path, | |
| 117 | + | UserSettings: "/user", | |
| 118 | + | LogoutURL: a.Config.Admin.Path + "/logout", | |
| 119 | + | ContainerData: formData, | |
| 122 | 120 | } | |
| 123 | 121 | ||
| 124 | - | return c.HTML(200, renderedHTML) | |
| 122 | + | return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData)) | |
| 125 | 123 | } | |
| 126 | 124 | ||
| 127 | 125 | // CreateContainerPOST handles the create container form submission | |
M internal/httpserve/handlers/container_management.go +58 -2
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/gordon/internal/templating/render" | |
| 13 | 13 | "github.com/bnema/gordon/pkg/docker" | |
| 14 | 14 | "github.com/bnema/gordon/pkg/humanize" | |
| 15 | + | "github.com/bnema/gordon/pkg/logger" | |
| 15 | 16 | "github.com/docker/docker/api/types" | |
| 16 | 17 | "github.com/labstack/echo/v4" | |
| 17 | 18 | ) | |
| @@ -58,6 +59,10 @@ type HumanReadableContainerImage struct { | |||
| 58 | 59 | SizeStr string | |
| 59 | 60 | RepoDigests []string | |
| 60 | 61 | RepoTags []string | |
| 62 | + | Repository string | |
| 63 | + | Tag string | |
| 64 | + | Size string | |
| 65 | + | Created string | |
| 61 | 66 | } | |
| 62 | 67 | ||
| 63 | 68 | type HumanReadableContainer struct { | |
| @@ -85,17 +90,33 @@ type ContainerDisplay struct { | |||
| 85 | 90 | ||
| 86 | 91 | // renderHTML is a generalized function to render HTML | |
| 87 | 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 | 95 | rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a) | |
| 89 | 96 | if err != nil { | |
| 97 | + | logger.Error("Error getting HTML renderer", "error", err) | |
| 90 | 98 | return err | |
| 91 | 99 | } | |
| 100 | + | ||
| 92 | 101 | renderedHTML, err := rendererData.Render(data, a) | |
| 93 | 102 | if err != nil { | |
| 103 | + | logger.Error("Error rendering HTML", "error", err) | |
| 94 | 104 | return err | |
| 95 | 105 | } | |
| 106 | + | ||
| 107 | + | logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML)) | |
| 96 | 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 | 120 | // ActionSuccess returns the success HTML fragment | |
| 100 | 121 | func ActionSuccess(a *server.App) string { | |
| 101 | 122 | // Create a data structure to pass to the template | |
| @@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string { | |||
| 114 | 135 | ||
| 115 | 136 | // ImageManagerComponent handles the /image-manager route (HTMX route) | |
| 116 | 137 | func ImageManagerComponent(c echo.Context, a *server.App) error { | |
| 138 | + | logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String()) | |
| 139 | + | ||
| 117 | 140 | images, err := docker.ListContainerImages() | |
| 118 | 141 | if err != nil { | |
| 142 | + | logger.Error("Error listing container images:", "error", err) | |
| 119 | 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 | 151 | var humanReadableImages []HumanReadableContainerImage | |
| 123 | 152 | ||
| 124 | 153 | for _, img := range images { | |
| @@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error { | |||
| 129 | 158 | sizeStr := humanize.BytesToReadableSize(img.Size) | |
| 130 | 159 | ||
| 131 | 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 | 169 | humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{ | |
| 133 | 170 | ID: img.ID, | |
| 134 | 171 | ShortID: shortID, | |
| @@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error { | |||
| 137 | 174 | Name: repoTag, | |
| 138 | 175 | RepoDigests: img.RepoDigests, | |
| 139 | 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 | 187 | "Images": humanReadableImages, | |
| 146 | 188 | } | |
| 147 | 189 | ||
| 190 | + | logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images") | |
| 148 | 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 | 214 | // ContainerManagerComponent handles the /container-manager route | |
| 172 | 215 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | |
| 216 | + | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | |
| 217 | + | ||
| 173 | 218 | containers, err := docker.ListRunningContainers() | |
| 174 | 219 | if err != nil { | |
| 220 | + | logger.Error("Error listing containers", "error", err) | |
| 175 | 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 | 229 | var humanReadableContainers []HumanReadableContainer | |
| 179 | 230 | ||
| 180 | 231 | for _, container := range containers { | |
| @@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||
| 212 | 263 | return err | |
| 213 | 264 | } | |
| 214 | 265 | data := map[string]interface{}{ | |
| 215 | - | "Lang": yamlData["Lang"], | |
| 216 | - | "containers": humanReadableContainers, | |
| 266 | + | "Lang": yamlData["Lang"], | |
| 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 | 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 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "log" | |
| 6 | 5 | "strings" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/gordon/internal/server" | |
| 9 | 8 | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 10 | 9 | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | 10 | "github.com/bnema/gordon/pkg/docker" | |
| 11 | + | "github.com/bnema/gordon/pkg/logger" | |
| 12 | 12 | "github.com/labstack/echo/v4" | |
| 13 | 13 | "gopkg.in/yaml.v3" | |
| 14 | 14 | ) | |
| @@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error { | |||
| 132 | 132 | for i, step := range tq.steps { | |
| 133 | 133 | err := step() | |
| 134 | 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 | 136 | transactionErrors = append(transactionErrors, err) | |
| 137 | 137 | for j := i - 1; j >= 0; j-- { | |
| 138 | 138 | rollback := tq.rollbacks[j] | |
| 139 | 139 | if rollback != nil { | |
| 140 | 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 | 142 | rollbackErrors = append(rollbackErrors, rollbackErr) | |
| 143 | 143 | } | |
| 144 | 144 | } | |
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/bnema/gordon/internal/proxy" | |
| 18 | 18 | "github.com/bnema/gordon/internal/server" | |
| 19 | 19 | "github.com/bnema/gordon/pkg/docker" | |
| 20 | - | "github.com/charmbracelet/log" | |
| 20 | + | "github.com/bnema/gordon/pkg/logger" | |
| 21 | 21 | "github.com/labstack/echo/v4" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| @@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||
| 26 | 26 | payload, err := validateAndPrepareDeployPayload(c) | |
| 27 | 27 | if err != nil { | |
| 28 | 28 | // Debug | |
| 29 | - | log.Error("Failed to validate and prepare payload", "error", err) | |
| 29 | + | logger.Error("Failed to validate and prepare payload", "error", err) | |
| 30 | 30 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 31 | 31 | Success: false, | |
| 32 | 32 | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| @@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||
| 37 | 37 | _, err = saveAndImportImage(c, a, payload) | |
| 38 | 38 | if err != nil { | |
| 39 | 39 | // Debug | |
| 40 | - | log.Error("Failed to save and import image", "error", err) | |
| 40 | + | logger.Error("Failed to save and import image", "error", err) | |
| 41 | 41 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 42 | 42 | Success: false, | |
| 43 | 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 | 48 | containerID, containerName, err := createAndStartContainer(a, payload) | |
| 49 | 49 | if err != nil { | |
| 50 | 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 | 52 | existingContainerID := extractContainerID(err.Error()) | |
| 53 | 53 | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 54 | 54 | // Remove the / from the container name | |
| 55 | 55 | existingContainerName = strings.TrimLeft(existingContainerName, "/") | |
| 56 | 56 | ||
| 57 | 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 | 59 | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 60 | 60 | Success: false, | |
| 61 | 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 | 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 | 75 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 76 | 76 | Success: false, | |
| 77 | 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 | 90 | containerIP := GetContainerIP(a, containerID, containerName) | |
| 91 | 91 | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | |
| 92 | 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 | 94 | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 95 | 95 | } | |
| 96 | 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 | 100 | // Arrived here means deployment was successful | |
| @@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||
| 124 | 124 | ||
| 125 | 125 | payload, err := validateAndPrepareDeployPayload(c) | |
| 126 | 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 | 128 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 129 | 129 | Success: false, | |
| 130 | 130 | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| @@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||
| 196 | 196 | // Chunks validation steps | |
| 197 | 197 | // | |
| 198 | 198 | if len(chunkData) == 0 { | |
| 199 | - | log.Error("Received empty chunk") | |
| 199 | + | logger.Error("Received empty chunk") | |
| 200 | 200 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 201 | 201 | Success: false, | |
| 202 | 202 | Message: "Received empty chunk", | |
| @@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||
| 206 | 206 | ||
| 207 | 207 | // Verify chunk size matches metadata | |
| 208 | 208 | if int64(len(chunkData)) != metadata.ChunkSize { | |
| 209 | - | log.Error("Chunk size mismatch", | |
| 209 | + | logger.Error("Chunk size mismatch", | |
| 210 | 210 | "expected", metadata.ChunkSize, | |
| 211 | 211 | "received", len(chunkData)) | |
| 212 | 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 | 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 | 240 | "transferID", transferID) | |
| 241 | 241 | ||
| 242 | 242 | // Get transfer data | |
| @@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||
| 296 | 296 | // Check for existing container | |
| 297 | 297 | existingContainerID := docker.GetContainerIDByName(containerName) | |
| 298 | 298 | if existingContainerID != "" { | |
| 299 | - | log.Warn("Container already exists", | |
| 299 | + | logger.Warn("Container already exists", | |
| 300 | 300 | "name", containerName, | |
| 301 | 301 | "id", existingContainerID) | |
| 302 | 302 | return sendDeployErrorResponse(c, "Container already exists", nil) | |
| 303 | 303 | } | |
| 304 | 304 | ||
| 305 | 305 | // Import the image | |
| 306 | - | log.Info("Importing image from file", | |
| 306 | + | logger.Info("Importing image from file", | |
| 307 | 307 | "path", tmpFile.Name(), | |
| 308 | 308 | "imageName", imageName) | |
| 309 | 309 | ||
| 310 | 310 | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 311 | 311 | if err != nil { | |
| 312 | 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 | 314 | "error", err, | |
| 315 | 315 | "imageName", imageName) | |
| 316 | 316 | return sendDeployErrorResponse(c, "Platform compatibility check failed", err) | |
| @@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||
| 343 | 343 | // Cleanup transfer data | |
| 344 | 344 | cleanupTransfer(transferID) | |
| 345 | 345 | ||
| 346 | - | log.Info("Deployment completed successfully", | |
| 346 | + | logger.Info("Deployment completed successfully", | |
| 347 | 347 | "containerID", containerID, | |
| 348 | 348 | "containerName", containerName, | |
| 349 | 349 | "domain", targetDomain) | |
| @@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||
| 352 | 352 | containerIP := GetContainerIP(a, containerID, containerName) | |
| 353 | 353 | err = AddProxyRoute(a, containerID, containerIP, port, targetDomain) | |
| 354 | 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 | 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 | 423 | // Double check that we have a valid container IP - critical for recreated containers | |
| 424 | 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 | 426 | "containerID", containerID, | |
| 427 | 427 | "invalid_ip", containerIP) | |
| 428 | 428 | ||
| @@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 437 | 437 | // Get the correct container IP with retry logic | |
| 438 | 438 | newContainerIP := GetContainerIP(a, containerID, containerName) | |
| 439 | 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 | 441 | "containerID", containerID, | |
| 442 | 442 | "old_ip", containerIP, | |
| 443 | 443 | "new_ip", newContainerIP) | |
| @@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 448 | 448 | // Get container info to check labels | |
| 449 | 449 | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 450 | 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 | 452 | } else { | |
| 453 | 453 | // Check if the container has a gordon.proxy.port label that might override containerPort | |
| 454 | 454 | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |
| 455 | 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 | 457 | "label_port", portLabel, | |
| 458 | 458 | "provided_port", containerPort) | |
| 459 | 459 | containerPort = portLabel | |
| @@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 467 | 467 | // Check if the target domain specifies HTTPS | |
| 468 | 468 | if strings.HasPrefix(strings.ToLower(targetDomain), "https://") { | |
| 469 | 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 | 471 | "domain", targetDomain, | |
| 472 | 472 | "protocol", protocol) | |
| 473 | 473 | } else { | |
| @@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 477 | 477 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |
| 478 | 478 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | |
| 479 | 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 | 481 | "domain", targetDomain, | |
| 482 | 482 | "containerID", containerID, | |
| 483 | 483 | "label_value", sslValue, | |
| @@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 496 | 496 | cleanDomain = strings.TrimPrefix(cleanDomain, ".") | |
| 497 | 497 | ||
| 498 | 498 | // 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 | 500 | "domain", cleanDomain) | |
| 501 | 501 | ||
| 502 | 502 | // Get container name for better logging | |
| 503 | 503 | containerName, err := docker.GetContainerName(containerID) | |
| 504 | 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 | 506 | } else { | |
| 507 | 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 | 511 | // 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 | 527 | if err == nil { | |
| 528 | 528 | // 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 | 530 | "domain", cleanDomain, | |
| 531 | 531 | "old_container_id", existingContainerID, | |
| 532 | 532 | "new_container_id", containerID) | |
| @@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 538 | 538 | networkName := a.Config.ContainerEngine.Network | |
| 539 | 539 | if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists && | |
| 540 | 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 | 542 | "domain", cleanDomain, | |
| 543 | 543 | "old_ip", containerIP, | |
| 544 | 544 | "new_ip", networkSettings.IPAddress) | |
| @@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 559 | 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 | 563 | "domain", cleanDomain, | |
| 564 | 564 | "old_container_id", existingContainerID, | |
| 565 | 565 | "new_container_id", containerID, | |
| @@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 579 | 579 | WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP) | |
| 580 | 580 | ||
| 581 | 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 | 583 | "expected", containerIP, | |
| 584 | 584 | "actual", updatedContainerIP) | |
| 585 | 585 | } else { | |
| 586 | - | log.Debug("Database verification passed: container IP updated correctly", | |
| 586 | + | logger.Debug("Database verification passed: container IP updated correctly", | |
| 587 | 587 | "container_ip", containerIP) | |
| 588 | 588 | } | |
| 589 | 589 | ||
| @@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 596 | 596 | routes := p.GetRoutes() | |
| 597 | 597 | if route, exists := routes[cleanDomain]; exists { | |
| 598 | 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 | 600 | "domain", cleanDomain, | |
| 601 | 601 | "expected", containerIP, | |
| 602 | 602 | "actual", route.ContainerIP) | |
| 603 | 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 | 605 | "domain", cleanDomain, | |
| 606 | 606 | "container_ip", containerIP) | |
| 607 | 607 | } | |
| @@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 623 | 623 | // and avoid database locking conflicts | |
| 624 | 624 | p.RegisterNewlyCreatedContainer(containerID) | |
| 625 | 625 | ||
| 626 | - | log.Debug("Added proxy route", | |
| 626 | + | logger.Debug("Added proxy route", | |
| 627 | 627 | "domain", targetDomain, | |
| 628 | 628 | "containerIP", containerIP, | |
| 629 | 629 | "containerPort", containerPort, | |
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
| @@ -18,7 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 19 | 19 | "github.com/bnema/gordon/pkg/docker" | |
| 20 | 20 | "github.com/bnema/gordon/pkg/filestore" | |
| 21 | - | "github.com/charmbracelet/log" | |
| 21 | + | "github.com/bnema/gordon/pkg/logger" | |
| 22 | 22 | "github.com/labstack/echo/v4" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| @@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) { | |||
| 71 | 71 | func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error { | |
| 72 | 72 | err := c.JSON(statusCode, response) | |
| 73 | 73 | if err != nil { | |
| 74 | - | log.Error("Failed to send JSON response", | |
| 74 | + | logger.Error("Failed to send JSON response", | |
| 75 | 75 | "error", err, | |
| 76 | 76 | "statusCode", statusCode) | |
| 77 | 77 | } | |
| @@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||
| 152 | 152 | imageID, err = docker.ImportImageToEngine(imagePath) | |
| 153 | 153 | if err != nil { | |
| 154 | 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 | 156 | "error", removeErr, | |
| 157 | 157 | "path", imagePath) | |
| 158 | 158 | } | |
| @@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||
| 161 | 161 | ||
| 162 | 162 | if imageID == "" { | |
| 163 | 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 | 165 | "error", removeErr, | |
| 166 | 166 | "path", imagePath) | |
| 167 | 167 | } | |
| @@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||
| 170 | 170 | ||
| 171 | 171 | err = filestore.RemoveFromStorage(imagePath) | |
| 172 | 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 | 174 | "error", err, | |
| 175 | 175 | "path", imagePath) | |
| 176 | 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 | 182 | // createAndStartContainer creates a container from the image, starts it and returns the container ID | |
| 183 | 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 | 185 | "imageName", payload.ImageName, | |
| 186 | 186 | "imageID", payload.ImageID, | |
| 187 | 187 | "port", payload.Port) | |
| @@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||
| 191 | 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 | 196 | containerID, err := docker.CreateContainer(params) | |
| 197 | 197 | if err != nil { | |
| 198 | 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 | 203 | containerName, err := docker.GetContainerName(containerID) | |
| 204 | 204 | if err != nil { | |
| 205 | 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 | 209 | "containerID", containerID, | |
| 210 | 210 | "name", containerName) | |
| 211 | 211 | ||
| @@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||
| 215 | 215 | // Get container logs if start failed | |
| 216 | 216 | logs, logErr := docker.GetContainerLogs(containerID) | |
| 217 | 217 | if logErr == nil { | |
| 218 | - | log.Error("Container start failed - container logs", | |
| 218 | + | logger.Error("Container start failed - container logs", | |
| 219 | 219 | "logs", logs, | |
| 220 | 220 | "containerID", containerID) | |
| 221 | 221 | } | |
| @@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||
| 223 | 223 | // Cleanup the failed container | |
| 224 | 224 | cleanupErr := docker.RemoveContainer(containerID) | |
| 225 | 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 | 227 | "containerID", containerID, | |
| 228 | 228 | "error", cleanupErr) | |
| 229 | 229 | } | |
| 230 | 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 | 234 | "containerID", containerID, | |
| 235 | 235 | "name", containerName) | |
| 236 | 236 | ||
| @@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool { | |||
| 292 | 292 | ||
| 293 | 293 | metadata := chunkStore.metadata[transferID] | |
| 294 | 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 | 296 | return false | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | chunksReceived := len(chunkStore.chunks[transferID]) | |
| 300 | 300 | isComplete := chunksReceived == metadata.TotalChunks | |
| 301 | 301 | ||
| 302 | - | log.Debug("Transfer completion check", | |
| 302 | + | logger.Debug("Transfer completion check", | |
| 303 | 303 | "transferID", transferID, | |
| 304 | 304 | "chunksReceived", chunksReceived, | |
| 305 | 305 | "totalExpected", metadata.TotalChunks, | |
| @@ -328,7 +328,7 @@ func cleanupOldTransfers() { | |||
| 328 | 328 | chunkStore.mu.Lock() | |
| 329 | 329 | for id, startTime := range chunkStore.started { | |
| 330 | 330 | if now.Sub(startTime) > transferTimeout { | |
| 331 | - | log.Debug("Cleaning up stale transfer", | |
| 331 | + | logger.Debug("Cleaning up stale transfer", | |
| 332 | 332 | "transferID", id, | |
| 333 | 333 | "age", now.Sub(startTime)) | |
| 334 | 334 | cleanupTransfer(id) | |
| @@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er | |||
| 376 | 376 | totalSize += int64(len(chunk)) | |
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | - | log.Debug("Starting chunk assembly", | |
| 379 | + | logger.Debug("Starting chunk assembly", | |
| 380 | 380 | "totalChunks", totalChunks, | |
| 381 | 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 | 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 | 439 | "totalBytesWritten", written, | |
| 440 | 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 | 464 | // If this is a retry, add an increasing delay | |
| 465 | 465 | if retry > 0 { | |
| 466 | 466 | sleepTime := time.Duration(500*retry) * time.Millisecond | |
| 467 | - | log.Debug("Retrying to get container IP", | |
| 467 | + | logger.Debug("Retrying to get container IP", | |
| 468 | 468 | "attempt", retry+1, | |
| 469 | 469 | "containerID", containerID, | |
| 470 | 470 | "delay", sleepTime) | |
| @@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||
| 486 | 486 | refreshedInfo, err := docker.GetContainerInfo(containerID) | |
| 487 | 487 | if err == nil { | |
| 488 | 488 | containerInfo = refreshedInfo | |
| 489 | - | log.Debug("Refreshed container info for IP retrieval", | |
| 489 | + | logger.Debug("Refreshed container info for IP retrieval", | |
| 490 | 490 | "containerName", containerName, | |
| 491 | 491 | "containerID", containerID) | |
| 492 | 492 | } else { | |
| @@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||
| 501 | 501 | networkName := a.Config.ContainerEngine.Network | |
| 502 | 502 | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 503 | 503 | containerIP = networkSettings.IPAddress | |
| 504 | - | log.Debug("Found container IP", | |
| 504 | + | logger.Debug("Found container IP", | |
| 505 | 505 | "containerName", containerName, | |
| 506 | 506 | "containerID", containerID, | |
| 507 | 507 | "containerIP", containerIP, | |
| @@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||
| 513 | 513 | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | |
| 514 | 514 | if networkSettings.IPAddress != "" { | |
| 515 | 515 | containerIP = networkSettings.IPAddress | |
| 516 | - | log.Debug("Found container IP in alternative network", | |
| 516 | + | logger.Debug("Found container IP in alternative network", | |
| 517 | 517 | "containerName", containerName, | |
| 518 | 518 | "containerID", containerID, | |
| 519 | 519 | "containerIP", containerIP, | |
| @@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||
| 533 | 533 | // Extract just the IP address from the CIDR format | |
| 534 | 534 | ipCIDR := endpoint.IPv4Address | |
| 535 | 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 | 537 | "containerName", containerName, | |
| 538 | 538 | "containerID", containerID, | |
| 539 | 539 | "containerIP", containerIP, | |
| @@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||
| 546 | 546 | ||
| 547 | 547 | // If we've tried multiple times and couldn't get an IP | |
| 548 | 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 | 550 | "error", lastErr, | |
| 551 | 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 | 555 | "containerName", containerName, | |
| 556 | 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 | 13 | // RegisterRoutes registers all routes and middlewares | |
| 14 | 14 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |
| 15 | - | ||
| 16 | 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 | 22 | // Initiate the session middleware | |
| 18 | 23 | e.Use(middleware.InitSessionMiddleware(a)) | |
| 19 | 24 | ||
| @@ -21,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||
| 21 | 26 | e.Use(middleware.LanguageDetection) | |
| 22 | 27 | ||
| 23 | 28 | AdminPath := a.Config.Admin.Path | |
| 29 | + | log.Debug("Binding login routes with AdminPath", "path", AdminPath) | |
| 24 | 30 | bindLoginRoute(e, a, AdminPath) | |
| 31 | + | ||
| 32 | + | log.Debug("Binding API endpoints") | |
| 25 | 33 | bindAPIEndpoints(e, a) | |
| 26 | 34 | ||
| 27 | 35 | // SetCommonDataMiddleware will pass data to the renderer | |
| @@ -36,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||
| 36 | 44 | // Color scheme detection for dark/light mode | |
| 37 | 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 | 51 | bindAdminRoute(e, a, AdminPath) | |
| 52 | + | ||
| 53 | + | log.Debug("Binding static routes") | |
| 40 | 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 | 64 | // Protect the root path with a 403 | |
| 44 | 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 | 70 | return e | |
| 49 | 71 | } | |
| 50 | 72 | ||
| @@ -100,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) { | |||
| 100 | 122 | ||
| 101 | 123 | // bindLoginRoute binds all login routes | |
| 102 | 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 | 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 | 134 | e.POST(adminPath+"/login/submit-token", func(c echo.Context) error { | |
| 107 | 135 | return handlers.HandleTokenSubmission(c, a) | |
| @@ -119,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |||
| 119 | 147 | ||
| 120 | 148 | // bindAdminRoute binds all admin routes | |
| 121 | 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 | 156 | adminGroup := e.Group(adminPath) | |
| 123 | 157 | // Since login is behind the admin path, we cannot use group middleware | |
| 124 | 158 | adminGroup.GET("", func(c echo.Context) error { | |
| @@ -126,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||
| 126 | 160 | }, middleware.RequireLogin(a)) | |
| 127 | 161 | ||
| 128 | 162 | adminGroup.GET("/manager", func(c echo.Context) error { | |
| 129 | - | return handlers.AdminManagerRoute(c, a) | |
| 163 | + | return handlers.RenderTemplManagerPage(c, a) | |
| 130 | 164 | }, middleware.RequireLogin(a)) | |
| 131 | 165 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | |
| 132 | 166 | return handlers.CreateContainerFullGET(c, a) | |
| @@ -145,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||
| 145 | 179 | ||
| 146 | 180 | // bindHTMXEndpoints binds all HTMX endpoints | |
| 147 | 181 | func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |
| 148 | - | // Create a group for /htmx endpoints | |
| 182 | + | // Skip if AdminWebUI is disabled | |
| 183 | + | if !a.Config.Admin.IsAdminWebUIEnabled() { | |
| 184 | + | log.Debug("Admin WebUI is disabled, skipping HTMX endpoints") | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Create a group for /htmx endpoints | |
| 149 | 189 | htmxGroup := e.Group("/htmx") | |
| 150 | 190 | ||
| 151 | 191 | // 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 | 203 | // List all containers | |
| 164 | 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 | 207 | // Stop a container | |
| 168 | 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 | 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 | 107 | // setupCertManager configures the autocert manager for Let's Encrypt | |
| 35 | 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 | 144 | // Create a cache directory if it doesn't exist | |
| 37 | 145 | dir := p.config.CertDir | |
| 38 | 146 | if dir == "" { | |
| @@ -169,11 +277,11 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) { | |||
| 169 | 277 | "expires_in", expiresInDays, | |
| 170 | 278 | "days", | |
| 171 | 279 | "type", certType) | |
| 172 | - | ||
| 280 | + | ||
| 173 | 281 | // Check if certificate matches current mode | |
| 174 | 282 | if p.config.LetsEncryptMode == "production" && certType == "staging" { | |
| 175 | 283 | logger.Info("Found staging certificate but production mode is enabled", | |
| 176 | - | "domain", domain, | |
| 284 | + | "domain", domain, | |
| 177 | 285 | "action", "will request new production certificate") | |
| 178 | 286 | return false, certType | |
| 179 | 287 | } else if p.config.LetsEncryptMode == "staging" && certType == "production" { | |
| @@ -183,7 +291,7 @@ func (p *Proxy) checkCertificateInCache(domain string) (bool, string) { | |||
| 183 | 291 | "domain", domain, | |
| 184 | 292 | "action", "using existing production certificate") | |
| 185 | 293 | } | |
| 186 | - | ||
| 294 | + | ||
| 187 | 295 | return true, certType | |
| 188 | 296 | } | |
| 189 | 297 | ||
| @@ -206,27 +314,27 @@ func getCertificateFileName(domain string, certType string) string { | |||
| 206 | 314 | func getCertificateType(cert *x509.Certificate) string { | |
| 207 | 315 | // Check issuer to determine the certificate type | |
| 208 | 316 | issuer := cert.Issuer.CommonName | |
| 209 | - | ||
| 317 | + | ||
| 210 | 318 | // Check for self-signed certificate | |
| 211 | 319 | if cert.Issuer.CommonName == cert.Subject.CommonName { | |
| 212 | 320 | return "self-signed" | |
| 213 | 321 | } | |
| 214 | - | ||
| 322 | + | ||
| 215 | 323 | // Check for Let's Encrypt staging certificate | |
| 216 | - | if strings.Contains(issuer, "STAGING") || | |
| 217 | - | strings.Contains(issuer, "Fake LE") || | |
| 218 | - | strings.Contains(issuer, "Counterfeit") || | |
| 219 | - | strings.Contains(issuer, "False Fennel") { | |
| 324 | + | if strings.Contains(issuer, "STAGING") || | |
| 325 | + | strings.Contains(issuer, "Fake LE") || | |
| 326 | + | strings.Contains(issuer, "Counterfeit") || | |
| 327 | + | strings.Contains(issuer, "False Fennel") { | |
| 220 | 328 | return "staging" | |
| 221 | 329 | } | |
| 222 | - | ||
| 330 | + | ||
| 223 | 331 | // Check for Let's Encrypt production certificate | |
| 224 | - | if strings.Contains(issuer, "Let's Encrypt") || | |
| 225 | - | strings.Contains(issuer, "R3") || | |
| 226 | - | strings.Contains(issuer, "E1") { | |
| 332 | + | if strings.Contains(issuer, "Let's Encrypt") || | |
| 333 | + | strings.Contains(issuer, "R3") || | |
| 334 | + | strings.Contains(issuer, "E1") { | |
| 227 | 335 | return "production" | |
| 228 | 336 | } | |
| 229 | - | ||
| 337 | + | ||
| 230 | 338 | // Unknown issuer | |
| 231 | 339 | return "unknown" | |
| 232 | 340 | } | |
| @@ -252,7 +360,7 @@ func isCertificateValid(certData []byte, domain string) (bool, float64, string, | |||
| 252 | 360 | ||
| 253 | 361 | // Determine the certificate type | |
| 254 | 362 | certType := getCertificateType(cert) | |
| 255 | - | ||
| 363 | + | ||
| 256 | 364 | // Check if the certificate is still valid | |
| 257 | 365 | now := time.Now() | |
| 258 | 366 | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | |
| @@ -303,21 +411,21 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 303 | 411 | getCertificateFileName(domain, "staging"), | |
| 304 | 412 | getCertificateFileName(domain, "production"), | |
| 305 | 413 | } | |
| 306 | - | ||
| 414 | + | ||
| 307 | 415 | // Check each possible certificate file using known patterns | |
| 308 | 416 | for _, baseName := range certFiles { | |
| 309 | 417 | certFile := filepath.Join(certDir, baseName) | |
| 310 | 418 | keyFile := filepath.Join(certDir, baseName+"+rsa") | |
| 311 | - | ||
| 419 | + | ||
| 312 | 420 | // Skip if either file doesn't exist | |
| 313 | 421 | if _, err := os.Stat(certFile); os.IsNotExist(err) { | |
| 314 | 422 | continue | |
| 315 | 423 | } | |
| 316 | - | ||
| 424 | + | ||
| 317 | 425 | if _, err := os.Stat(keyFile); os.IsNotExist(err) { | |
| 318 | 426 | continue | |
| 319 | 427 | } | |
| 320 | - | ||
| 428 | + | ||
| 321 | 429 | // If we found both files, validate the certificate | |
| 322 | 430 | certData, err := os.ReadFile(certFile) | |
| 323 | 431 | if err != nil { | |
| @@ -327,24 +435,24 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 327 | 435 | "error", err) | |
| 328 | 436 | continue | |
| 329 | 437 | } | |
| 330 | - | ||
| 438 | + | ||
| 331 | 439 | // Validate the certificate data | |
| 332 | 440 | validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |
| 333 | 441 | if !validCert { | |
| 334 | 442 | continue | |
| 335 | 443 | } | |
| 336 | - | ||
| 444 | + | ||
| 337 | 445 | logger.Info("Valid certificate found in filesystem with standard naming pattern", | |
| 338 | 446 | "domain", domain, | |
| 339 | 447 | "expires_in", expiresInDays, | |
| 340 | 448 | "days", | |
| 341 | 449 | "type", certType, | |
| 342 | 450 | "cert_file", certFile) | |
| 343 | - | ||
| 451 | + | ||
| 344 | 452 | // Check if certificate matches current mode | |
| 345 | 453 | if p.config.LetsEncryptMode == "production" && certType == "staging" { | |
| 346 | 454 | logger.Info("Found staging certificate but production mode is enabled", | |
| 347 | - | "domain", domain, | |
| 455 | + | "domain", domain, | |
| 348 | 456 | "action", "will request new production certificate") | |
| 349 | 457 | // Delete the staging certificate | |
| 350 | 458 | deleteCertificateFiles(certDir, baseName) | |
| @@ -356,7 +464,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 356 | 464 | "domain", domain, | |
| 357 | 465 | "action", "using existing production certificate") | |
| 358 | 466 | } | |
| 359 | - | ||
| 467 | + | ||
| 360 | 468 | // Log some debug information about the certificate files | |
| 361 | 469 | certFileInfo, _ := os.Stat(certFile) | |
| 362 | 470 | keyFileInfo, _ := os.Stat(keyFile) | |
| @@ -368,30 +476,30 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 368 | 476 | "key_file", keyFile, | |
| 369 | 477 | "key_size", keyFileInfo.Size(), | |
| 370 | 478 | "key_modified", keyFileInfo.ModTime()) | |
| 371 | - | ||
| 479 | + | ||
| 372 | 480 | // Attempt to add to autocert cache if it's not already there | |
| 373 | 481 | if certType == "production" || (certType == "staging" && p.config.LetsEncryptMode == "staging") { | |
| 374 | 482 | // Add to autocert cache for future use | |
| 375 | 483 | p.addCertificateToCache(domain, certFile, keyFile) | |
| 376 | 484 | } | |
| 377 | - | ||
| 485 | + | ||
| 378 | 486 | return true, certType | |
| 379 | 487 | } | |
| 380 | 488 | ||
| 381 | 489 | // 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 | 491 | // and domain_letsencrypt-staging | |
| 384 | 492 | logger.Debug("No standard certificate files found, searching for any matching certificates", | |
| 385 | 493 | "domain", domain) | |
| 386 | - | ||
| 494 | + | ||
| 387 | 495 | files, err := filepath.Glob(filepath.Join(certDir, domain+"*")) | |
| 388 | 496 | if err != nil { | |
| 389 | - | logger.Warn("Error searching for certificate files", | |
| 390 | - | "domain", domain, | |
| 497 | + | logger.Warn("Error searching for certificate files", | |
| 498 | + | "domain", domain, | |
| 391 | 499 | "error", err) | |
| 392 | 500 | return false, "" | |
| 393 | 501 | } | |
| 394 | - | ||
| 502 | + | ||
| 395 | 503 | // Extract base names without the path | |
| 396 | 504 | var candidates []string | |
| 397 | 505 | for _, file := range files { | |
| @@ -399,34 +507,34 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 399 | 507 | if strings.HasSuffix(file, "+rsa") { | |
| 400 | 508 | continue | |
| 401 | 509 | } | |
| 402 | - | ||
| 510 | + | ||
| 403 | 511 | baseFile := filepath.Base(file) | |
| 404 | 512 | // Check if the corresponding key file exists | |
| 405 | 513 | keyFile := file + "+rsa" | |
| 406 | 514 | if _, err := os.Stat(keyFile); os.IsNotExist(err) { | |
| 407 | 515 | continue | |
| 408 | 516 | } | |
| 409 | - | ||
| 517 | + | ||
| 410 | 518 | // Add this candidate | |
| 411 | 519 | candidates = append(candidates, baseFile) | |
| 412 | 520 | } | |
| 413 | - | ||
| 521 | + | ||
| 414 | 522 | // No certificate files found | |
| 415 | 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 | 525 | "domain", domain) | |
| 418 | 526 | return false, "" | |
| 419 | 527 | } | |
| 420 | - | ||
| 528 | + | ||
| 421 | 529 | logger.Debug("Found certificate candidates in glob search", | |
| 422 | 530 | "domain", domain, | |
| 423 | 531 | "candidates", candidates) | |
| 424 | - | ||
| 532 | + | ||
| 425 | 533 | // Process each candidate (if there are multiple matches) | |
| 426 | 534 | for _, baseName := range candidates { | |
| 427 | 535 | certFile := filepath.Join(certDir, baseName) | |
| 428 | 536 | keyFile := filepath.Join(certDir, baseName+"+rsa") | |
| 429 | - | ||
| 537 | + | ||
| 430 | 538 | // Read and validate the certificate | |
| 431 | 539 | certData, err := os.ReadFile(certFile) | |
| 432 | 540 | if err != nil { | |
| @@ -436,27 +544,27 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 436 | 544 | "error", err) | |
| 437 | 545 | continue | |
| 438 | 546 | } | |
| 439 | - | ||
| 547 | + | ||
| 440 | 548 | // Validate the certificate data | |
| 441 | 549 | validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |
| 442 | 550 | if !validCert { | |
| 443 | 551 | continue | |
| 444 | 552 | } | |
| 445 | - | ||
| 553 | + | ||
| 446 | 554 | logger.Info("Valid certificate found in filesystem with non-standard naming", | |
| 447 | 555 | "domain", domain, | |
| 448 | 556 | "expires_in", expiresInDays, | |
| 449 | 557 | "days", | |
| 450 | 558 | "type", certType, | |
| 451 | 559 | "cert_file", certFile) | |
| 452 | - | ||
| 560 | + | ||
| 453 | 561 | // If we're in production mode and this is a production certificate, use it | |
| 454 | 562 | if p.config.LetsEncryptMode == "production" && certType == "production" { | |
| 455 | 563 | // Attempt to add to autocert cache | |
| 456 | 564 | p.addCertificateToCache(domain, certFile, keyFile) | |
| 457 | 565 | return true, certType | |
| 458 | 566 | } | |
| 459 | - | ||
| 567 | + | ||
| 460 | 568 | // If we're in staging mode, prefer production certificates if available | |
| 461 | 569 | if p.config.LetsEncryptMode == "staging" { | |
| 462 | 570 | if certType == "production" { | |
| @@ -470,7 +578,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 470 | 578 | return true, certType | |
| 471 | 579 | } | |
| 472 | 580 | } | |
| 473 | - | ||
| 581 | + | ||
| 474 | 582 | // If this is a staging certificate but we're in production mode, ignore it | |
| 475 | 583 | // and continue checking other candidates | |
| 476 | 584 | if p.config.LetsEncryptMode == "production" && certType == "staging" { | |
| @@ -479,7 +587,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 479 | 587 | "cert_file", certFile) | |
| 480 | 588 | continue | |
| 481 | 589 | } | |
| 482 | - | ||
| 590 | + | ||
| 483 | 591 | // Fallback: use the certificate if it's valid | |
| 484 | 592 | // Attempt to add to autocert cache | |
| 485 | 593 | p.addCertificateToCache(domain, certFile, keyFile) | |
| @@ -495,7 +603,7 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) { | |||
| 495 | 603 | func deleteCertificateFiles(certDir string, baseName string) { | |
| 496 | 604 | certFile := filepath.Join(certDir, baseName) | |
| 497 | 605 | keyFile := filepath.Join(certDir, baseName+"+rsa") | |
| 498 | - | ||
| 606 | + | ||
| 499 | 607 | // Delete certificate file | |
| 500 | 608 | if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) { | |
| 501 | 609 | logger.Warn("Failed to delete certificate file", | |
| @@ -504,7 +612,7 @@ func deleteCertificateFiles(certDir string, baseName string) { | |||
| 504 | 612 | } else if !os.IsNotExist(err) { | |
| 505 | 613 | logger.Info("Deleted certificate file", "file", certFile) | |
| 506 | 614 | } | |
| 507 | - | ||
| 615 | + | ||
| 508 | 616 | // Delete key file | |
| 509 | 617 | if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) { | |
| 510 | 618 | logger.Warn("Failed to delete certificate key file", | |
| @@ -665,8 +773,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) { | |||
| 665 | 773 | // First check in the cache | |
| 666 | 774 | validInCache, cacheType := p.checkCertificateInCache(domain) | |
| 667 | 775 | if validInCache { | |
| 668 | - | logger.Debug("Certificate exists in cache", | |
| 669 | - | "domain", domain, | |
| 776 | + | logger.Debug("Certificate exists in cache", | |
| 777 | + | "domain", domain, | |
| 670 | 778 | "type", cacheType) | |
| 671 | 779 | return true, cacheType | |
| 672 | 780 | } | |
| @@ -674,8 +782,8 @@ func (p *Proxy) checkCertificateExists(domain string) (bool, string) { | |||
| 674 | 782 | // If not in cache, check the filesystem | |
| 675 | 783 | validInFS, fsType := p.checkCertificateInFilesystem(domain) | |
| 676 | 784 | if validInFS { | |
| 677 | - | logger.Debug("Certificate exists in filesystem but not in cache", | |
| 678 | - | "domain", domain, | |
| 785 | + | logger.Debug("Certificate exists in filesystem but not in cache", | |
| 786 | + | "domain", domain, | |
| 679 | 787 | "type", fsType) | |
| 680 | 788 | return true, fsType | |
| 681 | 789 | } | |
| @@ -699,6 +807,15 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 699 | 807 | 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 | 819 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | |
| 703 | 820 | host := adminDomain | |
| 704 | 821 | ips, err := net.LookupIP(host) | |
| @@ -721,7 +838,7 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 721 | 838 | ||
| 722 | 839 | // Check if we already have a valid certificate (cache or filesystem) | |
| 723 | 840 | hasValidCert, certType := p.checkCertificateExists(adminDomain) | |
| 724 | - | ||
| 841 | + | ||
| 725 | 842 | // If we have a valid cert that matches our mode, use it | |
| 726 | 843 | if hasValidCert { | |
| 727 | 844 | // Check for mode mismatch | |
| @@ -765,7 +882,7 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 765 | 882 | if certDir == "" { | |
| 766 | 883 | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 767 | 884 | } | |
| 768 | - | ||
| 885 | + | ||
| 769 | 886 | // Ensure certificate directory exists | |
| 770 | 887 | if err := os.MkdirAll(certDir, 0755); err != nil { | |
| 771 | 888 | logger.Warn("Failed to create certificate directory", | |
| @@ -863,15 +980,15 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 863 | 980 | if p.config.LetsEncryptMode == "staging" { | |
| 864 | 981 | certType = "staging" | |
| 865 | 982 | } | |
| 866 | - | ||
| 983 | + | ||
| 867 | 984 | // Try to rename the certificate files | |
| 868 | 985 | oldCertPath := filepath.Join(certDir, adminDomain) | |
| 869 | 986 | oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa") | |
| 870 | - | ||
| 987 | + | ||
| 871 | 988 | newName := getCertificateFileName(adminDomain, certType) | |
| 872 | 989 | newCertPath := filepath.Join(certDir, newName) | |
| 873 | 990 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | |
| 874 | - | ||
| 991 | + | ||
| 875 | 992 | // Only rename if the files exist and the new names are different | |
| 876 | 993 | if oldCertPath != newCertPath { | |
| 877 | 994 | // Check if old cert exists | |
| @@ -890,7 +1007,7 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 890 | 1007 | "from", oldCertPath, | |
| 891 | 1008 | "to", newCertPath) | |
| 892 | 1009 | } | |
| 893 | - | ||
| 1010 | + | ||
| 894 | 1011 | // Rename key | |
| 895 | 1012 | if _, err := os.Stat(oldKeyPath); err == nil { | |
| 896 | 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 | 1030 | logger.Info("Successfully obtained Let's Encrypt certificate", | |
| 914 | 1031 | "domain", adminDomain, | |
| 915 | 1032 | "type", certType) | |
| @@ -930,6 +1047,15 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 930 | 1047 | 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 | 1059 | // Extract hostname from domain, resolving it to see if it's publicly accessible | |
| 934 | 1060 | ips, err := net.LookupIP(domain) | |
| 935 | 1061 | if err != nil { | |
| @@ -951,7 +1077,7 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 951 | 1077 | ||
| 952 | 1078 | // Check if we already have a valid certificate (cache or filesystem) | |
| 953 | 1079 | hasValidCert, certType := p.checkCertificateExists(domain) | |
| 954 | - | ||
| 1080 | + | ||
| 955 | 1081 | // If we have a valid cert that matches our mode, use it | |
| 956 | 1082 | if hasValidCert { | |
| 957 | 1083 | // Check for mode mismatch | |
| @@ -995,7 +1121,7 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 995 | 1121 | if certDir == "" { | |
| 996 | 1122 | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 997 | 1123 | } | |
| 998 | - | ||
| 1124 | + | ||
| 999 | 1125 | // Ensure certificate directory exists | |
| 1000 | 1126 | if err := os.MkdirAll(certDir, 0755); err != nil { | |
| 1001 | 1127 | logger.Warn("Failed to create certificate directory", | |
| @@ -1093,15 +1219,15 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1093 | 1219 | if p.config.LetsEncryptMode == "staging" { | |
| 1094 | 1220 | certType = "staging" | |
| 1095 | 1221 | } | |
| 1096 | - | ||
| 1222 | + | ||
| 1097 | 1223 | // Try to rename the certificate files | |
| 1098 | 1224 | oldCertPath := filepath.Join(certDir, domain) | |
| 1099 | 1225 | oldKeyPath := filepath.Join(certDir, domain+"+rsa") | |
| 1100 | - | ||
| 1226 | + | ||
| 1101 | 1227 | newName := getCertificateFileName(domain, certType) | |
| 1102 | 1228 | newCertPath := filepath.Join(certDir, newName) | |
| 1103 | 1229 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | |
| 1104 | - | ||
| 1230 | + | ||
| 1105 | 1231 | // Only rename if the files exist and the new names are different | |
| 1106 | 1232 | if oldCertPath != newCertPath { | |
| 1107 | 1233 | // Check if old cert exists | |
| @@ -1120,7 +1246,7 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1120 | 1246 | "from", oldCertPath, | |
| 1121 | 1247 | "to", newCertPath) | |
| 1122 | 1248 | } | |
| 1123 | - | ||
| 1249 | + | ||
| 1124 | 1250 | // Rename key | |
| 1125 | 1251 | if _, err := os.Stat(oldKeyPath); err == nil { | |
| 1126 | 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 | 1269 | logger.Info("Successfully obtained Let's Encrypt certificate", | |
| 1144 | 1270 | "domain", domain, | |
| 1145 | 1271 | "type", certType) | |
| @@ -1224,15 +1350,15 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||
| 1224 | 1350 | if p.config.LetsEncryptMode == "staging" { | |
| 1225 | 1351 | certType = "staging" | |
| 1226 | 1352 | } | |
| 1227 | - | ||
| 1353 | + | ||
| 1228 | 1354 | // Rename the certificate files to include the type | |
| 1229 | 1355 | oldCertPath := filepath.Join(certDir, domain) | |
| 1230 | 1356 | oldKeyPath := filepath.Join(certDir, domain+"+rsa") | |
| 1231 | - | ||
| 1357 | + | ||
| 1232 | 1358 | newName := getCertificateFileName(domain, certType) | |
| 1233 | 1359 | newCertPath := filepath.Join(certDir, newName) | |
| 1234 | 1360 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | |
| 1235 | - | ||
| 1361 | + | ||
| 1236 | 1362 | // Only rename if the files exist and the new names are different | |
| 1237 | 1363 | if oldCertPath != newCertPath { | |
| 1238 | 1364 | // Check if old cert exists | |
| @@ -1251,7 +1377,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||
| 1251 | 1377 | "from", oldCertPath, | |
| 1252 | 1378 | "to", newCertPath) | |
| 1253 | 1379 | } | |
| 1254 | - | ||
| 1380 | + | ||
| 1255 | 1381 | // Rename key | |
| 1256 | 1382 | if _, err := os.Stat(oldKeyPath); err == nil { | |
| 1257 | 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 | 1400 | logger.Info("Successfully obtained Let's Encrypt certificate on retry", | |
| 1275 | 1401 | "domain", domain, | |
| 1276 | 1402 | "attempt", attempt, | |
| @@ -1326,7 +1452,7 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||
| 1326 | 1452 | "domain", domain, | |
| 1327 | 1453 | "max_retries", maxRetries, | |
| 1328 | 1454 | "fallback", "Generating self-signed certificate") | |
| 1329 | - | ||
| 1455 | + | ||
| 1330 | 1456 | // Generate a self-signed certificate for this domain | |
| 1331 | 1457 | fallbackCert, err := generateFallbackCertificates([]string{domain}) | |
| 1332 | 1458 | if err != nil { | |
| @@ -1338,13 +1464,13 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||
| 1338 | 1464 | selfSignedName := getCertificateFileName(domain, "self-signed") | |
| 1339 | 1465 | certPath := filepath.Join(certDir, selfSignedName) | |
| 1340 | 1466 | keyPath := filepath.Join(certDir, selfSignedName+"+rsa") | |
| 1341 | - | ||
| 1467 | + | ||
| 1342 | 1468 | // Save the certificate and key | |
| 1343 | 1469 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]}) | |
| 1344 | 1470 | keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey) | |
| 1345 | 1471 | if err == nil { | |
| 1346 | 1472 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) | |
| 1347 | - | ||
| 1473 | + | ||
| 1348 | 1474 | // Write files | |
| 1349 | 1475 | if err := os.WriteFile(certPath, certPEM, 0644); err != nil { | |
| 1350 | 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 | 1478 | "path", certPath, | |
| 1353 | 1479 | "error", err) | |
| 1354 | 1480 | } | |
| 1355 | - | ||
| 1481 | + | ||
| 1356 | 1482 | if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { | |
| 1357 | 1483 | logger.Error("Failed to write self-signed key to disk", | |
| 1358 | 1484 | "domain", domain, | |
| 1359 | 1485 | "path", keyPath, | |
| 1360 | 1486 | "error", err) | |
| 1361 | 1487 | } | |
| 1362 | - | ||
| 1488 | + | ||
| 1363 | 1489 | logger.Info("Generated and saved self-signed fallback certificate", | |
| 1364 | 1490 | "domain", domain, | |
| 1365 | 1491 | "cert_path", certPath, | |
M internal/proxy/containers.go +7 -5
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package proxy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | 4 | "os" | |
| 6 | 5 | "strings" | |
| 7 | 6 | ||
| @@ -12,11 +11,11 @@ import ( | |||
| 12 | 11 | // scanContainersAndCheckCertificates scans all containers from the Gordon network | |
| 13 | 12 | // and checks if we have certificates for their domains and subdomains | |
| 14 | 13 | func (p *Proxy) scanContainersAndCheckCertificates() error { | |
| 15 | - | // Get the network name from app config | |
| 16 | 14 | networkName := p.app.GetConfig().ContainerEngine.Network | |
| 17 | - | ||
| 18 | 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 | 21 | logger.Info("Scanning containers in network for certificate checks", | |
| @@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error { | |||
| 25 | 24 | // Try to get network info using the docker client | |
| 26 | 25 | networkInfo, err := docker.GetNetworkInfo(networkName) | |
| 27 | 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 | 33 | // Skip admin cert check if environment variable is set | |
M internal/proxy/core.go +34 -6
| @@ -22,7 +22,6 @@ import ( | |||
| 22 | 22 | // - certificates.go - Certificate management functions | |
| 23 | 23 | // - routes.go - Route management functions | |
| 24 | 24 | // - middleware.go - HTTP middleware functions | |
| 25 | - | // - blacklist.go - IP blacklisting functionality | |
| 26 | 25 | // - server.go - Server startup and shutdown | |
| 27 | 26 | // - utils.go - Utility functions | |
| 28 | 27 | ||
| @@ -58,6 +57,10 @@ type Proxy struct { | |||
| 58 | 57 | recentContainersMu sync.RWMutex | |
| 59 | 58 | containerCooldownPeriod time.Duration | |
| 60 | 59 | ||
| 60 | + | // Upstream proxy detection | |
| 61 | + | upstreamProxyDetected bool | |
| 62 | + | upstreamProxyMu sync.RWMutex | |
| 63 | + | ||
| 61 | 64 | // SQL queries | |
| 62 | 65 | queries *queries.ProxyQueries | |
| 63 | 66 | } | |
| @@ -66,8 +69,36 @@ type Proxy struct { | |||
| 66 | 69 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |
| 67 | 70 | logger.Debug("Initializing reverse proxy") | |
| 68 | 71 | ||
| 69 | - | // Log important configuration information | |
| 70 | - | logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol") | |
| 72 | + | // Set up the config | |
| 73 | + | config := app.GetConfig().ReverseProxy | |
| 74 | + | ||
| 75 | + | // Check if proxy is disabled through configuration | |
| 76 | + | if !config.Enabled { | |
| 77 | + | logger.Info("Reverse proxy is disabled via configuration") | |
| 78 | + | // Return a non-active proxy instance | |
| 79 | + | return &Proxy{ | |
| 80 | + | config: config, | |
| 81 | + | app: app, | |
| 82 | + | serverStarted: false, | |
| 83 | + | shutdown: make(chan struct{}), | |
| 84 | + | }, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Check if running inside a container | |
| 88 | + | isContainer := docker.IsRunningInContainer() | |
| 89 | + | logger.Debug("Container environment check", "is_running_in_container", isContainer) | |
| 90 | + | ||
| 91 | + | if !isContainer { | |
| 92 | + | logger.Info("Gordon is not running in a container, proxy will be disabled automatically") | |
| 93 | + | // Override the enabled setting to false | |
| 94 | + | config.Enabled = false | |
| 95 | + | return &Proxy{ | |
| 96 | + | config: config, | |
| 97 | + | app: app, | |
| 98 | + | serverStarted: false, | |
| 99 | + | shutdown: make(chan struct{}), | |
| 100 | + | }, nil | |
| 101 | + | } | |
| 71 | 102 | ||
| 72 | 103 | // Set up the echo server for HTTPS traffic | |
| 73 | 104 | httpsServer := echo.New() | |
| @@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 77 | 108 | httpServer := echo.New() | |
| 78 | 109 | httpServer.HideBanner = true | |
| 79 | 110 | ||
| 80 | - | // Set up the config | |
| 81 | - | config := app.GetConfig().ReverseProxy | |
| 82 | - | ||
| 83 | 111 | // Detect and store our container ID (if we're running in a container) | |
| 84 | 112 | var ourContainerID string | |
| 85 | 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 | 2 | <html lang="en"> | |
| 3 | 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 | 6 | {{template "menu.gohtml" .}} | |
| 7 | 7 | <!-- Main content here --> | |
| 8 | 8 | <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6"> | |
| 9 | 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"> | |
| 11 | - | <h1 class="text-2xl font-bold mb-4">Edit Container</h1> | |
| 10 | + | <div class="card flex-grow"> | |
| 11 | + | <h1 class="card-header mb-4">Edit Container</h1> | |
| 12 | 12 | <!-- Include the existing edit container fragment here --> | |
| 13 | 13 | {{template "createcontainer.gohtml" .}} | |
| 14 | 14 | </div> | |
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | <div id="dashboard"> | |
| 2 | 2 | <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 | 5 | Hello World from dashboard.html | |
| 6 | 6 | </h1> | |
M internal/templating/models/html/admin/index.gohtml +9 -9
| @@ -2,23 +2,23 @@ | |||
| 2 | 2 | <html lang="en"> | |
| 3 | 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 | 6 | {{template "menu.gohtml" .}} | |
| 7 | 7 | <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4"> | |
| 8 | 8 | <div | |
| 9 | 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"> | |
| 11 | - | <h2>{{index .Lang "dashboard" "content" "title"}}</h2> | |
| 10 | + | <div class="card flex-grow"> | |
| 11 | + | <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2> | |
| 12 | 12 | </div> | |
| 13 | 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"> | |
| 15 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | |
| 14 | + | <div class="card"> | |
| 15 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 16 | 16 | </div> | |
| 17 | - | <div class="bg-white p-4 rounded-md shadow mb-4"> | |
| 18 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | |
| 17 | + | <div class="card"> | |
| 18 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 19 | 19 | </div> | |
| 20 | - | <div class="bg-white p-4 rounded-md shadow mb-4"> | |
| 21 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | |
| 20 | + | <div class="card"> | |
| 21 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 22 | 22 | </div> | |
| 23 | 23 | </div> | |
| 24 | 24 | </div> | |
M internal/templating/models/html/admin/install.gohtml +7 -7
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | </head> | |
| 22 | 22 | ||
| 23 | 23 | <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 | 25 | hx-boost="true" | |
| 26 | 26 | > | |
| 27 | 27 | <!-- Logo and Title --> | |
| @@ -38,28 +38,28 @@ | |||
| 38 | 38 | </div> | |
| 39 | 39 | <div class="flex justify-center mt-4"> | |
| 40 | 40 | <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 | 43 | Gordon | |
| 44 | 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 | 46 | >v0.0.1</span | |
| 47 | 47 | > | |
| 48 | 48 | </div> | |
| 49 | 49 | </div> | |
| 50 | 50 | ||
| 51 | 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 | 53 | <!-- Built-in Proxy --> | |
| 54 | 54 | <div id="built-in-proxy" class="mt-8"> | |
| 55 | 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 | 57 | <span class="text-white font-bold">✓</span> | |
| 58 | 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 | 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 | 63 | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | |
| 64 | 64 | <p>The proxy automatically:</p> | |
| 65 | 65 | <ul class="list-disc ml-6 mb-4"> | |
M internal/templating/models/html/admin/manager.gohtml +65 -23
| @@ -1,34 +1,76 @@ | |||
| 1 | 1 | <!-- manager.gohtml --> | |
| 2 | 2 | <!DOCTYPE html> | |
| 3 | - | <html lang="en"> | |
| 3 | + | <html lang="en" data-theme="gordon"> | |
| 4 | 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 | 7 | {{template "menu.gohtml" .}} | |
| 8 | - | <!-- Main content here --> | |
| 9 | - | <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6"> | |
| 10 | - | <div class="flex flex-col md:flex-row w-full flex-grow"> | |
| 11 | - | <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4"> | |
| 12 | - | <!-- List all the containers --> | |
| 13 | - | <div | |
| 14 | - | id="container-manager" | |
| 15 | - | hx-get="/htmx/container-manager" | |
| 16 | - | hx-trigger="load" | |
| 17 | - | hx-swap="innerHTML" | |
| 18 | - | hx-swap-oob="true" | |
| 19 | - | ></div> | |
| 20 | - | <!-- List all the images --> | |
| 21 | - | <div | |
| 22 | - | id="image-manager" | |
| 23 | - | hx-get="/htmx/image-manager" | |
| 24 | - | hx-trigger="load" | |
| 25 | - | hx-swap="innerHTML" | |
| 26 | - | hx-swap-oob="true" | |
| 27 | - | ></div> | |
| 8 | + | ||
| 9 | + | <!-- Main content --> | |
| 10 | + | <div class="container mx-auto py-6 px-4 mt-16"> | |
| 11 | + | <div class="text-sm breadcrumbs mb-4"> | |
| 12 | + | <ul> | |
| 13 | + | <li><a href="{{ .AdminPath }}">Gordon</a></li> | |
| 14 | + | <li>Manager</li> | |
| 15 | + | </ul> | |
| 28 | 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 | 72 | </div> | |
| 30 | 73 | ||
| 31 | - | <!-- footer --> | |
| 32 | 74 | {{template "footer.gohtml" .}} | |
| 33 | 75 | </body> | |
| 34 | 76 | </html> | |
M internal/templating/models/html/fragments/components.gohtml +55 -12
| @@ -1,20 +1,24 @@ | |||
| 1 | 1 | <!-- @format --> | |
| 2 | 2 | ||
| 3 | - | <div id="header"> | |
| 4 | - | <h1 | |
| 5 | - | class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s" | |
| 6 | - | > | |
| 7 | - | {{ .body.header.helloworld }} | |
| 8 | - | </h1> | |
| 3 | + | <div id="header" class="hero bg-base-200"> | |
| 4 | + | <div class="hero-content text-center"> | |
| 5 | + | <div class="max-w-md"> | |
| 6 | + | <h1 class="text-5xl font-bold gordon-title"> | |
| 7 | + | {{ .body.header.helloworld }} | |
| 8 | + | </h1> | |
| 9 | + | <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p> | |
| 10 | + | <button class="btn btn-primary">Get Started</button> | |
| 11 | + | </div> | |
| 12 | + | </div> | |
| 9 | 13 | </div> | |
| 10 | 14 | ||
| 11 | - | <div id="hello"> | |
| 12 | - | <h1 | |
| 13 | - | class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s" | |
| 14 | - | > | |
| 15 | - | {{ .body.div.hello }} | |
| 16 | - | </h1> | |
| 15 | + | <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4"> | |
| 16 | + | <div> | |
| 17 | + | <span class="iconfb"></span> | |
| 18 | + | <span class="IBMPlexSans">{{ .body.div.hello }}</span> | |
| 19 | + | </div> | |
| 17 | 20 | </div> | |
| 21 | + | ||
| 18 | 22 | <div | |
| 19 | 23 | id="header" | |
| 20 | 24 | hx-get="/htmx" | |
| @@ -31,3 +35,42 @@ | |||
| 31 | 35 | hx-trigger="load" | |
| 32 | 36 | hx-swap="outerHTML" | |
| 33 | 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"> | |
| 2 | - | <div class="flex justify-between items-center mb-1"> | |
| 3 | - | <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue"> | |
| 4 | - | {{index .Lang "manager" "content" "h1-containers"}} | |
| 5 | - | </h1> | |
| 6 | - | <button | |
| 7 | - | id="refresh-containers-button" | |
| 8 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | |
| 9 | - | title="Refresh container list" | |
| 10 | - | > | |
| 11 | - | <span class="iconf text-xl align-middle"> </span> | |
| 12 | - | Refresh | |
| 13 | - | </button> | |
| 14 | - | </div> | |
| 15 | - | <table class="p-4 border-t-2 table-auto"> | |
| 16 | - | <thead> | |
| 17 | - | <tr class="text-center text-gray-400"> | |
| 18 | - | <th>{{index .Lang "manager" "content" "thName"}}</th> | |
| 19 | - | <th>{{index .Lang "manager" "content" "thPorts"}}</th> | |
| 20 | - | <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th> | |
| 21 | - | <th>{{index .Lang "manager" "content" "thStatus"}}</th> | |
| 22 | - | <th>{{index .Lang "manager" "content" "thState"}}</th> | |
| 23 | - | <th>{{index .Lang "manager" "content" "thActions"}}</th> | |
| 24 | - | </tr> | |
| 25 | - | </thead> | |
| 26 | - | <tbody> | |
| 27 | - | {{ $Lang := .Lang }} | |
| 28 | - | {{ | |
| 29 | - | range.containers | |
| 30 | - | }} | |
| 31 | - | <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100"> | |
| 32 | - | <td> | |
| 33 | - | <span | |
| 34 | - | title="{{.Container.ID}}" | |
| 35 | - | class="font-bold" | |
| 36 | - | >{{.Name}}</span | |
| 37 | - | > | |
| 38 | - | </td> | |
| 39 | - | <td class="port-container">{{ .Ports }}</td> | |
| 40 | - | <td class="entrypoint-container">{{.ProxyPort}}</td> | |
| 41 | - | <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td> | |
| 42 | - | <td style="color: {{.StateColor}};">{{.State}}</td> | |
| 43 | - | <td> | |
| 44 | - | <button | |
| 45 | - | id="start-button-{{.ID}}" | |
| 46 | - | hx-post="/htmx/container-manager/start/{{.ID}}" | |
| 47 | - | hx-target="#actionResponse-{{.ID}}" | |
| 48 | - | hx-target-*="#error-message-{{.ID}}" | |
| 49 | - | hx-swap="outerHTML" | |
| 50 | - | class="py-2 px-4" | |
| 51 | - | > | |
| 52 | - | <span title="Start the container" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span> | |
| 53 | - | </button> | |
| 54 | - | <!-- EDIT --> | |
| 55 | - | <button | |
| 56 | - | id="edit-button-{{.ID}}" | |
| 57 | - | hx-get="/htmx/container-manager/edit/{{.ID}}" | |
| 58 | - | hx-target="#actionResponse-{{.ID}}" | |
| 59 | - | hx-target-*="#error-message-{{.ID}}" | |
| 60 | - | hx-swap="innerHTML" | |
| 61 | - | class="py-2 px-4" | |
| 62 | - | data-active="false" | |
| 63 | - | > | |
| 64 | - | <span title="Edit the container" class="iconf text-2xl text-blue-600 hover:text-gordonmascot-hblue"></span> | |
| 65 | - | </button> | |
| 66 | - | <!-- stop --> | |
| 67 | - | <button | |
| 68 | - | id="stop-button-{{.ID}}" | |
| 69 | - | hx-post="/htmx/container-manager/stop/{{.ID}}" | |
| 70 | - | hx-target="#actionResponse-{{.ID}}" | |
| 71 | - | hx-target-*="#error-message-{{.ID}}" | |
| 72 | - | hx-swap="innerHTML" | |
| 73 | - | hx-confirm="{{index $Lang "manager" "content" "confirm-container-stop"}}" | |
| 74 | - | class="py-2 px-4" | |
| 75 | - | > | |
| 76 | - | <span title="Stop the container" class="iconf text-xl text-red-600 hover:text-red-900"></span> | |
| 77 | - | </button> | |
| 78 | - | <!-- REMOVE --> | |
| 79 | - | <button | |
| 80 | - | id="remove-button-img-{{.ID}}" | |
| 81 | - | hx-delete="/htmx/container-manager/delete/{{.ID}}" | |
| 82 | - | hx-target="#actionResponse-{{.ID}}" | |
| 83 | - | hx-target-*="#error-message-{{.ID}}" | |
| 84 | - | hx-swap="innerHTML" | |
| 85 | - | hx-confirm="{{index $Lang "manager" "content" "confirm-delete"}}" | |
| 86 | - | class="py-2 px-4" | |
| 87 | - | > | |
| 88 | - | <!-- iconoir trash --> | |
| 89 | - | <span title="Remove the container" class="iconf text-xl text-red-600 hover:text-red-900"></span> | |
| 90 | - | </button> | |
| 91 | - | </td> | |
| 1 | + | <!-- Container list using daisyUI components --> | |
| 2 | + | <div class="card w-full bg-base-300"> | |
| 3 | + | <div class="card-body p-4"> | |
| 4 | + | ||
| 5 | + | <!-- Action buttons --> | |
| 6 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |
| 7 | + | <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3> | |
| 8 | + | <div class="btn-group"> | |
| 9 | + | <button | |
| 10 | + | class="btn btn-sm btn-primary" | |
| 11 | + | hx-get="/htmx/container-manager" | |
| 12 | + | hx-target="#container-manager" | |
| 13 | + | hx-swap="innerHTML"> | |
| 14 | + | <span class="iconf"></span> Refresh | |
| 15 | + | </button> | |
| 16 | + | </div> | |
| 17 | + | </div> | |
| 18 | + | ||
| 19 | + | <!-- Container list --> | |
| 20 | + | <table class="table"> | |
| 21 | + | <!-- head --> | |
| 22 | + | <thead> | |
| 23 | + | <tr> | |
| 24 | + | <th>Name</th> | |
| 25 | + | <th>Image</th> | |
| 26 | + | <th>Status</th> | |
| 27 | + | <th>Created</th> | |
| 28 | + | <th>Actions</th> | |
| 29 | + | </tr> | |
| 30 | + | </thead> | |
| 31 | + | <tbody> | |
| 32 | + | {{ if or .NoContainer (eq (len .Containers) 0) }} | |
| 33 | + | <tr> | |
| 34 | + | <td colspan="5" class="text-center py-4"> | |
| 35 | + | <div class="alert alert-info"> | |
| 36 | + | <span class="iconf">ℹ️</span> | |
| 37 | + | <span>No containers found. Upload an image first, then create a container.</span> | |
| 38 | + | </div> | |
| 39 | + | </td> | |
| 92 | 40 | </tr> | |
| 93 | - | <tr> | |
| 94 | - | <td colspan="7"> | |
| 95 | - | <div id="actionResponse-{{.ID}}"></div> | |
| 96 | - | <div id="error-message-{{.ID}}" class="text-red-500"></div> | |
| 97 | - | </td> | |
| 98 | - | </tr> | |
| 99 | - | {{ else }} | |
| 100 | - | <tr> | |
| 101 | - | <td colspan="7" class="mt-2"> | |
| 102 | - | <p class="text-center text-gray-400 text-xl"> | |
| 103 | - | {{index $Lang "manager" "content" "no-containers"}} | |
| 104 | - | </p> | |
| 105 | - | </td> | |
| 106 | - | </tr> | |
| 107 | - | {{ | |
| 108 | - | end | |
| 109 | - | }} | |
| 110 | - | </tbody> | |
| 111 | - | </table> | |
| 41 | + | {{ else }} | |
| 42 | + | {{ range .Containers }} | |
| 43 | + | <tr class="hover"> | |
| 44 | + | <td> | |
| 45 | + | <div class="flex items-center gap-3"> | |
| 46 | + | <div> | |
| 47 | + | <div class="font-bold">{{ .Names }}</div> | |
| 48 | + | <div class="text-sm opacity-50">{{ .ID }}</div> | |
| 49 | + | </div> | |
| 50 | + | </div> | |
| 51 | + | </td> | |
| 52 | + | <td>{{ .Image }}</td> | |
| 53 | + | <td> | |
| 54 | + | {{ if eq .State "running" }} | |
| 55 | + | <div class="badge badge-success badge-sm">Running</div> | |
| 56 | + | {{ else if eq .State "exited" }} | |
| 57 | + | <div class="badge badge-warning badge-sm">Stopped</div> | |
| 58 | + | {{ else }} | |
| 59 | + | <div class="badge badge-ghost badge-sm">{{ .State }}</div> | |
| 60 | + | {{ end }} | |
| 61 | + | </td> | |
| 62 | + | <td>{{ .Created }}</td> | |
| 63 | + | <th> | |
| 64 | + | <div class="dropdown dropdown-end"> | |
| 65 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |
| 66 | + | <span class="iconf"></span> | |
| 67 | + | </div> | |
| 68 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40"> | |
| 69 | + | {{ if eq .State "running" }} | |
| 70 | + | <li> | |
| 71 | + | <button | |
| 72 | + | class="text-warning flex items-center" | |
| 73 | + | hx-post="/htmx/container-manager/stop/{{ .ID }}" | |
| 74 | + | hx-target="#container-manager" | |
| 75 | + | hx-swap="innerHTML"> | |
| 76 | + | <span class="iconf mr-2"></span> Stop | |
| 77 | + | </button> | |
| 78 | + | </li> | |
| 79 | + | {{ else if eq .State "exited" }} | |
| 80 | + | <li> | |
| 81 | + | <button | |
| 82 | + | class="text-success flex items-center" | |
| 83 | + | hx-post="/htmx/container-manager/start/{{ .ID }}" | |
| 84 | + | hx-target="#container-manager" | |
| 85 | + | hx-swap="innerHTML"> | |
| 86 | + | <span class="iconf mr-2"></span> Start | |
| 87 | + | </button> | |
| 88 | + | </li> | |
| 89 | + | {{ end }} | |
| 90 | + | <li> | |
| 91 | + | <button | |
| 92 | + | class="text-info flex items-center" | |
| 93 | + | hx-get="/htmx/container-manager/edit/{{ .ID }}" | |
| 94 | + | hx-target="#container-manager" | |
| 95 | + | hx-swap="innerHTML"> | |
| 96 | + | <span class="iconf mr-2"></span> Edit | |
| 97 | + | </button> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 100 | + | <button | |
| 101 | + | class="text-error flex items-center" | |
| 102 | + | hx-delete="/htmx/container-manager/delete/{{ .ID }}" | |
| 103 | + | hx-target="#container-manager" | |
| 104 | + | hx-swap="innerHTML" | |
| 105 | + | hx-confirm="Are you sure you want to delete this container?"> | |
| 106 | + | <span class="iconf mr-2"></span> Delete | |
| 107 | + | </button> | |
| 108 | + | </li> | |
| 109 | + | </ul> | |
| 110 | + | </div> | |
| 111 | + | </th> | |
| 112 | + | </tr> | |
| 113 | + | {{ end }} | |
| 114 | + | {{ end }} | |
| 115 | + | </tbody> | |
| 116 | + | </table> | |
| 117 | + | </div> | |
| 112 | 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 | 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 | 2 | <!-- Left Side: Docker Run Options --> | |
| 3 | - | <div class="flex-grow bg-slate-100 p-4 rounded-md"> | |
| 4 | - | <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2> | |
| 3 | + | <div class="flex-grow card p-6"> | |
| 4 | + | <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2> | |
| 5 | 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 | 10 | {{ end }} | |
| 8 | 11 | <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post" | |
| 9 | 12 | hx-post="/htmx/create-container/{{.ShortID}}" | |
| @@ -11,83 +14,104 @@ | |||
| 11 | 14 | hx-target="#create-container-{{.ShortID}}" | |
| 12 | 15 | hx-target-*="#error-message-create-container-{{.ShortID}}"> | |
| 13 | 16 | <!-- Image Name --> | |
| 14 | - | <div class="flex space-x-4 pb-4"> | |
| 15 | - | <div class="w-1/3"> | |
| 16 | - | <label class="block">Image name</label> | |
| 17 | - | <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2" | |
| 17 | + | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | |
| 18 | + | <div class="md:col-span-1"> | |
| 19 | + | <label class="label"> | |
| 20 | + | <span class="label-text">Image name</span> | |
| 21 | + | </label> | |
| 22 | + | <input type="text" name="image_name" class="input input-bordered input-disabled w-full" | |
| 18 | 23 | value="{{.ImageName}}" readonly /> | |
| 19 | 24 | </div> | |
| 20 | 25 | ||
| 21 | - | <div class="w-2/3"> | |
| 22 | - | <label class="block">Image ID</label> | |
| 23 | - | <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2" | |
| 26 | + | <div class="md:col-span-2"> | |
| 27 | + | <label class="label"> | |
| 28 | + | <span class="label-text">Image ID</span> | |
| 29 | + | </label> | |
| 30 | + | <input type="text" name="image_id" class="input input-bordered input-disabled w-full" | |
| 24 | 31 | value="{{.ImageID}}" readonly /> | |
| 25 | 32 | </div> | |
| 26 | 33 | </div> | |
| 27 | 34 | ||
| 28 | 35 | <!-- Container Name --> | |
| 29 | - | <div class="pb-4"> | |
| 30 | - | <label class="block">Container name</label> | |
| 31 | - | <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" /> | |
| 36 | + | <div class="form-control pb-4"> | |
| 37 | + | <label class="label"> | |
| 38 | + | <span class="label-text">Container name</span> | |
| 39 | + | </label> | |
| 40 | + | <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" /> | |
| 32 | 41 | </div> | |
| 42 | + | ||
| 33 | 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 | 45 | <!-- Protocol --> | |
| 36 | - | <div class="w-1/4"> | |
| 37 | - | <label class="block">Protocol</label> | |
| 38 | - | <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300"> | |
| 46 | + | <div class="md:col-span-3"> | |
| 47 | + | <label class="label"> | |
| 48 | + | <span class="label-text">Protocol</span> | |
| 49 | + | </label> | |
| 50 | + | <select name="container_protocol" class="select select-bordered w-full"> | |
| 39 | 51 | <option value="https">https</option> | |
| 40 | 52 | <option value="http">http</option> | |
| 41 | 53 | </select> | |
| 42 | 54 | </div> | |
| 43 | 55 | ||
| 44 | 56 | <!-- Subdomain --> | |
| 45 | - | <div class="w-1/3"> | |
| 46 | - | <label class="block">Subdomain</label> | |
| 47 | - | <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" /> | |
| 57 | + | <div class="md:col-span-4"> | |
| 58 | + | <label class="label"> | |
| 59 | + | <span class="label-text">Subdomain</span> | |
| 60 | + | </label> | |
| 61 | + | <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" /> | |
| 48 | 62 | </div> | |
| 49 | 63 | ||
| 50 | 64 | <!-- Domain --> | |
| 51 | - | <div class="w-5/12"> | |
| 52 | - | <label class="block">Domain</label> | |
| 53 | - | <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" /> | |
| 65 | + | <div class="md:col-span-5"> | |
| 66 | + | <label class="label"> | |
| 67 | + | <span class="label-text">Domain</span> | |
| 68 | + | </label> | |
| 69 | + | <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" /> | |
| 54 | 70 | </div> | |
| 55 | 71 | </div> | |
| 72 | + | ||
| 56 | 73 | <!-- Exposed Ports --> | |
| 57 | - | <div class="pb-4"> | |
| 58 | - | <label class="block">Exposed Ports (Optional)</label> | |
| 59 | - | <span class="text-gray-500"> | |
| 60 | - | eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma) | |
| 61 | - | </span> | |
| 62 | - | <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" /> | |
| 74 | + | <div class="form-control pb-4"> | |
| 75 | + | <label class="label"> | |
| 76 | + | <span class="label-text">Exposed Ports (Optional)</span> | |
| 77 | + | <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span> | |
| 78 | + | </label> | |
| 79 | + | <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" /> | |
| 63 | 80 | </div> | |
| 64 | 81 | ||
| 65 | 82 | <!-- Proxy Port --> | |
| 66 | - | <div class="mb-6"> | |
| 67 | - | <label class="block">Container Proxy Port (Required)</label> | |
| 68 | - | <div class="text-xs text-gray-500 mb-2"> | |
| 69 | - | Specify the internal container port that the proxy will route traffic to | |
| 70 | - | </div> | |
| 71 | - | <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> | |
| 83 | + | <div class="form-control pb-4"> | |
| 84 | + | <label class="label"> | |
| 85 | + | <span class="label-text">Container Proxy Port (Required)</span> | |
| 86 | + | <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span> | |
| 87 | + | </label> | |
| 88 | + | <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required /> | |
| 72 | 89 | </div> | |
| 73 | 90 | ||
| 74 | 91 | <!-- Volumes --> | |
| 75 | - | <div class="pb-4"> | |
| 76 | - | <label class="block">Volumes</label> | |
| 77 | - | <span class="text-gray-500">e.g. ./data:/container/data </span> | |
| 78 | - | <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" /> | |
| 92 | + | <div class="form-control pb-4"> | |
| 93 | + | <label class="label"> | |
| 94 | + | <span class="label-text">Volumes</span> | |
| 95 | + | <span class="label-text-alt">e.g. ./data:/container/data</span> | |
| 96 | + | </label> | |
| 97 | + | <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" /> | |
| 79 | 98 | </div> | |
| 80 | 99 | ||
| 81 | - | <div class="pb-4"> | |
| 82 | - | <label class="block">Environment variables</label> | |
| 83 | - | <span class="text-gray-500">e.g. KEY=VALUE </span> | |
| 84 | - | <textarea name="environment_variables" class="w-full p-4 rounded-md" | |
| 100 | + | <div class="form-control pb-4"> | |
| 101 | + | <label class="label"> | |
| 102 | + | <span class="label-text">Environment variables</span> | |
| 103 | + | <span class="label-text-alt">e.g. KEY=VALUE</span> | |
| 104 | + | </label> | |
| 105 | + | <textarea name="environment_variables" class="textarea textarea-bordered w-full" | |
| 85 | 106 | rows="4">{{.EnvironmentVariables}}</textarea> | |
| 86 | 107 | </div> | |
| 108 | + | ||
| 87 | 109 | <!-- Restart: always/on-failure/only when stopped/never --> | |
| 88 | - | <div class="pb-4"> | |
| 89 | - | <label class="block">Restart</label> | |
| 90 | - | <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300"> | |
| 110 | + | <div class="form-control pb-4"> | |
| 111 | + | <label class="label"> | |
| 112 | + | <span class="label-text">Restart</span> | |
| 113 | + | </label> | |
| 114 | + | <select name="restart" class="select select-bordered w-full"> | |
| 91 | 115 | <option value="always">Always</option> | |
| 92 | 116 | <option value="on-failure">On failure</option> | |
| 93 | 117 | <option value="unless-stopped">Unless stopped</option> | |
| @@ -96,20 +120,21 @@ | |||
| 96 | 120 | </div> | |
| 97 | 121 | ||
| 98 | 122 | <!-- Skip Proxy Setup Option --> | |
| 99 | - | <div class="pb-4"> | |
| 100 | - | <label class="flex items-center space-x-2"> | |
| 101 | - | <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" /> | |
| 123 | + | <div class="form-control pb-4"> | |
| 124 | + | <label class="label cursor-pointer justify-start gap-4"> | |
| 125 | + | <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" /> | |
| 102 | 126 | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | |
| 103 | 127 | </label> | |
| 104 | - | <p class="text-sm text-gray-500 ml-7"> | |
| 128 | + | <div class="text-sm opacity-70 ml-10"> | |
| 105 | 129 | Use this option if you only want to create the container without triggering proxy setup, | |
| 106 | 130 | certificate generation, and other related processes. | |
| 107 | - | </p> | |
| 131 | + | </div> | |
| 108 | 132 | </div> | |
| 109 | 133 | ||
| 110 | - | <input type="submit" value="Create" id="create-container-submit" | |
| 111 | - | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | |
| 134 | + | <button type="submit" id="create-container-submit" class="btn btn-primary"> | |
| 135 | + | Create Container | |
| 136 | + | </button> | |
| 112 | 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 | 139 | </div> | |
| 115 | 140 | </div>⏎̸ | |
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | <div | |
| 2 | 2 | id="edit-container-{{.ID}}" | |
| 3 | - | class="h-fit-content" | |
| 3 | + | class="card bg-base-100 shadow-xl" | |
| 4 | 4 | hx-ext="response-targets" | |
| 5 | 5 | > | |
| 6 | - | <div id="editor"> | |
| 6 | + | <div id="editor" class="card-body"> | |
| 7 | 7 | <form | |
| 8 | 8 | method="post" | |
| 9 | 9 | action="/htmx/container-manager/edit/{{.ID}}" | |
| @@ -14,17 +14,19 @@ | |||
| 14 | 14 | <textarea | |
| 15 | 15 | name="container_config" | |
| 16 | 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 | 18 | rows="20" | |
| 19 | 19 | >{{.ContainerInfo}}</textarea | |
| 20 | 20 | > | |
| 21 | - | <input | |
| 22 | - | type="submit" | |
| 23 | - | value="Save" | |
| 24 | - | id="save-button-{{.ID}}" | |
| 25 | - | class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer" | |
| 26 | - | /> | |
| 21 | + | <div class="card-actions justify-end mt-4"> | |
| 22 | + | <input | |
| 23 | + | type="submit" | |
| 24 | + | value="Save" | |
| 25 | + | id="save-button-{{.ID}}" | |
| 26 | + | class="btn btn-primary" | |
| 27 | + | /> | |
| 28 | + | </div> | |
| 27 | 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 | 31 | </div> | |
| 30 | 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 | 11 | <script src="/libs/htmx-sse.js"></script> | |
| 12 | 12 | <script defer src="/libs/hyperscript.min.js"></script> | |
| 13 | 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 | 24 | </head> | |
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
| @@ -1,82 +1,98 @@ | |||
| 1 | - | <div class="flex flex-col p-4"> | |
| 2 | - | <!-- Upload button aligned right of Images --> | |
| 3 | - | <div class="flex justify-between items-center mb-1"> | |
| 4 | - | <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1> | |
| 5 | - | <div class="flex space-x-2"> | |
| 6 | - | <button | |
| 7 | - | id="refresh-images-button" | |
| 8 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | |
| 9 | - | title="Refresh image list" | |
| 10 | - | > | |
| 11 | - | <span class="iconf text-xl align-middle"> </span> | |
| 12 | - | Refresh | |
| 13 | - | </button> | |
| 14 | - | <button | |
| 15 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | |
| 16 | - | id="upload-button" | |
| 17 | - | hx-get="/htmx/upload-image" | |
| 18 | - | hx-target="#upload-image" | |
| 19 | - | hx-swap="innerHTML" | |
| 20 | - | title="Manually upload a .tar container image" | |
| 21 | - | > | |
| 22 | - | <span class="iconf text-xl align-middle"> </span> | |
| 23 | - | Upload | |
| 24 | - | </button> | |
| 1 | + | <!-- Image list using daisyUI components --> | |
| 2 | + | <div class="card w-full bg-base-300"> | |
| 3 | + | <div class="card-body p-4"> | |
| 4 | + | ||
| 5 | + | <!-- Action buttons --> | |
| 6 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |
| 7 | + | <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3> | |
| 8 | + | <div class="flex gap-2"> | |
| 9 | + | <button | |
| 10 | + | class="btn btn-sm btn-secondary" | |
| 11 | + | hx-get="/htmx/upload-image" | |
| 12 | + | hx-target="#image-manager" | |
| 13 | + | hx-swap="innerHTML"> | |
| 14 | + | <span class="iconf"></span> Upload | |
| 15 | + | </button> | |
| 16 | + | <button | |
| 17 | + | class="btn btn-sm btn-primary" | |
| 18 | + | hx-get="/htmx/image-manager" | |
| 19 | + | hx-target="#image-manager" | |
| 20 | + | hx-swap="innerHTML"> | |
| 21 | + | <span class="iconf"></span> Refresh | |
| 22 | + | </button> | |
| 23 | + | </div> | |
| 25 | 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 | - | <button | |
| 55 | - | id="remove-button-img-{{.ShortID}}" | |
| 56 | - | hx-delete="/htmx/image-manager/delete/{{.ShortID}}" | |
| 57 | - | hx-target="#img-{{.ShortID}}" | |
| 58 | - | hx-target-*="#error-message-div-{{.ShortID}}" | |
| 59 | - | hx-swap="innerHTML" | |
| 60 | - | hx-confirm="Are you sure you want to delete this container image?" | |
| 61 | - | class="py-2 px-4" | |
| 62 | - | > | |
| 63 | - | <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span> | |
| 64 | - | </button> | |
| 65 | - | </td> | |
| 66 | - | </tr> | |
| 67 | - | <t> | |
| 68 | - | <td colspan="4"> | |
| 69 | - | <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div> | |
| 70 | - | <div class="create-container" id="create-container-{{.ShortID}}"></div> | |
| 71 | - | </td> | |
| 72 | - | </tr> | |
| 73 | - | {{ else }} | |
| 74 | - | <tr> | |
| 75 | - | <td colspan="4" class="mt-2">No images found.</td> | |
| 76 | - | </tr> | |
| 77 | - | {{ | |
| 78 | - | end | |
| 79 | - | }} | |
| 80 | - | </tbody> | |
| 81 | - | </table> | |
| 26 | + | <!-- Image list --> | |
| 27 | + | <table class="table"> | |
| 28 | + | <!-- head --> | |
| 29 | + | <thead> | |
| 30 | + | <tr> | |
| 31 | + | <th>Repository</th> | |
| 32 | + | <th>Tag</th> | |
| 33 | + | <th>Size</th> | |
| 34 | + | <th>Created</th> | |
| 35 | + | <th>Actions</th> | |
| 36 | + | </tr> | |
| 37 | + | </thead> | |
| 38 | + | <tbody> | |
| 39 | + | {{ if .NoImage }} | |
| 40 | + | <tr> | |
| 41 | + | <td colspan="5" class="text-center py-4"> | |
| 42 | + | <div class="alert alert-info"> | |
| 43 | + | <span class="iconf">ℹ️</span> | |
| 44 | + | <span>No images found. Upload an image first.</span> | |
| 45 | + | </div> | |
| 46 | + | </td> | |
| 47 | + | </tr> | |
| 48 | + | {{ else }} | |
| 49 | + | {{ range .Images }} | |
| 50 | + | <tr class="hover"> | |
| 51 | + | <td> | |
| 52 | + | <div class="flex items-center gap-3"> | |
| 53 | + | <div> | |
| 54 | + | <div class="font-bold">{{ .Repository }}</div> | |
| 55 | + | <div class="text-sm opacity-50">{{ .ID }}</div> | |
| 56 | + | </div> | |
| 57 | + | </div> | |
| 58 | + | </td> | |
| 59 | + | <td>{{ .Tag }}</td> | |
| 60 | + | <td>{{ .Size }}</td> | |
| 61 | + | <td>{{ .Created }}</td> | |
| 62 | + | <th> | |
| 63 | + | <div class="dropdown dropdown-end"> | |
| 64 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |
| 65 | + | <span class="iconf"></span> | |
| 66 | + | </div> | |
| 67 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | |
| 68 | + | <li> | |
| 69 | + | <a | |
| 70 | + | href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}" | |
| 71 | + | class="text-success flex items-center"> | |
| 72 | + | <span class="iconf mr-2"></span> Create Container | |
| 73 | + | </a> | |
| 74 | + | </li> | |
| 75 | + | <li> | |
| 76 | + | <button | |
| 77 | + | class="text-error flex items-center" | |
| 78 | + | hx-delete="/htmx/image-manager/delete/{{ .ID }}" | |
| 79 | + | hx-target="#image-manager" | |
| 80 | + | hx-swap="innerHTML" | |
| 81 | + | hx-confirm="Are you sure you want to delete this image?"> | |
| 82 | + | <span class="iconf mr-2"></span> Delete | |
| 83 | + | </button> | |
| 84 | + | </li> | |
| 85 | + | </ul> | |
| 86 | + | </div> | |
| 87 | + | </th> | |
| 88 | + | </tr> | |
| 89 | + | {{ end }} | |
| 90 | + | {{ end }} | |
| 91 | + | </tbody> | |
| 92 | + | </table> | |
| 93 | + | </div> | |
| 82 | 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 | - | <div | |
| 2 | - | id="menubar" | |
| 3 | - | class="w-full h-12 md:h-16 fixed top-0 left-0 bg-gordonmascot-lblue flex flex-row justify-evenly items-center px-4 IBMPlexSans"> | |
| 4 | - | <div class="w-full flex justify-start"> | |
| 5 | - | <h1 | |
| 6 | - | class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter"> | |
| 7 | - | Gordon | |
| 8 | - | </h1> | |
| 9 | - | <span | |
| 10 | - | id="actual-version" | |
| 11 | - | class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF"> | |
| 12 | - | {{ .BuildVersion }} | |
| 13 | - | </span> | |
| 14 | - | <span | |
| 15 | - | hidden | |
| 16 | - | id="update-available" | |
| 17 | - | class="text-green-300 text-xs md:text-sm JetBrainsMonoNF hidden" | |
| 18 | - | title="" | |
| 19 | - | ><a href="https://github.com/bnema/gordon/releases"></a> | |
| 20 | - | </span> | |
| 1 | + | <!-- DaisyUI Navbar --> | |
| 2 | + | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> | |
| 3 | + | <div class="navbar-start"> | |
| 4 | + | <div class="flex items-center"> | |
| 5 | + | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | |
| 6 | + | Gordon | |
| 7 | + | </h1> | |
| 8 | + | <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70"> | |
| 9 | + | {{ .BuildVersion }} | |
| 10 | + | </span> | |
| 11 | + | <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title=""> | |
| 12 | + | <a href="https://github.com/bnema/gordon/releases" class="link"></a> | |
| 13 | + | </span> | |
| 14 | + | </div> | |
| 21 | 15 | </div> | |
| 22 | - | <div class="w-full"> | |
| 23 | - | <ul | |
| 24 | - | class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center"> | |
| 25 | - | <li class=""> | |
| 26 | - | <span class="JetBrainsMonoBoldNF pr-1"> </span> | |
| 27 | - | <a href="{{ .AdminPath }}/manager">Manager</a> | |
| 16 | + | <div class="navbar-center hidden lg:flex"> | |
| 17 | + | <ul class="menu menu-horizontal px-1"> | |
| 18 | + | <li> | |
| 19 | + | <a href="{{ .AdminPath }}/manager" class="font-semibold"> | |
| 20 | + | <span class="JetBrainsMonoBoldNF pr-1"></span> Manager | |
| 21 | + | </a> | |
| 28 | 22 | </li> | |
| 29 | 23 | </ul> | |
| 30 | 24 | </div> | |
| 31 | - | <div class="w-full flex space-x-4 justify-end"> | |
| 32 | - | <a | |
| 33 | - | href="{{ .UserSettingsURL }}" | |
| 34 | - | title="User settings" | |
| 35 | - | class="iconf text-gordonmascot-mblue text-lg md:text-xl" | |
| 36 | - | ></a | |
| 37 | - | > | |
| 38 | - | <a | |
| 39 | - | href="{{ .LogoutURL }}" | |
| 40 | - | title="Logout" | |
| 41 | - | class="iconf text-gordonmascot-mblue text-lg md:text-xl" | |
| 42 | - | ></a | |
| 43 | - | > | |
| 25 | + | <div class="navbar-end"> | |
| 26 | + | <!-- Theme toggle --> | |
| 27 | + | <div class="dropdown dropdown-end"> | |
| 28 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> | |
| 29 | + | <span class="iconf text-lg md:text-xl"></span> | |
| 30 | + | </label> | |
| 31 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |
| 32 | + | <li><button onclick="setTheme('gordon')">Gordon Light</button></li> | |
| 33 | + | <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li> | |
| 34 | + | <li><button onclick="setTheme('light')">Light</button></li> | |
| 35 | + | <li><button onclick="setTheme('dark')">Dark</button></li> | |
| 36 | + | </ul> | |
| 37 | + | </div> | |
| 38 | + | ||
| 39 | + | <!-- User menu --> | |
| 40 | + | <div class="dropdown dropdown-end"> | |
| 41 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> | |
| 42 | + | <span class="iconf text-lg md:text-xl"></span> | |
| 43 | + | </label> | |
| 44 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |
| 45 | + | <li> | |
| 46 | + | <a href="{{ .UserSettingsURL }}" title="User settings"> | |
| 47 | + | <span class="iconf"></span> Settings | |
| 48 | + | </a> | |
| 49 | + | </li> | |
| 50 | + | <li> | |
| 51 | + | <a href="{{ .LogoutURL }}" title="Logout"> | |
| 52 | + | <span class="iconf"></span> Logout | |
| 53 | + | </a> | |
| 54 | + | </li> | |
| 55 | + | </ul> | |
| 56 | + | </div> | |
| 44 | 57 | </div> | |
| 45 | 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 | 1 | <div | |
| 2 | 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 | 4 | <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p> | |
| 5 | 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) --> | |
| 2 | - | <div | |
| 3 | - | class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" | |
| 4 | - | hx-ext="response-targets" | |
| 5 | - | hx-boost="true"> | |
| 6 | - | <div class="flex-grow bg-slate-100 p-4 rounded-md"> | |
| 7 | - | <h2>Upload Image Containers</h2> | |
| 8 | - | <!-- Upload Form --> | |
| 1 | + | <!-- Upload image form using DaisyUI components --> | |
| 2 | + | <div class="card bg-base-100 shadow"> | |
| 3 | + | <div class="card-body"> | |
| 4 | + | <div class="flex justify-between items-center"> | |
| 5 | + | <h2 class="card-title text-primary">Upload Image</h2> | |
| 6 | + | <button | |
| 7 | + | class="btn btn-sm btn-circle btn-ghost" | |
| 8 | + | hx-get="/htmx/image-manager" | |
| 9 | + | hx-target="#image-manager" | |
| 10 | + | hx-swap="innerHTML"> | |
| 11 | + | <span class="iconf text-lg">✕</span> | |
| 12 | + | </button> | |
| 13 | + | </div> | |
| 14 | + | <div class="divider"></div> | |
| 15 | + | ||
| 9 | 16 | <form | |
| 10 | - | action="/upload" | |
| 11 | - | method="post" | |
| 12 | - | enctype="multipart/form-data" | |
| 17 | + | id="upload-form" | |
| 18 | + | hx-encoding="multipart/form-data" | |
| 13 | 19 | hx-post="/htmx/upload-image" | |
| 14 | - | hx-swap="innerHTML" | |
| 15 | 20 | hx-target="#image-manager" | |
| 16 | - | hx-target-*="#error-message-div"> | |
| 17 | - | <div | |
| 18 | - | class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4" | |
| 19 | - | hx-drop="true"> | |
| 20 | - | Drag & Drop your file here or | |
| 21 | - | <input | |
| 22 | - | type="file" | |
| 23 | - | name="file" | |
| 21 | + | hx-swap="innerHTML" | |
| 22 | + | class="space-y-4"> | |
| 23 | + | ||
| 24 | + | <div class="alert alert-info shadow-lg mb-4"> | |
| 25 | + | <span class="iconf">ℹ️</span> | |
| 26 | + | <div> | |
| 27 | + | <p>This form allows you to upload a container image (.tar file) to Gordon.</p> | |
| 28 | + | <p>After uploading, you can create and manage containers from this image.</p> | |
| 29 | + | </div> | |
| 30 | + | </div> | |
| 31 | + | ||
| 32 | + | <div class="form-control w-full"> | |
| 33 | + | <label class="label"> | |
| 34 | + | <span class="label-text">Select a container image file (.tar)</span> | |
| 35 | + | </label> | |
| 36 | + | <input | |
| 37 | + | type="file" | |
| 38 | + | name="imageFile" | |
| 39 | + | class="file-input file-input-bordered w-full" | |
| 24 | 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 | 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 | 59 | </form> | |
| 33 | - | <div id="error-message-div" class="text-red-500"></div> | |
| 34 | 60 | </div> | |
| 35 | 61 | </div> | |
M internal/templating/models/html/login/index.gohtml +79 -63
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | <!doctype html> | |
| 2 | - | <html lang="en"> | |
| 2 | + | <html lang="en" data-theme="corporate"> | |
| 3 | 3 | <head> | |
| 4 | 4 | <meta charset="UTF-8" /> | |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| @@ -12,95 +12,111 @@ | |||
| 12 | 12 | <script src="/libs/htmx.min.js"></script> | |
| 13 | 13 | <!-- HTMX SSE --> | |
| 14 | 14 | <script src="/libs/htmx-sse.js"></script> | |
| 15 | + | <!-- DaisyUI --> | |
| 16 | + | <script src="/libs/daisyui.js"></script> | |
| 15 | 17 | <!-- Custom JS from assets/js --> | |
| 16 | 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 | 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 | 30 | <!-- Logo and Title --> | |
| 21 | 31 | <div | |
| 22 | 32 | id="logo-mascot" | |
| 23 | 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 | 35 | <img | |
| 26 | 36 | src="/assets/imgs/gordon-mascot-mq-trsp.png" | |
| 27 | 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 | 39 | </div> | |
| 30 | 40 | <div class="flex justify-center mt-4"> | |
| 31 | 41 | <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 | 43 | Gordon | |
| 34 | 44 | </h1> | |
| 35 | 45 | <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 | 47 | >{{ .BuildVersion }}</span | |
| 38 | 48 | > | |
| 39 | 49 | </div> | |
| 40 | 50 | </div> | |
| 41 | 51 | ||
| 42 | 52 | <!-- Main Content --> | |
| 43 | - | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | |
| 44 | - | <!-- Login Section --> | |
| 45 | - | <div id="login-section"> | |
| 46 | - | <div class="flex flex-col items-center"> | |
| 47 | - | <h2 class="text-2xl font-semibold text-gray-800">Login</h2> | |
| 48 | - | ||
| 49 | - | <!-- Error Message Display --> | |
| 50 | - | {{ if .Error }} | |
| 51 | - | <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert"> | |
| 52 | - | <span class="block sm:inline"> | |
| 53 | - | {{ if eq .Error "empty_token" }} | |
| 54 | - | Please enter a token. | |
| 55 | - | {{ else if eq .Error "invalid_token" }} | |
| 56 | - | Invalid token. Please try again. | |
| 57 | - | {{ else if eq .Error "server_error" }} | |
| 58 | - | A server error occurred. Please try again later. | |
| 59 | - | {{ else }} | |
| 60 | - | An error occurred. Please try again. | |
| 61 | - | {{ end }} | |
| 62 | - | </span> | |
| 63 | - | </div> | |
| 64 | - | {{ end }} | |
| 65 | - | ||
| 66 | - | <!-- Token Input Form --> | |
| 67 | - | <div class="mt-4 w-full max-w-md"> | |
| 68 | - | <form action="login/submit-token" method="POST" class="mb-4"> | |
| 69 | - | <div class="mb-2"> | |
| 70 | - | <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label> | |
| 71 | - | <input | |
| 72 | - | type="text" | |
| 73 | - | id="token" | |
| 74 | - | name="token" | |
| 75 | - | placeholder="Enter your admin token" | |
| 76 | - | class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue" | |
| 77 | - | required | |
| 78 | - | /> | |
| 79 | - | </div> | |
| 80 | - | <button | |
| 81 | - | type="submit" | |
| 82 | - | class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out" | |
| 53 | + | <div class="card bg-base-100 shadow-xl w-full max-w-md"> | |
| 54 | + | <div class="card-body"> | |
| 55 | + | <!-- Login Section --> | |
| 56 | + | <div id="login-section"> | |
| 57 | + | <div class="flex flex-col items-center"> | |
| 58 | + | <h2 class="card-title text-center mb-4">Login</h2> | |
| 59 | + | ||
| 60 | + | <!-- Error Message Display --> | |
| 61 | + | {{ if .Error }} | |
| 62 | + | <div class="alert alert-error mb-4"> | |
| 63 | + | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 64 | + | <span> | |
| 65 | + | {{ if eq .Error "empty_token" }} | |
| 66 | + | Please enter a token. | |
| 67 | + | {{ else if eq .Error "invalid_token" }} | |
| 68 | + | Invalid token. Please try again. | |
| 69 | + | {{ else if eq .Error "server_error" }} | |
| 70 | + | A server error occurred. Please try again later. | |
| 71 | + | {{ else }} | |
| 72 | + | An error occurred. Please try again. | |
| 73 | + | {{ end }} | |
| 74 | + | </span> | |
| 75 | + | </div> | |
| 76 | + | {{ end }} | |
| 77 | + | ||
| 78 | + | <!-- Token Input Form --> | |
| 79 | + | <div class="w-full"> | |
| 80 | + | <form action="login/submit-token" method="POST" class="mb-4"> | |
| 81 | + | <div class="form-control mb-4"> | |
| 82 | + | <label for="token" class="label"> | |
| 83 | + | <span class="label-text">Admin Token</span> | |
| 84 | + | </label> | |
| 85 | + | <input | |
| 86 | + | type="text" | |
| 87 | + | id="token" | |
| 88 | + | name="token" | |
| 89 | + | placeholder="Enter your admin token" | |
| 90 | + | class="input input-bordered w-full" | |
| 91 | + | required | |
| 92 | + | /> | |
| 93 | + | </div> | |
| 94 | + | <button | |
| 95 | + | type="submit" | |
| 96 | + | class="btn btn-primary w-full" | |
| 97 | + | > | |
| 98 | + | Continue with Token | |
| 99 | + | </button> | |
| 100 | + | </form> | |
| 101 | + | <div class="divider">OR</div> | |
| 102 | + | </div> | |
| 103 | + | ||
| 104 | + | <!-- GitHub OAuth --> | |
| 105 | + | <div class="w-full"> | |
| 106 | + | <a | |
| 107 | + | href="login/oauth/github" | |
| 108 | + | class="btn btn-secondary w-full" | |
| 83 | 109 | > | |
| 84 | - | Continue with Token | |
| 85 | - | </button> | |
| 86 | - | </form> | |
| 87 | - | <div class="text-center text-sm text-gray-500 mb-4">- OR -</div> | |
| 88 | - | </div> | |
| 89 | - | ||
| 90 | - | <!-- GitHub OAuth --> | |
| 91 | - | <div | |
| 92 | - | class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> | |
| 93 | - | <a href="login/oauth/github" class="flex items-center"> | |
| 94 | - | <img | |
| 95 | - | src="/assets/imgs/github/github-mark-white.png" | |
| 96 | - | alt="logo github" | |
| 97 | - | class="h-8 xl:h-12" /> | |
| 98 | - | <span class="w-full p-2 text-white">Login with GitHub</span> | |
| 99 | - | </a> | |
| 110 | + | <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> | |
| 111 | + | <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" /> | |
| 112 | + | </svg> | |
| 113 | + | Sign in with GitHub | |
| 114 | + | </a> | |
| 115 | + | </div> | |
| 100 | 116 | </div> | |
| 101 | 117 | </div> | |
| 118 | + | <!-- End Login Section --> | |
| 102 | 119 | </div> | |
| 103 | - | <!-- End Login Section --> | |
| 104 | 120 | </div> | |
| 105 | 121 | </body> | |
| 106 | 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 | 7 | /* Embeded path : assets/css/cutsom.css */ | |
| 8 | 8 | ||
| 9 | - | @tailwind base; | |
| 10 | - | @tailwind components; | |
| 11 | - | @tailwind utilities; | |
| 9 | + | @import "tailwindcss"; | |
| 10 | + | @source "../../../templating/models/html/**/*.{gohtml,go}"; | |
| 11 | + | @plugin "daisyui"; | |
| 12 | + | ||
| 13 | + | /* Custom DaisyUI theme */ | |
| 14 | + | @plugin "daisyui/theme" { | |
| 15 | + | name: "gordon-dark"; | |
| 16 | + | default: true; | |
| 17 | + | prefersdark: true; | |
| 18 | + | color-scheme: "dark"; | |
| 19 | + | --color-base-100: oklch(28% 0.091 267.935); | |
| 20 | + | --color-base-200: oklch(13% 0.028 261.692); | |
| 21 | + | --color-base-300: oklch(20% 0.042 265.755); | |
| 22 | + | --color-base-content: oklch(93% 0.034 272.788); | |
| 23 | + | --color-primary: oklch(87% 0 0); | |
| 24 | + | --color-primary-content: oklch(14% 0 0); | |
| 25 | + | --color-secondary: oklch(83% 0.145 321.434); | |
| 26 | + | --color-secondary-content: oklch(29% 0.136 325.661); | |
| 27 | + | --color-accent: oklch(80% 0.105 251.813); | |
| 28 | + | --color-accent-content: oklch(28% 0.091 267.935); | |
| 29 | + | --color-neutral: oklch(37% 0.034 259.733); | |
| 30 | + | --color-neutral-content: oklch(98% 0.002 247.839); | |
| 31 | + | --color-info: oklch(68% 0.169 237.323); | |
| 32 | + | --color-info-content: oklch(97% 0.013 236.62); | |
| 33 | + | --color-success: oklch(70% 0.14 182.503); | |
| 34 | + | --color-success-content: oklch(98% 0.014 180.72); | |
| 35 | + | --color-warning: oklch(76% 0.188 70.08); | |
| 36 | + | --color-warning-content: oklch(98% 0.022 95.277); | |
| 37 | + | --color-error: oklch(64% 0.246 16.439); | |
| 38 | + | --color-error-content: oklch(96% 0.015 12.422); | |
| 39 | + | --radius-selector: 1rem; | |
| 40 | + | --radius-field: 0rem; | |
| 41 | + | --radius-box: 0.25rem; | |
| 42 | + | --size-selector: 0.25rem; | |
| 43 | + | --size-field: 0.25rem; | |
| 44 | + | --border: 1px; | |
| 45 | + | --depth: 0; | |
| 46 | + | --noise: 0; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | ||
| 50 | + | /* Custom component classes */ | |
| 51 | + | @layer components { | |
| 52 | + | /* Button styles */ | |
| 53 | + | .btn-primary { | |
| 54 | + | @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | .btn-secondary { | |
| 58 | + | @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | .btn-danger { | |
| 62 | + | @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /* Alert styles */ | |
| 66 | + | .alert-error { | |
| 67 | + | @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | .alert-error-icon { | |
| 71 | + | @apply h-5 w-5 text-red-400; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .alert-error-text { | |
| 75 | + | @apply text-sm font-medium text-red-800 dark:text-red-300; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | .alert-success { | |
| 79 | + | @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | .alert-success-icon { | |
| 83 | + | @apply h-5 w-5 text-green-400; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | .alert-success-text { | |
| 87 | + | @apply text-sm font-medium text-green-800 dark:text-green-300; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /* Card styles */ | |
| 91 | + | .card { | |
| 92 | + | @apply bg-white p-4 rounded-md shadow mb-4; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | .card-header { | |
| 96 | + | @apply text-lg font-semibold text-indigo-600 mb-2; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /* Background styles */ | |
| 100 | + | .bg-app { | |
| 101 | + | @apply bg-indigo-50; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | .bg-sidebar { | |
| 105 | + | @apply bg-indigo-900; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | .bg-card { | |
| 109 | + | @apply bg-white; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /* Text styles */ | |
| 113 | + | .text-title { | |
| 114 | + | @apply text-indigo-600 font-semibold; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | .text-subtitle { | |
| 118 | + | @apply text-indigo-500; | |
| 119 | + | } | |
| 120 | + | } | |
| 12 | 121 | ||
| 13 | 122 | /* Import IBM Plex Fonts */ | |
| 14 | 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 | 188 | // Show loading indicator | |
| 189 | 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 | 198 | const originalContent = containerManager.html(); | |
| 191 | 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 | 204 | $.ajax({ | |
| 194 | 205 | url: '/htmx/container-manager', | |
| 195 | 206 | type: 'GET', | |
| 196 | 207 | success: function(data) { | |
| 208 | + | console.debug("Container data received, length:", data.length); | |
| 197 | 209 | $('#container-manager').html(data); | |
| 210 | + | ||
| 211 | + | // Force visibility | |
| 212 | + | $('#container-manager').removeClass('hidden'); | |
| 213 | + | ||
| 198 | 214 | console.debug("Container list refreshed successfully"); | |
| 199 | 215 | }, | |
| 200 | 216 | error: function(xhr, status, error) { | |
| 201 | 217 | console.error("Error refreshing container list:", error); | |
| 218 | + | console.error("Status:", status); | |
| 219 | + | console.error("Response:", xhr.responseText); | |
| 202 | 220 | // Restore original content on error | |
| 203 | 221 | containerManager.html(originalContent); | |
| 204 | 222 | } | |
| @@ -211,6 +229,14 @@ function refreshImageList() { | |||
| 211 | 229 | ||
| 212 | 230 | // Show loading indicator | |
| 213 | 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 | 240 | const originalContent = imageManager.html(); | |
| 215 | 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 | 244 | url: '/htmx/image-manager', | |
| 219 | 245 | type: 'GET', | |
| 220 | 246 | success: function(data) { | |
| 247 | + | console.debug("Image data received, length:", data.length); | |
| 221 | 248 | $('#image-manager').html(data); | |
| 222 | 249 | console.debug("Image list refreshed successfully"); | |
| 223 | 250 | }, | |
| 224 | 251 | error: function(xhr, status, error) { | |
| 225 | 252 | console.error("Error refreshing image list:", error); | |
| 253 | + | console.error("Status:", status); | |
| 254 | + | console.error("Response:", xhr.responseText); | |
| 226 | 255 | // Restore original content on error | |
| 227 | 256 | imageManager.html(originalContent); | |
| 228 | 257 | } | |
| @@ -236,6 +265,9 @@ function setupHtmxEventListeners() { | |||
| 236 | 265 | const targetId = event.detail.target.id; | |
| 237 | 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 | 271 | // Check if the target is an action response | |
| 240 | 272 | if (targetId && targetId.startsWith('actionResponse-')) { | |
| 241 | 273 | console.debug(`Action completed for ${targetId}`); | |
| @@ -257,7 +289,7 @@ function setupHtmxEventListeners() { | |||
| 257 | 289 | console.debug('Image manager updated'); | |
| 258 | 290 | ||
| 259 | 291 | // 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 | 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 | 3 | "devDependencies": { | |
| 4 | + | "daisyui": "^5.0.6", | |
| 3 | 5 | "prettier": "^3.5.3", | |
| 4 | 6 | "prettier-plugin-tailwindcss": "^0.5.14", | |
| 5 | 7 | "tailwindcss": "^4.0.14" | |
| @@ -9,7 +11,7 @@ | |||
| 9 | 11 | "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css" | |
| 10 | 12 | }, | |
| 11 | 13 | "dependencies": { | |
| 12 | - | "caniuse-lite": "^1.0.30001705", | |
| 14 | + | "@tailwindcss/cli": "^4.0.14", | |
| 13 | 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 | 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 | 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 | 34 | return err | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | // For Podman compatibility, we need to ensure we're using the right connection approach | |
| 37 | 38 | 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 | 41 | // Create the Docker client with appropriate options | |
| 41 | 42 | cli, err := client.NewClientWithOpts( | |
| @@ -47,7 +48,18 @@ func InitializeClient(config *Config) error { | |||
| 47 | 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 | 63 | dockerCli = cli | |
| 52 | 64 | currentConfig = config | |
| 53 | 65 | return nil | |
| @@ -56,17 +68,20 @@ func InitializeClient(config *Config) error { | |||
| 56 | 68 | func CheckIfInitialized() error { | |
| 57 | 69 | if currentConfig == nil || currentConfig.Sock == "" { | |
| 58 | 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 | 72 | return fmt.Errorf("docker client is not initialized or configuration is missing") | |
| 60 | 73 | } | |
| 61 | 74 | ||
| 62 | 75 | socketPath := currentConfig.Sock | |
| 63 | 76 | if _, err := os.Stat(socketPath); os.IsNotExist(err) { | |
| 64 | 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 | 79 | return fmt.Errorf("sock file does not exist: %s", socketPath) | |
| 66 | 80 | } | |
| 67 | 81 | ||
| 68 | 82 | if dockerCli == nil { | |
| 69 | 83 | logger.Error("Docker client check failed", "error", "client is nil") | |
| 84 | + | logger.Debug("Docker client check failed: client is nil") | |
| 70 | 85 | return fmt.Errorf("docker client is not initialized") | |
| 71 | 86 | } | |
| 72 | 87 | ||
| @@ -76,6 +91,7 @@ func CheckIfInitialized() error { | |||
| 76 | 91 | _, err := dockerCli.ContainerList(ctx, container.ListOptions{}) | |
| 77 | 92 | if err != nil { | |
| 78 | 93 | logger.Error("Docker client check failed", "error", err) | |
| 94 | + | logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err) | |
| 79 | 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 | 100 | if err != nil { | |
| 101 | 101 | return nil, err | |
| 102 | 102 | } | |
| 103 | - | // Check if the list is empty | |
| 104 | - | if len(containers) == 0 { | |
| 105 | - | return nil, fmt.Errorf("no containers found") | |
| 106 | - | } | |
| 103 | + | // Just return the containers, even if it's an empty slice | |
| 107 | 104 | return containers, nil | |
| 108 | 105 | } | |
| 109 | 106 | ||
| @@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) { | |||
| 420 | 417 | ||
| 421 | 418 | // GetNetworkInfo returns information about a network | |
| 422 | 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 | 425 | networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{}) | |
| 424 | 426 | if err != nil { | |
| 425 | 427 | return nil, err | |
| @@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s | |||
| 947 | 949 | logger.Debug("Container stopped", | |
| 948 | 950 | "container_id", event.id, | |
| 949 | 951 | "container_name", event.name) | |
| 950 | - | ||
| 952 | + | ||
| 951 | 953 | // Call the stop callback if provided | |
| 952 | 954 | if stopCallback != nil { | |
| 953 | 955 | logger.Debug("Calling container stop callback", | |
M tailwind.config.js +39 -0
| @@ -14,7 +14,46 @@ module.exports = { | |||
| 14 | 14 | lbeige: "#FFDCB3", | |
| 15 | 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 | + | )⏎̸ | |