Serve a local web UI from bangerd

Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action.

Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations.

Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links.

Validation: GOCACHE=/tmp/banger-gocache go test ./...
This commit is contained in:
Thales Maciel 2026-03-21 16:47:47 -03:00
parent 30f0c0b54a
commit 2362d0ae39
No known key found for this signature in database
GPG key ID: 33112E6833C34679
24 changed files with 3308 additions and 52 deletions

View file

@ -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`.

View file

@ -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 <vm> -- true`
succeeds

View file

@ -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"`
}

View file

@ -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
},

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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))
}
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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)...)
}

65
internal/daemon/web.go Normal file
View file

@ -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)
}

View file

@ -37,6 +37,7 @@ const (
type DaemonConfig struct {
RuntimeDir string
LogLevel string
WebListenAddr string
FirecrackerBin string
SSHKeyPath string
NamegenPath string

View file

@ -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 {

View file

@ -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 = `<span>${entry.name}</span><small>${entry.kind}</small>`;
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();
});
})();

View file

@ -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; }
}

1246
internal/webui/server.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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</a>", "href=\"/images/img-1\""} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q\n%s", want, body)
}
}
}

View file

@ -0,0 +1,124 @@
{{define "page"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · banger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">Local Control Plane</p>
<h1>banger</h1>
</div>
<nav class="nav">
<a href="/" class="{{if eq .Section "dashboard"}}active{{end}}">Dashboard</a>
<a href="/vms" class="{{if eq .Section "vms"}}active{{end}}">VMs</a>
<a href="/images" class="{{if eq .Section "images"}}active{{end}}">Images</a>
</nav>
</header>
{{if not .MutationAllowed}}
<section class="banner warning">
<strong>Mutating actions are paused.</strong>
<span>Run <code>{{.Summary.Sudo.Command}}</code> in a terminal and refresh this page. {{.Summary.Sudo.Error}}</span>
</section>
{{end}}
{{if .Flash}}
<section class="banner {{.Flash.Kind}}">
<span>{{.Flash.Message}}</span>
</section>
{{end}}
<section class="summary-grid">
<article class="summary-card resource-card cpu">
<div class="resource-head">
<h2>vCPU</h2>
<strong class="resource-ratio">{{.Summary.Banger.ConfiguredVCPUCount}} / {{.Summary.Host.CPUCount}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}%;"></span>
</div>
<div class="resource-foot">
<span>{{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}% allocated</span>
<span>{{.Summary.Banger.RunningVMCount}} running</span>
</div>
</article>
<article class="summary-card resource-card memory">
<div class="resource-head">
<h2>Memory</h2>
<strong class="resource-ratio">{{formatBytesCompact .Summary.Banger.ConfiguredMemoryBytes}} / {{formatBytesCompact .Summary.Host.TotalMemoryBytes}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}%;"></span>
</div>
<div class="resource-foot">
<span>{{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}% allocated</span>
<span>{{formatBytesCompact .Summary.Banger.RunningRSSBytes}} RSS live</span>
</div>
</article>
<article class="summary-card resource-card disk">
<div class="resource-head">
<h2>Disk</h2>
<strong class="resource-ratio">{{formatBytesCompact .Summary.Banger.ConfiguredDiskBytes}} / {{formatBytesCompact .Summary.Host.StateFilesystemTotalBytes}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredDiskBytes .Summary.Host.StateFilesystemTotalBytes}}%;"></span>
</div>
<div class="resource-foot">
<span>{{formatBytesCompact .Summary.Host.StateFilesystemFreeBytes}} free</span>
<span>{{formatBytesCompact (sumInt64 .Summary.Banger.UsedSystemOverlayBytes .Summary.Banger.UsedWorkDiskBytes)}} actual</span>
</div>
</article>
</section>
<div class="summary-notes">
<span>{{.Summary.Banger.RunningVMCount}} / {{.Summary.Banger.VMCount}} running</span>
<span>{{.Summary.Banger.ImageCount}} images</span>
<span>{{.Summary.Banger.ManagedImageCount}} managed</span>
<span>{{formatPercent .Summary.Banger.RunningCPUPercent}} live CPU</span>
</div>
<main class="content-panel">
<div class="panel-head">
<div><h2>{{.Title}}</h2></div>
</div>
{{.BodyHTML}}
</main>
</div>
<dialog class="picker-dialog" id="path-picker">
<form method="dialog" class="picker-shell">
<div class="picker-sidebar">
<h3>Roots</h3>
<div class="picker-roots">
{{range .PickerRoots}}
<button type="button" class="picker-root" data-picker-root="{{.Path}}">{{.Label}}</button>
{{end}}
</div>
</div>
<div class="picker-main">
<div class="picker-bar">
<strong id="picker-current-path">/</strong>
<div class="picker-actions">
<button type="button" id="picker-select-current" class="secondary">Use current folder</button>
<button type="button" id="picker-close" class="secondary">Close</button>
</div>
</div>
<p class="picker-help">Choose a host path. Directories open in place; files select immediately.</p>
<div class="picker-list" id="picker-list"></div>
</div>
</form>
</dialog>
<script src="/static/app.js"></script>
</body>
</html>
{{end}}
{{define "csrf_field"}}
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{end}}

View file

@ -0,0 +1,65 @@
{{define "dashboard_content"}}
<section class="split-grid">
<div>
<div class="section-head">
<h3>Virtual Machines</h3>
<a class="button" href="/vms/new">Create VM</a>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>IP</th>
<th>Spec</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .VMs}}
<tr>
<td><a class="table-link" href="/vms/{{.ID}}">{{.Name}}</a></td>
<td><span class="state-pill {{stateClass .State}}">{{.State}}</span></td>
<td>{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}</td>
<td>{{.Spec.VCPUCount}} vCPU / {{.Spec.MemoryMiB}} MiB / {{formatBytes .Spec.WorkDiskSizeBytes}}</td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No VMs yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
<div>
<div class="section-head">
<h3>Images</h3>
<div class="stack-inline">
<a class="button secondary" href="/images/register">Register</a>
<a class="button" href="/images/build">Build</a>
</div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Managed</th>
<th>Rootfs</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Images}}
<tr>
<td><a class="table-link" href="/images/{{.ID}}">{{.Name}}</a></td>
<td>{{formatBool .Managed}}</td>
<td><code>{{.RootfsPath}}</code></td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">No images registered.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}

