summaryrefslogtreecommitdiffstats
path: root/cmd/deploy/webui.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
committerbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
commit98b9af372025595e8a4255538e2836e019311474 (patch)
tree0a26fa5a8a19ea8565da6d63e1f19c21fc170d12 /cmd/deploy/webui.go
parent7fcb9dfa87310e91b527829ece9989decb6fda64 (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.go228
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 @@
1package main
2
3import (
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
19var templatesFS embed.FS
20
21func 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
31Launch a web interface to view and manage deployments.
32
33FLAGS:
34 -port string
35 Port to run the web UI on (default "8080")
36 -h Show this help message
37
38EXAMPLE:
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}