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:
Thales Maciel 2026-04-19 14:28:08 -03:00
parent 687fcf0b59
commit d1b9a8c102
No known key found for this signature in database
GPG key ID: 33112E6833C34679
24 changed files with 9 additions and 2752 deletions

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ id_rsa
/todos
/coverage.out
/coverage.html
/.codex

View file

@ -129,8 +129,6 @@ Most commonly set:
- `ssh_key_path` — host SSH key. If unset, banger creates
`~/.config/banger/ssh/id_ed25519`.
- `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`.
@ -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
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
- [`docs/dns-routing.md`](docs/dns-routing.md) — resolving

View file

@ -11,7 +11,6 @@ type Empty struct{}
type PingResult struct {
Status string `json:"status"`
PID int `json:"pid"`
WebURL string `json:"web_url,omitempty"`
Version string `json:"version,omitempty"`
Commit string `json:"commit,omitempty"`
BuiltAt string `json:"built_at,omitempty"`
@ -298,42 +297,3 @@ type KernelCatalogEntry struct {
type KernelCatalogResult struct {
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"`
}

View file

@ -658,24 +658,12 @@ func newDaemonCommand() *cobra.Command {
if err != nil {
return err
}
cfg, err := config.Load(layout)
if err != nil {
return err
}
ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath)
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
}
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)
return err
},

View file

@ -2027,10 +2027,6 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) {
if !strings.Contains(output, "dns: 127.0.0.1:42069") {
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) {
@ -2050,7 +2046,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
return api.PingResult{
Status: "ok",
PID: 42,
WebURL: "http://127.0.0.1:7777",
Version: "v1.2.3",
Commit: "abc123",
BuiltAt: "2026-03-22T12:00:00Z",
@ -2074,7 +2069,6 @@ func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) {
"commit: abc123",
"built_at: 2026-03-22T12:00:00Z",
"log: " + filepath.Join(stateHome, "banger", "bangerd.log"),
"web: http://127.0.0.1:7777",
} {
if !strings.Contains(output, want) {
t.Fatalf("output = %q, want %q", output, want)

View file

@ -21,7 +21,6 @@ import (
type fileConfig struct {
LogLevel string `toml:"log_level"`
WebListenAddr *string `toml:"web_listen_addr"`
FirecrackerBin string `toml:"firecracker_bin"`
SSHKeyPath string `toml:"ssh_key_path"`
DefaultImageName string `toml:"default_image_name"`
@ -55,10 +54,7 @@ type vmDefaultsFile struct {
func Load(layout paths.Layout) (model.DaemonConfig, error) {
cfg := model.DaemonConfig{
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: "",
LogLevel: "info",
AutoStopStaleAfter: 0,
StatsPollInterval: model.DefaultStatsPollInterval,
MetricsPollInterval: model.DefaultMetricsPollInterval,
@ -87,9 +83,6 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
if value := strings.TrimSpace(file.LogLevel); value != "" {
cfg.LogLevel = value
}
if file.WebListenAddr != nil {
cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr)
}
if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
cfg.FirecrackerBin = value
} else if path, err := system.LookupExecutable("firecracker"); err == nil {

View file

@ -39,16 +39,12 @@ func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) {
if cfg.DefaultImageName != "debian-bookworm" {
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) {
configDir := t.TempDir()
data := []byte(`
log_level = "debug"
web_listen_addr = "127.0.0.1:8080"
firecracker_bin = "/opt/firecracker"
ssh_key_path = "/tmp/custom-key"
default_image_name = "void"
@ -73,9 +69,6 @@ default_dns = "9.9.9.9"
if cfg.LogLevel != "debug" {
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" {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
}

View file

@ -34,7 +34,7 @@ owning types:
- `tapPool tapPool` — TAP interface pool; owns its own lock.
- `sessions sessionRegistry` — active guest session controllers; owns
its own lock.
- `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking.
- `listener`, `vmDNS` — networking.
- `vmCaps` — registered VM capability hooks.
- `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`,
`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`
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.

View file

@ -9,7 +9,6 @@ import (
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"sync"
@ -58,9 +57,6 @@ type Daemon struct {
once sync.Once
pid int
listener net.Listener
webListener net.Listener
webServer *http.Server
webURL string
vmDNS *vmdns.Server
vmCaps []vmCapability
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 {
_ = 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())
})
return err
@ -167,10 +157,6 @@ 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()
for {
@ -274,7 +260,6 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
result, _ := rpc.NewResult(api.PingResult{
Status: "ok",
PID: d.pid,
WebURL: d.webURL,
Version: info.Version,
Commit: info.Commit,
BuiltAt: info.BuiltAt,

View file

@ -33,7 +33,7 @@ func TestRegisterImageRequiresKernel(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"})
if !resp.OK {
@ -46,8 +46,8 @@ func TestDispatchPingIncludesBuildInfo(t *testing.T) {
}
info := buildinfo.Current()
if got.Status != "ok" || got.PID != 42 || got.WebURL != "http://127.0.0.1:7777" {
t.Fatalf("PingResult = %+v, want status/pid/weburl populated", got)
if got.Status != "ok" || got.PID != 42 {
t.Fatalf("PingResult = %+v, want status/pid populated", got)
}
if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt {
t.Fatalf("PingResult build info = %+v, want %+v", got, info)

View file

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

View file

@ -1,9 +1,8 @@
// Package daemon hosts the Banger daemon process.
//
// The daemon exposes a JSON-RPC endpoint over a Unix socket and, optionally,
// an experimental local web UI. It owns VM lifecycle, image management,
// guest sessions, host networking bootstrap, and state persistence via
// internal/store.
// The daemon exposes a JSON-RPC endpoint over a Unix socket. It owns VM
// lifecycle, image management, guest sessions, host networking bootstrap,
// and state persistence via internal/store.
//
// The package is organised into cohesive groups. Pure stateless helpers for
// each group have been lifted into subpackages; orchestrator methods
@ -68,11 +67,9 @@
// Core (in this package):
//
// daemon.go Daemon struct, Open/Close/Serve, dispatch
// dashboard.go dashboard metrics aggregation
// doctor.go host diagnostics
// logger.go slog configuration
// runtime_assets.go paths to bundled companion binaries
// web.go experimental local web UI server
//
// Lock ordering:
//

View file

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

View file

@ -53,7 +53,6 @@ const (
type DaemonConfig struct {
LogLevel string
WebListenAddr string
FirecrackerBin string
SSHKeyPath string
AutoStopStaleAfter time.Duration

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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