View file

@ -0,0 +1,3 @@
{{define "error_content"}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}

View file

@ -0,0 +1,182 @@
{{define "image_list_content"}}
<div class="section-head">
<p class="muted">Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.</p>
<div class="stack-inline">
<a class="button secondary" href="/images/register">Register Image</a>
<a class="button" href="/images/build">Build Image</a>
</div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Managed</th>
<th>Docker</th>
<th>Rootfs</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Images}}
<tr>
<td><a class="table-link" href="/images/{{.ID}}">{{.Name}}</a></td>
<td>{{formatBool .Managed}}</td>
<td>{{formatBool .Docker}}</td>
<td><code>{{.RootfsPath}}</code></td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No images registered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{define "image_build_content"}}
<p class="muted">Build a managed image from a base rootfs, then redirect into the async build progress view.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/images/build" class="form-grid">
{{template "csrf_field" .}}
<label><span>Name</span><input type="text" name="name" value="{{.ImageBuildForm.Name}}" placeholder="generated when empty"></label>
<label class="picker-field">
<span>Base Rootfs</span>
<div class="picker-input">
<input type="text" name="base_rootfs" value="{{.ImageBuildForm.BaseRootfs}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="base_rootfs" data-picker-kind="file">Browse</button>
</div>
</label>
<label><span>Size Override</span><input type="text" name="size" value="{{.ImageBuildForm.Size}}" placeholder="optional"></label>
<label class="picker-field">
<span>Kernel Path</span>
<div class="picker-input">
<input type="text" name="kernel_path" value="{{.ImageBuildForm.KernelPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Initrd Path</span>
<div class="picker-input">
<input type="text" name="initrd_path" value="{{.ImageBuildForm.InitrdPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Modules Directory</span>
<div class="picker-input">
<input type="text" name="modules_dir" value="{{.ImageBuildForm.ModulesDir}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
</div>
</label>
<label class="checkbox">
<input type="checkbox" name="docker" {{if .ImageBuildForm.Docker}}checked{{end}}>
<span>Install Docker</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/images">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Build Image</button>
</div>
</form>
{{end}}
{{define "image_register_content"}}
<p class="muted">Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/images/register" class="form-grid">
{{template "csrf_field" .}}
<label><span>Name</span><input type="text" name="name" value="{{.ImageRegisterForm.Name}}"></label>
<label class="picker-field">
<span>Rootfs Path</span>
<div class="picker-input">
<input type="text" name="rootfs_path" value="{{.ImageRegisterForm.RootfsPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="rootfs_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Work Seed Path</span>
<div class="picker-input">
<input type="text" name="work_seed_path" value="{{.ImageRegisterForm.WorkSeedPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="work_seed_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Kernel Path</span>
<div class="picker-input">
<input type="text" name="kernel_path" value="{{.ImageRegisterForm.KernelPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Initrd Path</span>
<div class="picker-input">
<input type="text" name="initrd_path" value="{{.ImageRegisterForm.InitrdPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Modules Directory</span>
<div class="picker-input">
<input type="text" name="modules_dir" value="{{.ImageRegisterForm.ModulesDir}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Packages Manifest</span>
<div class="picker-input">
<input type="text" name="packages_path" value="{{.ImageRegisterForm.PackagesPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="packages_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="checkbox">
<input type="checkbox" name="docker" {{if .ImageRegisterForm.Docker}}checked{{end}}>
<span>Mark image as Docker-ready</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/images">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Register Image</button>
</div>
</form>
{{end}}
{{define "image_show_content"}}
<section class="detail-grid">
<article class="detail-card">
<h2>{{.Image.Name}}</h2>
<dl>
<dt>ID</dt><dd><code>{{.Image.ID}}</code></dd>
<dt>Managed</dt><dd>{{formatBool .Image.Managed}}</dd>
<dt>Docker</dt><dd>{{formatBool .Image.Docker}}</dd>
<dt>Used By</dt><dd>{{.ImageUsers}} VM(s)</dd>
</dl>
</article>
<article class="detail-card">
<h2>Artifacts</h2>
<dl>
<dt>Rootfs</dt><dd><code>{{.Image.RootfsPath}}</code></dd>
<dt>Work Seed</dt><dd>{{if .Image.WorkSeedPath}}<code>{{.Image.WorkSeedPath}}</code>{{else}}-{{end}}</dd>
<dt>Kernel</dt><dd><code>{{.Image.KernelPath}}</code></dd>
<dt>Initrd</dt><dd>{{if .Image.InitrdPath}}<code>{{.Image.InitrdPath}}</code>{{else}}-{{end}}</dd>
<dt>Modules</dt><dd>{{if .Image.ModulesDir}}<code>{{.Image.ModulesDir}}</code>{{else}}-{{end}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Lifecycle</h2>
<dl>
<dt>Created</dt><dd>{{relativeTime .Image.CreatedAt}}</dd>
<dt>Updated</dt><dd>{{relativeTime .Image.UpdatedAt}}</dd>
<dt>Packages</dt><dd>{{if .Image.PackagesPath}}<code>{{.Image.PackagesPath}}</code>{{else}}-{{end}}</dd>
<dt>Artifact Dir</dt><dd>{{if .Image.ArtifactDir}}<code>{{.Image.ArtifactDir}}</code>{{else}}-{{end}}</dd>
</dl>
</article>
</section>
<div class="stack-inline">
{{if not .Image.Managed}}
<form method="post" action="/images/{{.Image.ID}}/promote">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Promote to Managed</button></form>
{{end}}
<form method="post" action="/images/{{.Image.ID}}/delete" data-confirm="Delete image {{.Image.Name}}?">{{template "csrf_field" .}}<button class="button danger" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Delete Image</button></form>
</div>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "operation_content"}}
<section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}>
<h2>{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}</h2>
{{if .VMCreateOperation}}
<h3 id="operation-stage">{{.VMCreateOperation.Stage}}</h3>
<p id="operation-detail">{{.VMCreateOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.VMCreateOperation.Error}}</p>
{{end}}
{{if .ImageBuildOperation}}
<h3 id="operation-stage">{{.ImageBuildOperation.Stage}}</h3>
<p id="operation-detail">{{.ImageBuildOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.ImageBuildOperation.Error}}</p>
{{end}}
{{if .OperationLogPath}}
<p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p>
{{else}}
<p class="muted" id="operation-log"></p>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,191 @@
{{define "vm_list_content"}}
<div class="section-head">
<p class="muted">Inspect lifecycle, capacity, and reachability for every VM.</p>
<a class="button" href="/vms/new">Create VM</a>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Image</th>
<th>IP</th>
<th>vCPU</th>
<th>Memory</th>
<th>Disk</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .VMs}}
<tr>
<td><a class="table-link" href="/vms/{{.ID}}">{{.Name}}</a></td>
<td><span class="state-pill {{stateClass .State}}">{{.State}}</span></td>
<td>{{$image := findImage $.Images .ImageID}}{{if $image.ID}}<a class="table-link" href="/images/{{$image.ID}}">{{$image.Name}}</a>{{else}}<code>{{shortID .ImageID}}</code>{{end}}</td>
<td>{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}</td>
<td>{{.Spec.VCPUCount}}</td>
<td>{{.Spec.MemoryMiB}} MiB</td>
<td>{{formatBytes .Spec.WorkDiskSizeBytes}}</td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="8" class="muted">No VMs registered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{define "vm_new_content"}}
<p class="muted">Create a VM and wait until the guest is fully ready. The browser will follow live create progress automatically.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/vms" class="form-grid">
{{template "csrf_field" .}}
<label>
<span>Name</span>
<input type="text" name="name" value="{{.VMCreateForm.Name}}" placeholder="generated when empty">
</label>
<label>
<span>Image</span>
<select name="image_name">
<option value="">Default image</option>
{{range .Images}}
<option value="{{.Name}}" {{if eq $.VMCreateForm.ImageName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label>
<span>vCPU</span>
<input type="number" name="vcpu" min="1" value="{{.VMCreateForm.VCPU}}">
</label>
<label>
<span>Memory (MiB)</span>
<input type="number" name="memory" min="128" value="{{.VMCreateForm.Memory}}">
</label>
<label>
<span>System Overlay Size</span>
<input type="text" name="system_overlay_size" value="{{.VMCreateForm.SystemOverlaySize}}">
</label>
<label>
<span>Work Disk Size</span>
<input type="text" name="work_disk_size" value="{{.VMCreateForm.WorkDiskSize}}">
</label>
<label class="checkbox">
<input type="checkbox" name="nat_enabled" {{if .VMCreateForm.NATEnabled}}checked{{end}}>
<span>Enable NAT</span>
</label>
<label class="checkbox">
<input type="checkbox" name="no_start" {{if .VMCreateForm.NoStart}}checked{{end}}>
<span>Create without starting</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/vms">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Create VM</button>
</div>
</form>
{{end}}
{{define "vm_show_content"}}
<section class="detail-grid">
<article class="detail-card">
<h2>{{.VM.Name}}</h2>
<dl>
<dt>ID</dt><dd><code>{{.VM.ID}}</code></dd>
<dt>Image</dt><dd>{{if .VMImage.ID}}<a class="table-link" href="/images/{{.VMImage.ID}}">{{.VMImage.Name}}</a>{{else}}<code>{{shortID .VM.ImageID}}</code>{{end}}</dd>
<dt>State</dt><dd><span class="state-pill {{stateClass .VM.State}}">{{.VM.State}}</span></dd>
<dt>Guest IP</dt><dd>{{if .VM.Runtime.GuestIP}}{{.VM.Runtime.GuestIP}}{{else}}-{{end}}</dd>
<dt>Created</dt><dd>{{relativeTime .VM.CreatedAt}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Configured Spec</h2>
<dl>
<dt>vCPU</dt><dd>{{.VM.Spec.VCPUCount}}</dd>
<dt>Memory</dt><dd>{{.VM.Spec.MemoryMiB}} MiB</dd>
<dt>Disk</dt><dd>{{formatBytes .VM.Spec.WorkDiskSizeBytes}}</dd>
<dt>NAT</dt><dd>{{formatBool .VM.Spec.NATEnabled}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Current Usage</h2>
<dl>
<dt>CPU</dt><dd>{{formatPercent .VMStats.CPUPercent}}</dd>
<dt>RSS</dt><dd>{{formatBytes .VMStats.RSSBytes}}</dd>
<dt>Overlay</dt><dd>{{formatBytes .VMStats.SystemOverlayBytes}}</dd>
<dt>Work Disk</dt><dd>{{formatBytes .VMStats.WorkDiskBytes}}</dd>
</dl>
</article>
</section>
<div class="section-head">
<h3>Actions</h3>
<a class="button secondary" href="/vms/{{.VM.ID}}/logs">Logs</a>
</div>
<div class="stack-inline">
{{if eq .VM.State "running"}}
<form method="post" action="/vms/{{.VM.ID}}/stop" data-confirm="Stop VM {{.VM.Name}}?">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Stop</button></form>
<form method="post" action="/vms/{{.VM.ID}}/restart">{{template "csrf_field" .}}<button class="button secondary" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Restart</button></form>
{{else}}
<form method="post" action="/vms/{{.VM.ID}}/start">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Start</button></form>
{{end}}
<form method="post" action="/vms/{{.VM.ID}}/delete" data-confirm="Delete VM {{.VM.Name}}?">{{template "csrf_field" .}}<button class="button danger" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Delete</button></form>
</div>
<section class="split-grid">
<div>
<div class="section-head"><h3>Listening Ports</h3></div>
{{if .VMPortsError}}
<p class="inline-error">{{.VMPortsError}}</p>
{{else}}
<table>
<thead>
<tr><th>Port</th><th>Process</th><th>Endpoint</th></tr>
</thead>
<tbody>
{{range .VMPorts.Ports}}
<tr>
<td>{{.Proto}}/{{.Port}}</td>
<td>{{if .Process}}{{.Process}}{{else}}-{{end}}</td>
<td>{{if .Endpoint}}{{if endpointHref .Endpoint}}<a class="table-link" href="{{endpointHref .Endpoint}}" target="_blank" rel="noreferrer">{{.Endpoint}}</a>{{else}}<code>{{.Endpoint}}</code>{{end}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">No host-reachable listeners reported.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div>
<div class="section-head"><h3>Update Settings</h3></div>
<form method="post" action="/vms/{{.VM.ID}}/set" class="form-grid compact">
{{template "csrf_field" .}}
<label><span>vCPU</span><input type="number" name="vcpu" min="1" value="{{.VMSetForm.VCPU}}"></label>
<label><span>Memory (MiB)</span><input type="number" name="memory" min="128" value="{{.VMSetForm.Memory}}"></label>
<label><span>Work Disk Size</span><input type="text" name="work_disk_size" value="{{.VMSetForm.WorkDiskSize}}"></label>
<label>
<span>NAT</span>
<select name="nat_enabled">
<option value="true" {{if .VMSetForm.NATEnabled}}selected{{end}}>Enabled</option>
<option value="false" {{if not .VMSetForm.NATEnabled}}selected{{end}}>Disabled</option>
</select>
</label>
<div class="form-actions">
<a class="button secondary" href="/vms/{{.VM.ID}}">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Save Settings</button>
</div>
</form>
</div>
</section>
{{end}}
{{define "vm_logs_content"}}
<div class="section-head">
<p class="muted">Showing the last 200 lines from the Firecracker log.</p>
<div class="stack-inline">
<label class="checkbox inline"><input type="checkbox" id="log-auto-refresh"><span>Auto refresh</span></label>
<a class="button secondary" href="/vms/{{.VM.ID}}/logs">Refresh</a>
</div>
</div>
<pre class="log-output">{{.LogText}}</pre>
{{end}}