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:
parent
38d7eac430
commit
9e98445fa2
4 changed files with 387 additions and 21 deletions
|
|
@ -507,12 +507,27 @@ type tuiModel struct {
|
||||||
formKeys formKeyMap
|
formKeys formKeyMap
|
||||||
confirmKeys confirmKeyMap
|
confirmKeys confirmKeyMap
|
||||||
|
|
||||||
lastRefresh time.Time
|
lastRefresh time.Time
|
||||||
statusText string
|
statusText string
|
||||||
statusErr bool
|
statusErr bool
|
||||||
|
hostCPUCount int
|
||||||
|
hostMemoryBytes int64
|
||||||
|
hostDiskBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
|
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(
|
vmTable := table.New(
|
||||||
table.WithColumns([]table.Column{
|
table.WithColumns([]table.Column{
|
||||||
{Title: "NAME", Width: 18},
|
{Title: "NAME", Width: 18},
|
||||||
|
|
@ -544,22 +559,25 @@ func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
|
||||||
helpView := help.New()
|
helpView := help.New()
|
||||||
|
|
||||||
model := tuiModel{
|
model := tuiModel{
|
||||||
layout: layout,
|
layout: layout,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 32,
|
height: 32,
|
||||||
ready: true,
|
ready: true,
|
||||||
table: vmTable,
|
table: vmTable,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
help: helpView,
|
help: helpView,
|
||||||
spinner: spin,
|
spinner: spin,
|
||||||
browseKeys: newBrowseKeyMap(),
|
browseKeys: newBrowseKeyMap(),
|
||||||
formKeys: newFormKeyMap(),
|
formKeys: newFormKeyMap(),
|
||||||
confirmKeys: newConfirmKeyMap(),
|
confirmKeys: newConfirmKeyMap(),
|
||||||
loadGeneration: 1,
|
loadGeneration: 1,
|
||||||
loading: true,
|
loading: true,
|
||||||
daemonPending: true,
|
daemonPending: true,
|
||||||
statusText: "Starting daemon...",
|
statusText: "Starting daemon...",
|
||||||
|
hostCPUCount: hostCPUCount,
|
||||||
|
hostMemoryBytes: hostMemoryBytes,
|
||||||
|
hostDiskBytes: hostDiskBytes,
|
||||||
}
|
}
|
||||||
model.resize()
|
model.resize()
|
||||||
return model
|
return model
|
||||||
|
|
@ -612,6 +630,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.cfg = msg.cfg
|
m.cfg = msg.cfg
|
||||||
m.daemonReady = true
|
m.daemonReady = true
|
||||||
m.daemonLoadDur = msg.duration
|
m.daemonLoadDur = msg.duration
|
||||||
|
if diskUsage, err := readTUIFilesystemUsage(m.layout); err == nil {
|
||||||
|
m.hostDiskBytes = diskUsage.TotalBytes
|
||||||
|
}
|
||||||
m.beginListLoad("")
|
m.beginListLoad("")
|
||||||
cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration))
|
cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration))
|
||||||
case vmListLoadedMsg:
|
case vmListLoadedMsg:
|
||||||
|
|
@ -907,11 +928,12 @@ func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
|
||||||
|
|
||||||
func (m tuiModel) View() string {
|
func (m tuiModel) View() string {
|
||||||
header := m.renderHeader()
|
header := m.renderHeader()
|
||||||
|
resourceBar := m.renderResourceBar()
|
||||||
body := m.renderBody()
|
body := m.renderBody()
|
||||||
status := m.renderStatus()
|
status := m.renderStatus()
|
||||||
m.help.Width = m.width
|
m.help.Width = m.width
|
||||||
helpView := m.help.View(m.currentKeyMap())
|
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 {
|
func (m tuiModel) currentKeyMap() help.KeyMap {
|
||||||
|
|
@ -947,7 +969,7 @@ func (m *tuiModel) resize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m tuiModel) bodyHeight() int {
|
func (m tuiModel) bodyHeight() int {
|
||||||
return maxInt(8, m.height-4)
|
return maxInt(8, m.height-5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m tuiModel) renderHeader() string {
|
func (m tuiModel) renderHeader() string {
|
||||||
|
|
@ -969,6 +991,24 @@ func (m tuiModel) renderHeader() string {
|
||||||
return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header)
|
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 {
|
func (m tuiModel) renderBody() string {
|
||||||
bodyHeight := m.bodyHeight()
|
bodyHeight := m.bodyHeight()
|
||||||
if m.mode == tuiModeForm && m.form != nil {
|
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 {
|
func imageNames(images []model.Image) []string {
|
||||||
names := make([]string, 0, len(images))
|
names := make([]string, 0, len(images))
|
||||||
for _, image := range images {
|
for _, image := range images {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -233,3 +235,125 @@ func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
|
||||||
t.Fatalf("statusText = %q, want stage timings", m.statusText)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -105,6 +106,16 @@ type ProcessStats struct {
|
||||||
VSZBytes int64
|
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) {
|
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
if pid <= 0 {
|
if pid <= 0 {
|
||||||
|
|
@ -151,6 +162,35 @@ func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
|
||||||
}, nil
|
}, 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 {
|
func TailCommand(path string, follow bool) *exec.Cmd {
|
||||||
if follow {
|
if follow {
|
||||||
return exec.Command("tail", "-f", path)
|
return exec.Command("tail", "-f", path)
|
||||||
|
|
@ -178,6 +218,42 @@ func ParseMetricsFile(path string) map[string]any {
|
||||||
return result
|
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 {
|
func lastJSONLine(data []byte) []byte {
|
||||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
var last []byte
|
var last []byte
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestMountTempDirRemovesTempDirWhenMountFails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue