diff --git a/AGENTS.md b/AGENTS.md index c5d6dc7..2f547b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,13 @@ - `make verify-void` registers `void-exp` and runs the normal smoke test against that image. - `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. - `./banger vm create --name testbox` creates and starts a VM. +- `./banger vm create` now blocks until the guest reaches the daemon's default readiness checks and shows live progress stages on TTY stderr while it waits. - `./banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits. - `./banger vm stop testbox` stops a VM while preserving its disks. - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. +- `bangerd` now also serves a localhost web UI on `http://127.0.0.1:7777` by default unless `web_listen_addr = ""` disables it; the UI uses server-rendered templates, polls async VM/image operations, and keeps image path selection on the host via a server-side file picker. - `make test` runs `go test ./...`. - `./verify.sh` runs the smoke test for the Go VM workflow. @@ -35,9 +37,10 @@ - Primary automated coverage is `go test ./...`. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. +- The web UI follows the same sudo model as the CLI path: bangerd stays unprivileged and privileged writes only work when `sudo -v` is already warm or sudo is passwordless. - Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. - The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. -- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. +- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. Older managed images may take one slower create to refresh seeded SSH access before they rejoin the fast path. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - If you add a new operational workflow, document how to exercise it in `README.md`. - For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./verify.sh --nat`. diff --git a/README.md b/README.md index dac9f0b..18c59d1 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ Create and boot a VM: banger vm create --name calm-otter --disk-size 16G ``` +`banger vm create` now waits for full guest readiness by default, including the +guest vsock agent and the default `opencode` service, and prints live progress +stages on TTY stderr while it waits. + Check host/runtime readiness before creating VMs: ```bash banger doctor @@ -148,7 +152,24 @@ banger daemon stop ``` `banger daemon status` prints the daemon PID, socket path, daemon log path, and -the built-in DNS listener address. +the built-in DNS listener address. The daemon also serves a local web UI on +`http://127.0.0.1:7777` by default, and `daemon status` prints that URL when it +is enabled. + +Use the web UI for dashboard, VM lifecycle, image inventory, VM create +progress, ports/log inspection, and image build/register/promote/delete flows: +```text +http://127.0.0.1:7777 +``` + +The image forms use a server-side host-path picker. They do not upload files +through the browser; they select absolute paths that already exist on the host. +Mutating actions in the UI require the same sudo readiness as the CLI-backed +workflow. If the page shows writes as disabled, run: +```bash +sudo -v +``` +and refresh the page. State lives under XDG directories: - config: `~/.config/banger` @@ -164,6 +185,7 @@ repo-built `./banger`. You can override either with `runtime_dir` in Useful config keys: - `log_level` - `runtime_dir` +- `web_listen_addr` (`""` disables the web UI) - `tap_pool_size` - `firecracker_bin` - `namegen_path` @@ -210,6 +232,10 @@ Build a managed image: banger image build --name docker-dev --docker ``` +The web UI exposes both managed image build and unmanaged image register forms. +Builds run through an async progress page; register, promote, and delete remain +direct form actions. + Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it for bash login and interactive shells, install `opencode` through `mise`, expose `/usr/local/bin/opencode`, configure `tmux-resurrect` plus @@ -274,8 +300,12 @@ guest IP or via the endpoint shown by `banger vm ports`. - Each VM gets its own sparse writable system overlay for `/`. - Each VM gets its own persistent ext4 work disk mounted at `/root`. - When an image has a `work-seed.ext4` sidecar, new VM creates clone that seed - and only resize it when needed. Older images still work, but create more - slowly because `/root` must be built from scratch. + and only resize it when needed. +- Older managed images without the seeded SSH metadata may take one slower + create to repair `/root` access and refresh their managed work-seed; later + creates use the fast path. +- Images without any `work-seed.ext4` still work, but create more slowly + because `/root` must be built from scratch. - The daemon can keep a small idle TAP pool warm in the background so VM create does not need to synchronously create a fresh TAP every time. `tap_pool_size` controls the pool depth. @@ -462,7 +492,8 @@ make bench-create ARGS="--runs 3 --image docker-dev" ``` The benchmark prints JSON with: -- `create_ms`: wall time for `banger vm create` +- `create_ms`: wall time for `banger vm create`, including full readiness + gating for the guest vsock agent and default `opencode` service - `ssh_ready_ms`: wall time from create start until `banger vm ssh -- true` succeeds diff --git a/internal/api/types.go b/internal/api/types.go index 77ec00e..ca44542 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,6 +11,7 @@ type Empty struct{} type PingResult struct { Status string `json:"status"` PID int `json:"pid"` + WebURL string `json:"web_url,omitempty"` } type ShutdownResult struct { @@ -54,6 +55,33 @@ type VMCreateStatusResult struct { Operation VMCreateOperation `json:"operation"` } +type ImageBuildStatusParams struct { + ID string `json:"id"` +} + +type ImageBuildOperation struct { + ID string `json:"id"` + ImageID string `json:"image_id,omitempty"` + ImageName string `json:"image_name,omitempty"` + Stage string `json:"stage,omitempty"` + Detail string `json:"detail,omitempty"` + BuildLogPath string `json:"build_log_path,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Done bool `json:"done"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Image *model.Image `json:"image,omitempty"` +} + +type ImageBuildBeginResult struct { + Operation ImageBuildOperation `json:"operation"` +} + +type ImageBuildStatusResult struct { + Operation ImageBuildOperation `json:"operation"` +} + type VMRefParams struct { IDOrName string `json:"id_or_name"` } @@ -151,3 +179,42 @@ type ImageListResult struct { type ImageShowResult struct { Image model.Image `json:"image"` } + +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 0d53a05..89154e0 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -188,11 +188,23 @@ func newDaemonCommand() *cobra.Command { if err != nil { return err } + cfg, err := config.Load(layout) + if err != nil { + return err + } ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{}) 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 } + if strings.TrimSpace(ping.WebURL) != "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) + return err + } _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e64f6be..49c166a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -798,6 +798,9 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) { if !strings.Contains(output, "dns: 127.0.0.1:42069") { t.Fatalf("output = %q, want dns listener", output) } + if !strings.Contains(output, "web: http://127.0.0.1:7777") { + t.Fatalf("output = %q, want default web listener", output) + } } func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index fc6807d..ebdca41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,36 +15,38 @@ import ( ) type fileConfig struct { - RuntimeDir string `toml:"runtime_dir"` - RepoRoot string `toml:"repo_root"` - LogLevel string `toml:"log_level"` - FirecrackerBin string `toml:"firecracker_bin"` - SSHKeyPath string `toml:"ssh_key_path"` - NamegenPath string `toml:"namegen_path"` - CustomizeScript string `toml:"customize_script"` - VSockAgent string `toml:"vsock_agent_path"` - VSockPingHelper string `toml:"vsock_ping_helper_path"` - DefaultWorkSeed string `toml:"default_work_seed"` - DefaultImageName string `toml:"default_image_name"` - DefaultRootfs string `toml:"default_rootfs"` - DefaultBaseRootfs string `toml:"default_base_rootfs"` - DefaultKernel string `toml:"default_kernel"` - DefaultInitrd string `toml:"default_initrd"` - DefaultModulesDir string `toml:"default_modules_dir"` - DefaultPackages string `toml:"default_packages_file"` - AutoStopStaleAfter string `toml:"auto_stop_stale_after"` - StatsPollInterval string `toml:"stats_poll_interval"` - MetricsPoll string `toml:"metrics_poll_interval"` - BridgeName string `toml:"bridge_name"` - BridgeIP string `toml:"bridge_ip"` - CIDR string `toml:"cidr"` - TapPoolSize int `toml:"tap_pool_size"` - DefaultDNS string `toml:"default_dns"` + RuntimeDir string `toml:"runtime_dir"` + RepoRoot string `toml:"repo_root"` + LogLevel string `toml:"log_level"` + WebListenAddr *string `toml:"web_listen_addr"` + FirecrackerBin string `toml:"firecracker_bin"` + SSHKeyPath string `toml:"ssh_key_path"` + NamegenPath string `toml:"namegen_path"` + CustomizeScript string `toml:"customize_script"` + VSockAgent string `toml:"vsock_agent_path"` + VSockPingHelper string `toml:"vsock_ping_helper_path"` + DefaultWorkSeed string `toml:"default_work_seed"` + DefaultImageName string `toml:"default_image_name"` + DefaultRootfs string `toml:"default_rootfs"` + DefaultBaseRootfs string `toml:"default_base_rootfs"` + DefaultKernel string `toml:"default_kernel"` + DefaultInitrd string `toml:"default_initrd"` + DefaultModulesDir string `toml:"default_modules_dir"` + DefaultPackages string `toml:"default_packages_file"` + AutoStopStaleAfter string `toml:"auto_stop_stale_after"` + StatsPollInterval string `toml:"stats_poll_interval"` + MetricsPoll string `toml:"metrics_poll_interval"` + BridgeName string `toml:"bridge_name"` + BridgeIP string `toml:"bridge_ip"` + CIDR string `toml:"cidr"` + TapPoolSize int `toml:"tap_pool_size"` + DefaultDNS string `toml:"default_dns"` } func Load(layout paths.Layout) (model.DaemonConfig, error) { cfg := model.DaemonConfig{ LogLevel: "info", + WebListenAddr: "127.0.0.1:7777", AutoStopStaleAfter: 0, StatsPollInterval: model.DefaultStatsPollInterval, MetricsPollInterval: model.DefaultMetricsPollInterval, @@ -84,6 +86,9 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if file.LogLevel != "" { cfg.LogLevel = file.LogLevel } + if file.WebListenAddr != nil { + cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr) + } if file.NamegenPath != "" { cfg.NamegenPath = file.NamegenPath } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4791084..665ab9b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -289,3 +289,25 @@ func TestLoadAcceptsLegacyConfigVsockPingHelperPath(t *testing.T) { t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) } } + +func TestLoadWebListenAddrDefaultsAndAllowsDisable(t *testing.T) { + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load default config: %v", err) + } + if cfg.WebListenAddr != "127.0.0.1:7777" { + t.Fatalf("WebListenAddr = %q, want default 127.0.0.1:7777", cfg.WebListenAddr) + } + + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("web_listen_addr = \"\"\n"), 0o644); err != nil { + t.Fatalf("write config.toml: %v", err) + } + cfg, err = Load(paths.Layout{ConfigDir: configDir}) + if err != nil { + t.Fatalf("Load disabled config: %v", err) + } + if cfg.WebListenAddr != "" { + t.Fatalf("WebListenAddr = %q, want disabled empty string", cfg.WebListenAddr) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 6eb0ea6..1042caf 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "path/filepath" "strings" @@ -26,27 +27,32 @@ import ( ) type Daemon struct { - layout paths.Layout - config model.DaemonConfig - store *store.Store - runner system.CommandRunner - logger *slog.Logger - mu sync.Mutex - createOpsMu sync.Mutex - createOps map[string]*vmCreateOperationState - vmLocksMu sync.Mutex - vmLocks map[string]*sync.Mutex - tapPoolMu sync.Mutex - tapPool []string - tapPoolNext int - closing chan struct{} - once sync.Once - pid int - listener net.Listener - vmDNS *vmdns.Server - vmCaps []vmCapability - imageBuild func(context.Context, imageBuildSpec) error - requestHandler func(context.Context, rpc.Request) rpc.Response + layout paths.Layout + config model.DaemonConfig + store *store.Store + runner system.CommandRunner + logger *slog.Logger + mu sync.Mutex + createOpsMu sync.Mutex + createOps map[string]*vmCreateOperationState + imageBuildOpsMu sync.Mutex + imageBuildOps map[string]*imageBuildOperationState + vmLocksMu sync.Mutex + vmLocks map[string]*sync.Mutex + tapPoolMu sync.Mutex + tapPool []string + tapPoolNext int + closing chan struct{} + once sync.Once + pid int + listener net.Listener + webListener net.Listener + webServer *http.Server + webURL string + vmDNS *vmdns.Server + vmCaps []vmCapability + imageBuild func(context.Context, imageBuildSpec) error + requestHandler func(context.Context, rpc.Request) rpc.Response } func Open(ctx context.Context) (d *Daemon, err error) { @@ -115,6 +121,12 @@ 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.stopVMDNS(), d.store.Close()) }) return err @@ -138,6 +150,9 @@ 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() @@ -238,7 +253,7 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } switch req.Method { case "ping": - result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid}) + result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid, WebURL: d.webURL}) return result case "shutdown": go d.Close() @@ -392,6 +407,27 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.BuildImage(ctx, params) return marshalResultOrError(api.ImageShowResult{Image: image}, err) + case "image.build.begin": + params, err := rpc.DecodeParams[api.ImageBuildParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.BeginImageBuild(ctx, params) + return marshalResultOrError(api.ImageBuildBeginResult{Operation: op}, err) + case "image.build.status": + params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.ImageBuildStatus(ctx, params.ID) + return marshalResultOrError(api.ImageBuildStatusResult{Operation: op}, err) + case "image.build.cancel": + params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + err = d.CancelImageBuild(ctx, params.ID) + return marshalResultOrError(api.Empty{}, err) case "image.register": params, err := rpc.DecodeParams[api.ImageRegisterParams](req) if err != nil { @@ -436,6 +472,7 @@ func (d *Daemon) backgroundLoop() { d.logger.Error("background stale sweep failed", "error", err.Error()) } d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) + d.pruneImageBuildOperations(time.Now().Add(-10 * time.Minute)) } } } diff --git a/internal/daemon/dashboard.go b/internal/daemon/dashboard.go new file mode 100644 index 0000000..b0953b5 --- /dev/null +++ b/internal/daemon/dashboard.go @@ -0,0 +1,63 @@ +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 vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + 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/image_build_ops.go b/internal/daemon/image_build_ops.go new file mode 100644 index 0000000..813a7a2 --- /dev/null +++ b/internal/daemon/image_build_ops.go @@ -0,0 +1,218 @@ +package daemon + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "banger/internal/api" + "banger/internal/model" +) + +type imageBuildProgressKey struct{} + +type imageBuildOperationState struct { + mu sync.Mutex + cancel context.CancelFunc + op api.ImageBuildOperation +} + +func newImageBuildOperationState() (*imageBuildOperationState, error) { + id, err := model.NewID() + if err != nil { + return nil, err + } + now := model.Now() + return &imageBuildOperationState{ + op: api.ImageBuildOperation{ + ID: id, + Stage: "queued", + Detail: "waiting to start", + StartedAt: now, + UpdatedAt: now, + }, + }, nil +} + +func withImageBuildProgress(ctx context.Context, op *imageBuildOperationState) context.Context { + if op == nil { + return ctx + } + return context.WithValue(ctx, imageBuildProgressKey{}, op) +} + +func imageBuildProgressFromContext(ctx context.Context) *imageBuildOperationState { + if ctx == nil { + return nil + } + op, _ := ctx.Value(imageBuildProgressKey{}).(*imageBuildOperationState) + return op +} + +func imageBuildStage(ctx context.Context, stage, detail string) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.stage(stage, detail) + } +} + +func imageBuildBindImage(ctx context.Context, image model.Image) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.bindImage(image) + } +} + +func imageBuildSetLogPath(ctx context.Context, path string) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.setLogPath(path) + } +} + +func (op *imageBuildOperationState) setCancel(cancel context.CancelFunc) { + op.mu.Lock() + defer op.mu.Unlock() + op.cancel = cancel +} + +func (op *imageBuildOperationState) setLogPath(path string) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.BuildLogPath = strings.TrimSpace(path) + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) bindImage(image model.Image) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.ImageID = image.ID + op.op.ImageName = image.Name +} + +func (op *imageBuildOperationState) stage(stage, detail string) { + op.mu.Lock() + defer op.mu.Unlock() + stage = strings.TrimSpace(stage) + detail = strings.TrimSpace(detail) + if stage == "" { + stage = op.op.Stage + } + if stage == op.op.Stage && detail == op.op.Detail { + return + } + op.op.Stage = stage + op.op.Detail = detail + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) done(image model.Image) { + op.mu.Lock() + defer op.mu.Unlock() + imageCopy := image + op.op.ImageID = image.ID + op.op.ImageName = image.Name + op.op.Stage = "ready" + op.op.Detail = "image is ready" + op.op.Done = true + op.op.Success = true + op.op.Error = "" + op.op.Image = &imageCopy + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) fail(err error) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.Done = true + op.op.Success = false + if err != nil { + op.op.Error = err.Error() + } + if strings.TrimSpace(op.op.Detail) == "" { + op.op.Detail = "image build failed" + } + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) snapshot() api.ImageBuildOperation { + op.mu.Lock() + defer op.mu.Unlock() + snapshot := op.op + if snapshot.Image != nil { + imageCopy := *snapshot.Image + snapshot.Image = &imageCopy + } + return snapshot +} + +func (op *imageBuildOperationState) cancelOperation() { + op.mu.Lock() + cancel := op.cancel + op.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (d *Daemon) BeginImageBuild(_ context.Context, params api.ImageBuildParams) (api.ImageBuildOperation, error) { + op, err := newImageBuildOperationState() + if err != nil { + return api.ImageBuildOperation{}, err + } + buildCtx, cancel := context.WithCancel(context.Background()) + op.setCancel(cancel) + + d.imageBuildOpsMu.Lock() + if d.imageBuildOps == nil { + d.imageBuildOps = map[string]*imageBuildOperationState{} + } + d.imageBuildOps[op.op.ID] = op + d.imageBuildOpsMu.Unlock() + + go d.runImageBuildOperation(withImageBuildProgress(buildCtx, op), op, params) + return op.snapshot(), nil +} + +func (d *Daemon) runImageBuildOperation(ctx context.Context, op *imageBuildOperationState, params api.ImageBuildParams) { + image, err := d.BuildImage(ctx, params) + if err != nil { + op.fail(err) + return + } + op.done(image) +} + +func (d *Daemon) ImageBuildStatus(_ context.Context, id string) (api.ImageBuildOperation, error) { + d.imageBuildOpsMu.Lock() + op, ok := d.imageBuildOps[strings.TrimSpace(id)] + d.imageBuildOpsMu.Unlock() + if !ok { + return api.ImageBuildOperation{}, fmt.Errorf("image build operation not found: %s", id) + } + return op.snapshot(), nil +} + +func (d *Daemon) CancelImageBuild(_ context.Context, id string) error { + d.imageBuildOpsMu.Lock() + op, ok := d.imageBuildOps[strings.TrimSpace(id)] + d.imageBuildOpsMu.Unlock() + if !ok { + return fmt.Errorf("image build operation not found: %s", id) + } + op.cancelOperation() + return nil +} + +func (d *Daemon) pruneImageBuildOperations(olderThan time.Time) { + d.imageBuildOpsMu.Lock() + defer d.imageBuildOpsMu.Unlock() + for id, op := range d.imageBuildOps { + snapshot := op.snapshot() + if !snapshot.Done { + continue + } + if snapshot.UpdatedAt.Before(olderThan) { + delete(d.imageBuildOps, id) + } + } +} diff --git a/internal/daemon/images.go b/internal/daemon/images.go index d24aa9c..365e53d 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -30,6 +30,7 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i }() name := params.Name + imageBuildStage(ctx, "resolve_image", "resolving image build inputs") if name == "" { name = fmt.Sprintf("image-%d", model.Now().Unix()) } @@ -57,6 +58,7 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i return model.Image{}, err } buildLogPath = filepath.Join(buildLogDir, id+".log") + imageBuildSetLogPath(ctx, buildLogPath) logFile, err := os.OpenFile(buildLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return model.Image{}, err @@ -93,22 +95,26 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i Size: params.Size, } op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir) + imageBuildStage(ctx, "launch_builder", "building rootfs from base image") if err := d.runImageBuild(ctx, spec); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed") if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access") seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) if err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "write_metadata", "writing image metadata") if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) @@ -131,10 +137,12 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i CreatedAt: now, UpdatedAt: now, } + imageBuildBindImage(ctx, image) if err := d.store.UpsertImage(ctx, image); err != nil { return model.Image{}, err } op.stage("persisted", "build_log_path", buildLogPath) + imageBuildStage(ctx, "persisted", "image metadata saved") if d.logger != nil { d.logger.Info("image build log preserved", append(imageLogAttrs(image), "build_log_path", buildLogPath)...) } diff --git a/internal/daemon/web.go b/internal/daemon/web.go new file mode 100644 index 0000000..11cc951 --- /dev/null +++ b/internal/daemon/web.go @@ -0,0 +1,65 @@ +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 400019f..2955765 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -37,6 +37,7 @@ const ( type DaemonConfig struct { RuntimeDir string LogLevel string + WebListenAddr string FirecrackerBin string SSHKeyPath string NamegenPath string diff --git a/internal/system/system.go b/internal/system/system.go index f29b464..753b532 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -59,6 +59,22 @@ func EnsureSudo(ctx context.Context) error { return cmd.Run() } +func CheckSudo(ctx context.Context) error { + if _, err := exec.LookPath("sudo"); err != nil { + return err + } + cmd := exec.CommandContext(ctx, "sudo", "-n", "-v") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return err + } + return nil +} + func RequireCommands(ctx context.Context, commands ...string) error { for _, command := range commands { if _, err := exec.LookPath(command); err != nil { diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js new file mode 100644 index 0000000..0897317 --- /dev/null +++ b/internal/webui/assets/app.js @@ -0,0 +1,130 @@ +(() => { + 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 new file mode 100644 index 0000000..0b28255 --- /dev/null +++ b/internal/webui/assets/style.css @@ -0,0 +1,513 @@ +: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 new file mode 100644 index 0000000..d87dccb --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,1246 @@ +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) + BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) + ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, 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 imageBuildForm struct { + Name string + BaseRootfs string + Size string + KernelPath string + InitrdPath string + ModulesDir string + Docker bool +} + +type imageRegisterForm struct { + Name string + RootfsPath string + WorkSeedPath string + KernelPath string + InitrdPath string + ModulesDir string + PackagesPath 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 + ImageBuildForm imageBuildForm + ImageRegisterForm imageRegisterForm + LogText string + VMCreateOperation *api.VMCreateOperation + ImageBuildOperation *api.ImageBuildOperation + 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/build", s.wrap(s.handleImageBuildForm)) + mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild)) + 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 /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage)) + mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI)) + mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI)) + 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) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error { + cfg := s.backend.Config() + return s.renderImageBuildPage(w, r, imageBuildForm{ + BaseRootfs: cfg.DefaultBaseRootfs, + KernelPath: cfg.DefaultKernel, + InitrdPath: cfg.DefaultInitrd, + ModulesDir: cfg.DefaultModulesDir, + }, "") +} + +func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error { + return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error { + data.Section = "images" + data.ImageBuildForm = form + data.ErrorMessage = formErr + return nil + }) +} + +func (s *Server) handleImageBuild(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.parseImageBuildForm(r) + if err != nil { + return s.renderImageBuildPage(w, r, form, err.Error()) + } + if !allowed { + return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") + } + op, err := s.backend.BeginImageBuild(r.Context(), params) + if err != nil { + return s.renderImageBuildPage(w, r, form, err.Error()) + } + http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther) + return nil +} + +func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error { + cfg := s.backend.Config() + return s.renderImageRegisterPage(w, r, imageRegisterForm{ + KernelPath: cfg.DefaultKernel, + InitrdPath: cfg.DefaultInitrd, + ModulesDir: cfg.DefaultModulesDir, + }, "") +} + +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) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error { + data.Section = "images" + data.OperationKind = "image" + data.ImageBuildOperation = &op + data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID) + if op.ImageID != "" { + data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID) + } + data.OperationLogPath = op.BuildLogPath + 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) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return writeJSON(w, api.ImageBuildStatusResult{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}) + } + if runtimeDir := s.backend.Config().RuntimeDir; runtimeDir != "" { + roots = append(roots, pickerRoot{Label: "Runtime", Path: runtimeDir}) + } + 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) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) { + if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { + return imageBuildForm{}, api.ImageBuildParams{}, err + } + form := imageBuildForm{ + Name: strings.TrimSpace(r.FormValue("name")), + BaseRootfs: strings.TrimSpace(r.FormValue("base_rootfs")), + Size: strings.TrimSpace(r.FormValue("size")), + 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.ImageBuildParams{ + Name: form.Name, + BaseRootfs: form.BaseRootfs, + Size: form.Size, + KernelPath: form.KernelPath, + InitrdPath: form.InitrdPath, + ModulesDir: form.ModulesDir, + Docker: form.Docker, + } + return form, 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")), + PackagesPath: strings.TrimSpace(r.FormValue("packages_path")), + 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, + PackagesPath: form.PackagesPath, + 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 new file mode 100644 index 0000000..bbe6f0c --- /dev/null +++ b/internal/webui/server_test.go @@ -0,0 +1,231 @@ +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 + buildOp api.ImageBuildOperation +} + +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) BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) { + return f.buildOp, nil +} +func (f fakeBackend) ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) { + return f.buildOp, 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-exp", 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-exp", "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-exp"}, + ports: api.VMPortsResult{ + Name: "smth", + Ports: []api.VMPort{ + {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "opencode"}, + }, + }, + } + + 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{"opencode attach", "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-exp"}, + }, + } + + 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-exp", "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 new file mode 100644 index 0000000..2fb2473 --- /dev/null +++ b/internal/webui/templates/base.html @@ -0,0 +1,124 @@ +{{define "page"}} + + + + + + {{.Title}} ยท banger + + + +
+
+
+

Local Control Plane

+

banger

+
+ +
+ + {{if not .MutationAllowed}} + + {{end}} + + {{if .Flash}} + + {{end}} + +
+
+
+

vCPU

+ {{.Summary.Banger.ConfiguredVCPUCount}} / {{.Summary.Host.CPUCount}} +
+ +
+ {{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}% allocated + {{.Summary.Banger.RunningVMCount}} running +
+
+
+
+

Memory

+ {{formatBytesCompact .Summary.Banger.ConfiguredMemoryBytes}} / {{formatBytesCompact .Summary.Host.TotalMemoryBytes}} +
+ +
+ {{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}% allocated + {{formatBytesCompact .Summary.Banger.RunningRSSBytes}} RSS live +
+
+
+
+

Disk

+ {{formatBytesCompact .Summary.Banger.ConfiguredDiskBytes}} / {{formatBytesCompact .Summary.Host.StateFilesystemTotalBytes}} +
+ +
+ {{formatBytesCompact .Summary.Host.StateFilesystemFreeBytes}} free + {{formatBytesCompact (sumInt64 .Summary.Banger.UsedSystemOverlayBytes .Summary.Banger.UsedWorkDiskBytes)}} actual +
+
+
+
+ {{.Summary.Banger.RunningVMCount}} / {{.Summary.Banger.VMCount}} running + {{.Summary.Banger.ImageCount}} images + {{.Summary.Banger.ManagedImageCount}} managed + {{formatPercent .Summary.Banger.RunningCPUPercent}} live CPU +
+ +
+
+

{{.Title}}

+
+ {{.BodyHTML}} +
+
+ + +
+
+

Roots

+
+ {{range .PickerRoots}} + + {{end}} +
+
+
+
+ / +
+ + +
+
+

Choose a host path. Directories open in place; files select immediately.

+
+
+
+
+ + + + +{{end}} + +{{define "csrf_field"}} + +{{end}} diff --git a/internal/webui/templates/dashboard.html b/internal/webui/templates/dashboard.html new file mode 100644 index 0000000..aa18698 --- /dev/null +++ b/internal/webui/templates/dashboard.html @@ -0,0 +1,65 @@ +{{define "dashboard_content"}} +
+
+
+

Virtual Machines

+ Create VM +
+ + + + + + + + + + + + {{range .VMs}} + + + + + + + + {{else}} + + {{end}} + +
NameStateIPSpecCreated
{{.Name}}{{.State}}{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}{{.Spec.VCPUCount}} vCPU / {{.Spec.MemoryMiB}} MiB / {{formatBytes .Spec.WorkDiskSizeBytes}}{{relativeTime .CreatedAt}}
No VMs yet.
+
+
+
+

Images

+
+ Register + Build +
+
+ + + + + + + + + + + {{range .Images}} + + + + + + + {{else}} + + {{end}} + +
NameManagedRootfsCreated
{{.Name}}{{formatBool .Managed}}{{.RootfsPath}}{{relativeTime .CreatedAt}}
No images registered.
+
+
+{{end}} diff --git a/internal/webui/templates/error.html b/internal/webui/templates/error.html new file mode 100644 index 0000000..71e45b1 --- /dev/null +++ b/internal/webui/templates/error.html @@ -0,0 +1,3 @@ +{{define "error_content"}} +
{{.ErrorMessage}}
+{{end}} diff --git a/internal/webui/templates/images.html b/internal/webui/templates/images.html new file mode 100644 index 0000000..0b4fe3b --- /dev/null +++ b/internal/webui/templates/images.html @@ -0,0 +1,182 @@ +{{define "image_list_content"}} +
+

Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.

+ +
+ + + + + + + + + + + + {{range .Images}} + + + + + + + + {{else}} + + {{end}} + +
NameManagedDockerRootfsCreated
{{.Name}}{{formatBool .Managed}}{{formatBool .Docker}}{{.RootfsPath}}{{relativeTime .CreatedAt}}
No images registered.
+{{end}} + +{{define "image_build_content"}} +

Build a managed image from a base rootfs, then redirect into the async build progress view.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "image_register_content"}} +

Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "image_show_content"}} +
+
+

{{.Image.Name}}

+
+
ID
{{.Image.ID}}
+
Managed
{{formatBool .Image.Managed}}
+
Docker
{{formatBool .Image.Docker}}
+
Used By
{{.ImageUsers}} VM(s)
+
+
+
+

Artifacts

+
+
Rootfs
{{.Image.RootfsPath}}
+
Work Seed
{{if .Image.WorkSeedPath}}{{.Image.WorkSeedPath}}{{else}}-{{end}}
+
Kernel
{{.Image.KernelPath}}
+
Initrd
{{if .Image.InitrdPath}}{{.Image.InitrdPath}}{{else}}-{{end}}
+
Modules
{{if .Image.ModulesDir}}{{.Image.ModulesDir}}{{else}}-{{end}}
+
+
+
+

Lifecycle

+
+
Created
{{relativeTime .Image.CreatedAt}}
+
Updated
{{relativeTime .Image.UpdatedAt}}
+
Packages
{{if .Image.PackagesPath}}{{.Image.PackagesPath}}{{else}}-{{end}}
+
Artifact Dir
{{if .Image.ArtifactDir}}{{.Image.ArtifactDir}}{{else}}-{{end}}
+
+
+
+ +
+ {{if not .Image.Managed}} +
{{template "csrf_field" .}}
+ {{end}} +
{{template "csrf_field" .}}
+
+{{end}} diff --git a/internal/webui/templates/operation.html b/internal/webui/templates/operation.html new file mode 100644 index 0000000..87ff45e --- /dev/null +++ b/internal/webui/templates/operation.html @@ -0,0 +1,20 @@ +{{define "operation_content"}} +
+

