diff --git a/internal/cli/tui.go b/internal/cli/tui.go index d66e024..f67356f 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -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 { diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 1008b8c..6ae077e 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -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) + } +} diff --git a/internal/system/system.go b/internal/system/system.go index fe4d52d..ff63516 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -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 diff --git a/internal/system/system_test.go b/internal/system/system_test.go index 0fbab5b..c2b43a8 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -282,6 +282,41 @@ func TestParseProcHelpers(t *testing.T) { } } +func TestParseMemTotal(t *testing.T) { + t.Parallel() + + total, err := parseMemTotal("MemTotal: 131900020 kB\nMemFree: 1024 kB\n") + if err != nil { + t.Fatalf("parseMemTotal: %v", err) + } + if total != 131900020*1024 { + t.Fatalf("total = %d, want %d", total, int64(131900020*1024)) + } +} + +func TestParseMemTotalErrorsWhenMissing(t *testing.T) { + t.Parallel() + + if _, err := parseMemTotal("MemFree: 123 kB\n"); err == nil { + t.Fatal("parseMemTotal() error = nil, want missing MemTotal") + } +} + +func TestReadFilesystemUsage(t *testing.T) { + t.Parallel() + + usage, err := ReadFilesystemUsage(t.TempDir()) + if err != nil { + t.Fatalf("ReadFilesystemUsage: %v", err) + } + if usage.TotalBytes <= 0 { + t.Fatalf("usage.TotalBytes = %d, want positive", usage.TotalBytes) + } + if usage.FreeBytes < 0 { + t.Fatalf("usage.FreeBytes = %d, want non-negative", usage.FreeBytes) + } +} + func TestMountTempDirRemovesTempDirWhenMountFails(t *testing.T) { t.Parallel()