Add visual VM resource bars to the TUI

The TUI should show VM capacity pressure at a glance instead of making users read raw numbers or drill into per-VM details.

Add a compact colored status row under the header that renders CPU, RAM, and disk usage as progress bars. CPU and RAM reflect reserved resources for running VMs, while disk reflects actual allocated overlay and work-disk bytes across all VMs against the filesystem backing banger state.

Add host resource and filesystem helpers in the system package and cover the new aggregation and rendering behavior with TUI and system tests. Verified with GOCACHE=/tmp/banger-gocache go test ./... and GOCACHE=/tmp/banger-gocache make build.
This commit is contained in:
Thales Maciel 2026-03-18 18:05:09 -03:00
parent 38d7eac430
commit 9e98445fa2
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 387 additions and 21 deletions

View file

@ -507,12 +507,27 @@ type tuiModel struct {
formKeys formKeyMap
confirmKeys confirmKeyMap
lastRefresh time.Time
statusText string
statusErr bool
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},
@ -544,22 +559,25 @@ func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
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...",
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
@ -612,6 +630,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
@ -907,11 +928,12 @@ func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
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, body, status, helpView)
return lipgloss.JoinVertical(lipgloss.Left, header, resourceBar, body, status, helpView)
}
func (m tuiModel) currentKeyMap() help.KeyMap {
@ -947,7 +969,7 @@ func (m *tuiModel) resize() {
}
func (m tuiModel) bodyHeight() int {
return maxInt(8, m.height-4)
return maxInt(8, m.height-5)
}
func (m tuiModel) renderHeader() string {
@ -969,6 +991,24 @@ func (m tuiModel) renderHeader() string {
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 {
@ -1504,6 +1544,97 @@ func formatTUIDuration(value time.Duration) 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 {

View file

@ -2,6 +2,8 @@ package cli
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -233,3 +235,125 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
t.Fatalf("statusText = %q, want stage timings", m.statusText)
}
}
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)
}
}