{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}

+ {{if .VMCreateOperation}} +

{{.VMCreateOperation.Stage}}

+

{{.VMCreateOperation.Detail}}

+

{{.VMCreateOperation.Error}}

+ {{end}} + {{if .ImageBuildOperation}} +

{{.ImageBuildOperation.Stage}}

+

{{.ImageBuildOperation.Detail}}

+

{{.ImageBuildOperation.Error}}

+ {{end}} + {{if .OperationLogPath}} +

Build log: {{.OperationLogPath}}

+ {{else}} +

+ {{end}} +
+{{end}} diff --git a/internal/webui/templates/vms.html b/internal/webui/templates/vms.html new file mode 100644 index 0000000..886e44c --- /dev/null +++ b/internal/webui/templates/vms.html @@ -0,0 +1,191 @@ +{{define "vm_list_content"}} +
+

Inspect lifecycle, capacity, and reachability for every VM.

+ Create VM +
+ + + + + + + + + + + + + + + {{range .VMs}} + + + + + + + + + + + {{else}} + + {{end}} + +
NameStateImageIPvCPUMemoryDiskCreated
{{.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.
+{{end}} + +{{define "vm_new_content"}} +

Create a VM and wait until the guest is fully ready. The browser will follow live create progress automatically.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "vm_show_content"}} +
+
+

