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 }