diff --git a/.gitignore b/.gitignore index a411108..cab6aed 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ id_rsa /todos /coverage.out /coverage.html +/.codex diff --git a/README.md b/README.md index 94913d8..89a4c4e 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,6 @@ Most commonly set: - `ssh_key_path` — host SSH key. If unset, banger creates `~/.config/banger/ssh/id_ed25519`. - `firecracker_bin` — override the auto-resolved `PATH` lookup. -- `web_listen_addr` — experimental web UI; disabled by default. Set - e.g. `"127.0.0.1:7777"` to enable. Full key list in `internal/config/config.go`. @@ -205,10 +203,6 @@ VMs are reachable only through the host bridge network (`172.16.0.0/24` by default). Do not expose the bridge interface or guest IPs to an untrusted network. -The web UI is disabled by default. If you opt in via -`web_listen_addr`, it binds `127.0.0.1` — do not publish it to a -shared network. - ## Further reading - [`docs/dns-routing.md`](docs/dns-routing.md) — resolving diff --git a/internal/api/types.go b/internal/api/types.go index 9610610..5ae0e32 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,7 +11,6 @@ type Empty struct{} type PingResult struct { Status string `json:"status"` PID int `json:"pid"` - WebURL string `json:"web_url,omitempty"` Version string `json:"version,omitempty"` Commit string `json:"commit,omitempty"` BuiltAt string `json:"built_at,omitempty"` @@ -298,42 +297,3 @@ type KernelCatalogEntry struct { type KernelCatalogResult struct { Entries []KernelCatalogEntry `json:"entries"` } - -type SudoStatus struct { - Available bool `json:"available"` - Command string `json:"command,omitempty"` - Error string `json:"error,omitempty"` -} - -type HostSummary struct { - CPUCount int `json:"cpu_count"` - TotalMemoryBytes int64 `json:"total_memory_bytes"` - StateFilesystemTotalBytes int64 `json:"state_filesystem_total_bytes"` - StateFilesystemFreeBytes int64 `json:"state_filesystem_free_bytes"` -} - -type BangerSummary struct { - ImageCount int `json:"image_count"` - ManagedImageCount int `json:"managed_image_count"` - VMCount int `json:"vm_count"` - RunningVMCount int `json:"running_vm_count"` - ConfiguredVCPUCount int `json:"configured_vcpu_count"` - ConfiguredMemoryBytes int64 `json:"configured_memory_bytes"` - ConfiguredDiskBytes int64 `json:"configured_disk_bytes"` - UsedSystemOverlayBytes int64 `json:"used_system_overlay_bytes"` - UsedWorkDiskBytes int64 `json:"used_work_disk_bytes"` - RunningCPUPercent float64 `json:"running_cpu_percent"` - RunningRSSBytes int64 `json:"running_rss_bytes"` - RunningVSZBytes int64 `json:"running_vsz_bytes"` -} - -type DashboardSummary struct { - GeneratedAt time.Time `json:"generated_at"` - Host HostSummary `json:"host"` - Sudo SudoStatus `json:"sudo"` - Banger BangerSummary `json:"banger"` -} - -type DashboardSummaryResult struct { - Summary DashboardSummary `json:"summary"` -} diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 6a55166..1119d14 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -658,24 +658,12 @@ func newDaemonCommand() *cobra.Command { if err != nil { return err } - cfg, err := config.Load(layout) - if err != nil { - return err - } ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath) if pingErr != nil { - if strings.TrimSpace(cfg.WebListenAddr) != "" { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr) - return err - } _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt) - if strings.TrimSpace(ping.WebURL) != "" { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) - return err - } _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c9b26cc..0c74a6e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2027,10 +2027,6 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) { if !strings.Contains(output, "dns: 127.0.0.1:42069") { t.Fatalf("output = %q, want dns listener", output) } - // Web UI is opt-in; with no config it should be omitted entirely. - if strings.Contains(output, "web:") { - t.Fatalf("output = %q, should not list web (disabled by default)", output) - } } func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) { @@ -2050,7 +2046,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) { return api.PingResult{ Status: "ok", PID: 42, - WebURL: "http://127.0.0.1:7777", Version: "v1.2.3", Commit: "abc123", BuiltAt: "2026-03-22T12:00:00Z", @@ -2074,7 +2069,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) { "commit: abc123", "built_at: 2026-03-22T12:00:00Z", "log: " + filepath.Join(stateHome, "banger", "bangerd.log"), - "web: http://127.0.0.1:7777", } { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) diff --git a/internal/config/config.go b/internal/config/config.go index ecb8923..ae61484 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,7 +21,6 @@ import ( type fileConfig struct { LogLevel string `toml:"log_level"` - WebListenAddr *string `toml:"web_listen_addr"` FirecrackerBin string `toml:"firecracker_bin"` SSHKeyPath string `toml:"ssh_key_path"` DefaultImageName string `toml:"default_image_name"` @@ -55,10 +54,7 @@ type vmDefaultsFile struct { func Load(layout paths.Layout) (model.DaemonConfig, error) { cfg := model.DaemonConfig{ - LogLevel: "info", - // Experimental web UI is opt-in: users set web_listen_addr in - // config.toml (e.g. "127.0.0.1:7777") to enable it. - WebListenAddr: "", + LogLevel: "info", AutoStopStaleAfter: 0, StatsPollInterval: model.DefaultStatsPollInterval, MetricsPollInterval: model.DefaultMetricsPollInterval, @@ -87,9 +83,6 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if value := strings.TrimSpace(file.LogLevel); value != "" { cfg.LogLevel = value } - if file.WebListenAddr != nil { - cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr) - } if value := strings.TrimSpace(file.FirecrackerBin); value != "" { cfg.FirecrackerBin = value } else if path, err := system.LookupExecutable("firecracker"); err == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b22f63c..c1e717d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -39,16 +39,12 @@ func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) { if cfg.DefaultImageName != "debian-bookworm" { t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName) } - if cfg.WebListenAddr != "" { - t.Fatalf("WebListenAddr default = %q, want empty (experimental web UI is opt-in)", cfg.WebListenAddr) - } } func TestLoadAppliesConfigOverrides(t *testing.T) { configDir := t.TempDir() data := []byte(` log_level = "debug" -web_listen_addr = "127.0.0.1:8080" firecracker_bin = "/opt/firecracker" ssh_key_path = "/tmp/custom-key" default_image_name = "void" @@ -73,9 +69,6 @@ default_dns = "9.9.9.9" if cfg.LogLevel != "debug" { t.Fatalf("LogLevel = %q", cfg.LogLevel) } - if cfg.WebListenAddr != "127.0.0.1:8080" { - t.Fatalf("WebListenAddr = %q, want 127.0.0.1:8080", cfg.WebListenAddr) - } if cfg.FirecrackerBin != "/opt/firecracker" { t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) } diff --git a/internal/daemon/ARCHITECTURE.md b/internal/daemon/ARCHITECTURE.md index 93f7d10..2a3b63e 100644 --- a/internal/daemon/ARCHITECTURE.md +++ b/internal/daemon/ARCHITECTURE.md @@ -34,7 +34,7 @@ owning types: - `tapPool tapPool` — TAP interface pool; owns its own lock. - `sessions sessionRegistry` — active guest session controllers; owns its own lock. -- `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking. +- `listener`, `vmDNS` — networking. - `vmCaps` — registered VM capability hooks. - `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`, `requestHandler`, `guestWaitForSSH`, `guestDial`, @@ -98,9 +98,3 @@ Only `internal/cli` imports this package. The surface is: All other `*Daemon` methods are reached only through the RPC `dispatch` switch in `daemon.go` and are free to move/rename during refactoring. - -## Web UI - -The optional web UI served at `web_listen_addr` is experimental. It is -enabled by default for local observability but is not considered a stable -or supported interface. Set `web_listen_addr = ""` in config to disable. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a548294..0da8756 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -9,7 +9,6 @@ import ( "fmt" "log/slog" "net" - "net/http" "os" "strings" "sync" @@ -58,9 +57,6 @@ type Daemon struct { once sync.Once pid int listener net.Listener - webListener net.Listener - webServer *http.Server - webURL string vmDNS *vmdns.Server vmCaps []vmCapability pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) @@ -138,12 +134,6 @@ func (d *Daemon) Close() error { if d.listener != nil { _ = d.listener.Close() } - if d.webServer != nil { - _ = d.webServer.Close() - } - if d.webListener != nil { - _ = d.webListener.Close() - } err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.closeGuestSessionControllers(), d.store.Close()) }) return err @@ -167,10 +157,6 @@ func (d *Daemon) Serve(ctx context.Context) error { if d.logger != nil { d.logger.Info("daemon serving", "socket", d.layout.SocketPath, "pid", d.pid) } - if err := d.startWebServer(); err != nil { - return err - } - go d.backgroundLoop() for { @@ -274,7 +260,6 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { result, _ := rpc.NewResult(api.PingResult{ Status: "ok", PID: d.pid, - WebURL: d.webURL, Version: info.Version, Commit: info.Commit, BuiltAt: info.BuiltAt, diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index e0da9ff..04b2b98 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -33,7 +33,7 @@ func TestRegisterImageRequiresKernel(t *testing.T) { } func TestDispatchPingIncludesBuildInfo(t *testing.T) { - d := &Daemon{pid: 42, webURL: "http://127.0.0.1:7777"} + d := &Daemon{pid: 42} resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "ping"}) if !resp.OK { @@ -46,8 +46,8 @@ func TestDispatchPingIncludesBuildInfo(t *testing.T) { } info := buildinfo.Current() - if got.Status != "ok" || got.PID != 42 || got.WebURL != "http://127.0.0.1:7777" { - t.Fatalf("PingResult = %+v, want status/pid/weburl populated", got) + if got.Status != "ok" || got.PID != 42 { + t.Fatalf("PingResult = %+v, want status/pid populated", got) } if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt { t.Fatalf("PingResult build info = %+v, want %+v", got, info) diff --git a/internal/daemon/dashboard.go b/internal/daemon/dashboard.go deleted file mode 100644 index cfd42d9..0000000 --- a/internal/daemon/dashboard.go +++ /dev/null @@ -1,63 +0,0 @@ -package daemon - -import ( - "context" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/system" -) - -func (d *Daemon) DashboardSummary(ctx context.Context) (api.DashboardSummary, error) { - summary := api.DashboardSummary{ - GeneratedAt: model.Now(), - Sudo: api.SudoStatus{ - Command: "sudo -v", - }, - } - if err := system.CheckSudo(ctx); err != nil { - summary.Sudo.Error = err.Error() - } else { - summary.Sudo.Available = true - } - - if host, err := system.ReadHostResources(); err == nil { - summary.Host.CPUCount = host.CPUCount - summary.Host.TotalMemoryBytes = host.TotalMemoryBytes - } - if usage, err := system.ReadFilesystemUsage(d.layout.StateDir); err == nil { - summary.Host.StateFilesystemTotalBytes = usage.TotalBytes - summary.Host.StateFilesystemFreeBytes = usage.FreeBytes - } - - images, err := d.store.ListImages(ctx) - if err != nil { - return api.DashboardSummary{}, err - } - for _, image := range images { - summary.Banger.ImageCount++ - if image.Managed { - summary.Banger.ManagedImageCount++ - } - } - - vms, err := d.store.ListVMs(ctx) - if err != nil { - return api.DashboardSummary{}, err - } - for _, vm := range vms { - summary.Banger.VMCount++ - summary.Banger.ConfiguredVCPUCount += vm.Spec.VCPUCount - summary.Banger.ConfiguredMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024 - summary.Banger.ConfiguredDiskBytes += vm.Spec.WorkDiskSizeBytes - summary.Banger.UsedSystemOverlayBytes += vm.Stats.SystemOverlayBytes - summary.Banger.UsedWorkDiskBytes += vm.Stats.WorkDiskBytes - if d.vmAlive(vm) { - summary.Banger.RunningVMCount++ - summary.Banger.RunningCPUPercent += vm.Stats.CPUPercent - summary.Banger.RunningRSSBytes += vm.Stats.RSSBytes - summary.Banger.RunningVSZBytes += vm.Stats.VSZBytes - } - } - return summary, nil -} diff --git a/internal/daemon/doc.go b/internal/daemon/doc.go index 8d68090..0e696c2 100644 --- a/internal/daemon/doc.go +++ b/internal/daemon/doc.go @@ -1,9 +1,8 @@ // Package daemon hosts the Banger daemon process. // -// The daemon exposes a JSON-RPC endpoint over a Unix socket and, optionally, -// an experimental local web UI. It owns VM lifecycle, image management, -// guest sessions, host networking bootstrap, and state persistence via -// internal/store. +// The daemon exposes a JSON-RPC endpoint over a Unix socket. It owns VM +// lifecycle, image management, guest sessions, host networking bootstrap, +// and state persistence via internal/store. // // The package is organised into cohesive groups. Pure stateless helpers for // each group have been lifted into subpackages; orchestrator methods @@ -68,11 +67,9 @@ // Core (in this package): // // daemon.go Daemon struct, Open/Close/Serve, dispatch -// dashboard.go dashboard metrics aggregation // doctor.go host diagnostics // logger.go slog configuration // runtime_assets.go paths to bundled companion binaries -// web.go experimental local web UI server // // Lock ordering: // diff --git a/internal/daemon/web.go b/internal/daemon/web.go deleted file mode 100644 index 11cc951..0000000 --- a/internal/daemon/web.go +++ /dev/null @@ -1,65 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "strings" - "time" - - "banger/internal/model" - "banger/internal/paths" - "banger/internal/webui" -) - -func (d *Daemon) startWebServer() error { - listenAddr := strings.TrimSpace(d.config.WebListenAddr) - if listenAddr == "" { - d.webURL = "" - return nil - } - listener, err := net.Listen("tcp", listenAddr) - if err != nil { - if d.logger != nil { - d.logger.Error("web ui listen failed", "addr", listenAddr, "error", err.Error()) - } - return fmt.Errorf("web ui listen on %s: %w", listenAddr, err) - } - d.webListener = listener - d.webURL = "http://" + listener.Addr().String() - d.webServer = &http.Server{ - Handler: webui.NewHandler(d), - ReadHeaderTimeout: 5 * time.Second, - } - if d.logger != nil { - d.logger.Info("web ui serving", "addr", listener.Addr().String(), "url", d.webURL) - } - go func() { - err := d.webServer.Serve(listener) - if err == nil || errors.Is(err, http.ErrServerClosed) { - return - } - if d.logger != nil { - d.logger.Error("web ui serve failed", "addr", listener.Addr().String(), "error", err.Error()) - } - }() - return nil -} - -func (d *Daemon) Layout() paths.Layout { - return d.layout -} - -func (d *Daemon) Config() model.DaemonConfig { - return d.config -} - -func (d *Daemon) ListVMs(ctx context.Context) ([]model.VMRecord, error) { - return d.store.ListVMs(ctx) -} - -func (d *Daemon) ListImages(ctx context.Context) ([]model.Image, error) { - return d.store.ListImages(ctx) -} diff --git a/internal/model/types.go b/internal/model/types.go index 5d8cd0a..2eb0b45 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -53,7 +53,6 @@ const ( type DaemonConfig struct { LogLevel string - WebListenAddr string FirecrackerBin string SSHKeyPath string AutoStopStaleAfter time.Duration diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js deleted file mode 100644 index 0897317..0000000 --- a/internal/webui/assets/app.js +++ /dev/null @@ -1,130 +0,0 @@ -(() => { - const operationCard = document.querySelector("[data-operation-url]"); - if (operationCard) { - const stageNode = document.getElementById("operation-stage"); - const detailNode = document.getElementById("operation-detail"); - const errorNode = document.getElementById("operation-error"); - const logNode = document.getElementById("operation-log"); - const statusUrl = operationCard.dataset.operationUrl; - const successUrl = operationCard.dataset.operationSuccess; - - const poll = async () => { - const response = await fetch(statusUrl, { headers: { Accept: "application/json" } }); - if (!response.ok) { - return; - } - const payload = await response.json(); - const op = payload.operation || {}; - if (stageNode) stageNode.textContent = op.stage || "queued"; - if (detailNode) detailNode.textContent = op.detail || ""; - if (errorNode) errorNode.textContent = op.error || ""; - if (logNode && op.build_log_path) logNode.textContent = op.build_log_path; - if (op.done && op.success && successUrl) { - window.location.assign(successUrl); - return; - } - if (!op.done) { - window.setTimeout(poll, 1000); - } - }; - window.setTimeout(poll, 800); - } - - const copyButtons = document.querySelectorAll("[data-copy-text]"); - copyButtons.forEach((button) => { - button.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(button.dataset.copyText || ""); - button.textContent = "Copied"; - window.setTimeout(() => { button.textContent = "Copy"; }, 1000); - } catch (_) {} - }); - }); - - document.querySelectorAll("form[data-confirm]").forEach((form) => { - form.addEventListener("submit", (event) => { - const message = form.dataset.confirm || "Are you sure?"; - if (!window.confirm(message)) { - event.preventDefault(); - } - }); - }); - - const logToggle = document.getElementById("log-auto-refresh"); - if (logToggle) { - const schedule = () => { - if (!logToggle.checked) return; - window.setTimeout(() => { - if (logToggle.checked) { - window.location.reload(); - } - }, 4000); - }; - logToggle.addEventListener("change", schedule); - schedule(); - } - - const dialog = document.getElementById("path-picker"); - if (!dialog) return; - - const listNode = document.getElementById("picker-list"); - const currentPathNode = document.getElementById("picker-current-path"); - const closeButton = document.getElementById("picker-close"); - const selectCurrentButton = document.getElementById("picker-select-current"); - let currentInput = null; - let currentKind = "file"; - let currentPath = "/"; - - const loadListing = async (path) => { - const response = await fetch(`/api/fs?path=${encodeURIComponent(path)}&kind=${encodeURIComponent(currentKind)}`, { - headers: { Accept: "application/json" } - }); - if (!response.ok) return; - const payload = await response.json(); - currentPath = payload.path; - currentPathNode.textContent = payload.path; - listNode.innerHTML = ""; - payload.entries.forEach((entry) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "picker-entry"; - button.dataset.kind = entry.kind; - button.dataset.path = entry.path; - button.innerHTML = `${entry.name}${entry.kind}`; - button.addEventListener("click", () => { - if (entry.kind === "dir" || entry.kind === "up") { - loadListing(entry.path); - return; - } - if (currentInput) { - currentInput.value = entry.path; - dialog.close(); - } - }); - listNode.appendChild(button); - }); - }; - - document.querySelectorAll("[data-picker-target]").forEach((button) => { - button.addEventListener("click", () => { - const fieldName = button.dataset.pickerTarget; - currentKind = button.dataset.pickerKind || "file"; - currentInput = document.querySelector(`input[name="${fieldName}"]`); - if (!currentInput) return; - const initialPath = currentInput.value || "/"; - dialog.showModal(); - loadListing(initialPath); - }); - }); - - document.querySelectorAll("[data-picker-root]").forEach((button) => { - button.addEventListener("click", () => loadListing(button.dataset.pickerRoot || "/")); - }); - - closeButton.addEventListener("click", () => dialog.close()); - selectCurrentButton.addEventListener("click", () => { - if (!currentInput) return; - currentInput.value = currentPath; - dialog.close(); - }); -})(); diff --git a/internal/webui/assets/style.css b/internal/webui/assets/style.css deleted file mode 100644 index 0b28255..0000000 --- a/internal/webui/assets/style.css +++ /dev/null @@ -1,513 +0,0 @@ -:root { - --bg: #f2eadf; - --panel: rgba(255, 252, 246, 0.92); - --panel-strong: #fffdf7; - --ink: #1f2a22; - --muted: #5f675f; - --accent: #c8622d; - --accent-strong: #9a3f14; - --success: #33643b; - --warning: #9a5b11; - --danger: #8f2f24; - --line: rgba(31, 42, 34, 0.14); - --shadow: 0 24px 60px rgba(57, 41, 24, 0.12); - --radius: 20px; -} - -* { box-sizing: border-box; } -body { - margin: 0; - font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; - color: var(--ink); - background: - radial-gradient(circle at top left, rgba(200, 98, 45, 0.18), transparent 28%), - radial-gradient(circle at top right, rgba(92, 141, 89, 0.14), transparent 24%), - linear-gradient(180deg, #efe1d1 0%, #f7f1ea 48%, #efe8de 100%); -} - -code, pre, input, select, button { - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; -} - -a { color: inherit; text-decoration: none; } -a[href] { cursor: pointer; } -button:not(:disabled) { cursor: pointer; } - -.app-shell { - max-width: 1320px; - margin: 0 auto; - padding: 28px 20px 56px; -} - -.topbar, .content-panel, .summary-card, .banner, .detail-card, .operation-card { - backdrop-filter: blur(12px); - background: var(--panel); - box-shadow: var(--shadow); -} - -.topbar, .content-panel, .banner { - border-radius: var(--radius); -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: end; - gap: 24px; - padding: 24px 28px; -} - -.topbar h1, .panel-head h2, .detail-card h2, .detail-card h3, .operation-card h2, .operation-card h3 { - margin: 0; - font-family: Georgia, "Iowan Old Style", serif; -} - -.eyebrow { - margin: 0 0 8px; - text-transform: uppercase; - letter-spacing: 0.16em; - font-size: 0.72rem; - color: var(--muted); -} - -.nav { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.nav a, .button { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - border: 1px solid transparent; - padding: 11px 16px; - transition: 160ms ease; - cursor: pointer; -} - -.nav a { - background: rgba(255, 255, 255, 0.48); -} - -.nav a.active, .nav a:hover { - background: #fff7ee; - border-color: rgba(200, 98, 45, 0.22); -} - -.banner { - margin-top: 18px; - padding: 16px 20px; - display: flex; - gap: 12px; - flex-wrap: wrap; - border: 1px solid var(--line); -} - -.banner.warning { border-color: rgba(154, 91, 17, 0.25); } -.banner.success { border-color: rgba(51, 100, 59, 0.25); } -.banner.error { border-color: rgba(143, 47, 36, 0.25); } -.banner.info { border-color: rgba(31, 42, 34, 0.18); } - -.summary-grid, .detail-grid, .split-grid, .command-grid { - display: grid; - gap: 16px; - margin-top: 20px; -} - -.summary-grid { - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); -} - -.summary-card, .detail-card, .operation-card { - border-radius: 18px; - border: 1px solid var(--line); - padding: 18px 20px; -} - -.detail-card h2, .operation-card h2 { - margin-bottom: 12px; - font-size: 1.25rem; -} - -.summary-card p:last-child { margin: 0; color: var(--muted); } - -.resource-card { - display: grid; - gap: 14px; - padding: 20px 22px; - overflow: hidden; - position: relative; -} - -.resource-card::before { - content: ""; - position: absolute; - inset: 0; - opacity: 0.7; - pointer-events: none; -} - -.resource-card.cpu::before { - background: radial-gradient(circle at top right, rgba(200, 98, 45, 0.18), transparent 38%); -} - -.resource-card.memory::before { - background: radial-gradient(circle at top right, rgba(92, 141, 89, 0.16), transparent 38%); -} - -.resource-card.disk::before { - background: radial-gradient(circle at top right, rgba(31, 42, 34, 0.1), transparent 42%); -} - -.resource-head, .resource-foot { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; - flex-wrap: wrap; - position: relative; - z-index: 1; -} - -.resource-card h2 { - margin: 0; - font-size: 1rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted); -} - -.resource-ratio { - font-size: 1.8rem; - line-height: 1; - letter-spacing: -0.04em; -} - -.resource-meter { - position: relative; - z-index: 1; - height: 16px; - border-radius: 999px; - overflow: hidden; - border: 1px solid rgba(31, 42, 34, 0.12); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(236, 227, 216, 0.9)), - repeating-linear-gradient(90deg, rgba(31, 42, 34, 0.05) 0 32px, transparent 32px 64px); -} - -.resource-fill { - display: block; - height: 100%; - border-radius: inherit; - position: relative; -} - -.resource-fill::after { - content: ""; - position: absolute; - inset: 0; - background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.28) 0 10px, transparent 10px 20px); -} - -.resource-card.cpu .resource-fill { - background: linear-gradient(90deg, #c8622d, #e08a4f); -} - -.resource-card.memory .resource-fill { - background: linear-gradient(90deg, #4d8155, #79ab72); -} - -.resource-card.disk .resource-fill { - background: linear-gradient(90deg, #415147, #69806f); -} - -.resource-foot { - font-size: 0.86rem; - color: var(--muted); -} - -.summary-notes { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 12px; -} - -.summary-notes span { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-radius: 999px; - border: 1px solid var(--line); - background: rgba(255, 252, 246, 0.72); - color: var(--muted); -} - -.content-panel { - margin-top: 22px; - padding: 28px; -} - -.panel-head, .section-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} - -.section-head { margin-bottom: 16px; } - -.muted { color: var(--muted); } -.inline-error { - background: rgba(143, 47, 36, 0.08); - color: var(--danger); - border: 1px solid rgba(143, 47, 36, 0.2); - padding: 14px 16px; - border-radius: 14px; - margin-bottom: 18px; -} - -table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--line); - border-radius: 16px; - overflow: hidden; -} - -th, td { - text-align: left; - padding: 14px 12px; - border-bottom: 1px solid var(--line); - vertical-align: top; -} - -th { - font-size: 0.78rem; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--muted); - background: rgba(255,255,255,0.42); -} - -tr:last-child td { border-bottom: 0; } - -.table-link { - font-weight: 600; - transition: 160ms ease; - cursor: pointer; -} - -.table-link:hover { - font-weight: 700; - text-decoration: underline; -} - -.state-pill { - display: inline-flex; - align-items: center; - gap: 8px; - border-radius: 999px; - padding: 6px 10px; - font-size: 0.82rem; - border: 1px solid var(--line); -} - -.state-pill.running { color: var(--success); border-color: rgba(51, 100, 59, 0.25); } -.state-pill.stopped { color: var(--muted); } -.state-pill.error { color: var(--danger); border-color: rgba(143, 47, 36, 0.22); } - -.button { - background: var(--accent); - color: #fff8f0; - border: 1px solid rgba(0,0,0,0.04); - font-weight: 600; -} - -.button:hover { - background: var(--accent-strong); - font-weight: 700; - text-decoration: underline; -} -.button.secondary { - background: rgba(255,255,255,0.74); - color: var(--ink); - border-color: rgba(31, 42, 34, 0.12); -} -.button.danger { background: var(--danger); } -.button:disabled { opacity: 0.55; cursor: not-allowed; } - -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - gap: 16px; -} - -.form-grid.compact { margin-top: 12px; } - -label { - display: grid; - gap: 8px; - font-size: 0.94rem; -} - -input[type="text"], input[type="number"], select { - width: 100%; - border: 1px solid rgba(31, 42, 34, 0.18); - border-radius: 14px; - padding: 12px 14px; - background: var(--panel-strong); - color: var(--ink); -} - -.checkbox { - grid-auto-flow: column; - justify-content: start; - align-items: center; -} - -.checkbox.inline { display: inline-flex; gap: 8px; } - -.stack-inline { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.form-actions { - grid-column: 1 / -1; - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.detail-grid { - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); -} - -.split-grid { - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); -} - -.command-grid { - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - margin: 18px 0; -} - -dl { - margin: 14px 0 0; - display: grid; - grid-template-columns: auto 1fr; - gap: 10px 12px; -} - -dt { color: var(--muted); } -dd { margin: 0; word-break: break-word; } - -pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; -} - -.log-output { - min-height: 260px; - padding: 16px; - border-radius: 16px; - background: #201d1a; - color: #f3eee4; - overflow: auto; -} - -.picker-field { grid-column: 1 / -1; } -.picker-input { display: flex; gap: 10px; } -.picker-input input { flex: 1; } - -.picker-dialog { - border: 0; - padding: 0; - border-radius: 22px; - width: min(960px, calc(100vw - 24px)); - max-width: 100%; -} - -.picker-dialog::backdrop { - background: rgba(17, 12, 8, 0.48); -} - -.picker-shell { - display: grid; - grid-template-columns: 220px 1fr; - min-height: 420px; -} - -.picker-sidebar { - padding: 20px; - border-right: 1px solid var(--line); - background: rgba(255,255,255,0.56); -} - -.picker-roots { - display: grid; - gap: 8px; -} - -.picker-root, .picker-entry { - display: flex; - width: 100%; - align-items: center; - justify-content: space-between; - gap: 12px; - border: 1px solid var(--line); - background: white; - border-radius: 12px; - padding: 10px 12px; - cursor: pointer; -} - -.picker-main { - padding: 20px; -} - -.picker-bar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} - -.picker-actions { - display: flex; - gap: 10px; -} - -.picker-list { - display: grid; - gap: 8px; - max-height: 320px; - overflow: auto; - margin-top: 16px; -} - -.picker-help { color: var(--muted); margin: 12px 0 0; } - -.operation-card { - min-height: 180px; - display: grid; - gap: 12px; - align-content: start; -} - -@media (max-width: 760px) { - .app-shell { padding: 18px 14px 40px; } - .topbar, .content-panel { padding: 20px; } - .resource-ratio { font-size: 1.45rem; } - .picker-shell { grid-template-columns: 1fr; } - .picker-sidebar { border-right: 0; border-bottom: 1px solid var(--line); } - .picker-input { flex-direction: column; } -} diff --git a/internal/webui/server.go b/internal/webui/server.go deleted file mode 100644 index 19f8024..0000000 --- a/internal/webui/server.go +++ /dev/null @@ -1,1124 +0,0 @@ -package webui - -import ( - "context" - "crypto/rand" - "embed" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "html/template" - "io/fs" - "math" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" -) - -type Backend interface { - Config() model.DaemonConfig - Layout() paths.Layout - DashboardSummary(context.Context) (api.DashboardSummary, error) - ListVMs(context.Context) ([]model.VMRecord, error) - FindVM(context.Context, string) (model.VMRecord, error) - GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) - BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) - VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) - StartVM(context.Context, string) (model.VMRecord, error) - StopVM(context.Context, string) (model.VMRecord, error) - RestartVM(context.Context, string) (model.VMRecord, error) - DeleteVM(context.Context, string) (model.VMRecord, error) - SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) - PortsVM(context.Context, string) (api.VMPortsResult, error) - ListImages(context.Context) ([]model.Image, error) - FindImage(context.Context, string) (model.Image, error) - RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) - PromoteImage(context.Context, string) (model.Image, error) - DeleteImage(context.Context, string) (model.Image, error) -} - -type Server struct { - backend Backend - templates *template.Template - pickerFS fs.FS -} - -type pickerRoot struct { - Label string - Path string -} - -type flashMessage struct { - Kind string - Message string -} - -type vmCreateForm struct { - Name string - ImageName string - VCPU string - Memory string - SystemOverlaySize string - WorkDiskSize string - NATEnabled bool - NoStart bool -} - -type vmSetForm struct { - VCPU string - Memory string - WorkDiskSize string - NATEnabled bool -} - -type imageRegisterForm struct { - Name string - RootfsPath string - WorkSeedPath string - KernelPath string - InitrdPath string - ModulesDir string - Docker bool -} - -type pageData struct { - Title string - BodyTemplate string - BodyHTML template.HTML - Section string - Summary api.DashboardSummary - Flash *flashMessage - CSRFToken string - PickerRoots []pickerRoot - MutationAllowed bool - ErrorMessage string - VMs []model.VMRecord - VM model.VMRecord - VMImage model.Image - VMStats model.VMStats - VMPorts api.VMPortsResult - VMPortsError string - VMCreateForm vmCreateForm - VMSetForm vmSetForm - Images []model.Image - Image model.Image - ImageUsers int - ImageRegisterForm imageRegisterForm - LogText string - VMCreateOperation *api.VMCreateOperation - OperationStatusURL string - OperationSuccessURL string - OperationLogPath string - OperationKind string -} - -type fsEntry struct { - Name string `json:"name"` - Path string `json:"path"` - Kind string `json:"kind"` -} - -type fsListingResponse struct { - Path string `json:"path"` - Parent string `json:"parent,omitempty"` - Kind string `json:"kind"` - Entries []fsEntry `json:"entries"` - Roots []pickerRoot `json:"roots"` -} - -//go:embed templates/*.html assets/* -var embeddedAssets embed.FS - -func NewHandler(backend Backend) http.Handler { - tmpl := template.Must(template.New("page").Funcs(template.FuncMap{ - "shortID": shortID, - "formatBytes": formatBytes, - "formatBytesCompact": formatBytesCompact, - "formatPercent": formatPercent, - "percentOf": percentOf, - "relativeTime": relativeTime, - "formatBool": formatBool, - "stateClass": stateClass, - "findImage": findImage, - "endpointHref": endpointHref, - "sumInt64": sumInt64, - "eq": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) }, - }).ParseFS(embeddedAssets, "templates/*.html")) - staticFS, err := fs.Sub(embeddedAssets, "assets") - if err != nil { - panic(err) - } - server := &Server{ - backend: backend, - templates: tmpl, - pickerFS: staticFS, - } - mux := http.NewServeMux() - server.registerRoutes(mux) - return mux -} - -func (s *Server) registerRoutes(mux *http.ServeMux) { - mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.pickerFS))) - mux.HandleFunc("GET /", s.wrap(s.handleDashboard)) - mux.HandleFunc("GET /vms", s.wrap(s.handleVMList)) - mux.HandleFunc("GET /vms/new", s.wrap(s.handleVMNew)) - mux.HandleFunc("POST /vms", s.wrap(s.handleVMCreate)) - mux.HandleFunc("GET /vms/{id}", s.wrap(s.handleVMShow)) - mux.HandleFunc("GET /vms/{id}/logs", s.wrap(s.handleVMLogs)) - mux.HandleFunc("POST /vms/{id}/start", s.wrap(s.handleVMStart)) - mux.HandleFunc("POST /vms/{id}/stop", s.wrap(s.handleVMStop)) - mux.HandleFunc("POST /vms/{id}/restart", s.wrap(s.handleVMRestart)) - mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete)) - mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet)) - mux.HandleFunc("GET /images", s.wrap(s.handleImageList)) - mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm)) - mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister)) - mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow)) - mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote)) - mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete)) - mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage)) - mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI)) - mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI)) -} - -func (s *Server) wrap(fn func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := fn(w, r); err != nil { - s.writeError(w, r, err) - } - } -} - -func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { - status := http.StatusInternalServerError - lower := strings.ToLower(err.Error()) - switch { - case errors.Is(err, os.ErrNotExist), strings.Contains(lower, "not found"): - status = http.StatusNotFound - case strings.Contains(lower, "csrf"), strings.Contains(lower, "cross-origin"): - status = http.StatusForbidden - case strings.Contains(lower, "path must"), strings.Contains(lower, "not a directory"): - status = http.StatusBadRequest - } - if status == http.StatusInternalServerError { - http.Error(w, err.Error(), status) - return - } - if renderErr := s.renderPage(w, r, status, "Not Found", "error_content", func(data *pageData) error { - data.Section = "none" - data.ErrorMessage = err.Error() - return nil - }); renderErr != nil { - http.Error(w, err.Error(), status) - } -} - -func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, title, body string, fill func(*pageData) error) error { - summary, err := s.backend.DashboardSummary(r.Context()) - if err != nil { - return err - } - flash := s.popFlash(w, r) - data := &pageData{ - Title: title, - BodyTemplate: body, - Summary: summary, - Flash: flash, - CSRFToken: s.ensureCSRFToken(w, r), - PickerRoots: s.pickerRoots(), - MutationAllowed: summary.Sudo.Available, - } - if fill != nil { - if err := fill(data); err != nil { - return err - } - } - var bodyHTML strings.Builder - if err := s.templates.ExecuteTemplate(&bodyHTML, body, data); err != nil { - return err - } - data.BodyHTML = template.HTML(bodyHTML.String()) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(status) - return s.templates.ExecuteTemplate(w, "page", data) -} - -func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error { - return s.renderPage(w, r, http.StatusOK, "Dashboard", "dashboard_content", func(data *pageData) error { - data.Section = "dashboard" - vms, err := s.backend.ListVMs(r.Context()) - if err != nil { - return err - } - images, err := s.backend.ListImages(r.Context()) - if err != nil { - return err - } - data.VMs = vms - data.Images = images - return nil - }) -} - -func (s *Server) handleVMList(w http.ResponseWriter, r *http.Request) error { - return s.renderPage(w, r, http.StatusOK, "VMs", "vm_list_content", func(data *pageData) error { - data.Section = "vms" - vms, err := s.backend.ListVMs(r.Context()) - if err != nil { - return err - } - images, err := s.backend.ListImages(r.Context()) - if err != nil { - return err - } - data.VMs = vms - data.Images = images - return nil - }) -} - -func (s *Server) handleVMNew(w http.ResponseWriter, r *http.Request) error { - return s.renderVMNewPage(w, r, vmCreateForm{ - VCPU: strconv.Itoa(model.DefaultVCPUCount), - Memory: strconv.Itoa(model.DefaultMemoryMiB), - SystemOverlaySize: model.FormatSizeBytes(model.DefaultSystemOverlaySize), - WorkDiskSize: model.FormatSizeBytes(model.DefaultWorkDiskSize), - }, "") -} - -func (s *Server) renderVMNewPage(w http.ResponseWriter, r *http.Request, form vmCreateForm, formErr string) error { - return s.renderPage(w, r, http.StatusOK, "Create VM", "vm_new_content", func(data *pageData) error { - data.Section = "vms" - images, err := s.backend.ListImages(r.Context()) - if err != nil { - return err - } - data.Images = images - data.VMCreateForm = form - data.ErrorMessage = formErr - return nil - }) -} - -func (s *Server) handleVMCreate(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - form, params, err := s.parseVMCreateForm(r) - if err != nil { - return s.renderVMNewPage(w, r, form, err.Error()) - } - if !allowed { - return s.renderVMNewPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") - } - op, err := s.backend.BeginVMCreate(r.Context(), params) - if err != nil { - return s.renderVMNewPage(w, r, form, err.Error()) - } - http.Redirect(w, r, "/operations/vm-create/"+url.PathEscape(op.ID), http.StatusSeeOther) - return nil -} - -func (s *Server) handleVMShow(w http.ResponseWriter, r *http.Request) error { - _, vmStats, err := s.backend.GetVMStats(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - image, _ := s.backend.FindImage(r.Context(), vm.ImageID) - return s.renderPage(w, r, http.StatusOK, vm.Name, "vm_show_content", func(data *pageData) error { - data.Section = "vms" - data.VM = vm - data.VMImage = image - data.VMStats = vmStats - data.VMSetForm = vmSetForm{ - VCPU: strconv.Itoa(vm.Spec.VCPUCount), - Memory: strconv.Itoa(vm.Spec.MemoryMiB), - WorkDiskSize: model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), - NATEnabled: vm.Spec.NATEnabled, - } - if vm.State == model.VMStateRunning { - ports, err := s.backend.PortsVM(r.Context(), vm.ID) - if err != nil { - data.VMPortsError = err.Error() - } else { - data.VMPorts = ports - } - } - return nil - }) -} - -func (s *Server) handleVMLogs(w http.ResponseWriter, r *http.Request) error { - vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - logText, err := tailFile(vm.Runtime.LogPath, 200) - if err != nil { - logText = err.Error() - } - return s.renderPage(w, r, http.StatusOK, vm.Name+" Logs", "vm_logs_content", func(data *pageData) error { - data.Section = "vms" - data.VM = vm - data.LogText = logText - return nil - }) -} - -func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) error { - return s.runVMAction(w, r, func(ctx context.Context, id string) error { - _, err := s.backend.StartVM(ctx, id) - return err - }, "VM started") -} - -func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) error { - return s.runVMAction(w, r, func(ctx context.Context, id string) error { - _, err := s.backend.StopVM(ctx, id) - return err - }, "VM stopped") -} - -func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) error { - return s.runVMAction(w, r, func(ctx context.Context, id string) error { - _, err := s.backend.RestartVM(ctx, id) - return err - }, "VM restarted") -} - -func (s *Server) handleVMDelete(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - if !allowed { - s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") - http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther) - return nil - } - if _, err := s.backend.DeleteVM(r.Context(), r.PathValue("id")); err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther) - return nil - } - s.setFlash(w, "success", "VM deleted") - http.Redirect(w, r, "/vms", http.StatusSeeOther) - return nil -} - -func (s *Server) handleVMSet(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - target := "/vms/" + url.PathEscape(r.PathValue("id")) - if !allowed { - s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - params, err := s.parseVMSetForm(r, vm) - if err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil { - s.setFlash(w, "info", "No VM settings changed") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - if _, err := s.backend.SetVM(r.Context(), params); err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - s.setFlash(w, "success", "VM settings updated") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil -} - -func (s *Server) runVMAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) error, successMessage string) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - target := "/vms/" + url.PathEscape(r.PathValue("id")) - if !allowed { - s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - if err := action(r.Context(), r.PathValue("id")); err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - s.setFlash(w, "success", successMessage) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil -} - -func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error { - return s.renderPage(w, r, http.StatusOK, "Images", "image_list_content", func(data *pageData) error { - data.Section = "images" - images, err := s.backend.ListImages(r.Context()) - if err != nil { - return err - } - data.Images = images - return nil - }) -} - -func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error { - return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "") -} - -func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error { - return s.renderPage(w, r, http.StatusOK, "Register Image", "image_register_content", func(data *pageData) error { - data.Section = "images" - data.ImageRegisterForm = form - data.ErrorMessage = formErr - return nil - }) -} - -func (s *Server) handleImageRegister(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - form, params, err := s.parseImageRegisterForm(r) - if err != nil { - return s.renderImageRegisterPage(w, r, form, err.Error()) - } - if !allowed { - return s.renderImageRegisterPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") - } - image, err := s.backend.RegisterImage(r.Context(), params) - if err != nil { - return s.renderImageRegisterPage(w, r, form, err.Error()) - } - s.setFlash(w, "success", "Image registered") - http.Redirect(w, r, "/images/"+url.PathEscape(image.ID), http.StatusSeeOther) - return nil -} - -func (s *Server) handleImageShow(w http.ResponseWriter, r *http.Request) error { - image, err := s.backend.FindImage(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - vms, err := s.backend.ListVMs(r.Context()) - if err != nil { - return err - } - userCount := 0 - for _, vm := range vms { - if vm.ImageID == image.ID { - userCount++ - } - } - return s.renderPage(w, r, http.StatusOK, image.Name, "image_show_content", func(data *pageData) error { - data.Section = "images" - data.Image = image - data.ImageUsers = userCount - return nil - }) -} - -func (s *Server) handleImagePromote(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - target := "/images/" + url.PathEscape(r.PathValue("id")) - if !allowed { - s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - if _, err := s.backend.PromoteImage(r.Context(), r.PathValue("id")); err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - s.setFlash(w, "success", "Image promoted") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil -} - -func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - target := "/images/" + url.PathEscape(r.PathValue("id")) - if !allowed { - s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - if _, err := s.backend.DeleteImage(r.Context(), r.PathValue("id")); err != nil { - s.setFlash(w, "error", err.Error()) - http.Redirect(w, r, target, http.StatusSeeOther) - return nil - } - s.setFlash(w, "success", "Image deleted") - http.Redirect(w, r, "/images", http.StatusSeeOther) - return nil -} - -func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Request) error { - op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - return s.renderPage(w, r, http.StatusOK, "Creating VM", "operation_content", func(data *pageData) error { - data.Section = "vms" - data.OperationKind = "vm" - data.VMCreateOperation = &op - data.OperationStatusURL = "/api/operations/vm-create/" + url.PathEscape(op.ID) - if op.VMID != "" { - data.OperationSuccessURL = "/vms/" + url.PathEscape(op.VMID) - } - return nil - }) -} - -func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error { - op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - return writeJSON(w, api.VMCreateStatusResult{Operation: op}) -} - -func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error { - path := strings.TrimSpace(r.URL.Query().Get("path")) - if path == "" { - path = s.pickerRoots()[0].Path - } - path = filepath.Clean(path) - if !filepath.IsAbs(path) { - return fmt.Errorf("path must be absolute") - } - info, err := os.Stat(path) - if err != nil { - return err - } - if !info.IsDir() { - return fmt.Errorf("%s is not a directory", path) - } - kind := r.URL.Query().Get("kind") - if kind != "dir" { - kind = "file" - } - entries, err := os.ReadDir(path) - if err != nil { - return err - } - result := fsListingResponse{ - Path: path, - Kind: kind, - Entries: make([]fsEntry, 0, len(entries)+1), - Roots: s.pickerRoots(), - } - parent := filepath.Dir(path) - if parent != path { - result.Parent = parent - result.Entries = append(result.Entries, fsEntry{Name: "..", Path: parent, Kind: "up"}) - } - for _, entry := range entries { - entryKind := "file" - if entry.IsDir() { - entryKind = "dir" - } - result.Entries = append(result.Entries, fsEntry{ - Name: entry.Name(), - Path: filepath.Join(path, entry.Name()), - Kind: entryKind, - }) - } - sort.Slice(result.Entries, func(i, j int) bool { - left, right := result.Entries[i], result.Entries[j] - leftRank := kindRank(left.Kind) - rightRank := kindRank(right.Kind) - if leftRank != rightRank { - return leftRank < rightRank - } - return strings.ToLower(left.Name) < strings.ToLower(right.Name) - }) - return writeJSON(w, result) -} - -func kindRank(kind string) int { - switch kind { - case "up": - return 0 - case "dir": - return 1 - default: - return 2 - } -} - -func (s *Server) pickerRoots() []pickerRoot { - seen := map[string]struct{}{} - roots := []pickerRoot{{Label: "Filesystem", Path: "/"}} - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - roots = append(roots, pickerRoot{Label: "Home", Path: home}) - } - layout := s.backend.Layout() - if layout.StateDir != "" { - roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir}) - } - result := make([]pickerRoot, 0, len(roots)) - for _, root := range roots { - root.Path = filepath.Clean(root.Path) - if _, ok := seen[root.Path]; ok { - continue - } - seen[root.Path] = struct{}{} - result = append(result, root) - } - return result -} - -func (s *Server) verifyPOST(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - return nil - } - if err := r.ParseForm(); err != nil { - return err - } - if err := verifySameOrigin(r); err != nil { - return err - } - tokenCookie, err := r.Cookie("banger_csrf") - if err != nil { - return errors.New("missing csrf cookie") - } - if tokenCookie.Value == "" || r.FormValue("csrf_token") != tokenCookie.Value { - return errors.New("csrf token mismatch") - } - return nil -} - -func verifySameOrigin(r *http.Request) error { - for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} { - if strings.TrimSpace(raw) == "" { - continue - } - parsed, err := url.Parse(raw) - if err != nil { - return fmt.Errorf("invalid origin: %w", err) - } - if parsed.Host != r.Host { - return errors.New("cross-origin POST rejected") - } - return nil - } - return nil -} - -func (s *Server) ensureCSRFToken(w http.ResponseWriter, r *http.Request) string { - if cookie, err := r.Cookie("banger_csrf"); err == nil && strings.TrimSpace(cookie.Value) != "" { - return cookie.Value - } - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - panic(err) - } - token := hex.EncodeToString(buf) - http.SetCookie(w, &http.Cookie{ - Name: "banger_csrf", - Value: token, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - return token -} - -func (s *Server) setFlash(w http.ResponseWriter, kind, message string) { - payload := base64.RawURLEncoding.EncodeToString([]byte(kind + "\n" + message)) - http.SetCookie(w, &http.Cookie{ - Name: "banger_flash", - Value: payload, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) -} - -func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) *flashMessage { - cookie, err := r.Cookie("banger_flash") - if err != nil || cookie.Value == "" { - return nil - } - http.SetCookie(w, &http.Cookie{ - Name: "banger_flash", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - data, err := base64.RawURLEncoding.DecodeString(cookie.Value) - if err != nil { - return nil - } - parts := strings.SplitN(string(data), "\n", 2) - if len(parts) != 2 { - return nil - } - return &flashMessage{Kind: parts[0], Message: parts[1]} -} - -func (s *Server) requireMutationAllowed(ctx context.Context) (bool, error) { - summary, err := s.backend.DashboardSummary(ctx) - if err != nil { - return false, err - } - return summary.Sudo.Available, nil -} - -func (s *Server) parseVMCreateForm(r *http.Request) (vmCreateForm, api.VMCreateParams, error) { - if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { - return vmCreateForm{}, api.VMCreateParams{}, err - } - form := vmCreateForm{ - Name: strings.TrimSpace(r.FormValue("name")), - ImageName: strings.TrimSpace(r.FormValue("image_name")), - VCPU: strings.TrimSpace(r.FormValue("vcpu")), - Memory: strings.TrimSpace(r.FormValue("memory")), - SystemOverlaySize: strings.TrimSpace(r.FormValue("system_overlay_size")), - WorkDiskSize: strings.TrimSpace(r.FormValue("work_disk_size")), - NATEnabled: r.FormValue("nat_enabled") == "on", - NoStart: r.FormValue("no_start") == "on", - } - vcpu, err := strconv.Atoi(form.VCPU) - if err != nil { - return form, api.VMCreateParams{}, errors.New("vcpu must be an integer") - } - memory, err := strconv.Atoi(form.Memory) - if err != nil { - return form, api.VMCreateParams{}, errors.New("memory must be an integer") - } - params := api.VMCreateParams{ - Name: form.Name, - ImageName: form.ImageName, - VCPUCount: &vcpu, - MemoryMiB: &memory, - SystemOverlaySize: form.SystemOverlaySize, - WorkDiskSize: form.WorkDiskSize, - NATEnabled: form.NATEnabled, - NoStart: form.NoStart, - } - return form, params, nil -} - -func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetParams, error) { - if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { - return api.VMSetParams{}, err - } - params := api.VMSetParams{IDOrName: vm.ID} - if raw := strings.TrimSpace(r.FormValue("vcpu")); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil { - return api.VMSetParams{}, errors.New("vcpu must be an integer") - } - if value != vm.Spec.VCPUCount { - params.VCPUCount = &value - } - } - if raw := strings.TrimSpace(r.FormValue("memory")); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil { - return api.VMSetParams{}, errors.New("memory must be an integer") - } - if value != vm.Spec.MemoryMiB { - params.MemoryMiB = &value - } - } - if raw := strings.TrimSpace(r.FormValue("work_disk_size")); raw != "" && raw != model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes) { - params.WorkDiskSize = raw - } - if raw := strings.TrimSpace(r.FormValue("nat_enabled")); raw != "" { - value := raw == "true" - if value != vm.Spec.NATEnabled { - params.NATEnabled = &value - } - } - return params, nil -} - -func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) { - if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { - return imageRegisterForm{}, api.ImageRegisterParams{}, err - } - form := imageRegisterForm{ - Name: strings.TrimSpace(r.FormValue("name")), - RootfsPath: strings.TrimSpace(r.FormValue("rootfs_path")), - WorkSeedPath: strings.TrimSpace(r.FormValue("work_seed_path")), - KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), - InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), - ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")), - Docker: r.FormValue("docker") == "on", - } - params := api.ImageRegisterParams{ - Name: form.Name, - RootfsPath: form.RootfsPath, - WorkSeedPath: form.WorkSeedPath, - KernelPath: form.KernelPath, - InitrdPath: form.InitrdPath, - ModulesDir: form.ModulesDir, - Docker: form.Docker, - } - return form, params, nil -} - -type nilResponseWriter struct{} - -func (nilResponseWriter) Header() http.Header { return http.Header{} } -func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil } -func (nilResponseWriter) WriteHeader(statusCode int) {} - -func writeJSON(w http.ResponseWriter, value any) error { - w.Header().Set("Content-Type", "application/json") - return json.NewEncoder(w).Encode(value) -} - -func tailFile(path string, maxLines int) (string, error) { - if strings.TrimSpace(path) == "" { - return "", errors.New("log path is unavailable") - } - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - if maxLines > 0 && len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - return strings.Join(lines, "\n"), nil -} - -func findImage(images []model.Image, id string) model.Image { - for _, image := range images { - if image.ID == id { - return image - } - } - return model.Image{} -} - -func endpointHref(endpoint string) string { - endpoint = strings.TrimSpace(endpoint) - if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { - return endpoint - } - return "" -} - -func shortID(id string) string { - if len(id) <= 12 { - return id - } - return id[:12] -} - -func sumInt64(values ...int64) int64 { - var total int64 - for _, value := range values { - total += value - } - return total -} - -func formatBytes(bytes int64) string { - const ( - ki = 1024 - mi = ki * 1024 - gi = mi * 1024 - ti = gi * 1024 - ) - switch { - case bytes >= ti: - return fmt.Sprintf("%.1f TiB", float64(bytes)/float64(ti)) - case bytes >= gi: - return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gi)) - case bytes >= mi: - return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mi)) - case bytes >= ki: - return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(ki)) - default: - return fmt.Sprintf("%d B", bytes) - } -} - -func formatBytesCompact(bytes int64) string { - const ( - ki = 1024 - mi = ki * 1024 - gi = mi * 1024 - ti = gi * 1024 - ) - type unit struct { - size int64 - suffix string - } - units := []unit{ - {size: ti, suffix: "T"}, - {size: gi, suffix: "G"}, - {size: mi, suffix: "M"}, - {size: ki, suffix: "K"}, - } - abs := bytes - if abs < 0 { - abs = -abs - } - for _, candidate := range units { - if abs >= candidate.size { - value := float64(bytes) / float64(candidate.size) - if math.Abs(value-math.Round(value)) < 0.05 { - return fmt.Sprintf("%.0f%s", math.Round(value), candidate.suffix) - } - return fmt.Sprintf("%.1f%s", value, candidate.suffix) - } - } - return fmt.Sprintf("%dB", bytes) -} - -func percentOf(used, total any) int { - totalValue := numericValue(total) - if totalValue <= 0 { - return 0 - } - usedValue := numericValue(used) - percent := int(math.Round((usedValue / totalValue) * 100)) - switch { - case percent < 0: - return 0 - case percent > 100: - return 100 - default: - return percent - } -} - -func numericValue(value any) float64 { - switch typed := value.(type) { - case int: - return float64(typed) - case int8: - return float64(typed) - case int16: - return float64(typed) - case int32: - return float64(typed) - case int64: - return float64(typed) - case uint: - return float64(typed) - case uint8: - return float64(typed) - case uint16: - return float64(typed) - case uint32: - return float64(typed) - case uint64: - return float64(typed) - case float32: - return float64(typed) - case float64: - return typed - default: - return 0 - } -} - -func formatPercent(value float64) string { - return fmt.Sprintf("%.1f%%", value) -} - -func relativeTime(ts time.Time) string { - if ts.IsZero() { - return "-" - } - delta := time.Since(ts) - switch { - case delta < time.Minute: - return "just now" - case delta < time.Hour: - return fmt.Sprintf("%d minutes ago", int(delta.Minutes())) - case delta < 24*time.Hour: - return fmt.Sprintf("%d hours ago", int(delta.Hours())) - default: - return fmt.Sprintf("%d days ago", int(delta.Hours()/24)) - } -} - -func formatBool(value bool) string { - if value { - return "yes" - } - return "no" -} - -func stateClass(state model.VMState) string { - switch state { - case model.VMStateRunning: - return "running" - case model.VMStateStopped: - return "stopped" - case model.VMStateError: - return "error" - default: - return "created" - } -} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go deleted file mode 100644 index 9ee5db9..0000000 --- a/internal/webui/server_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package webui - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" -) - -type fakeBackend struct { - layout paths.Layout - config model.DaemonConfig - summary api.DashboardSummary - vms []model.VMRecord - images []model.Image - vm model.VMRecord - image model.Image - ports api.VMPortsResult - createOp api.VMCreateOperation -} - -func (f fakeBackend) Config() model.DaemonConfig { return f.config } -func (f fakeBackend) Layout() paths.Layout { return f.layout } -func (f fakeBackend) DashboardSummary(context.Context) (api.DashboardSummary, error) { - return f.summary, nil -} -func (f fakeBackend) ListVMs(context.Context) ([]model.VMRecord, error) { return f.vms, nil } -func (f fakeBackend) FindVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } -func (f fakeBackend) GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) { - return f.vm, f.vm.Stats, nil -} -func (f fakeBackend) BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) { - return f.createOp, nil -} -func (f fakeBackend) VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) { - return f.createOp, nil -} -func (f fakeBackend) StartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } -func (f fakeBackend) StopVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } -func (f fakeBackend) RestartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } -func (f fakeBackend) DeleteVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } -func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) { - return f.vm, nil -} -func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil } -func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil } -func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil } -func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) { - return f.image, nil -} -func (f fakeBackend) PromoteImage(context.Context, string) (model.Image, error) { return f.image, nil } -func (f fakeBackend) DeleteImage(context.Context, string) (model.Image, error) { return f.image, nil } - -func TestDashboardPageRendersSummaryAndTables(t *testing.T) { - backend := fakeBackend{ - layout: paths.Layout{StateDir: t.TempDir()}, - config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, - summary: api.DashboardSummary{ - Host: api.HostSummary{CPUCount: 8, TotalMemoryBytes: 16 << 30, StateFilesystemFreeBytes: 9 << 30, StateFilesystemTotalBytes: 20 << 30}, - Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}, - Banger: api.BangerSummary{ - VMCount: 1, RunningVMCount: 1, ImageCount: 1, ManagedImageCount: 1, ConfiguredVCPUCount: 2, - ConfiguredMemoryBytes: 1 << 30, - ConfiguredDiskBytes: 8 << 30, - UsedWorkDiskBytes: 3 << 30, - }, - }, - vms: []model.VMRecord{{ID: "vm-1", Name: "smth", State: model.VMStateRunning, CreatedAt: model.Now(), Runtime: model.VMRuntime{GuestIP: "172.16.0.2"}, Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}}, - images: []model.Image{{ID: "img-1", Name: "void", Managed: true, RootfsPath: "/tmp/rootfs.ext4", CreatedAt: model.Now()}}, - } - - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - NewHandler(backend).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{"vCPU", "2 / 8", "1G / 16G", "8G / 20G", "9G free", "smth", "void", "Create VM"} { - if !strings.Contains(body, want) { - t.Fatalf("body missing %q\n%s", want, body) - } - } - if len(rec.Result().Cookies()) == 0 { - t.Fatal("expected csrf cookie to be set") - } -} - -func TestVMActionRejectsMissingCSRF(t *testing.T) { - backend := fakeBackend{ - layout: paths.Layout{StateDir: t.TempDir()}, - summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, - vm: model.VMRecord{ID: "vm-1", Name: "smth"}, - } - req := httptest.NewRequest(http.MethodPost, "/vms/vm-1/start", strings.NewReader("")) - req.Header.Set("Origin", "http://example.com") - rec := httptest.NewRecorder() - NewHandler(backend).ServeHTTP(rec, req) - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403", rec.Code) - } -} - -func TestFSAPIListsEntries(t *testing.T) { - dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "rootfs.ext4"), []byte("data"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - backend := fakeBackend{ - layout: paths.Layout{StateDir: dir}, - summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, - } - - req := httptest.NewRequest(http.MethodGet, "/api/fs?path="+url.QueryEscape(dir)+"&kind=file", nil) - rec := httptest.NewRecorder() - NewHandler(backend).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - data, err := io.ReadAll(rec.Body) - if err != nil { - t.Fatalf("ReadAll: %v", err) - } - body := string(data) - for _, want := range []string{"rootfs.ext4", "nested"} { - if !strings.Contains(body, want) { - t.Fatalf("body missing %q\n%s", want, body) - } - } -} - -func TestVMShowPageRendersRunningActions(t *testing.T) { - backend := fakeBackend{ - layout: paths.Layout{StateDir: t.TempDir()}, - config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, - summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}}, - vm: model.VMRecord{ - ID: "vm-1", - Name: "smth", - State: model.VMStateRunning, - Runtime: model.VMRuntime{ - GuestIP: "172.16.0.2", - }, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 8 << 30, - }, - Stats: model.VMStats{ - CPUPercent: 12.5, - RSSBytes: 64 << 20, - SystemOverlayBytes: 2 << 20, - WorkDiskBytes: 32 << 20, - }, - }, - image: model.Image{ID: "img-1", Name: "void"}, - ports: api.VMPortsResult{ - Name: "smth", - Ports: []api.VMPort{ - {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "devserver"}, - }, - }, - } - - req := httptest.NewRequest(http.MethodGet, "/vms/vm-1", nil) - rec := httptest.NewRecorder() - NewHandler(backend).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{"Stop", "Restart", "href=\"http://172.16.0.2:4096\"", "data-confirm=\"Stop VM smth?\"", "data-confirm=\"Delete VM smth?\""} { - if !strings.Contains(body, want) { - t.Fatalf("body missing %q\n%s", want, body) - } - } - for _, unwanted := range []string{"root@172.16.0.2"} { - if strings.Contains(body, unwanted) { - t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body) - } - } -} - -func TestVMListShowsImageNameAndLink(t *testing.T) { - backend := fakeBackend{ - layout: paths.Layout{StateDir: t.TempDir()}, - summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, - vms: []model.VMRecord{ - {ID: "vm-1", Name: "smth", ImageID: "img-1", State: model.VMStateRunning, CreatedAt: model.Now(), Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}, - }, - images: []model.Image{ - {ID: "img-1", Name: "void"}, - }, - } - - req := httptest.NewRequest(http.MethodGet, "/vms", nil) - rec := httptest.NewRecorder() - NewHandler(backend).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{">void", "href=\"/images/img-1\""} { - if !strings.Contains(body, want) { - t.Fatalf("body missing %q\n%s", want, body) - } - } -} diff --git a/internal/webui/templates/base.html b/internal/webui/templates/base.html deleted file mode 100644 index 2fb2473..0000000 --- a/internal/webui/templates/base.html +++ /dev/null @@ -1,124 +0,0 @@ -{{define "page"}} - - -
- - -Local Control Plane
-| Name | -State | -IP | -Spec | -Created | -
|---|---|---|---|---|
| {{.Name}} | -{{.State}} | -{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}} | -{{.Spec.VCPUCount}} vCPU / {{.Spec.MemoryMiB}} MiB / {{formatBytes .Spec.WorkDiskSizeBytes}} | -{{relativeTime .CreatedAt}} | -
| No VMs yet. | ||||
Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.
-| Name | -Managed | -Docker | -Rootfs | -Created | -
|---|---|---|---|---|
| {{.Name}} | -{{formatBool .Managed}} | -{{formatBool .Docker}} | -{{.RootfsPath}} |
- {{relativeTime .CreatedAt}} | -
| No images registered. | ||||
Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.
-{{if .ErrorMessage}} -{{.Image.ID}}{{.Image.RootfsPath}}{{.Image.WorkSeedPath}}{{else}}-{{end}}{{.Image.KernelPath}}{{.Image.InitrdPath}}{{else}}-{{end}}{{.Image.ModulesDir}}{{else}}-{{end}}{{.Image.ArtifactDir}}{{else}}-{{end}}{{.VMCreateOperation.Detail}}
-{{.VMCreateOperation.Error}}
- {{end}} - {{if .OperationLogPath}} -Build log: {{.OperationLogPath}}
Inspect lifecycle, capacity, and reachability for every VM.
- Create VM -| Name | -State | -Image | -IP | -vCPU | -Memory | -Disk | -Created | -
|---|---|---|---|---|---|---|---|
| {{.Name}} | -{{.State}} | -{{$image := findImage $.Images .ImageID}}{{if $image.ID}}{{$image.Name}}{{else}}{{shortID .ImageID}}{{end}} |
- {{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}} | -{{.Spec.VCPUCount}} | -{{.Spec.MemoryMiB}} MiB | -{{formatBytes .Spec.WorkDiskSizeBytes}} | -{{relativeTime .CreatedAt}} | -
| No VMs registered. | |||||||
Create a VM and wait until the guest is fully ready. The browser will follow live create progress automatically.
-{{if .ErrorMessage}} -{{.VM.ID}}{{shortID .VM.ImageID}}{{end}}{{.VMPortsError}}
- {{else}} -| Port | Process | Endpoint |
|---|---|---|
| {{.Proto}}/{{.Port}} | -{{if .Process}}{{.Process}}{{else}}-{{end}} | -{{if .Endpoint}}{{if endpointHref .Endpoint}}{{.Endpoint}}{{else}}{{.Endpoint}}{{end}}{{else}}-{{end}} |
-
| No host-reachable listeners reported. | ||
Showing the last 200 lines from the Firecracker log.
-{{.LogText}}
-{{end}}