From 0c80d030818660ca816af9047c9c56fece5e0f57 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 19 Mar 2026 22:56:16 -0300 Subject: [PATCH] Remove the banger TUI Hard cut the terminal UI so the supported management surface is the daemon-backed CLI only. Drop the tui subcommand, delete the Bubble Tea implementation and its tests, and keep a regression check that the legacy command is rejected. Prune the Charmbracelet dependencies with go mod tidy and remove the stale README and AGENTS references. Validated with go test ./... and GOCACHE=/tmp/banger-gocache go test ./internal/cli. --- AGENTS.md | 1 - README.md | 7 +- go.mod | 15 +- go.sum | 38 - internal/cli/banger.go | 2 +- internal/cli/cli_test.go | 11 +- internal/cli/tui.go | 1833 -------------------------------------- internal/cli/tui_test.go | 396 -------- 8 files changed, 13 insertions(+), 2290 deletions(-) delete mode 100644 internal/cli/tui.go delete mode 100644 internal/cli/tui_test.go diff --git a/AGENTS.md b/AGENTS.md index b81aaec..9c8df2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. -- `./banger tui` launches the terminal UI. - `make test` runs `go test ./...`. - `./verify.sh` runs the smoke test for the Go VM workflow. diff --git a/README.md b/README.md index e4a33de..2282fb7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # banger -Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI. +Persistent Firecracker development VMs managed through a Go daemon and CLI. ## Requirements - Linux host with KVM (`/dev/kvm` access) @@ -137,11 +137,6 @@ banger vm kill --signal KILL aa12bb34 cc56dd78 banger vm set --nat web-1 web-2 web-3 ``` -Launch the TUI: -```bash -banger tui -``` - ## Daemon The CLI auto-starts `bangerd` when needed. diff --git a/go.mod b/go.mod index 3b9a5ca..3a07334 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,7 @@ module banger go 1.25.0 require ( - github.com/charmbracelet/bubbles v0.14.0 - github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873 - github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 - github.com/mattn/go-isatty v0.0.20 github.com/miekg/dns v1.1.72 github.com/pelletier/go-toml v1.9.5 github.com/sirupsen/logrus v1.9.4 @@ -21,8 +17,6 @@ require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/containerd/console v1.0.3 // indirect github.com/containerd/fifo v1.0.0 // indirect github.com/containernetworking/cni v1.0.1 // indirect github.com/containernetworking/plugins v1.0.1 // indirect @@ -43,22 +37,16 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/socket v0.2.0 // indirect github.com/mdlayher/vsock v1.1.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect - github.com/muesli/cancelreader v0.2.1 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 // indirect github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect @@ -67,7 +55,6 @@ require ( golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 3ba2450..44fbb17 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:l github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -93,15 +91,6 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3k github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og= -github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= -github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= -github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873 h1:ti/1QRoSzanYHPW4jLgIjCkfJ3beXh2h1nr6nEkWOig= -github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= -github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 h1:xO5Bh21Ii+0p3EYp1GdFEF/Iax7VhBgMbBVCOFBZ2/Q= -github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -132,8 +121,6 @@ github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= -github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= @@ -487,9 +474,6 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -502,14 +486,9 @@ github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kN github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -540,17 +519,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs= -github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -651,9 +619,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -662,7 +627,6 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= @@ -952,12 +916,10 @@ golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 7f1160f..b7f3361 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -60,7 +60,7 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newTUICommand(), newInternalCommand()) + root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newInternalCommand()) return root } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cbda292..54af725 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -25,12 +25,21 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "tui", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } } +func TestLegacyRemovedCommandIsRejected(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"tui"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown command \"tui\"") { + t.Fatalf("Execute() error = %v, want unknown legacy command", err) + } +} + func TestDoctorCommandPrintsReportAndFailsOnHardFailures(t *testing.T) { original := doctorFunc t.Cleanup(func() { diff --git a/internal/cli/tui.go b/internal/cli/tui.go deleted file mode 100644 index 8448c04..0000000 --- a/internal/cli/tui.go +++ /dev/null @@ -1,1833 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" - "banger/internal/rpc" - "banger/internal/system" - "banger/internal/vsockagent" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" -) - -const tuiRefreshInterval = 3 * time.Second - -var ( - tuiEnsureDaemonFunc = ensureDaemon - tuiProgramRunner = func(model tuiModel) error { - program := tea.NewProgram(model, tea.WithAltScreen()) - return program.Start() - } - tuiIsTerminal = func(fd uintptr) bool { - return isatty.IsTerminal(fd) - } -) - -type tuiMode int - -const ( - tuiModeBrowse tuiMode = iota - tuiModeForm - tuiModeConfirmDelete -) - -type actionKind string - -const ( - actionCreate actionKind = "create" - actionEdit actionKind = "edit" - actionStart actionKind = "start" - actionStop actionKind = "stop" - actionRestart actionKind = "restart" - actionDelete actionKind = "delete" - actionSSH actionKind = "ssh" - actionLogs actionKind = "logs" -) - -type daemonReadyMsg struct { - generation int - layout paths.Layout - cfg model.DaemonConfig - duration time.Duration - err error -} - -type vmListLoadedMsg struct { - generation int - vms []model.VMRecord - focusID string - duration time.Duration - err error -} - -type imageListLoadedMsg struct { - generation int - images []model.Image - duration time.Duration - err error -} - -type statsLoadedMsg struct { - id string - stats model.VMStats - err error -} - -type actionResultMsg struct { - action actionRequest - focusID string - status string - err error - refresh bool -} - -type externalPreparedMsg struct { - action actionRequest - command *exec.Cmd - doneStatus string - done func(error) tea.Msg - refresh bool - err error -} - -type sudoValidatedMsg struct { - err error -} - -type refreshTickMsg struct{} - -type actionRequest struct { - kind actionKind - id string - name string - create api.VMCreateParams - set api.VMSetParams -} - -type browseKeyMap struct { - refresh key.Binding - create key.Binding - edit key.Binding - start key.Binding - stop key.Binding - restart key.Binding - delete key.Binding - ssh key.Binding - logs key.Binding - help key.Binding - quit key.Binding -} - -func newBrowseKeyMap() browseKeyMap { - return browseKeyMap{ - refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), - create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), - edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - start: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start")), - stop: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop")), - restart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), - delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - ssh: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")), - logs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), - help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), - quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), - } -} - -func (k browseKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.refresh, k.create, k.edit, k.start, k.stop, k.delete, k.ssh, k.logs, k.quit} -} - -func (k browseKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.refresh, k.create, k.edit, k.start, k.stop, k.restart, k.delete}, - {k.ssh, k.logs, k.help, k.quit}, - } -} - -type formKeyMap struct { - next key.Binding - prev key.Binding - change key.Binding - toggle key.Binding - submit key.Binding - cancel key.Binding -} - -func newFormKeyMap() formKeyMap { - return formKeyMap{ - next: key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab", "next")), - prev: key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab", "prev")), - change: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("left/right", "change")), - toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), - submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")), - cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - } -} - -func (k formKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.next, k.change, k.toggle, k.submit, k.cancel} -} - -func (k formKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.next, k.prev, k.change}, {k.toggle, k.submit, k.cancel}} -} - -type confirmKeyMap struct { - confirm key.Binding - cancel key.Binding -} - -func newConfirmKeyMap() confirmKeyMap { - return confirmKeyMap{ - confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("enter", "confirm")), - cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("esc", "cancel")), - } -} - -func (k confirmKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.confirm, k.cancel} -} - -func (k confirmKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.confirm, k.cancel}} -} - -type formFieldKind int - -const ( - formFieldText formFieldKind = iota - formFieldSelect -) - -type formField struct { - label string - kind formFieldKind - input textinput.Model - options []string - index int -} - -func newTextField(label, value string) formField { - input := textinput.New() - input.Prompt = "" - input.SetValue(value) - input.CharLimit = 128 - return formField{ - label: label, - kind: formFieldText, - input: input, - } -} - -func newSelectField(label string, options []string, index int) formField { - if len(options) == 0 { - options = []string{""} - index = 0 - } - if index < 0 || index >= len(options) { - index = 0 - } - return formField{ - label: label, - kind: formFieldSelect, - options: options, - index: index, - } -} - -func (f formField) value() string { - if f.kind == formFieldText { - return f.input.Value() - } - if len(f.options) == 0 { - return "" - } - return f.options[f.index] -} - -type vmForm struct { - mode actionKind - title string - submitLabel string - targetID string - fields []formField - focus int -} - -func newCreateVMForm(images []model.Image, cfg model.DaemonConfig) *vmForm { - imageOptions := imageNames(images) - selectedImage := 0 - if cfg.DefaultImageName != "" { - for i, name := range imageOptions { - if name == cfg.DefaultImageName { - selectedImage = i - break - } - } - } - form := &vmForm{ - mode: actionCreate, - title: "Create VM", - submitLabel: "Create", - fields: []formField{ - newTextField("Name", ""), - newSelectField("Image", imageOptions, selectedImage), - newTextField("VCPU", strconv.Itoa(model.DefaultVCPUCount)), - newTextField("Memory (MiB)", strconv.Itoa(model.DefaultMemoryMiB)), - newTextField("System Overlay", model.FormatSizeBytes(model.DefaultSystemOverlaySize)), - newTextField("Work Disk", model.FormatSizeBytes(model.DefaultWorkDiskSize)), - newSelectField("NAT Enabled", []string{"no", "yes"}, 0), - newSelectField("No Start", []string{"no", "yes"}, 0), - }, - } - form.focusField(0) - return form -} - -func newEditVMForm(vm model.VMRecord) *vmForm { - form := &vmForm{ - mode: actionEdit, - title: "Edit VM", - submitLabel: "Save", - targetID: vm.ID, - fields: []formField{ - newTextField("VCPU", strconv.Itoa(vm.Spec.VCPUCount)), - newTextField("Memory (MiB)", strconv.Itoa(vm.Spec.MemoryMiB)), - newTextField("Work Disk", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), - newSelectField("NAT Enabled", []string{"no", "yes"}, boolToIndex(vm.Spec.NATEnabled)), - }, - } - form.focusField(0) - return form -} - -func (f *vmForm) focusField(index int) tea.Cmd { - if len(f.fields) == 0 { - f.focus = 0 - return nil - } - if f.focus >= 0 && f.focus < len(f.fields) && f.fields[f.focus].kind == formFieldText { - f.fields[f.focus].input.Blur() - } - f.focus = wrapIndex(index, len(f.fields)) - if f.fields[f.focus].kind == formFieldText { - return f.fields[f.focus].input.Focus() - } - return nil -} - -func (f *vmForm) move(delta int) tea.Cmd { - return f.focusField(f.focus + delta) -} - -func (f *vmForm) setWidth(width int) { - inputWidth := maxInt(12, width-22) - for i := range f.fields { - if f.fields[i].kind == formFieldText { - f.fields[i].input.Width = inputWidth - } - } -} - -func (f *vmForm) update(msg tea.Msg) tea.Cmd { - if len(f.fields) == 0 { - return nil - } - if f.fields[f.focus].kind != formFieldText { - return nil - } - var cmd tea.Cmd - f.fields[f.focus].input, cmd = f.fields[f.focus].input.Update(msg) - return cmd -} - -func (f *vmForm) change(delta int) { - if len(f.fields) == 0 { - return - } - field := &f.fields[f.focus] - if field.kind != formFieldSelect || len(field.options) == 0 { - return - } - field.index = wrapIndex(field.index+delta, len(field.options)) -} - -func (f *vmForm) view(width int) string { - f.setWidth(width) - titleStyle := lipgloss.NewStyle().Bold(true) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) - valueStyle := lipgloss.NewStyle().Bold(true) - - lines := []string{titleStyle.Render(f.title), ""} - for i := range f.fields { - marker := " " - lbl := labelStyle.Render(f.fields[i].label) - if i == f.focus { - marker = "> " - lbl = activeStyle.Render(f.fields[i].label) - } - value := "" - if f.fields[i].kind == formFieldText { - value = f.fields[i].input.View() - } else { - value = valueStyle.Render(f.fields[i].value()) - } - lines = append(lines, fmt.Sprintf("%s%-16s %s", marker, lbl, value)) - } - lines = append(lines, "", fmt.Sprintf("Enter %s Esc cancel", strings.ToLower(f.submitLabel))) - - style := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(1, 2). - Width(maxInt(36, width)) - return style.Render(strings.Join(lines, "\n")) -} - -func (f *vmForm) submit() (actionRequest, error) { - switch f.mode { - case actionCreate: - return f.createRequest() - case actionEdit: - return f.editRequest() - default: - return actionRequest{}, errors.New("unsupported form mode") - } -} - -func (f *vmForm) createRequest() (actionRequest, error) { - vcpu, err := parsePositiveInt("vcpu", f.fields[2].value()) - if err != nil { - return actionRequest{}, err - } - memory, err := parsePositiveInt("memory", f.fields[3].value()) - if err != nil { - return actionRequest{}, err - } - params := api.VMCreateParams{ - Name: strings.TrimSpace(f.fields[0].value()), - ImageName: strings.TrimSpace(f.fields[1].value()), - VCPUCount: &vcpu, - MemoryMiB: &memory, - SystemOverlaySize: strings.TrimSpace(f.fields[4].value()), - WorkDiskSize: strings.TrimSpace(f.fields[5].value()), - NATEnabled: isYes(f.fields[6].value()), - NoStart: isYes(f.fields[7].value()), - } - if params.ImageName == "" || params.ImageName == "" { - return actionRequest{}, errors.New("create requires an image") - } - return actionRequest{kind: actionCreate, create: params}, nil -} - -func (f *vmForm) editRequest() (actionRequest, error) { - vcpu, err := parsePositiveInt("vcpu", f.fields[0].value()) - if err != nil { - return actionRequest{}, err - } - memory, err := parsePositiveInt("memory", f.fields[1].value()) - if err != nil { - return actionRequest{}, err - } - params, err := vmSetParamsFromFlags( - f.targetID, - vcpu, - memory, - strings.TrimSpace(f.fields[2].value()), - isYes(f.fields[3].value()), - false, - ) - if err != nil { - return actionRequest{}, err - } - return actionRequest{ - kind: actionEdit, - id: f.targetID, - set: params, - }, nil -} - -type tuiModel struct { - layout paths.Layout - cfg model.DaemonConfig - - width int - height int - ready bool - - loadGeneration int - loading bool - busy string - sudoValidated bool - daemonReady bool - daemonPending bool - vmListPending bool - imagePending bool - imagesLoaded bool - daemonLoadDur time.Duration - vmListDur time.Duration - imageListDur time.Duration - - mode tuiMode - form *vmForm - pendingAction *actionRequest - - vms []model.VMRecord - images []model.Image - selectedID string - selectedStats model.VMStats - statsID string - statsErr string - - table table.Model - detail viewport.Model - help help.Model - spinner spinner.Model - - browseKeys browseKeyMap - formKeys formKeyMap - confirmKeys confirmKeyMap - - lastRefresh time.Time - statusText string - statusErr bool - hostCPUCount int - hostMemoryBytes int64 - hostDiskBytes int64 -} - -func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel { - hostResources, err := system.ReadHostResources() - hostCPUCount := 0 - hostMemoryBytes := int64(0) - hostDiskBytes := int64(0) - if err == nil { - hostCPUCount = hostResources.CPUCount - hostMemoryBytes = hostResources.TotalMemoryBytes - } - if diskUsage, err := readTUIFilesystemUsage(layout); err == nil { - hostDiskBytes = diskUsage.TotalBytes - } - - vmTable := table.New( - table.WithColumns([]table.Column{ - {Title: "NAME", Width: 18}, - {Title: "STATE", Width: 9}, - {Title: "IP", Width: 14}, - {Title: "VCPU", Width: 4}, - {Title: "MEM", Width: 8}, - {Title: "DISK", Width: 8}, - {Title: "AGE", Width: 12}, - }), - table.WithRows(nil), - table.WithFocused(true), - table.WithHeight(10), - table.WithWidth(60), - table.WithKeyMap(tuiTableKeyMap()), - ) - tableStyles := table.DefaultStyles() - tableStyles.Header = lipgloss.NewStyle().Bold(true).Padding(0, 1) - tableStyles.Cell = lipgloss.NewStyle().Padding(0, 1) - tableStyles.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62")) - vmTable.SetStyles(tableStyles) - - detail := viewport.New(0, 0) - detail.Style = lipgloss.NewStyle() - - spin := spinner.New(spinner.WithSpinner(spinner.Line)) - spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) - - helpView := help.New() - - model := tuiModel{ - layout: layout, - cfg: cfg, - width: 120, - height: 32, - ready: true, - table: vmTable, - detail: detail, - help: helpView, - spinner: spin, - browseKeys: newBrowseKeyMap(), - formKeys: newFormKeyMap(), - confirmKeys: newConfirmKeyMap(), - loadGeneration: 1, - loading: true, - daemonPending: true, - statusText: "Starting daemon...", - hostCPUCount: hostCPUCount, - hostMemoryBytes: hostMemoryBytes, - hostDiskBytes: hostDiskBytes, - } - model.resize() - return model -} - -func newTUICommand() *cobra.Command { - return &cobra.Command{ - Use: "tui", - Short: "Launch a terminal UI to manage VMs", - Args: noArgsUsage("usage: banger tui"), - RunE: func(cmd *cobra.Command, args []string) error { - if !tuiIsTerminal(os.Stdin.Fd()) || !tuiIsTerminal(os.Stdout.Fd()) { - return errors.New("tui requires an interactive terminal") - } - return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{})) - }, - } -} - -func (m tuiModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, refreshTickCmd(), ensureDaemonCmd(m.loadGeneration)) -} - -func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if m.loading || m.busy != "" { - var spinCmd tea.Cmd - m.spinner, spinCmd = m.spinner.Update(msg) - cmds = append(cmds, spinCmd) - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.ready = true - m.width = msg.Width - m.height = msg.Height - m.resize() - case daemonReadyMsg: - if msg.generation != m.loadGeneration { - break - } - m.daemonPending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("starting daemon: %v", msg.err), true) - break - } - m.layout = msg.layout - m.cfg = msg.cfg - m.daemonReady = true - m.daemonLoadDur = msg.duration - if diskUsage, err := readTUIFilesystemUsage(m.layout); err == nil { - m.hostDiskBytes = diskUsage.TotalBytes - } - m.beginListLoad("") - cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration)) - case vmListLoadedMsg: - if msg.generation != m.loadGeneration { - break - } - m.vmListPending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("loading vms: %v", msg.err), true) - break - } - m.vms = msg.vms - m.vmListDur = msg.duration - targetID := m.selectedID - if msg.focusID != "" { - targetID = msg.focusID - } - m.selectedID = resolveSelectedID(targetID, msg.vms) - m.lastRefresh = time.Now() - m.rebuildTable() - m.syncLoadingState() - m.setLoadStatus(false) - if m.selectedID != "" { - cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID)) - } else { - m.selectedStats = model.VMStats{} - m.statsID = "" - m.statsErr = "" - } - case imageListLoadedMsg: - if msg.generation != m.loadGeneration { - break - } - m.imagePending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("loading images: %v", msg.err), true) - break - } - m.images = msg.images - m.imagesLoaded = true - m.imageListDur = msg.duration - m.syncLoadingState() - m.setLoadStatus(false) - case statsLoadedMsg: - if msg.id != m.selectedID { - break - } - if msg.err != nil { - m.statsErr = msg.err.Error() - m.setStatus(msg.err.Error(), true) - break - } - m.selectedStats = msg.stats - m.statsID = msg.id - m.statsErr = "" - case actionResultMsg: - m.busy = "" - if msg.err != nil { - if looksLikeSudoExpiry(msg.err) { - m.sudoValidated = false - } - m.setStatus(msg.err.Error(), true) - break - } - if msg.action.kind == actionCreate || msg.action.kind == actionEdit { - m.form = nil - m.mode = tuiModeBrowse - } - if msg.action.kind == actionDelete { - m.mode = tuiModeBrowse - } - m.setStatus(msg.status, false) - if msg.refresh { - cmds = append(cmds, m.beginRefreshLoad(msg.focusID)...) - } - case externalPreparedMsg: - if msg.err != nil { - m.setStatus(msg.err.Error(), true) - break - } - cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { - err = normalizeExecError(err) - if msg.done != nil { - return msg.done(err) - } - return actionResultMsg{ - action: msg.action, - status: msg.doneStatus, - err: err, - refresh: msg.refresh, - focusID: m.selectedID, - } - })) - case sudoValidatedMsg: - if msg.err != nil { - m.pendingAction = nil - m.busy = "" - m.setStatus(msg.err.Error(), true) - break - } - m.sudoValidated = true - if m.pendingAction != nil { - action := *m.pendingAction - m.pendingAction = nil - m.busy = action.activity() - cmds = append(cmds, m.spinner.Tick, m.runActionCmd(action)) - } - case refreshTickMsg: - cmds = append(cmds, refreshTickCmd()) - if m.busy == "" && m.mode == tuiModeBrowse && m.daemonReady && !m.vmListPending && !m.imagePending { - cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...) - } - case tea.KeyMsg: - switch m.mode { - case tuiModeBrowse: - nextModel, extraCmds := m.updateBrowse(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - case tuiModeForm: - nextModel, extraCmds := m.updateForm(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - case tuiModeConfirmDelete: - nextModel, extraCmds := m.updateConfirmDelete(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - } - } - - m.refreshDetail() - return m, tea.Batch(cmds...) -} - -func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - var cmds []tea.Cmd - switch { - case key.Matches(msg, m.browseKeys.quit): - cmds = append(cmds, tea.Quit) - case key.Matches(msg, m.browseKeys.help): - m.help.ShowAll = !m.help.ShowAll - case key.Matches(msg, m.browseKeys.refresh): - if !m.daemonReady { - cmds = append(cmds, m.beginBootstrapLoad()...) - } else { - cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...) - } - case key.Matches(msg, m.browseKeys.create): - if !m.imagesLoaded { - m.setStatus("Images are still loading", true) - return m, cmds - } - if len(m.images) == 0 { - m.setStatus("Create requires at least one image", true) - return m, cmds - } - m.form = newCreateVMForm(m.images, m.cfg) - m.mode = tuiModeForm - cmds = append(cmds, m.form.focusField(m.form.focus)) - case key.Matches(msg, m.browseKeys.edit): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State == model.VMStateRunning { - m.setStatus("Stop the VM before editing it", true) - return m, cmds - } - m.form = newEditVMForm(vm) - m.mode = tuiModeForm - cmds = append(cmds, m.form.focusField(m.form.focus)) - case key.Matches(msg, m.browseKeys.delete): - if _, ok := m.selectedVM(); !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - m.mode = tuiModeConfirmDelete - case key.Matches(msg, m.browseKeys.start): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State == model.VMStateRunning { - m.setStatus("VM is already running", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStart, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.stop): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State != model.VMStateRunning { - m.setStatus("VM is not running", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStop, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.restart): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionRestart, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.ssh): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State != model.VMStateRunning { - m.setStatus("SSH requires a running VM", true) - return m, cmds - } - cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionSSH, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.logs): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionLogs, id: vm.ID, name: vm.Name})) - default: - if len(m.vms) == 0 { - return m, cmds - } - oldCursor := m.table.Cursor() - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - cmds = append(cmds, tableCmd) - if m.table.Cursor() != oldCursor { - if vm, ok := m.selectedVMByCursor(); ok { - m.selectedID = vm.ID - m.selectedStats = model.VMStats{} - m.statsID = "" - m.statsErr = "" - cmds = append(cmds, fetchStatsCmd(m.layout, vm.ID)) - } - } - } - return m, cmds -} - -func (m tuiModel) updateForm(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - var cmds []tea.Cmd - switch { - case key.Matches(msg, m.formKeys.cancel): - m.form = nil - m.mode = tuiModeBrowse - case key.Matches(msg, m.formKeys.next): - cmds = append(cmds, m.form.move(1)) - case key.Matches(msg, m.formKeys.prev): - cmds = append(cmds, m.form.move(-1)) - case key.Matches(msg, m.formKeys.change): - switch msg.String() { - case "left": - m.form.change(-1) - case "right": - m.form.change(1) - } - case key.Matches(msg, m.formKeys.toggle): - m.form.change(1) - case key.Matches(msg, m.formKeys.submit): - action, err := m.form.submit() - if err != nil { - m.setStatus(err.Error(), true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(action)) - default: - cmds = append(cmds, m.form.update(msg)) - } - return m, cmds -} - -func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - switch { - case key.Matches(msg, m.confirmKeys.cancel): - m.mode = tuiModeBrowse - return m, nil - case key.Matches(msg, m.confirmKeys.confirm): - vm, ok := m.selectedVM() - if !ok { - m.mode = tuiModeBrowse - m.setStatus("No VM selected", true) - return m, nil - } - return m, []tea.Cmd{m.dispatchAction(actionRequest{kind: actionDelete, id: vm.ID, name: vm.Name})} - default: - return m, nil - } -} - -func (m tuiModel) View() string { - header := m.renderHeader() - resourceBar := m.renderResourceBar() - body := m.renderBody() - status := m.renderStatus() - m.help.Width = m.width - helpView := m.help.View(m.currentKeyMap()) - return lipgloss.JoinVertical(lipgloss.Left, header, resourceBar, body, status, helpView) -} - -func (m tuiModel) currentKeyMap() help.KeyMap { - switch m.mode { - case tuiModeForm: - return m.formKeys - case tuiModeConfirmDelete: - return m.confirmKeys - default: - return m.browseKeys - } -} - -func (m *tuiModel) resize() { - bodyHeight := m.bodyHeight() - leftWidth := maxInt(42, (m.width*55)/100) - if leftWidth > m.width-24 { - leftWidth = maxInt(24, m.width/2) - } - rightWidth := maxInt(24, m.width-leftWidth-1) - - leftInnerWidth := maxInt(20, leftWidth-4) - rightInnerWidth := maxInt(20, rightWidth-4) - panelInnerHeight := maxInt(8, bodyHeight-2) - - m.table.SetWidth(leftInnerWidth) - m.table.SetHeight(maxInt(4, panelInnerHeight-2)) - m.detail.Width = rightInnerWidth - m.detail.Height = maxInt(4, panelInnerHeight-2) - if m.form != nil { - m.form.setWidth(maxInt(28, minInt(58, m.width-10))) - } -} - -func (m tuiModel) bodyHeight() int { - return maxInt(8, m.height-5) -} - -func (m tuiModel) renderHeader() string { - status := "idle" - if m.busy != "" { - status = m.spinner.View() + " " + m.busy - } else if phase := m.loadingPhase(); phase != "" { - status = m.spinner.View() + " " + phase - } - refreshed := "never" - if !m.lastRefresh.IsZero() { - refreshed = relativeTime(m.lastRefresh) - } - socketLabel := filepath.Base(m.layout.SocketPath) - if socketLabel == "." || socketLabel == "" { - socketLabel = "pending" - } - header := fmt.Sprintf("banger tui socket %s %s last refresh %s", socketLabel, status, refreshed) - return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header) -} - -func (m tuiModel) renderResourceBar() string { - runningVMs, totalVCPUs, totalMemoryBytes := aggregateRunningVMResources(m.vms) - totalDiskBytes := aggregateVMDiskUsage(m.vms) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - runningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) - - parts := []string{ - labelStyle.Render("VMs") + " " + runningStyle.Render(fmt.Sprintf("%d/%d", runningVMs, len(m.vms))), - renderUsageMeter("CPU", int64(totalVCPUs), int64(m.hostCPUCount), strconv.Itoa(totalVCPUs), totalLabel(m.hostCPUCount)), - renderUsageMeter("RAM", totalMemoryBytes, m.hostMemoryBytes, formatBytes(totalMemoryBytes), bytesTotalLabel(m.hostMemoryBytes)), - renderUsageMeter("Disk", totalDiskBytes, m.hostDiskBytes, formatBytes(totalDiskBytes), bytesTotalLabel(m.hostDiskBytes)), - } - - return lipgloss.NewStyle(). - Width(m.width). - Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...)) -} - -func (m tuiModel) renderBody() string { - bodyHeight := m.bodyHeight() - if m.mode == tuiModeForm && m.form != nil { - return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.form.view(minInt(58, m.width-10))) - } - if m.mode == tuiModeConfirmDelete { - return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.confirmDeleteView()) - } - - leftWidth := maxInt(42, (m.width*55)/100) - if leftWidth > m.width-24 { - leftWidth = maxInt(24, m.width/2) - } - rightWidth := maxInt(24, m.width-leftWidth-1) - leftInnerWidth := maxInt(20, leftWidth-4) - rightInnerWidth := maxInt(20, rightWidth-4) - panelHeight := maxInt(8, bodyHeight-1) - - leftContent := m.table.View() - if len(m.vms) == 0 { - leftContent = m.vmListPlaceholder() - } - leftPanel := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(leftWidth). - Height(panelHeight). - Render(lipgloss.NewStyle().Width(leftInnerWidth).Render(leftContent)) - - rightPanel := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(rightWidth). - Height(panelHeight). - Render(lipgloss.NewStyle().Width(rightInnerWidth).Render(m.detail.View())) - - return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) -} - -func (m tuiModel) confirmDeleteView() string { - vm, ok := m.selectedVM() - name := "this VM" - if ok { - name = vm.Name - } - content := fmt.Sprintf("Delete %s?\n\nThis removes the VM and its persistent state.\n\nEnter confirm Esc cancel", name) - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(1, 2). - Width(54). - Render(content) -} - -func (m tuiModel) renderStatus() string { - if m.statusText == "" { - return " " - } - style := lipgloss.NewStyle().Foreground(lipgloss.Color("70")) - if m.statusErr { - style = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) - } - return style.Width(m.width).Render(m.statusText) -} - -func (m *tuiModel) rebuildTable() { - rows := make([]table.Row, 0, len(m.vms)) - cursor := 0 - for i, vm := range m.vms { - rows = append(rows, table.Row{ - vm.Name, - string(vm.State), - vm.Runtime.GuestIP, - strconv.Itoa(vm.Spec.VCPUCount), - fmt.Sprintf("%dM", vm.Spec.MemoryMiB), - model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), - relativeTime(vm.CreatedAt), - }) - if vm.ID == m.selectedID { - cursor = i - } - } - m.table.SetRows(rows) - if len(rows) > 0 { - m.table.SetCursor(cursor) - } -} - -func (m *tuiModel) refreshDetail() { - vm, ok := m.selectedVM() - if !ok { - m.detail.SetContent(m.detailPlaceholder()) - return - } - stats := vm.Stats - if m.statsID == vm.ID && !m.selectedStats.CollectedAt.IsZero() { - stats = m.selectedStats - } - lines := []string{ - fmt.Sprintf("Name: %s", vm.Name), - fmt.Sprintf("State: %s", vm.State), - fmt.Sprintf("IP: %s", orDash(vm.Runtime.GuestIP)), - fmt.Sprintf("DNS: %s", orDash(vm.Runtime.DNSName)), - fmt.Sprintf("Image: %s", shortID(vm.ImageID)), - "", - "Config", - fmt.Sprintf(" vCPU: %d", vm.Spec.VCPUCount), - fmt.Sprintf(" Memory: %d MiB", vm.Spec.MemoryMiB), - fmt.Sprintf(" Overlay: %s", model.FormatSizeBytes(vm.Spec.SystemOverlaySizeByte)), - fmt.Sprintf(" Work disk: %s", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), - fmt.Sprintf(" NAT: %s", yesNo(vm.Spec.NATEnabled)), - "", - "Usage", - fmt.Sprintf(" Overlay: %s", formatBytes(stats.SystemOverlayBytes)), - fmt.Sprintf(" Work disk: %s", formatBytes(stats.WorkDiskBytes)), - fmt.Sprintf(" CPU: %.1f%%", stats.CPUPercent), - fmt.Sprintf(" RSS: %s", formatBytes(stats.RSSBytes)), - fmt.Sprintf(" VSZ: %s", formatBytes(stats.VSZBytes)), - } - if !stats.CollectedAt.IsZero() { - lines = append(lines, fmt.Sprintf(" Updated: %s", stats.CollectedAt.Format(time.RFC3339))) - } - lines = append(lines, - "", - "Timestamps", - fmt.Sprintf(" Created: %s", vm.CreatedAt.Format(time.RFC3339)), - fmt.Sprintf(" Updated: %s", vm.UpdatedAt.Format(time.RFC3339)), - fmt.Sprintf(" Touched: %s", vm.LastTouchedAt.Format(time.RFC3339)), - ) - if vm.Runtime.LastError != "" { - lines = append(lines, "", "Last error", " "+vm.Runtime.LastError) - } - if m.statsErr != "" && m.statsID == vm.ID { - lines = append(lines, "", "Stats error", " "+m.statsErr) - } - m.detail.SetContent(strings.Join(lines, "\n")) -} - -func (m *tuiModel) setStatus(text string, isErr bool) { - m.statusText = text - m.statusErr = isErr -} - -func (m *tuiModel) beginBootstrapLoad() []tea.Cmd { - m.loadGeneration++ - m.daemonReady = false - m.daemonPending = true - m.vmListPending = false - m.imagePending = false - m.imagesLoaded = false - m.images = nil - m.daemonLoadDur = 0 - m.vmListDur = 0 - m.imageListDur = 0 - m.syncLoadingState() - m.setLoadStatus(false) - return []tea.Cmd{m.spinner.Tick, ensureDaemonCmd(m.loadGeneration)} -} - -func (m *tuiModel) beginListLoad(focusID string) { - m.vmListPending = true - m.imagePending = true - if len(m.images) == 0 { - m.imagesLoaded = false - } - m.vmListDur = 0 - m.imageListDur = 0 - m.syncLoadingState() - m.setLoadStatus(false) - if focusID == "" { - return - } - m.selectedID = focusID -} - -func (m *tuiModel) beginRefreshLoad(focusID string) []tea.Cmd { - if !m.daemonReady { - return m.beginBootstrapLoad() - } - m.loadGeneration++ - m.beginListLoad(focusID) - return []tea.Cmd{ - m.spinner.Tick, - fetchVMListCmd(m.layout, focusID, m.loadGeneration), - fetchImageListCmd(m.layout, m.loadGeneration), - } -} - -func (m *tuiModel) syncLoadingState() { - m.loading = m.daemonPending || m.vmListPending || m.imagePending -} - -func (m tuiModel) loadingPhase() string { - switch { - case m.daemonPending: - return "starting daemon" - case m.vmListPending && m.imagePending: - return "loading vms and images" - case m.vmListPending: - return "loading vms" - case m.imagePending: - return "loading images" - default: - return "" - } -} - -func (m *tuiModel) setLoadStatus(isErr bool) { - if phase := m.loadingPhase(); phase != "" { - durations := m.stageDurations() - switch phase { - case "loading images": - prefix := fmt.Sprintf("Loaded %d VM(s); loading images", len(m.vms)) - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr) - return - } - m.setStatus(prefix+"...", isErr) - return - case "loading vms": - prefix := fmt.Sprintf("Loaded %d image(s); loading vms", len(m.images)) - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr) - return - } - m.setStatus(prefix+"...", isErr) - return - } - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", capitalizePhase(phase), durations), isErr) - return - } - m.setStatus(capitalizePhase(phase)+"...", isErr) - return - } - if m.daemonReady && m.vmListDur > 0 && m.imageListDur > 0 { - m.setStatus(fmt.Sprintf("Loaded %d VM(s) (%s)", len(m.vms), m.stageDurations()), isErr) - } -} - -func (m tuiModel) stageDurations() string { - parts := make([]string, 0, 3) - if m.daemonLoadDur > 0 { - parts = append(parts, "daemon "+formatTUIDuration(m.daemonLoadDur)) - } - if m.vmListDur > 0 { - parts = append(parts, "vm list "+formatTUIDuration(m.vmListDur)) - } - if m.imageListDur > 0 { - parts = append(parts, "image list "+formatTUIDuration(m.imageListDur)) - } - return strings.Join(parts, ", ") -} - -func (m tuiModel) vmListPlaceholder() string { - switch { - case m.daemonPending: - return "Starting daemon...\n\nWaiting for bangerd to become ready." - case m.vmListPending: - return "Loading VMs..." - default: - return "No VMs.\n\nPress c to create one." - } -} - -func (m tuiModel) detailPlaceholder() string { - switch { - case m.daemonPending: - return "Starting daemon...\n\nThe TUI will populate once bangerd is ready." - case m.vmListPending: - return "Loading VMs..." - case len(m.vms) == 0: - if m.imagePending { - return "No VM selected.\n\nImages are still loading." - } - return "No VM selected.\n\nUse c to create a VM." - default: - return "No VM selected." - } -} - -func ensureDaemonCmd(generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - layout, cfg, err := tuiEnsureDaemonFunc(context.Background()) - return daemonReadyMsg{ - generation: generation, - layout: layout, - cfg: cfg, - duration: time.Since(start), - err: err, - } - } -} - -func (m tuiModel) selectedVM() (model.VMRecord, bool) { - for _, vm := range m.vms { - if vm.ID == m.selectedID { - return vm, true - } - } - return model.VMRecord{}, false -} - -func (m tuiModel) selectedVMByCursor() (model.VMRecord, bool) { - cursor := m.table.Cursor() - if cursor < 0 || cursor >= len(m.vms) { - return model.VMRecord{}, false - } - return m.vms[cursor], true -} - -func (m *tuiModel) dispatchAction(action actionRequest) tea.Cmd { - if action.needsSudo() && !m.sudoValidated { - m.pendingAction = &action - m.busy = "Authorizing sudo..." - return tea.ExecProcess(exec.Command("sudo", "-v"), func(err error) tea.Msg { - return sudoValidatedMsg{err: err} - }) - } - m.busy = action.activity() - return tea.Batch(m.spinner.Tick, m.runActionCmd(action)) -} - -func (m tuiModel) runActionCmd(action actionRequest) tea.Cmd { - switch action.kind { - case actionCreate: - return createActionCmd(m.layout, action) - case actionEdit: - return editActionCmd(m.layout, action) - case actionStart, actionStop, actionRestart: - return lifecycleActionCmd(m.layout, action) - case actionDelete: - return deleteActionCmd(m.layout, action) - case actionSSH: - return prepareSSHCmd(m.layout, m.cfg, action) - case actionLogs: - return prepareLogsCmd(m.layout, action) - default: - return func() tea.Msg { - return actionResultMsg{action: action, err: fmt.Errorf("unsupported action %s", action.kind)} - } - } -} - -func createActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.create", action.create) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("created %s", result.VM.Name), - refresh: true, - } - } -} - -func editActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.set", action.set) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("updated %s", result.VM.Name), - refresh: true, - } - } -} - -func lifecycleActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - method := "" - status := "" - switch action.kind { - case actionStart: - method = "vm.start" - status = "started" - case actionStop: - method = "vm.stop" - status = "stopped" - case actionRestart: - method = "vm.restart" - status = "restarted" - } - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, method, api.VMRefParams{IDOrName: action.id}) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("%s %s", status, result.VM.Name), - refresh: true, - } - } -} - -func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - _, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - status: fmt.Sprintf("deleted %s", action.name), - refresh: true, - } - } -} - -func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd { - return func() tea.Msg { - if err := validateSSHPrereqs(cfg); err != nil { - return externalPreparedMsg{action: action, err: err} - } - result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - args, err := sshCommandArgs(cfg, result.GuestIP, nil) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - return externalPreparedMsg{ - action: action, - command: exec.Command("ssh", args...), - done: func(execErr error) tea.Msg { - return sshDoneMsg(layout, action, result.Name, execErr) - }, - refresh: true, - } - } -} - -func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr error) tea.Msg { - if execErr != nil { - return actionResultMsg{ - action: action, - err: execErr, - refresh: true, - focusID: action.id, - } - } - pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - health, err := vmHealthFunc(pingCtx, layout.SocketPath, name) - if err != nil { - return actionResultMsg{ - action: action, - status: vsockagent.WarningMessage(name, err), - refresh: true, - focusID: action.id, - } - } - if health.Healthy { - if strings.TrimSpace(health.Name) != "" { - name = health.Name - } - return actionResultMsg{ - action: action, - status: vsockagent.ReminderMessage(name), - refresh: true, - focusID: action.id, - } - } - return actionResultMsg{ - action: action, - status: fmt.Sprintf("ssh session ended for %s", name), - refresh: true, - focusID: action.id, - } -} - -func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - if result.LogPath == "" { - return externalPreparedMsg{action: action, err: errors.New("vm has no log path")} - } - return externalPreparedMsg{ - action: action, - command: system.TailCommand(result.LogPath, true), - doneStatus: fmt.Sprintf("closed log view for %s", action.name), - refresh: false, - } - } -} - -func fetchVMListCmd(layout paths.Layout, focusID string, generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{}) - if err != nil { - return vmListLoadedMsg{generation: generation, err: err, focusID: focusID} - } - return vmListLoadedMsg{ - generation: generation, - vms: vms.VMs, - focusID: focusID, - duration: time.Since(start), - } - } -} - -func fetchImageListCmd(layout paths.Layout, generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{}) - if err != nil { - return imageListLoadedMsg{generation: generation, err: err} - } - return imageListLoadedMsg{ - generation: generation, - images: images.Images, - duration: time.Since(start), - } - } -} - -func fetchStatsCmd(layout paths.Layout, id string) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMStatsResult](context.Background(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: id}) - if err != nil { - return statsLoadedMsg{id: id, err: err} - } - return statsLoadedMsg{id: id, stats: result.Stats} - } -} - -func refreshTickCmd() tea.Cmd { - return tea.Tick(tuiRefreshInterval, func(time.Time) tea.Msg { - return refreshTickMsg{} - }) -} - -func resolveSelectedID(targetID string, vms []model.VMRecord) string { - if len(vms) == 0 { - return "" - } - if targetID != "" { - for _, vm := range vms { - if vm.ID == targetID { - return targetID - } - } - } - return vms[0].ID -} - -func capitalizePhase(value string) string { - if value == "" { - return "" - } - return strings.ToUpper(value[:1]) + value[1:] -} - -func formatTUIDuration(value time.Duration) string { - switch { - case value >= time.Second: - return value.Round(100 * time.Millisecond).String() - case value >= 100*time.Millisecond: - return value.Round(10 * time.Millisecond).String() - default: - return value.Round(time.Millisecond).String() - } -} - -func aggregateRunningVMResources(vms []model.VMRecord) (runningCount, totalVCPUs int, totalMemoryBytes int64) { - for _, vm := range vms { - if vm.State != model.VMStateRunning { - continue - } - runningCount++ - totalVCPUs += vm.Spec.VCPUCount - totalMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024 - } - return runningCount, totalVCPUs, totalMemoryBytes -} - -func aggregateVMDiskUsage(vms []model.VMRecord) int64 { - var total int64 - for _, vm := range vms { - total += system.AllocatedBytes(vm.Runtime.SystemOverlay) - total += system.AllocatedBytes(vm.Runtime.WorkDiskPath) - } - return total -} - -func renderUsageMeter(label string, used, total int64, usedText, totalText string) string { - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - valueStyle := lipgloss.NewStyle().Bold(true) - bar := renderProgressBar(used, total, 12) - return fmt.Sprintf(" %s %s %s", labelStyle.Render(label), bar, valueStyle.Render(usedText+"/"+totalText)) -} - -func renderProgressBar(used, total int64, width int) string { - if width <= 0 { - return "" - } - if total <= 0 { - unknownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - return unknownStyle.Render("[" + strings.Repeat("?", width) + "]") - } - ratio := float64(used) / float64(total) - if ratio < 0 { - ratio = 0 - } - if ratio > 1 { - ratio = 1 - } - filled := int(ratio * float64(width)) - if used > 0 && filled == 0 { - filled = 1 - } - if filled > width { - filled = width - } - empty := width - filled - - barColor := lipgloss.Color("70") - switch { - case ratio >= 0.9: - barColor = lipgloss.Color("160") - case ratio >= 0.75: - barColor = lipgloss.Color("214") - } - - filledStyle := lipgloss.NewStyle().Foreground(barColor) - emptyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - return "[" + filledStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", empty)) + "]" -} - -func totalLabel(total int) string { - if total <= 0 { - return "-" - } - return strconv.Itoa(total) -} - -func bytesTotalLabel(total int64) string { - if total <= 0 { - return "-" - } - return formatBytes(total) -} - -func readTUIFilesystemUsage(layout paths.Layout) (system.FilesystemUsage, error) { - target := strings.TrimSpace(layout.StateDir) - if target == "" { - resolved, err := paths.Resolve() - if err != nil { - return system.FilesystemUsage{}, err - } - target = resolved.StateDir - } - return system.ReadFilesystemUsage(target) -} - -func imageNames(images []model.Image) []string { - names := make([]string, 0, len(images)) - for _, image := range images { - names = append(names, image.Name) - } - return names -} - -func tuiTableKeyMap() table.KeyMap { - return table.KeyMap{ - LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up", "up")), - LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down", "down")), - PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), - PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), - HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "half up")), - HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "half down")), - GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")), - GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")), - } -} - -func (a actionRequest) needsSudo() bool { - switch a.kind { - case actionCreate, actionEdit, actionStart, actionStop, actionRestart, actionDelete: - return true - default: - return false - } -} - -func (a actionRequest) activity() string { - switch a.kind { - case actionCreate: - return "Creating VM..." - case actionEdit: - return "Saving VM..." - case actionStart: - return "Starting VM..." - case actionStop: - return "Stopping VM..." - case actionRestart: - return "Restarting VM..." - case actionDelete: - return "Deleting VM..." - case actionSSH: - return "Opening SSH..." - case actionLogs: - return "Opening logs..." - default: - return "Working..." - } -} - -func parsePositiveInt(label, raw string) (int, error) { - value, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || value <= 0 { - return 0, fmt.Errorf("%s must be a positive integer", label) - } - return value, nil -} - -func normalizeExecError(err error) error { - if err == nil { - return nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 { - return nil - } - return err -} - -func looksLikeSudoExpiry(err error) bool { - if err == nil { - return false - } - text := err.Error() - return strings.Contains(text, "sudo") || strings.Contains(text, "password is required") -} - -func formatBytes(bytes int64) string { - if bytes <= 0 { - return "0" - } - const ( - kib = 1024 - mib = 1024 * kib - gib = 1024 * mib - ) - switch { - case bytes >= gib: - return fmt.Sprintf("%.1fG", float64(bytes)/gib) - case bytes >= mib: - return fmt.Sprintf("%.1fM", float64(bytes)/mib) - case bytes >= kib: - return fmt.Sprintf("%.1fK", float64(bytes)/kib) - default: - return strconv.FormatInt(bytes, 10) + "B" - } -} - -func boolToIndex(value bool) int { - if value { - return 1 - } - return 0 -} - -func yesNo(value bool) string { - if value { - return "yes" - } - return "no" -} - -func isYes(value string) bool { - return strings.EqualFold(strings.TrimSpace(value), "yes") -} - -func orDash(value string) string { - if strings.TrimSpace(value) == "" { - return "-" - } - return value -} - -func wrapIndex(value, length int) int { - if length <= 0 { - return 0 - } - for value < 0 { - value += length - } - return value % length -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go deleted file mode 100644 index acdb078..0000000 --- a/internal/cli/tui_test.go +++ /dev/null @@ -1,396 +0,0 @@ -package cli - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestCreateVMFormSubmit(t *testing.T) { - form := newCreateVMForm([]model.Image{{Name: "default"}}, model.DaemonConfig{DefaultImageName: "default"}) - form.fields[0].input.SetValue("devbox") - form.fields[2].input.SetValue("4") - form.fields[3].input.SetValue("2048") - form.fields[4].input.SetValue("12G") - form.fields[5].input.SetValue("24G") - form.fields[6].index = 1 - - action, err := form.submit() - if err != nil { - t.Fatalf("submit: %v", err) - } - if action.kind != actionCreate { - t.Fatalf("kind = %s, want %s", action.kind, actionCreate) - } - if action.create.Name != "devbox" || action.create.ImageName != "default" { - t.Fatalf("unexpected create params: %+v", action.create) - } - if action.create.VCPUCount == nil || *action.create.VCPUCount != 4 || action.create.MemoryMiB == nil || *action.create.MemoryMiB != 2048 { - t.Fatalf("unexpected cpu/memory: %+v", action.create) - } - if action.create.SystemOverlaySize != "12G" || action.create.WorkDiskSize != "24G" { - t.Fatalf("unexpected disk sizes: %+v", action.create) - } - if !action.create.NATEnabled { - t.Fatalf("expected NAT enabled: %+v", action.create) - } -} - -func TestEditVMFormSubmit(t *testing.T) { - form := newEditVMForm(model.VMRecord{ - ID: "vm-1", - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024, - NATEnabled: false, - }, - }) - form.fields[0].input.SetValue("6") - form.fields[1].input.SetValue("4096") - form.fields[2].input.SetValue("32G") - form.fields[3].index = 1 - - action, err := form.submit() - if err != nil { - t.Fatalf("submit: %v", err) - } - if action.kind != actionEdit { - t.Fatalf("kind = %s, want %s", action.kind, actionEdit) - } - if action.set.IDOrName != "vm-1" { - t.Fatalf("unexpected vm id: %+v", action.set) - } - if action.set.VCPUCount == nil || *action.set.VCPUCount != 6 { - t.Fatalf("unexpected vcpu: %+v", action.set) - } - if action.set.MemoryMiB == nil || *action.set.MemoryMiB != 4096 { - t.Fatalf("unexpected memory: %+v", action.set) - } - if action.set.WorkDiskSize != "32G" { - t.Fatalf("unexpected disk size: %+v", action.set) - } - if action.set.NATEnabled == nil || !*action.set.NATEnabled { - t.Fatalf("expected nat enabled: %+v", action.set) - } -} - -func TestResolveSelectedID(t *testing.T) { - vms := []model.VMRecord{{ID: "one"}, {ID: "two"}} - if got := resolveSelectedID("two", vms); got != "two" { - t.Fatalf("resolveSelectedID existing = %q, want %q", got, "two") - } - if got := resolveSelectedID("missing", vms); got != "one" { - t.Fatalf("resolveSelectedID fallback = %q, want %q", got, "one") - } - if got := resolveSelectedID("anything", nil); got != "" { - t.Fatalf("resolveSelectedID empty = %q, want empty", got) - } -} - -func TestNewTUICommandStartsProgramWithoutEnsuringDaemon(t *testing.T) { - origEnsure := tuiEnsureDaemonFunc - origRunner := tuiProgramRunner - origTerminal := tuiIsTerminal - t.Cleanup(func() { - tuiEnsureDaemonFunc = origEnsure - tuiProgramRunner = origRunner - tuiIsTerminal = origTerminal - }) - - ensureCalled := false - tuiEnsureDaemonFunc = func(ctx context.Context) (paths.Layout, model.DaemonConfig, error) { - ensureCalled = true - return paths.Layout{}, model.DaemonConfig{}, nil - } - tuiProgramRunner = func(model tuiModel) error { - if ensureCalled { - t.Fatal("ensureDaemon should not run before the TUI starts") - } - if !model.daemonPending || !model.loading { - t.Fatalf("startup model = %+v, want pending daemon startup", model) - } - return nil - } - tuiIsTerminal = func(fd uintptr) bool { return true } - - cmd := NewBangerCommand() - cmd.SetArgs([]string{"tui"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - if ensureCalled { - t.Fatal("ensureDaemon should not have been called") - } -} - -func TestTUIViewRendersLayoutImmediately(t *testing.T) { - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - view := m.View() - if strings.Contains(view, "Loading...") { - t.Fatalf("view = %q, want full layout instead of one-line loading", view) - } - if !strings.Contains(view, "Starting daemon") { - t.Fatalf("view = %q, want startup placeholder", view) - } -} - -func TestTUIVMLoadCanCompleteBeforeImages(t *testing.T) { - now := time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC) - initial := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - - updated, _ := initial.Update(daemonReadyMsg{ - generation: initial.loadGeneration, - layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"}, - cfg: model.DaemonConfig{DefaultImageName: "default"}, - duration: 2400 * time.Millisecond, - }) - m := updated.(tuiModel) - if !m.daemonReady || !m.vmListPending || !m.imagePending { - t.Fatalf("model after daemonReady = %+v, want pending vm/image loads", m) - } - - vm := model.VMRecord{ - ID: "vm-1", - Name: "devbox", - State: model.VMStateRunning, - CreatedAt: now, - UpdatedAt: now, - LastTouchedAt: now, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024, - }, - Runtime: model.VMRuntime{ - GuestIP: "172.16.0.2", - DNSName: "devbox.vm", - }, - } - updated, _ = m.Update(vmListLoadedMsg{ - generation: m.loadGeneration, - vms: []model.VMRecord{vm}, - duration: 20 * time.Millisecond, - }) - m = updated.(tuiModel) - if len(m.vms) != 1 || m.selectedID != vm.ID { - t.Fatalf("model after vmListLoaded = %+v, want selected vm", m) - } - if !m.imagePending { - t.Fatalf("image load should still be pending: %+v", m) - } - if strings.Contains(m.View(), "No VMs") { - t.Fatalf("view should render the loaded VM while images are pending: %q", m.View()) - } - if !strings.Contains(m.View(), "devbox") { - t.Fatalf("view = %q, want loaded VM name", m.View()) - } -} - -func TestTUICreateBlockedWhileImagesLoad(t *testing.T) { - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - m.daemonPending = false - m.daemonReady = true - m.imagePending = true - m.loading = true - - updated, _ := m.updateBrowse(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) - if updated.mode != tuiModeBrowse { - t.Fatalf("mode = %v, want browse", updated.mode) - } - if updated.statusText != "Images are still loading" { - t.Fatalf("status = %q, want image loading warning", updated.statusText) - } -} - -func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) { - initial := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - updated, _ := initial.Update(daemonReadyMsg{ - generation: initial.loadGeneration, - layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"}, - duration: 2400 * time.Millisecond, - }) - m := updated.(tuiModel) - updated, _ = m.Update(vmListLoadedMsg{ - generation: m.loadGeneration, - vms: []model.VMRecord{}, - duration: 20 * time.Millisecond, - }) - m = updated.(tuiModel) - updated, _ = m.Update(imageListLoadedMsg{ - generation: m.loadGeneration, - images: []model.Image{{Name: "default"}}, - duration: 15 * time.Millisecond, - }) - m = updated.(tuiModel) - if !strings.Contains(m.statusText, "daemon 2.4s") || !strings.Contains(m.statusText, "vm list 20ms") || !strings.Contains(m.statusText, "image list 15ms") { - t.Fatalf("statusText = %q, want stage timings", m.statusText) - } -} - -func TestSSHDoneMsgShowsReminderWhenHealthCheckPasses(t *testing.T) { - origHealth := vmHealthFunc - t.Cleanup(func() { - vmHealthFunc = origHealth - }) - vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { - return api.VMHealthResult{Name: "devbox", Healthy: true}, nil - } - - msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) - result, ok := msg.(actionResultMsg) - if !ok { - t.Fatalf("msg = %T, want actionResultMsg", msg) - } - if !strings.Contains(result.status, "devbox is still running") { - t.Fatalf("status = %q, want reminder", result.status) - } -} - -func TestSSHDoneMsgShowsWarningWhenHealthCheckFails(t *testing.T) { - origHealth := vmHealthFunc - t.Cleanup(func() { - vmHealthFunc = origHealth - }) - vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { - return api.VMHealthResult{}, errors.New("dial failed") - } - - msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) - result := msg.(actionResultMsg) - if !strings.Contains(result.status, "failed to check whether devbox is still running") { - t.Fatalf("status = %q, want warning", result.status) - } -} - -func TestAggregateRunningVMResources(t *testing.T) { - t.Parallel() - - running, vcpus, memoryBytes := aggregateRunningVMResources([]model.VMRecord{ - { - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - }, - }, - { - State: model.VMStateStopped, - Spec: model.VMSpec{ - VCPUCount: 8, - MemoryMiB: 8192, - }, - }, - { - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 4, - MemoryMiB: 2048, - }, - }, - }) - - if running != 2 || vcpus != 6 || memoryBytes != 3*1024*1024*1024 { - t.Fatalf("aggregateRunningVMResources = (%d, %d, %d), want (2, 6, %d)", running, vcpus, memoryBytes, int64(3*1024*1024*1024)) - } -} - -func TestTUIViewShowsResourceBar(t *testing.T) { - t.Parallel() - - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - m.hostCPUCount = 32 - m.hostMemoryBytes = 125 * 1024 * 1024 * 1024 - m.hostDiskBytes = 200 * 1024 * 1024 * 1024 - m.daemonPending = false - m.loading = false - stateDir := t.TempDir() - overlayPath := filepath.Join(stateDir, "system.cow") - workDiskPath := filepath.Join(stateDir, "root.ext4") - if err := os.WriteFile(overlayPath, make([]byte, 1024), 0o644); err != nil { - t.Fatalf("WriteFile overlay: %v", err) - } - if err := os.WriteFile(workDiskPath, make([]byte, 2048), 0o644); err != nil { - t.Fatalf("WriteFile work disk: %v", err) - } - m.vms = []model.VMRecord{ - { - ID: "vm-1", - Name: "devbox", - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024, - }, - Runtime: model.VMRuntime{ - SystemOverlay: overlayPath, - WorkDiskPath: workDiskPath, - }, - }, - { - ID: "vm-2", - Name: "db", - State: model.VMStateStopped, - Spec: model.VMSpec{ - VCPUCount: 4, - MemoryMiB: 4096, - WorkDiskSizeBytes: 32 * 1024 * 1024 * 1024, - }, - }, - } - m.selectedID = "vm-1" - m.rebuildTable() - m.refreshDetail() - - view := m.View() - if !strings.Contains(view, "VMs") || !strings.Contains(view, "1/2") { - t.Fatalf("view = %q, want running VM count", view) - } - if !strings.Contains(view, "CPU") || !strings.Contains(view, "2/32") { - t.Fatalf("view = %q, want vcpu aggregate", view) - } - if !strings.Contains(view, "RAM") || !strings.Contains(view, "1.0G/125.0G") { - t.Fatalf("view = %q, want memory aggregate", view) - } - if !strings.Contains(view, "Disk") { - t.Fatalf("view = %q, want disk aggregate", view) - } - if !strings.Contains(view, "█") || !strings.Contains(view, "░") { - t.Fatalf("view = %q, want visual progress bars", view) - } -} - -func TestAggregateVMDiskUsage(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - overlayPath := filepath.Join(dir, "system.cow") - workDiskPath := filepath.Join(dir, "root.ext4") - if err := os.WriteFile(overlayPath, make([]byte, 4096), 0o644); err != nil { - t.Fatalf("WriteFile overlay: %v", err) - } - if err := os.WriteFile(workDiskPath, make([]byte, 8192), 0o644); err != nil { - t.Fatalf("WriteFile work disk: %v", err) - } - - total := aggregateVMDiskUsage([]model.VMRecord{{ - Runtime: model.VMRuntime{ - SystemOverlay: overlayPath, - WorkDiskPath: workDiskPath, - }, - }}) - if total <= 0 { - t.Fatalf("aggregateVMDiskUsage = %d, want positive allocated bytes", total) - } -}