From 5861e465a2ccf31d87ea25ac145770786f9cc96e Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 24 Jan 2026 09:48:34 -0800 Subject: Rename project from deploy to ship - Rename module to github.com/bdw/ship - Rename cmd/deploy to cmd/ship - Update all import paths - Update config path from ~/.config/deploy to ~/.config/ship - Update VPS env path from /etc/deploy to /etc/ship - Update README, Makefile, and docs --- cmd/ship/ui.go | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 cmd/ship/ui.go (limited to 'cmd/ship/ui.go') diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go new file mode 100644 index 0000000..cfaea08 --- /dev/null +++ b/cmd/ship/ui.go @@ -0,0 +1,199 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "sort" + "strconv" + + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +var uiCmd = &cobra.Command{ + Use: "ui", + Short: "Launch web management UI", + RunE: runUI, +} + +func init() { + uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") +} + +func runUI(cmd *cobra.Command, args []string) error { + port, _ := cmd.Flags().GetString("port") + + tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") + if err != nil { + return fmt.Errorf("error parsing template: %w", err) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + type AppData struct { + Name string + Type string + Domain string + Port int + Env map[string]string + Host string + } + + type HostData struct { + Host string + Apps []AppData + } + + var hosts []HostData + for hostName, host := range st.Hosts { + var apps []AppData + for appName, app := range host.Apps { + apps = append(apps, AppData{ + Name: appName, + Type: app.Type, + Domain: app.Domain, + Port: app.Port, + Env: app.Env, + Host: hostName, + }) + } + + sort.Slice(apps, func(i, j int) bool { + return apps[i].Name < apps[j].Name + }) + + hosts = append(hosts, HostData{ + Host: hostName, + Apps: apps, + }) + } + + sort.Slice(hosts, func(i, j int) bool { + return hosts[i].Host < hosts[j].Host + }) + + data := struct { + Hosts []HostData + }{ + Hosts: hosts, + } + + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) + return + } + }) + + http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(st) + }) + + http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + appName := r.URL.Query().Get("app") + + if host == "" || appName == "" { + http.Error(w, "Missing host or app parameter", http.StatusBadRequest) + return + } + + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + app, err := st.GetApp(host, appName) + if err != nil { + http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) + return + } + + configs := make(map[string]string) + + if app.Env != nil && len(app.Env) > 0 { + envContent := "" + for k, v := range app.Env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + configs["env"] = envContent + configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName) + } + + if app.Type == "app" { + workDir := fmt.Sprintf("/var/lib/%s", appName) + binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName) + + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": appName, + "User": appName, + "WorkDir": workDir, + "BinaryPath": binaryPath, + "Port": strconv.Itoa(app.Port), + "EnvFile": envFilePath, + "Args": app.Args, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) + return + } + configs["systemd"] = serviceContent + configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) + + caddyContent, err := templates.AppCaddy(map[string]string{ + "Domain": app.Domain, + "Port": strconv.Itoa(app.Port), + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) + return + } + configs["caddy"] = caddyContent + configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) + } else if app.Type == "static" { + remoteDir := fmt.Sprintf("/var/www/%s", appName) + caddyContent, err := templates.StaticCaddy(map[string]string{ + "Domain": app.Domain, + "RootDir": remoteDir, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) + return + } + configs["caddy"] = caddyContent + configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(configs) + }) + + addr := fmt.Sprintf("localhost:%s", port) + fmt.Printf("Starting web UI on http://%s\n", addr) + fmt.Printf("Press Ctrl+C to stop\n") + + if err := http.ListenAndServe(addr, nil); err != nil { + return fmt.Errorf("error starting server: %w", err) + } + return nil +} -- cgit v1.2.3