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

@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
@ -105,6 +106,16 @@ type ProcessStats struct {
VSZBytes int64
}
type HostResources struct {
CPUCount int
TotalMemoryBytes int64
}
type FilesystemUsage struct {
TotalBytes int64
FreeBytes int64
}
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
_ = ctx
if pid <= 0 {
@ -151,6 +162,35 @@ func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
}, nil
}
func ReadHostResources() (HostResources, error) {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return HostResources{}, err
}
totalMemoryBytes, err := parseMemTotal(string(data))
if err != nil {
return HostResources{}, err
}
return HostResources{
CPUCount: runtime.NumCPU(),
TotalMemoryBytes: totalMemoryBytes,
}, nil
}
func ReadFilesystemUsage(path string) (FilesystemUsage, error) {
if strings.TrimSpace(path) == "" {
return FilesystemUsage{}, errors.New("filesystem path is required")
}
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return FilesystemUsage{}, err
}
return FilesystemUsage{
TotalBytes: int64(stat.Blocks) * int64(stat.Bsize),
FreeBytes: int64(stat.Bavail) * int64(stat.Bsize),
}, nil
}
func TailCommand(path string, follow bool) *exec.Cmd {
if follow {
return exec.Command("tail", "-f", path)
@ -178,6 +218,42 @@ func ParseMetricsFile(path string) map[string]any {
return result
}
func parseMemTotal(data string) (int64, error) {
scanner := bufio.NewScanner(strings.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
return 0, errors.New("meminfo MemTotal is malformed")
}
value, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse meminfo MemTotal: %w", err)
}
unit := "kB"
if len(fields) >= 3 {
unit = fields[2]
}
switch unit {
case "kB":
return value * 1024, nil
case "mB", "MB":
return value * 1024 * 1024, nil
case "gB", "GB":
return value * 1024 * 1024 * 1024, nil
default:
return 0, fmt.Errorf("unsupported meminfo unit %q", unit)
}
}
if err := scanner.Err(); err != nil {
return 0, err
}
return 0, errors.New("meminfo MemTotal not found")
}
func lastJSONLine(data []byte) []byte {
scanner := bufio.NewScanner(bytes.NewReader(data))
var last []byte