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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue