coder/cli/clistat/stat.go

237 lines
5.4 KiB
Go

package clistat
import (
"math"
"runtime"
"strconv"
"strings"
"time"
"github.com/elastic/go-sysinfo"
"github.com/spf13/afero"
"golang.org/x/xerrors"
"tailscale.com/types/ptr"
sysinfotypes "github.com/elastic/go-sysinfo/types"
)
// Prefix is a scale multiplier for a result.
// Used when creating a human-readable representation.
type Prefix float64
const (
PrefixDefault = 1.0
PrefixKibi = 1024.0
PrefixMebi = PrefixKibi * 1024.0
PrefixGibi = PrefixMebi * 1024.0
PrefixTebi = PrefixGibi * 1024.0
)
var (
PrefixHumanKibi = "Ki"
PrefixHumanMebi = "Mi"
PrefixHumanGibi = "Gi"
PrefixHumanTebi = "Ti"
)
func (s *Prefix) String() string {
switch *s {
case PrefixKibi:
return "Ki"
case PrefixMebi:
return "Mi"
case PrefixGibi:
return "Gi"
case PrefixTebi:
return "Ti"
default:
return ""
}
}
func ParsePrefix(s string) Prefix {
switch s {
case PrefixHumanKibi:
return PrefixKibi
case PrefixHumanMebi:
return PrefixMebi
case PrefixHumanGibi:
return PrefixGibi
case PrefixHumanTebi:
return PrefixTebi
default:
return PrefixDefault
}
}
// Result is a generic result type for a statistic.
// Total is the total amount of the resource available.
// It is nil if the resource is not a finite quantity.
// Unit is the unit of the resource.
// Used is the amount of the resource used.
type Result struct {
Total *float64 `json:"total"`
Unit string `json:"unit"`
Used float64 `json:"used"`
Prefix Prefix `json:"-"`
}
// String returns a human-readable representation of the result.
func (r *Result) String() string {
if r == nil {
return "-"
}
scale := 1.0
if r.Prefix != 0.0 {
scale = float64(r.Prefix)
}
var sb strings.Builder
var usedScaled, totalScaled float64
usedScaled = r.Used / scale
_, _ = sb.WriteString(humanizeFloat(usedScaled))
if r.Total != (*float64)(nil) {
_, _ = sb.WriteString("/")
totalScaled = *r.Total / scale
_, _ = sb.WriteString(humanizeFloat(totalScaled))
}
_, _ = sb.WriteString(" ")
_, _ = sb.WriteString(r.Prefix.String())
_, _ = sb.WriteString(r.Unit)
if r.Total != (*float64)(nil) && *r.Total > 0 {
_, _ = sb.WriteString(" (")
pct := r.Used / *r.Total * 100.0
_, _ = sb.WriteString(strconv.FormatFloat(pct, 'f', 0, 64))
_, _ = sb.WriteString("%)")
}
return strings.TrimSpace(sb.String())
}
func humanizeFloat(f float64) string {
// humanize.FtoaWithDigits does not round correctly.
prec := precision(f)
rat := math.Pow(10, float64(prec))
rounded := math.Round(f*rat) / rat
return strconv.FormatFloat(rounded, 'f', -1, 64)
}
// limit precision to 3 digits at most to preserve space
func precision(f float64) int {
fabs := math.Abs(f)
if fabs == 0.0 {
return 0
}
if fabs < 1.0 {
return 3
}
if fabs < 10.0 {
return 2
}
if fabs < 100.0 {
return 1
}
return 0
}
// Statter is a system statistics collector.
// It is a thin wrapper around the elastic/go-sysinfo library.
type Statter struct {
hi sysinfotypes.Host
fs afero.Fs
sampleInterval time.Duration
nproc int
wait func(time.Duration)
}
type Option func(*Statter)
// WithSampleInterval sets the sample interval for the statter.
func WithSampleInterval(d time.Duration) Option {
return func(s *Statter) {
s.sampleInterval = d
}
}
// WithFS sets the fs for the statter.
func WithFS(fs afero.Fs) Option {
return func(s *Statter) {
s.fs = fs
}
}
func New(opts ...Option) (*Statter, error) {
hi, err := sysinfo.Host()
if err != nil {
return nil, xerrors.Errorf("get host info: %w", err)
}
s := &Statter{
hi: hi,
fs: afero.NewReadOnlyFs(afero.NewOsFs()),
sampleInterval: 100 * time.Millisecond,
nproc: runtime.NumCPU(),
wait: func(d time.Duration) {
<-time.After(d)
},
}
for _, opt := range opts {
opt(s)
}
return s, nil
}
// HostCPU returns the CPU usage of the host. This is calculated by
// taking two samples of CPU usage and calculating the difference.
// Total will always be equal to the number of cores.
// Used will be an estimate of the number of cores used during the sample interval.
// This is calculated by taking the difference between the total and idle HostCPU time
// and scaling it by the number of cores.
// Units are in "cores".
func (s *Statter) HostCPU() (*Result, error) {
r := &Result{
Unit: "cores",
Total: ptr.To(float64(s.nproc)),
Prefix: PrefixDefault,
}
c1, err := s.hi.CPUTime()
if err != nil {
return nil, xerrors.Errorf("get first cpu sample: %w", err)
}
s.wait(s.sampleInterval)
c2, err := s.hi.CPUTime()
if err != nil {
return nil, xerrors.Errorf("get second cpu sample: %w", err)
}
total := c2.Total() - c1.Total()
if total == 0 {
return r, nil // no change
}
idle := c2.Idle - c1.Idle
used := total - idle
scaleFactor := float64(s.nproc) / total.Seconds()
r.Used = used.Seconds() * scaleFactor
return r, nil
}
// HostMemory returns the memory usage of the host, in gigabytes.
func (s *Statter) HostMemory(p Prefix) (*Result, error) {
r := &Result{
Unit: "B",
Prefix: p,
}
hm, err := s.hi.Memory()
if err != nil {
return nil, xerrors.Errorf("get memory info: %w", err)
}
r.Total = ptr.To(float64(hm.Total))
// On Linux, hm.Used equates to MemTotal - MemFree in /proc/stat.
// This includes buffers and cache.
// So use MemAvailable instead, which only equates to physical memory.
// On Windows, this is also calculated as Total - Available.
r.Used = float64(hm.Total - hm.Available)
return r, nil
}