remove experimental web UI
The web UI shipped as "experimental" and was never finished — no nav
off the dashboard, no live updates, no settled design, never a
supported surface. It was opt-in by default already; leaving the code
in the tree for v0.1.0 only invited "does this work?" questions and
kept HostSummary/BangerSummary/SudoStatus types on the public RPC
surface that nothing else uses.
Removed:
internal/webui/ (all Go + templates + assets)
internal/daemon/web.go (server start / Layout / Config / ListVMs / ListImages)
internal/daemon/dashboard.go (DashboardSummary aggregator)
Simplified:
internal/api/types.go drop WebURL on PingResult, drop
HostSummary / SudoStatus / BangerSummary /
DashboardSummary / DashboardSummaryResult
internal/model/types.go drop DaemonConfig.WebListenAddr
internal/config/config.go drop web_listen_addr from fileConfig + Load
internal/daemon/daemon.go drop webListener / webServer / webURL fields +
startWebServer() call + ping WebURL population
internal/cli/banger.go `daemon status` output no longer branches on web
internal/daemon/{doc.go,ARCHITECTURE.md} drop web UI sections
README.md drop web_listen_addr config bullet + security paragraph
Tests updated to reflect the new shape. Coverage 57.3 -> 58.9% (the
webui package was largely untested; its removal lifts the ratio
without moving the numerator). `banger daemon status` output and
--help are web-free. Lint + full suite green.
This commit is contained in:
parent
687fcf0b59
commit
d1b9a8c102
24 changed files with 9 additions and 2752 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@ id_rsa
|
||||||
/todos
|
/todos
|
||||||
/coverage.out
|
/coverage.out
|
||||||
/coverage.html
|
/coverage.html
|
||||||
|
/.codex
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,6 @@ Most commonly set:
|
||||||
- `ssh_key_path` — host SSH key. If unset, banger creates
|
- `ssh_key_path` — host SSH key. If unset, banger creates
|
||||||
`~/.config/banger/ssh/id_ed25519`.
|
`~/.config/banger/ssh/id_ed25519`.
|
||||||
- `firecracker_bin` — override the auto-resolved `PATH` lookup.
|
- `firecracker_bin` — override the auto-resolved `PATH` lookup.
|
||||||
- `web_listen_addr` — experimental web UI; disabled by default. Set
|
|
||||||
e.g. `"127.0.0.1:7777"` to enable.
|
|
||||||
|
|
||||||
Full key list in `internal/config/config.go`.
|
Full key list in `internal/config/config.go`.
|
||||||
|
|
||||||
|
|
@ -205,10 +203,6 @@ VMs are reachable only through the host bridge network
|
||||||
(`172.16.0.0/24` by default). Do not expose the bridge interface or
|
(`172.16.0.0/24` by default). Do not expose the bridge interface or
|
||||||
guest IPs to an untrusted network.
|
guest IPs to an untrusted network.
|
||||||
|
|
||||||
The web UI is disabled by default. If you opt in via
|
|
||||||
`web_listen_addr`, it binds `127.0.0.1` — do not publish it to a
|
|
||||||
shared network.
|
|
||||||
|
|
||||||
## Further reading
|
## Further reading
|
||||||
|
|
||||||
- [`docs/dns-routing.md`](docs/dns-routing.md) — resolving
|
- [`docs/dns-routing.md`](docs/dns-routing.md) — resolving
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ type Empty struct{}
|
||||||
type PingResult struct {
|
type PingResult struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
WebURL string `json:"web_url,omitempty"`
|
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
Commit string `json:"commit,omitempty"`
|
Commit string `json:"commit,omitempty"`
|
||||||
BuiltAt string `json:"built_at,omitempty"`
|
BuiltAt string `json:"built_at,omitempty"`
|
||||||
|
|
@ -298,42 +297,3 @@ type KernelCatalogEntry struct {
|
||||||
type KernelCatalogResult struct {
|
type KernelCatalogResult struct {
|
||||||
Entries []KernelCatalogEntry `json:"entries"`
|
Entries []KernelCatalogEntry `json:"entries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SudoStatus struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
Command string `json:"command,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HostSummary struct {
|
|
||||||
CPUCount int `json:"cpu_count"`
|
|
||||||
TotalMemoryBytes int64 `json:"total_memory_bytes"`
|
|
||||||
StateFilesystemTotalBytes int64 `json:"state_filesystem_total_bytes"`
|
|
||||||
StateFilesystemFreeBytes int64 `json:"state_filesystem_free_bytes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BangerSummary struct {
|
|
||||||
ImageCount int `json:"image_count"`
|
|
||||||
ManagedImageCount int `json:"managed_image_count"`
|
|
||||||
VMCount int `json:"vm_count"`
|
|
||||||
RunningVMCount int `json:"running_vm_count"`
|
|
||||||
ConfiguredVCPUCount int `json:"configured_vcpu_count"`
|
|
||||||
ConfiguredMemoryBytes int64 `json:"configured_memory_bytes"`
|
|
||||||
ConfiguredDiskBytes int64 `json:"configured_disk_bytes"`
|
|
||||||
UsedSystemOverlayBytes int64 `json:"used_system_overlay_bytes"`
|
|
||||||
UsedWorkDiskBytes int64 `json:"used_work_disk_bytes"`
|
|
||||||
RunningCPUPercent float64 `json:"running_cpu_percent"`
|
|
||||||
RunningRSSBytes int64 `json:"running_rss_bytes"`
|
|
||||||
RunningVSZBytes int64 `json:"running_vsz_bytes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardSummary struct {
|
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
|
||||||
Host HostSummary `json:"host"`
|
|
||||||
Sudo SudoStatus `json:"sudo"`
|
|
||||||
Banger BangerSummary `json:"banger"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardSummaryResult struct {
|
|
||||||
Summary DashboardSummary `json:"summary"`
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -658,24 +658,12 @@ func newDaemonCommand() *cobra.Command {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg, err := config.Load(layout)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath)
|
ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath)
|
||||||
if pingErr != nil {
|
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)
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
|
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
|
||||||
if strings.TrimSpace(ping.WebURL) != "" {
|
|
||||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2027,10 +2027,6 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
|
||||||
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
|
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
|
||||||
t.Fatalf("output = %q, want dns listener", output)
|
t.Fatalf("output = %q, want dns listener", output)
|
||||||
}
|
}
|
||||||
// Web UI is opt-in; with no config it should be omitted entirely.
|
|
||||||
if strings.Contains(output, "web:") {
|
|
||||||
t.Fatalf("output = %q, should not list web (disabled by default)", output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
||||||
|
|
@ -2050,7 +2046,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
||||||
return api.PingResult{
|
return api.PingResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
PID: 42,
|
PID: 42,
|
||||||
WebURL: "http://127.0.0.1:7777",
|
|
||||||
Version: "v1.2.3",
|
Version: "v1.2.3",
|
||||||
Commit: "abc123",
|
Commit: "abc123",
|
||||||
BuiltAt: "2026-03-22T12:00:00Z",
|
BuiltAt: "2026-03-22T12:00:00Z",
|
||||||
|
|
@ -2074,7 +2069,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
|
||||||
"commit: abc123",
|
"commit: abc123",
|
||||||
"built_at: 2026-03-22T12:00:00Z",
|
"built_at: 2026-03-22T12:00:00Z",
|
||||||
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
|
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
|
||||||
"web: http://127.0.0.1:7777",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Fatalf("output = %q, want %q", output, want)
|
t.Fatalf("output = %q, want %q", output, want)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import (
|
||||||
|
|
||||||
type fileConfig struct {
|
type fileConfig struct {
|
||||||
LogLevel string `toml:"log_level"`
|
LogLevel string `toml:"log_level"`
|
||||||
WebListenAddr *string `toml:"web_listen_addr"`
|
|
||||||
FirecrackerBin string `toml:"firecracker_bin"`
|
FirecrackerBin string `toml:"firecracker_bin"`
|
||||||
SSHKeyPath string `toml:"ssh_key_path"`
|
SSHKeyPath string `toml:"ssh_key_path"`
|
||||||
DefaultImageName string `toml:"default_image_name"`
|
DefaultImageName string `toml:"default_image_name"`
|
||||||
|
|
@ -55,10 +54,7 @@ type vmDefaultsFile struct {
|
||||||
|
|
||||||
func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
||||||
cfg := model.DaemonConfig{
|
cfg := model.DaemonConfig{
|
||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
// Experimental web UI is opt-in: users set web_listen_addr in
|
|
||||||
// config.toml (e.g. "127.0.0.1:7777") to enable it.
|
|
||||||
WebListenAddr: "",
|
|
||||||
AutoStopStaleAfter: 0,
|
AutoStopStaleAfter: 0,
|
||||||
StatsPollInterval: model.DefaultStatsPollInterval,
|
StatsPollInterval: model.DefaultStatsPollInterval,
|
||||||
MetricsPollInterval: model.DefaultMetricsPollInterval,
|
MetricsPollInterval: model.DefaultMetricsPollInterval,
|
||||||
|
|
@ -87,9 +83,6 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
||||||
if value := strings.TrimSpace(file.LogLevel); value != "" {
|
if value := strings.TrimSpace(file.LogLevel); value != "" {
|
||||||
cfg.LogLevel = value
|
cfg.LogLevel = value
|
||||||
}
|
}
|
||||||
if file.WebListenAddr != nil {
|
|
||||||
cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr)
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
|
if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
|
||||||
cfg.FirecrackerBin = value
|
cfg.FirecrackerBin = value
|
||||||
} else if path, err := system.LookupExecutable("firecracker"); err == nil {
|
} else if path, err := system.LookupExecutable("firecracker"); err == nil {
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,12 @@ func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) {
|
||||||
if cfg.DefaultImageName != "debian-bookworm" {
|
if cfg.DefaultImageName != "debian-bookworm" {
|
||||||
t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName)
|
t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName)
|
||||||
}
|
}
|
||||||
if cfg.WebListenAddr != "" {
|
|
||||||
t.Fatalf("WebListenAddr default = %q, want empty (experimental web UI is opt-in)", cfg.WebListenAddr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAppliesConfigOverrides(t *testing.T) {
|
func TestLoadAppliesConfigOverrides(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
data := []byte(`
|
data := []byte(`
|
||||||
log_level = "debug"
|
log_level = "debug"
|
||||||
web_listen_addr = "127.0.0.1:8080"
|
|
||||||
firecracker_bin = "/opt/firecracker"
|
firecracker_bin = "/opt/firecracker"
|
||||||
ssh_key_path = "/tmp/custom-key"
|
ssh_key_path = "/tmp/custom-key"
|
||||||
default_image_name = "void"
|
default_image_name = "void"
|
||||||
|
|
@ -73,9 +69,6 @@ default_dns = "9.9.9.9"
|
||||||
if cfg.LogLevel != "debug" {
|
if cfg.LogLevel != "debug" {
|
||||||
t.Fatalf("LogLevel = %q", cfg.LogLevel)
|
t.Fatalf("LogLevel = %q", cfg.LogLevel)
|
||||||
}
|
}
|
||||||
if cfg.WebListenAddr != "127.0.0.1:8080" {
|
|
||||||
t.Fatalf("WebListenAddr = %q, want 127.0.0.1:8080", cfg.WebListenAddr)
|
|
||||||
}
|
|
||||||
if cfg.FirecrackerBin != "/opt/firecracker" {
|
if cfg.FirecrackerBin != "/opt/firecracker" {
|
||||||
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
|
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ owning types:
|
||||||
- `tapPool tapPool` — TAP interface pool; owns its own lock.
|
- `tapPool tapPool` — TAP interface pool; owns its own lock.
|
||||||
- `sessions sessionRegistry` — active guest session controllers; owns
|
- `sessions sessionRegistry` — active guest session controllers; owns
|
||||||
its own lock.
|
its own lock.
|
||||||
- `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking.
|
- `listener`, `vmDNS` — networking.
|
||||||
- `vmCaps` — registered VM capability hooks.
|
- `vmCaps` — registered VM capability hooks.
|
||||||
- `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`,
|
- `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`,
|
||||||
`requestHandler`, `guestWaitForSSH`, `guestDial`,
|
`requestHandler`, `guestWaitForSSH`, `guestDial`,
|
||||||
|
|
@ -98,9 +98,3 @@ Only `internal/cli` imports this package. The surface is:
|
||||||
|
|
||||||
All other `*Daemon` methods are reached only through the RPC `dispatch`
|
All other `*Daemon` methods are reached only through the RPC `dispatch`
|
||||||
switch in `daemon.go` and are free to move/rename during refactoring.
|
switch in `daemon.go` and are free to move/rename during refactoring.
|
||||||
|
|
||||||
## Web UI
|
|
||||||
|
|
||||||
The optional web UI served at `web_listen_addr` is experimental. It is
|
|
||||||
enabled by default for local observability but is not considered a stable
|
|
||||||
or supported interface. Set `web_listen_addr = ""` in config to disable.
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -58,9 +57,6 @@ type Daemon struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
pid int
|
pid int
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
webListener net.Listener
|
|
||||||
webServer *http.Server
|
|
||||||
webURL string
|
|
||||||
vmDNS *vmdns.Server
|
vmDNS *vmdns.Server
|
||||||
vmCaps []vmCapability
|
vmCaps []vmCapability
|
||||||
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
|
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
|
||||||
|
|
@ -138,12 +134,6 @@ func (d *Daemon) Close() error {
|
||||||
if d.listener != nil {
|
if d.listener != nil {
|
||||||
_ = d.listener.Close()
|
_ = d.listener.Close()
|
||||||
}
|
}
|
||||||
if d.webServer != nil {
|
|
||||||
_ = d.webServer.Close()
|
|
||||||
}
|
|
||||||
if d.webListener != nil {
|
|
||||||
_ = d.webListener.Close()
|
|
||||||
}
|
|
||||||
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.closeGuestSessionControllers(), d.store.Close())
|
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.closeGuestSessionControllers(), d.store.Close())
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
|
@ -167,10 +157,6 @@ func (d *Daemon) Serve(ctx context.Context) error {
|
||||||
if d.logger != nil {
|
if d.logger != nil {
|
||||||
d.logger.Info("daemon serving", "socket", d.layout.SocketPath, "pid", d.pid)
|
d.logger.Info("daemon serving", "socket", d.layout.SocketPath, "pid", d.pid)
|
||||||
}
|
}
|
||||||
if err := d.startWebServer(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
go d.backgroundLoop()
|
go d.backgroundLoop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -274,7 +260,6 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
||||||
result, _ := rpc.NewResult(api.PingResult{
|
result, _ := rpc.NewResult(api.PingResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
PID: d.pid,
|
PID: d.pid,
|
||||||
WebURL: d.webURL,
|
|
||||||
Version: info.Version,
|
Version: info.Version,
|
||||||
Commit: info.Commit,
|
Commit: info.Commit,
|
||||||
BuiltAt: info.BuiltAt,
|
BuiltAt: info.BuiltAt,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func TestRegisterImageRequiresKernel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDispatchPingIncludesBuildInfo(t *testing.T) {
|
func TestDispatchPingIncludesBuildInfo(t *testing.T) {
|
||||||
d := &Daemon{pid: 42, webURL: "http://127.0.0.1:7777"}
|
d := &Daemon{pid: 42}
|
||||||
|
|
||||||
resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "ping"})
|
resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "ping"})
|
||||||
if !resp.OK {
|
if !resp.OK {
|
||||||
|
|
@ -46,8 +46,8 @@ func TestDispatchPingIncludesBuildInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
info := buildinfo.Current()
|
info := buildinfo.Current()
|
||||||
if got.Status != "ok" || got.PID != 42 || got.WebURL != "http://127.0.0.1:7777" {
|
if got.Status != "ok" || got.PID != 42 {
|
||||||
t.Fatalf("PingResult = %+v, want status/pid/weburl populated", got)
|
t.Fatalf("PingResult = %+v, want status/pid populated", got)
|
||||||
}
|
}
|
||||||
if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt {
|
if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt {
|
||||||
t.Fatalf("PingResult build info = %+v, want %+v", got, info)
|
t.Fatalf("PingResult build info = %+v, want %+v", got, info)
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
package daemon
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"banger/internal/api"
|
|
||||||
"banger/internal/model"
|
|
||||||
"banger/internal/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (d *Daemon) DashboardSummary(ctx context.Context) (api.DashboardSummary, error) {
|
|
||||||
summary := api.DashboardSummary{
|
|
||||||
GeneratedAt: model.Now(),
|
|
||||||
Sudo: api.SudoStatus{
|
|
||||||
Command: "sudo -v",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := system.CheckSudo(ctx); err != nil {
|
|
||||||
summary.Sudo.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
summary.Sudo.Available = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if host, err := system.ReadHostResources(); err == nil {
|
|
||||||
summary.Host.CPUCount = host.CPUCount
|
|
||||||
summary.Host.TotalMemoryBytes = host.TotalMemoryBytes
|
|
||||||
}
|
|
||||||
if usage, err := system.ReadFilesystemUsage(d.layout.StateDir); err == nil {
|
|
||||||
summary.Host.StateFilesystemTotalBytes = usage.TotalBytes
|
|
||||||
summary.Host.StateFilesystemFreeBytes = usage.FreeBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
images, err := d.store.ListImages(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return api.DashboardSummary{}, err
|
|
||||||
}
|
|
||||||
for _, image := range images {
|
|
||||||
summary.Banger.ImageCount++
|
|
||||||
if image.Managed {
|
|
||||||
summary.Banger.ManagedImageCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vms, err := d.store.ListVMs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return api.DashboardSummary{}, err
|
|
||||||
}
|
|
||||||
for _, vm := range vms {
|
|
||||||
summary.Banger.VMCount++
|
|
||||||
summary.Banger.ConfiguredVCPUCount += vm.Spec.VCPUCount
|
|
||||||
summary.Banger.ConfiguredMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024
|
|
||||||
summary.Banger.ConfiguredDiskBytes += vm.Spec.WorkDiskSizeBytes
|
|
||||||
summary.Banger.UsedSystemOverlayBytes += vm.Stats.SystemOverlayBytes
|
|
||||||
summary.Banger.UsedWorkDiskBytes += vm.Stats.WorkDiskBytes
|
|
||||||
if d.vmAlive(vm) {
|
|
||||||
summary.Banger.RunningVMCount++
|
|
||||||
summary.Banger.RunningCPUPercent += vm.Stats.CPUPercent
|
|
||||||
summary.Banger.RunningRSSBytes += vm.Stats.RSSBytes
|
|
||||||
summary.Banger.RunningVSZBytes += vm.Stats.VSZBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// Package daemon hosts the Banger daemon process.
|
// Package daemon hosts the Banger daemon process.
|
||||||
//
|
//
|
||||||
// The daemon exposes a JSON-RPC endpoint over a Unix socket and, optionally,
|
// The daemon exposes a JSON-RPC endpoint over a Unix socket. It owns VM
|
||||||
// an experimental local web UI. It owns VM lifecycle, image management,
|
// lifecycle, image management, guest sessions, host networking bootstrap,
|
||||||
// guest sessions, host networking bootstrap, and state persistence via
|
// and state persistence via internal/store.
|
||||||
// internal/store.
|
|
||||||
//
|
//
|
||||||
// The package is organised into cohesive groups. Pure stateless helpers for
|
// The package is organised into cohesive groups. Pure stateless helpers for
|
||||||
// each group have been lifted into subpackages; orchestrator methods
|
// each group have been lifted into subpackages; orchestrator methods
|
||||||
|
|
@ -68,11 +67,9 @@
|
||||||
// Core (in this package):
|
// Core (in this package):
|
||||||
//
|
//
|
||||||
// daemon.go Daemon struct, Open/Close/Serve, dispatch
|
// daemon.go Daemon struct, Open/Close/Serve, dispatch
|
||||||
// dashboard.go dashboard metrics aggregation
|
|
||||||
// doctor.go host diagnostics
|
// doctor.go host diagnostics
|
||||||
// logger.go slog configuration
|
// logger.go slog configuration
|
||||||
// runtime_assets.go paths to bundled companion binaries
|
// runtime_assets.go paths to bundled companion binaries
|
||||||
// web.go experimental local web UI server
|
|
||||||
//
|
//
|
||||||
// Lock ordering:
|
// Lock ordering:
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
package daemon
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"banger/internal/model"
|
|
||||||
"banger/internal/paths"
|
|
||||||
"banger/internal/webui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (d *Daemon) startWebServer() error {
|
|
||||||
listenAddr := strings.TrimSpace(d.config.WebListenAddr)
|
|
||||||
if listenAddr == "" {
|
|
||||||
d.webURL = ""
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
listener, err := net.Listen("tcp", listenAddr)
|
|
||||||
if err != nil {
|
|
||||||
if d.logger != nil {
|
|
||||||
d.logger.Error("web ui listen failed", "addr", listenAddr, "error", err.Error())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("web ui listen on %s: %w", listenAddr, err)
|
|
||||||
}
|
|
||||||
d.webListener = listener
|
|
||||||
d.webURL = "http://" + listener.Addr().String()
|
|
||||||
d.webServer = &http.Server{
|
|
||||||
Handler: webui.NewHandler(d),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
if d.logger != nil {
|
|
||||||
d.logger.Info("web ui serving", "addr", listener.Addr().String(), "url", d.webURL)
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
err := d.webServer.Serve(listener)
|
|
||||||
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if d.logger != nil {
|
|
||||||
d.logger.Error("web ui serve failed", "addr", listener.Addr().String(), "error", err.Error())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) Layout() paths.Layout {
|
|
||||||
return d.layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) Config() model.DaemonConfig {
|
|
||||||
return d.config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
|
|
||||||
return d.store.ListVMs(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) ListImages(ctx context.Context) ([]model.Image, error) {
|
|
||||||
return d.store.ListImages(ctx)
|
|
||||||
}
|
|
||||||
|
|
@ -53,7 +53,6 @@ const (
|
||||||
|
|
||||||
type DaemonConfig struct {
|
type DaemonConfig struct {
|
||||||
LogLevel string
|
LogLevel string
|
||||||
WebListenAddr string
|
|
||||||
FirecrackerBin string
|
FirecrackerBin string
|
||||||
SSHKeyPath string
|
SSHKeyPath string
|
||||||
AutoStopStaleAfter time.Duration
|
AutoStopStaleAfter time.Duration
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
(() => {
|
|
||||||
const operationCard = document.querySelector("[data-operation-url]");
|
|
||||||
if (operationCard) {
|
|
||||||
const stageNode = document.getElementById("operation-stage");
|
|
||||||
const detailNode = document.getElementById("operation-detail");
|
|
||||||
const errorNode = document.getElementById("operation-error");
|
|
||||||
const logNode = document.getElementById("operation-log");
|
|
||||||
const statusUrl = operationCard.dataset.operationUrl;
|
|
||||||
const successUrl = operationCard.dataset.operationSuccess;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
const response = await fetch(statusUrl, { headers: { Accept: "application/json" } });
|
|
||||||
if (!response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = await response.json();
|
|
||||||
const op = payload.operation || {};
|
|
||||||
if (stageNode) stageNode.textContent = op.stage || "queued";
|
|
||||||
if (detailNode) detailNode.textContent = op.detail || "";
|
|
||||||
if (errorNode) errorNode.textContent = op.error || "";
|
|
||||||
if (logNode && op.build_log_path) logNode.textContent = op.build_log_path;
|
|
||||||
if (op.done && op.success && successUrl) {
|
|
||||||
window.location.assign(successUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!op.done) {
|
|
||||||
window.setTimeout(poll, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.setTimeout(poll, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyButtons = document.querySelectorAll("[data-copy-text]");
|
|
||||||
copyButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(button.dataset.copyText || "");
|
|
||||||
button.textContent = "Copied";
|
|
||||||
window.setTimeout(() => { button.textContent = "Copy"; }, 1000);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
|
||||||
form.addEventListener("submit", (event) => {
|
|
||||||
const message = form.dataset.confirm || "Are you sure?";
|
|
||||||
if (!window.confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const logToggle = document.getElementById("log-auto-refresh");
|
|
||||||
if (logToggle) {
|
|
||||||
const schedule = () => {
|
|
||||||
if (!logToggle.checked) return;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (logToggle.checked) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}, 4000);
|
|
||||||
};
|
|
||||||
logToggle.addEventListener("change", schedule);
|
|
||||||
schedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialog = document.getElementById("path-picker");
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
const listNode = document.getElementById("picker-list");
|
|
||||||
const currentPathNode = document.getElementById("picker-current-path");
|
|
||||||
const closeButton = document.getElementById("picker-close");
|
|
||||||
const selectCurrentButton = document.getElementById("picker-select-current");
|
|
||||||
let currentInput = null;
|
|
||||||
let currentKind = "file";
|
|
||||||
let currentPath = "/";
|
|
||||||
|
|
||||||
const loadListing = async (path) => {
|
|
||||||
const response = await fetch(`/api/fs?path=${encodeURIComponent(path)}&kind=${encodeURIComponent(currentKind)}`, {
|
|
||||||
headers: { Accept: "application/json" }
|
|
||||||
});
|
|
||||||
if (!response.ok) return;
|
|
||||||
const payload = await response.json();
|
|
||||||
currentPath = payload.path;
|
|
||||||
currentPathNode.textContent = payload.path;
|
|
||||||
listNode.innerHTML = "";
|
|
||||||
payload.entries.forEach((entry) => {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "picker-entry";
|
|
||||||
button.dataset.kind = entry.kind;
|
|
||||||
button.dataset.path = entry.path;
|
|
||||||
button.innerHTML = `<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();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,513 +0,0 @@
|
||||||
:root {
|
|
||||||
--bg: #f2eadf;
|
|
||||||
--panel: rgba(255, 252, 246, 0.92);
|
|
||||||
--panel-strong: #fffdf7;
|
|
||||||
--ink: #1f2a22;
|
|
||||||
--muted: #5f675f;
|
|
||||||
--accent: #c8622d;
|
|
||||||
--accent-strong: #9a3f14;
|
|
||||||
--success: #33643b;
|
|
||||||
--warning: #9a5b11;
|
|
||||||
--danger: #8f2f24;
|
|
||||||
--line: rgba(31, 42, 34, 0.14);
|
|
||||||
--shadow: 0 24px 60px rgba(57, 41, 24, 0.12);
|
|
||||||
--radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(200, 98, 45, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at top right, rgba(92, 141, 89, 0.14), transparent 24%),
|
|
||||||
linear-gradient(180deg, #efe1d1 0%, #f7f1ea 48%, #efe8de 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
code, pre, input, select, button {
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { color: inherit; text-decoration: none; }
|
|
||||||
a[href] { cursor: pointer; }
|
|
||||||
button:not(:disabled) { cursor: pointer; }
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
max-width: 1320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 28px 20px 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar, .content-panel, .summary-card, .banner, .detail-card, .operation-card {
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar, .content-panel, .banner {
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: end;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 24px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar h1, .panel-head h2, .detail-card h2, .detail-card h3, .operation-card h2, .operation-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Georgia, "Iowan Old Style", serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a, .button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 11px 16px;
|
|
||||||
transition: 160ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
background: rgba(255, 255, 255, 0.48);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active, .nav a:hover {
|
|
||||||
background: #fff7ee;
|
|
||||||
border-color: rgba(200, 98, 45, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner.warning { border-color: rgba(154, 91, 17, 0.25); }
|
|
||||||
.banner.success { border-color: rgba(51, 100, 59, 0.25); }
|
|
||||||
.banner.error { border-color: rgba(143, 47, 36, 0.25); }
|
|
||||||
.banner.info { border-color: rgba(31, 42, 34, 0.18); }
|
|
||||||
|
|
||||||
.summary-grid, .detail-grid, .split-grid, .command-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card, .detail-card, .operation-card {
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card h2, .operation-card h2 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card p:last-child { margin: 0; color: var(--muted); }
|
|
||||||
|
|
||||||
.resource-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 20px 22px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.cpu::before {
|
|
||||||
background: radial-gradient(circle at top right, rgba(200, 98, 45, 0.18), transparent 38%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.memory::before {
|
|
||||||
background: radial-gradient(circle at top right, rgba(92, 141, 89, 0.16), transparent 38%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.disk::before {
|
|
||||||
background: radial-gradient(circle at top right, rgba(31, 42, 34, 0.1), transparent 42%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-head, .resource-foot {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-ratio {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-meter {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(31, 42, 34, 0.12);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(236, 227, 216, 0.9)),
|
|
||||||
repeating-linear-gradient(90deg, rgba(31, 42, 34, 0.05) 0 32px, transparent 32px 64px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-fill::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.28) 0 10px, transparent 10px 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.cpu .resource-fill {
|
|
||||||
background: linear-gradient(90deg, #c8622d, #e08a4f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.memory .resource-fill {
|
|
||||||
background: linear-gradient(90deg, #4d8155, #79ab72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card.disk .resource-fill {
|
|
||||||
background: linear-gradient(90deg, #415147, #69806f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-foot {
|
|
||||||
font-size: 0.86rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-notes {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-notes span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 252, 246, 0.72);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-panel {
|
|
||||||
margin-top: 22px;
|
|
||||||
padding: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-head, .section-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head { margin-bottom: 16px; }
|
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.inline-error {
|
|
||||||
background: rgba(143, 47, 36, 0.08);
|
|
||||||
color: var(--danger);
|
|
||||||
border: 1px solid rgba(143, 47, 36, 0.2);
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 14px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 14px 12px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(255,255,255,0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td { border-bottom: 0; }
|
|
||||||
|
|
||||||
.table-link {
|
|
||||||
font-weight: 600;
|
|
||||||
transition: 160ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-link:hover {
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-pill.running { color: var(--success); border-color: rgba(51, 100, 59, 0.25); }
|
|
||||||
.state-pill.stopped { color: var(--muted); }
|
|
||||||
.state-pill.error { color: var(--danger); border-color: rgba(143, 47, 36, 0.22); }
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff8f0;
|
|
||||||
border: 1px solid rgba(0,0,0,0.04);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background: var(--accent-strong);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.button.secondary {
|
|
||||||
background: rgba(255,255,255,0.74);
|
|
||||||
color: var(--ink);
|
|
||||||
border-color: rgba(31, 42, 34, 0.12);
|
|
||||||
}
|
|
||||||
.button.danger { background: var(--danger); }
|
|
||||||
.button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid.compact { margin-top: 12px; }
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.94rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"], input[type="number"], select {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid rgba(31, 42, 34, 0.18);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: var(--panel-strong);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
grid-auto-flow: column;
|
|
||||||
justify-content: start;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox.inline { display: inline-flex; gap: 8px; }
|
|
||||||
|
|
||||||
.stack-inline {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
||||||
margin: 18px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl {
|
|
||||||
margin: 14px 0 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt { color: var(--muted); }
|
|
||||||
dd { margin: 0; word-break: break-word; }
|
|
||||||
|
|
||||||
pre {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-output {
|
|
||||||
min-height: 260px;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #201d1a;
|
|
||||||
color: #f3eee4;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-field { grid-column: 1 / -1; }
|
|
||||||
.picker-input { display: flex; gap: 10px; }
|
|
||||||
.picker-input input { flex: 1; }
|
|
||||||
|
|
||||||
.picker-dialog {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 22px;
|
|
||||||
width: min(960px, calc(100vw - 24px));
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-dialog::backdrop {
|
|
||||||
background: rgba(17, 12, 8, 0.48);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 220px 1fr;
|
|
||||||
min-height: 420px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-sidebar {
|
|
||||||
padding: 20px;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
background: rgba(255,255,255,0.56);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-roots {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-root, .picker-entry {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-main {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow: auto;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-help { color: var(--muted); margin: 12px 0 0; }
|
|
||||||
|
|
||||||
.operation-card {
|
|
||||||
min-height: 180px;
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.app-shell { padding: 18px 14px 40px; }
|
|
||||||
.topbar, .content-panel { padding: 20px; }
|
|
||||||
.resource-ratio { font-size: 1.45rem; }
|
|
||||||
.picker-shell { grid-template-columns: 1fr; }
|
|
||||||
.picker-sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.picker-input { flex-direction: column; }
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,224 +0,0 @@
|
||||||
package webui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"banger/internal/api"
|
|
||||||
"banger/internal/model"
|
|
||||||
"banger/internal/paths"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeBackend struct {
|
|
||||||
layout paths.Layout
|
|
||||||
config model.DaemonConfig
|
|
||||||
summary api.DashboardSummary
|
|
||||||
vms []model.VMRecord
|
|
||||||
images []model.Image
|
|
||||||
vm model.VMRecord
|
|
||||||
image model.Image
|
|
||||||
ports api.VMPortsResult
|
|
||||||
createOp api.VMCreateOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeBackend) Config() model.DaemonConfig { return f.config }
|
|
||||||
func (f fakeBackend) Layout() paths.Layout { return f.layout }
|
|
||||||
func (f fakeBackend) DashboardSummary(context.Context) (api.DashboardSummary, error) {
|
|
||||||
return f.summary, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) ListVMs(context.Context) ([]model.VMRecord, error) { return f.vms, nil }
|
|
||||||
func (f fakeBackend) FindVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
||||||
func (f fakeBackend) GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) {
|
|
||||||
return f.vm, f.vm.Stats, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) {
|
|
||||||
return f.createOp, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) {
|
|
||||||
return f.createOp, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) StartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
||||||
func (f fakeBackend) StopVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
||||||
func (f fakeBackend) RestartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
||||||
func (f fakeBackend) DeleteVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
||||||
func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) {
|
|
||||||
return f.vm, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil }
|
|
||||||
func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil }
|
|
||||||
func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
||||||
func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) {
|
|
||||||
return f.image, nil
|
|
||||||
}
|
|
||||||
func (f fakeBackend) PromoteImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
||||||
func (f fakeBackend) DeleteImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
||||||
|
|
||||||
func TestDashboardPageRendersSummaryAndTables(t *testing.T) {
|
|
||||||
backend := fakeBackend{
|
|
||||||
layout: paths.Layout{StateDir: t.TempDir()},
|
|
||||||
config: model.DaemonConfig{SSHKeyPath: "/tmp/id"},
|
|
||||||
summary: api.DashboardSummary{
|
|
||||||
Host: api.HostSummary{CPUCount: 8, TotalMemoryBytes: 16 << 30, StateFilesystemFreeBytes: 9 << 30, StateFilesystemTotalBytes: 20 << 30},
|
|
||||||
Sudo: api.SudoStatus{Available: true, Command: "sudo -v"},
|
|
||||||
Banger: api.BangerSummary{
|
|
||||||
VMCount: 1, RunningVMCount: 1, ImageCount: 1, ManagedImageCount: 1, ConfiguredVCPUCount: 2,
|
|
||||||
ConfiguredMemoryBytes: 1 << 30,
|
|
||||||
ConfiguredDiskBytes: 8 << 30,
|
|
||||||
UsedWorkDiskBytes: 3 << 30,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vms: []model.VMRecord{{ID: "vm-1", Name: "smth", State: model.VMStateRunning, CreatedAt: model.Now(), Runtime: model.VMRuntime{GuestIP: "172.16.0.2"}, Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}},
|
|
||||||
images: []model.Image{{ID: "img-1", Name: "void", Managed: true, RootfsPath: "/tmp/rootfs.ext4", CreatedAt: model.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
NewHandler(backend).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{"vCPU", "2 / 8", "1G / 16G", "8G / 20G", "9G free", "smth", "void", "Create VM"} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("body missing %q\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(rec.Result().Cookies()) == 0 {
|
|
||||||
t.Fatal("expected csrf cookie to be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMActionRejectsMissingCSRF(t *testing.T) {
|
|
||||||
backend := fakeBackend{
|
|
||||||
layout: paths.Layout{StateDir: t.TempDir()},
|
|
||||||
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
||||||
vm: model.VMRecord{ID: "vm-1", Name: "smth"},
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/vms/vm-1/start", strings.NewReader(""))
|
|
||||||
req.Header.Set("Origin", "http://example.com")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
NewHandler(backend).ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFSAPIListsEntries(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil {
|
|
||||||
t.Fatalf("mkdir: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "rootfs.ext4"), []byte("data"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
backend := fakeBackend{
|
|
||||||
layout: paths.Layout{StateDir: dir},
|
|
||||||
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/fs?path="+url.QueryEscape(dir)+"&kind=file", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
NewHandler(backend).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(rec.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadAll: %v", err)
|
|
||||||
}
|
|
||||||
body := string(data)
|
|
||||||
for _, want := range []string{"rootfs.ext4", "nested"} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("body missing %q\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMShowPageRendersRunningActions(t *testing.T) {
|
|
||||||
backend := fakeBackend{
|
|
||||||
layout: paths.Layout{StateDir: t.TempDir()},
|
|
||||||
config: model.DaemonConfig{SSHKeyPath: "/tmp/id"},
|
|
||||||
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}},
|
|
||||||
vm: model.VMRecord{
|
|
||||||
ID: "vm-1",
|
|
||||||
Name: "smth",
|
|
||||||
State: model.VMStateRunning,
|
|
||||||
Runtime: model.VMRuntime{
|
|
||||||
GuestIP: "172.16.0.2",
|
|
||||||
},
|
|
||||||
Spec: model.VMSpec{
|
|
||||||
VCPUCount: 2,
|
|
||||||
MemoryMiB: 1024,
|
|
||||||
WorkDiskSizeBytes: 8 << 30,
|
|
||||||
},
|
|
||||||
Stats: model.VMStats{
|
|
||||||
CPUPercent: 12.5,
|
|
||||||
RSSBytes: 64 << 20,
|
|
||||||
SystemOverlayBytes: 2 << 20,
|
|
||||||
WorkDiskBytes: 32 << 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
image: model.Image{ID: "img-1", Name: "void"},
|
|
||||||
ports: api.VMPortsResult{
|
|
||||||
Name: "smth",
|
|
||||||
Ports: []api.VMPort{
|
|
||||||
{Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "devserver"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/vms/vm-1", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
NewHandler(backend).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{"Stop", "Restart", "href=\"http://172.16.0.2:4096\"", "data-confirm=\"Stop VM smth?\"", "data-confirm=\"Delete VM smth?\""} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("body missing %q\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unwanted := range []string{"root@172.16.0.2"} {
|
|
||||||
if strings.Contains(body, unwanted) {
|
|
||||||
t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMListShowsImageNameAndLink(t *testing.T) {
|
|
||||||
backend := fakeBackend{
|
|
||||||
layout: paths.Layout{StateDir: t.TempDir()},
|
|
||||||
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
||||||
vms: []model.VMRecord{
|
|
||||||
{ID: "vm-1", Name: "smth", ImageID: "img-1", State: model.VMStateRunning, CreatedAt: model.Now(), Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}},
|
|
||||||
},
|
|
||||||
images: []model.Image{
|
|
||||||
{ID: "img-1", Name: "void"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/vms", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
NewHandler(backend).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{">void</a>", "href=\"/images/img-1\""} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("body missing %q\n%s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
{{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}}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
{{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>
|
|
||||||
</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}}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{{define "error_content"}}
|
|
||||||
<div class="inline-error">{{.ErrorMessage}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
{{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>
|
|
||||||
</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_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="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>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}}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{{define "operation_content"}}
|
|
||||||
<section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}>
|
|
||||||
<h2>VM readiness</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 .OperationLogPath}}
|
|
||||||
<p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted" id="operation-log"></p>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
{{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