Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ has a matching GitHub release whose body comes from its section below.

### Fixed

- Windows: the host's CPU is read (GetSystemTimes), so the harbor's machine meter and `berth status` show real CPU
use instead of 0%. Windows has no load averages: the node now leaves them out of its JSON and the harbor and
`berth status` say n/a instead of three zeros. Reported by @azrael-229 (#139).
- One empty `models--*` husk no longer makes a whole store read as an HF cache with no models,
reported by @azrael-229 (#138). A root is an HF cache only when a `models--*` directory holds
`snapshots`, so an abandoned download beside plain models keeps them listed.
Expand Down
8 changes: 6 additions & 2 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,14 @@ func hostLine(c *Context, h *proto.Host) {
c.Printf("%s no host telemetry on this platform\n", c.Style.Dim("host"))
return
}
load := "load n/a"
if h.Load != nil {
load = fmt.Sprintf("load %.2f %.2f %.2f", h.Load[0], h.Load[1], h.Load[2])
}
c.Printf("%s %s\n", c.Style.Dim("host"),
fmt.Sprintf("CPU %.0f%% of %d cores, RAM %s of %s, swap %s of %s, load %.2f %.2f %.2f",
fmt.Sprintf("CPU %.0f%% of %d cores, RAM %s of %s, swap %s of %s, %s",
h.CPU, h.Cores, server.MiB(h.RAMUsed), server.MiB(h.RAMTotal),
server.MiB(h.SwapUsed), server.MiB(h.SwapTotal), h.Load[0], h.Load[1], h.Load[2]))
server.MiB(h.SwapUsed), server.MiB(h.SwapTotal), load))
}

// alertsLine is the alert state the node holds: the node is the source of
Expand Down
3 changes: 2 additions & 1 deletion internal/harbor/web/harbor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2007,7 +2007,8 @@ function renderMachine(s) {
setMeter(machineRows.cpu, h.cpu_pct, 100, fmtPct(h.cpu_pct, 1));
setMeter(machineRows.ram, h.ram_used, h.ram_total, fmtGiB(h.ram_used) + " / " + fmtGiB(h.ram_total) + " GiB");
if (hasSwap) setMeter(machineRows.swap, h.swap_used, h.swap_total, fmtGiB(h.swap_used) + " / " + fmtGiB(h.swap_total) + " GiB");
setText(machineRows.load.val, (h.load || []).map((v) => v.toFixed(2)).join(" "));
// Windows reads no load averages: the node leaves them out, and the row says so.
setText(machineRows.load.val, na(h.load, (l) => l.map((v) => v.toFixed(2)).join(" ")));
}
// The CPU/RAM lines are history-driven and repaint on the heavy gate from
// renderHeavy, so a status poll never rebuilds them; a poll that reads the
Expand Down
42 changes: 42 additions & 0 deletions internal/host/cpu_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 Trevin Tindall (T-Crypt)
// SPDX-License-Identifier: Apache-2.0

package host

import (
"encoding/json"
"strings"
"testing"
)

func TestCPUPct(t *testing.T) {
for _, c := range []struct {
name string
prevIdle, prevTot, idle, tot uint64
want float64
}{
{"first read has no interval", 0, 0, 800, 1000, 0},
{"half busy", 800, 1000, 900, 1200, 50},
{"all idle", 800, 1000, 1000, 1200, 0},
{"all busy", 800, 1000, 800, 1200, 100},
{"counters went back", 800, 1000, 100, 200, 0},
{"no time passed", 800, 1000, 800, 1000, 0},
} {
if got := cpuPct(c.prevIdle, c.prevTot, c.idle, c.tot); got != c.want {
t.Errorf("%s: cpuPct = %v, want %v", c.name, got, c.want)
}
}
}

// A platform that reads no load averages says so by leaving them out, so a
// page shows n/a instead of three measured zeros.
func TestLoadUnmeasuredIsAbsent(t *testing.T) {
b, _ := json.Marshal(Stats{Supported: true})
if strings.Contains(string(b), `"load"`) {
t.Fatalf("unmeasured load is in the JSON: %s", b)
}
b, _ = json.Marshal(Stats{Supported: true, Load: &[3]float64{1.5, 0.75, 0.25}})
if !strings.Contains(string(b), `"load":[1.5,0.75,0.25]`) {
t.Fatalf("measured load = %s", b)
}
}
28 changes: 20 additions & 8 deletions internal/host/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@ package host
import "sync"

type Stats struct {
Supported bool `json:"supported"`
CPU float64 `json:"cpu_pct"` // all cores, since the previous Read
Cores int `json:"cores"`
RAMTotal uint64 `json:"ram_total"`
RAMUsed uint64 `json:"ram_used"` // total minus available
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Load [3]float64 `json:"load"`
Supported bool `json:"supported"`
CPU float64 `json:"cpu_pct"` // all cores, since the previous Read
Cores int `json:"cores"`
RAMTotal uint64 `json:"ram_total"`
RAMUsed uint64 `json:"ram_used"` // total minus available
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
// Load is the 1, 5 and 15 minute load averages; nil where the platform
// has none (Windows), so the JSON leaves it out instead of saying 0.
Load *[3]float64 `json:"load,omitempty"`
}

// cpuPct is the share of time not idle between two readings of cumulative
// counters. The first reading, or counters that did not move forward,
// give 0: there is no interval to measure.
func cpuPct(prevIdle, prevTot, idle, tot uint64) float64 {
if prevTot == 0 || tot <= prevTot || idle < prevIdle {
return 0
}
return 100 * (1 - float64(idle-prevIdle)/float64(tot-prevTot))
}

// Reader keeps the previous CPU counters so each Read returns usage over
Expand Down
8 changes: 4 additions & 4 deletions internal/host/host_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,7 @@ func (r *Reader) Read() Stats {
idle += vals[4] // iowait
}
r.mu.Lock()
if r.tot > 0 && tot > r.tot {
s.CPU = 100 * (1 - float64(idle-r.idle)/float64(tot-r.tot))
}
s.CPU = cpuPct(r.idle, r.tot, idle, tot)
r.idle, r.tot = idle, tot
r.mu.Unlock()
} else if strings.HasPrefix(line, "cpu") {
Expand All @@ -123,9 +121,11 @@ func (r *Reader) Read() Stats {
}
if b, err := os.ReadFile(r.root() + "/loadavg"); err == nil {
f := strings.Fields(string(b))
var load [3]float64
for i := 0; i < 3 && i < len(f); i++ {
s.Load[i], _ = strconv.ParseFloat(f[i], 64)
load[i], _ = strconv.ParseFloat(f[i], 64)
}
s.Load = &load
}
return s
}
2 changes: 1 addition & 1 deletion internal/host/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestRead(t *testing.T) {
w("loadavg", "1.50 0.75 0.25 2/300 99\n")
r := &Reader{ProcRoot: dir}
s := r.Read()
if !s.Supported || s.Cores != 2 || s.RAMUsed != 750*1024 || s.SwapUsed != 60*1024 || s.Load[0] != 1.5 || s.CPU != 0 {
if !s.Supported || s.Cores != 2 || s.RAMUsed != 750*1024 || s.SwapUsed != 60*1024 || s.Load == nil || s.Load[0] != 1.5 || s.CPU != 0 {
t.Fatalf("first read = %+v", s)
}
w("stat", "cpu 150 0 150 900 0 0 0 0 0 0\ncpu0 1\ncpu1 1\n") // +100 busy, +100 idle
Expand Down
30 changes: 25 additions & 5 deletions internal/host/host_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import (
"golang.org/x/sys/windows"
)

// GlobalMemoryStatusEx is not in golang.org/x/sys, so it is loaded by
// name.
// GlobalMemoryStatusEx and GetSystemTimes are not in golang.org/x/sys,
// so they are loaded by name.
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
)

// memoryStatusEx is MEMORYSTATUSEX (sysinfoapi.h), what
Expand Down Expand Up @@ -68,15 +69,34 @@ func MemPressure(root string) (float64, bool) {
return 0, false
}

// Read reports RAM and the page file from GlobalMemoryStatusEx and the
// core count from the runtime. CPU and the load averages have no Windows
// reading yet, so they stay 0; Cores is always the real one.
// systemTimes is GetSystemTimes as cumulative idle and total time, in
// 100 ns ticks. Kernel time already includes idle time, so the total is
// kernel plus user.
func systemTimes() (idle, tot uint64, ok bool) {
var i, k, u windows.Filetime
r, _, _ := procGetSystemTimes.Call(uintptr(unsafe.Pointer(&i)), uintptr(unsafe.Pointer(&k)), uintptr(unsafe.Pointer(&u)))
if r == 0 {
return 0, 0, false
}
ticks := func(f windows.Filetime) uint64 { return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) }
return ticks(i), ticks(k) + ticks(u), true
}

// Read reports RAM and the page file from GlobalMemoryStatusEx, CPU from
// GetSystemTimes over the interval since the last Read, and the core count
// from the runtime. Windows has no load averages, so Load stays nil.
func (r *Reader) Read() Stats {
m, ok := globalMemoryStatus()
if !ok {
return Stats{}
}
s := Stats{Supported: true, Cores: runtime.NumCPU()}
if idle, tot, ok := systemTimes(); ok {
r.mu.Lock()
s.CPU = cpuPct(r.idle, r.tot, idle, tot)
r.idle, r.tot = idle, tot
r.mu.Unlock()
}
s.RAMTotal = m.TotalPhys
if m.AvailPhys <= m.TotalPhys {
s.RAMUsed = m.TotalPhys - m.AvailPhys
Expand Down
15 changes: 14 additions & 1 deletion internal/host/host_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

package host

import "testing"
import (
"testing"
"time"
)

// A real Windows machine must read its own memory: the totals are the
// installed RAM, the available figure sits inside it, and the page file
Expand Down Expand Up @@ -34,4 +37,14 @@ func TestStatsWindows(t *testing.T) {
if s.Cores <= 0 {
t.Fatalf("Cores = %d, want the real count", s.Cores)
}
if s.Load != nil {
t.Fatalf("Load = %v, want nil: Windows has no load averages", *s.Load)
}
r := &Reader{}
r.Read()
for end := time.Now().Add(200 * time.Millisecond); time.Now().Before(end); {
}
if s = r.Read(); s.CPU <= 0 || s.CPU > 100 {
t.Fatalf("CPU = %v after a busy interval, want a reading in (0, 100]", s.CPU)
}
}
17 changes: 9 additions & 8 deletions internal/proto/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,15 @@ type GPU struct {

// Host is the machine the node runs on.
type Host struct {
Supported bool `json:"supported"`
CPU float64 `json:"cpu_pct"`
Cores int `json:"cores"`
RAMTotal uint64 `json:"ram_total"`
RAMUsed uint64 `json:"ram_used"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Load [3]float64 `json:"load"`
Supported bool `json:"supported"`
CPU float64 `json:"cpu_pct"`
Cores int `json:"cores"`
RAMTotal uint64 `json:"ram_total"`
RAMUsed uint64 `json:"ram_used"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
// Load is absent where the platform has no load averages (Windows).
Load *[3]float64 `json:"load,omitempty"`
}

// Sample is one point of the node's in-memory history (the graphs).
Expand Down
Loading