{{.VM.Name}}

+
+
ID
{{.VM.ID}}
+
Image
{{if .VMImage.ID}}{{.VMImage.Name}}{{else}}{{shortID .VM.ImageID}}{{end}}
+
State
{{.VM.State}}
+
Guest IP
{{if .VM.Runtime.GuestIP}}{{.VM.Runtime.GuestIP}}{{else}}-{{end}}
+
Created
{{relativeTime .VM.CreatedAt}}
+
+
+
+

Configured Spec

+
+
vCPU
{{.VM.Spec.VCPUCount}}
+
Memory
{{.VM.Spec.MemoryMiB}} MiB
+
Disk
{{formatBytes .VM.Spec.WorkDiskSizeBytes}}
+
NAT
{{formatBool .VM.Spec.NATEnabled}}
+
+
+
+

Current Usage

+
+
CPU
{{formatPercent .VMStats.CPUPercent}}
+
RSS
{{formatBytes .VMStats.RSSBytes}}
+
Overlay
{{formatBytes .VMStats.SystemOverlayBytes}}
+
Work Disk
{{formatBytes .VMStats.WorkDiskBytes}}
+
+
+
+ +
+

Actions

+ Logs +
+
+ {{if eq .VM.State "running"}} +
{{template "csrf_field" .}}
+
{{template "csrf_field" .}}
+ {{else}} +
{{template "csrf_field" .}}
+ {{end}} +
{{template "csrf_field" .}}
+
+ +
+
+

Listening Ports

+ {{if .VMPortsError}} +

{{.VMPortsError}}

+ {{else}} + + + + + + {{range .VMPorts.Ports}} + + + + + + {{else}} + + {{end}} + +
PortProcessEndpoint
{{.Proto}}/{{.Port}}{{if .Process}}{{.Process}}{{else}}-{{end}}{{if .Endpoint}}{{if endpointHref .Endpoint}}{{.Endpoint}}{{else}}{{.Endpoint}}{{end}}{{else}}-{{end}}
No host-reachable listeners reported.
+ {{end}} +
+
+

Update Settings

+
+ {{template "csrf_field" .}} + + + + +
+ Cancel + +
+
+
+
+{{end}} + +{{define "vm_logs_content"}} +
+

Showing the last 200 lines from the Firecracker log.

+
+ + Refresh +
+
+
{{.LogText}}
+{{end}}