diff options
| author | bndw <ben@bdw.to> | 2026-01-23 20:54:46 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-01-23 20:54:46 -0800 |
| commit | 98b9af372025595e8a4255538e2836e019311474 (patch) | |
| tree | 0a26fa5a8a19ea8565da6d63e1f19c21fc170d12 /cmd/deploy/webui.go | |
| parent | 7fcb9dfa87310e91b527829ece9989decb6fda64 (diff) | |
Add deploy command and fix static site naming
Static sites now default to using the domain as the name instead of
the source directory basename, preventing conflicts when multiple
sites use the same directory name (e.g., dist).
Also fixes .gitignore to not exclude cmd/deploy/ directory.
Diffstat (limited to 'cmd/deploy/webui.go')
| -rw-r--r-- | cmd/deploy/webui.go | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/cmd/deploy/webui.go b/cmd/deploy/webui.go new file mode 100644 index 0000000..f57400e --- /dev/null +++ b/cmd/deploy/webui.go | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "embed" | ||
| 5 | "encoding/json" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "html/template" | ||
| 9 | "net/http" | ||
| 10 | "os" | ||
| 11 | "sort" | ||
| 12 | "strconv" | ||
| 13 | |||
| 14 | "github.com/bdw/deploy/internal/state" | ||
| 15 | "github.com/bdw/deploy/internal/templates" | ||
| 16 | ) | ||
| 17 | |||
| 18 | //go:embed templates/*.html | ||
| 19 | var templatesFS embed.FS | ||
| 20 | |||
| 21 | func runWebUI(args []string) { | ||
| 22 | fs := flag.NewFlagSet("webui", flag.ExitOnError) | ||
| 23 | port := fs.String("port", "8080", "Port to run the web UI on") | ||
| 24 | help := fs.Bool("h", false, "Show help") | ||
| 25 | |||
| 26 | fs.Parse(args) | ||
| 27 | |||
| 28 | if *help { | ||
| 29 | fmt.Fprintf(os.Stderr, `Usage: deploy webui [flags] | ||
| 30 | |||
| 31 | Launch a web interface to view and manage deployments. | ||
| 32 | |||
| 33 | FLAGS: | ||
| 34 | -port string | ||
| 35 | Port to run the web UI on (default "8080") | ||
| 36 | -h Show this help message | ||
| 37 | |||
| 38 | EXAMPLE: | ||
| 39 | # Launch web UI on default port (8080) | ||
| 40 | deploy webui | ||
| 41 | |||
| 42 | # Launch on custom port | ||
| 43 | deploy webui -port 3000 | ||
| 44 | `) | ||
| 45 | os.Exit(0) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Parse template | ||
| 49 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") | ||
| 50 | if err != nil { | ||
| 51 | fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) | ||
| 52 | os.Exit(1) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Handler for the main page | ||
| 56 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 57 | // Reload state on each request to show latest changes | ||
| 58 | st, err := state.Load() | ||
| 59 | if err != nil { | ||
| 60 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 61 | return | ||
| 62 | } | ||
| 63 | |||
| 64 | // Prepare data for template | ||
| 65 | type AppData struct { | ||
| 66 | Name string | ||
| 67 | Type string | ||
| 68 | Domain string | ||
| 69 | Port int | ||
| 70 | Env map[string]string | ||
| 71 | Host string | ||
| 72 | } | ||
| 73 | |||
| 74 | type HostData struct { | ||
| 75 | Host string | ||
| 76 | Apps []AppData | ||
| 77 | } | ||
| 78 | |||
| 79 | var hosts []HostData | ||
| 80 | for hostName, host := range st.Hosts { | ||
| 81 | var apps []AppData | ||
| 82 | for appName, app := range host.Apps { | ||
| 83 | apps = append(apps, AppData{ | ||
| 84 | Name: appName, | ||
| 85 | Type: app.Type, | ||
| 86 | Domain: app.Domain, | ||
| 87 | Port: app.Port, | ||
| 88 | Env: app.Env, | ||
| 89 | Host: hostName, | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | |||
| 93 | // Sort apps by name | ||
| 94 | sort.Slice(apps, func(i, j int) bool { | ||
| 95 | return apps[i].Name < apps[j].Name | ||
| 96 | }) | ||
| 97 | |||
| 98 | hosts = append(hosts, HostData{ | ||
| 99 | Host: hostName, | ||
| 100 | Apps: apps, | ||
| 101 | }) | ||
| 102 | } | ||
| 103 | |||
| 104 | // Sort hosts by name | ||
| 105 | sort.Slice(hosts, func(i, j int) bool { | ||
| 106 | return hosts[i].Host < hosts[j].Host | ||
| 107 | }) | ||
| 108 | |||
| 109 | data := struct { | ||
| 110 | Hosts []HostData | ||
| 111 | }{ | ||
| 112 | Hosts: hosts, | ||
| 113 | } | ||
| 114 | |||
| 115 | if err := tmpl.Execute(w, data); err != nil { | ||
| 116 | http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) | ||
| 117 | return | ||
| 118 | } | ||
| 119 | }) | ||
| 120 | |||
| 121 | // API endpoint to get state as JSON | ||
| 122 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { | ||
| 123 | st, err := state.Load() | ||
| 124 | if err != nil { | ||
| 125 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 126 | return | ||
| 127 | } | ||
| 128 | |||
| 129 | w.Header().Set("Content-Type", "application/json") | ||
| 130 | json.NewEncoder(w).Encode(st) | ||
| 131 | }) | ||
| 132 | |||
| 133 | // API endpoint to get rendered configs for an app | ||
| 134 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { | ||
| 135 | host := r.URL.Query().Get("host") | ||
| 136 | appName := r.URL.Query().Get("app") | ||
| 137 | |||
| 138 | if host == "" || appName == "" { | ||
| 139 | http.Error(w, "Missing host or app parameter", http.StatusBadRequest) | ||
| 140 | return | ||
| 141 | } | ||
| 142 | |||
| 143 | st, err := state.Load() | ||
| 144 | if err != nil { | ||
| 145 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | |||
| 149 | app, err := st.GetApp(host, appName) | ||
| 150 | if err != nil { | ||
| 151 | http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) | ||
| 152 | return | ||
| 153 | } | ||
| 154 | |||
| 155 | configs := make(map[string]string) | ||
| 156 | |||
| 157 | // Render environment file | ||
| 158 | if app.Env != nil && len(app.Env) > 0 { | ||
| 159 | envContent := "" | ||
| 160 | for k, v := range app.Env { | ||
| 161 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 162 | } | ||
| 163 | configs["env"] = envContent | ||
| 164 | configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 165 | } | ||
| 166 | |||
| 167 | // Render configs based on app type | ||
| 168 | if app.Type == "app" { | ||
| 169 | // Render systemd service | ||
| 170 | workDir := fmt.Sprintf("/var/lib/%s", appName) | ||
| 171 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) | ||
| 172 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 173 | |||
| 174 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 175 | "Name": appName, | ||
| 176 | "User": appName, | ||
| 177 | "WorkDir": workDir, | ||
| 178 | "BinaryPath": binaryPath, | ||
| 179 | "Port": strconv.Itoa(app.Port), | ||
| 180 | "EnvFile": envFilePath, | ||
| 181 | "Args": app.Args, | ||
| 182 | }) | ||
| 183 | if err != nil { | ||
| 184 | http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) | ||
| 185 | return | ||
| 186 | } | ||
| 187 | configs["systemd"] = serviceContent | ||
| 188 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) | ||
| 189 | |||
| 190 | // Render Caddy config | ||
| 191 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 192 | "Domain": app.Domain, | ||
| 193 | "Port": strconv.Itoa(app.Port), | ||
| 194 | }) | ||
| 195 | if err != nil { | ||
| 196 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 197 | return | ||
| 198 | } | ||
| 199 | configs["caddy"] = caddyContent | ||
| 200 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 201 | } else if app.Type == "static" { | ||
| 202 | // Render Caddy config for static site | ||
| 203 | remoteDir := fmt.Sprintf("/var/www/%s", appName) | ||
| 204 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 205 | "Domain": app.Domain, | ||
| 206 | "RootDir": remoteDir, | ||
| 207 | }) | ||
| 208 | if err != nil { | ||
| 209 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 210 | return | ||
| 211 | } | ||
| 212 | configs["caddy"] = caddyContent | ||
| 213 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 214 | } | ||
| 215 | |||
| 216 | w.Header().Set("Content-Type", "application/json") | ||
| 217 | json.NewEncoder(w).Encode(configs) | ||
| 218 | }) | ||
| 219 | |||
| 220 | addr := fmt.Sprintf("localhost:%s", *port) | ||
| 221 | fmt.Printf("Starting web UI on http://%s\n", addr) | ||
| 222 | fmt.Printf("Press Ctrl+C to stop\n") | ||
| 223 | |||
| 224 | if err := http.ListenAndServe(addr, nil); err != nil { | ||
| 225 | fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) | ||
| 226 | os.Exit(1) | ||
| 227 | } | ||
| 228 | } | ||
