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:
parent
30f0c0b54a
commit
2362d0ae39
24 changed files with 3308 additions and 52 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
internal/daemon/dashboard.go
Normal file
63
internal/daemon/dashboard.go
Normal 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
|
||||
}
|
||||
218
internal/daemon/image_build_ops.go
Normal file
218
internal/daemon/image_build_ops.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
65
internal/daemon/web.go
Normal 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)
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ const (
|
|||
type DaemonConfig struct {
|
||||
RuntimeDir string
|
||||
LogLevel string
|
||||
WebListenAddr string
|
||||
FirecrackerBin string
|
||||
SSHKeyPath string
|
||||
NamegenPath string
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
130
internal/webui/assets/app.js
Normal file
130
internal/webui/assets/app.js
Normal 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();
|
||||
});
|
||||
})();
|
||||
513
internal/webui/assets/style.css
Normal file
513
internal/webui/assets/style.css
Normal 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
1246
internal/webui/server.go
Normal file
File diff suppressed because it is too large
Load diff
231
internal/webui/server_test.go
Normal file
231
internal/webui/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
internal/webui/templates/base.html
Normal file
124
internal/webui/templates/base.html
Normal 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}}
|
||||
65
internal/webui/templates/dashboard.html
Normal file
65
internal/webui/templates/dashboard.html
Normal 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}}
|
||||
3
internal/webui/templates/error.html
Normal file
3
internal/webui/templates/error.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{{define "error_content"}}
|
||||
<div class="inline-error">{{.ErrorMessage}}</div>
|
||||
{{end}}
|
||||
182
internal/webui/templates/images.html
Normal file
182
internal/webui/templates/images.html
Normal 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}}
|
||||
20
internal/webui/templates/operation.html
Normal file
20
internal/webui/templates/operation.html
Normal 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}}
|
||||
191
internal/webui/templates/vms.html
Normal file
191
internal/webui/templates/vms.html
Normal 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}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue