Skip to content

Commit 4c081dc

Browse files
committed
move clistats to its own package
1 parent 251fdda commit 4c081dc

File tree

7 files changed

+294
-132
lines changed

7 files changed

+294
-132
lines changed

cli/clistat/disk.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build !windows
2+
3+
package clistat
4+
5+
import (
6+
"syscall"
7+
8+
"tailscale.com/types/ptr"
9+
)
10+
11+
// Disk returns the disk usage of the given path.
12+
// If path is empty, it returns the usage of the root directory.
13+
func (s *Statter) Disk(path string) (*Result, error) {
14+
if path == "" {
15+
path = "/"
16+
}
17+
var stat syscall.Statfs_t
18+
if err := syscall.Statfs(path, &stat); err != nil {
19+
return nil, err
20+
}
21+
var r Result
22+
r.Total = ptr.To(float64(stat.Blocks*uint64(stat.Bsize)) / 1024 / 1024 / 1024)
23+
r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) / 1024 / 1024 / 1024
24+
r.Unit = "GB"
25+
return &r, nil
26+
}

cli/clistat/disk_windows.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package clistat
2+
3+
import (
4+
"golang.org/x/sys/windows"
5+
"tailscale.com/types/ptr"
6+
)
7+
8+
// Disk returns the disk usage of the given path.
9+
// If path is empty, it defaults to C:\
10+
func (s *Statter) Disk(path string) (*Result, error) {
11+
if path == "" {
12+
path = `C:\`
13+
}
14+
15+
pathPtr, err := windows.UTF16PtrFromString(path)
16+
if err != nil {
17+
return nil, err
18+
}
19+
20+
var freeBytes, totalBytes, availBytes uint64
21+
if err := windows.GetDiskFreeSpaceEx(
22+
pathPtr,
23+
&freeBytes,
24+
&totalBytes,
25+
&availBytes,
26+
); err != nil {
27+
return nil, err
28+
}
29+
30+
var r Result
31+
r.Total = ptr.To(float64(totalBytes) / 1024 / 1024 / 1024)
32+
r.Used = ptr.To(float64(totalBytes-freeBytes) / 1024 / 1024 / 1024)
33+
r.Unit = "GB"
34+
return &r, nil
35+
}

cli/clistat/stat.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package clistat
2+
3+
import (
4+
"github.com/elastic/go-sysinfo"
5+
"golang.org/x/xerrors"
6+
"runtime"
7+
"strconv"
8+
"strings"
9+
"tailscale.com/types/ptr"
10+
"time"
11+
12+
sysinfotypes "github.com/elastic/go-sysinfo/types"
13+
)
14+
15+
// Result is a generic result type for a statistic.
16+
// Total is the total amount of the resource available.
17+
// It is nil if the resource is not a finite quantity.
18+
// Unit is the unit of the resource.
19+
// Used is the amount of the resource used.
20+
type Result struct {
21+
Total *float64 `json:"total"`
22+
Unit string `json:"unit"`
23+
Used float64 `json:"used"`
24+
}
25+
26+
// String returns a human-readable representation of the result.
27+
func (r *Result) String() string {
28+
if r == nil {
29+
return "-"
30+
}
31+
var sb strings.Builder
32+
_, _ = sb.WriteString(strconv.FormatFloat(r.Used, 'f', 1, 64))
33+
if r.Total != (*float64)(nil) {
34+
_, _ = sb.WriteString("/")
35+
_, _ = sb.WriteString(strconv.FormatFloat(*r.Total, 'f', 1, 64))
36+
}
37+
if r.Unit != "" {
38+
_, _ = sb.WriteString(" ")
39+
_, _ = sb.WriteString(r.Unit)
40+
}
41+
return sb.String()
42+
}
43+
44+
// Statter is a system statistics collector.
45+
// It is a thin wrapper around the elastic/go-sysinfo library.
46+
type Statter struct {
47+
hi sysinfotypes.Host
48+
sampleInterval time.Duration
49+
}
50+
51+
type Option func(*Statter)
52+
53+
// WithSampleInterval sets the sample interval for the statter.
54+
func WithSampleInterval(d time.Duration) Option {
55+
return func(s *Statter) {
56+
s.sampleInterval = d
57+
}
58+
}
59+
60+
func New(opts ...Option) (*Statter, error) {
61+
hi, err := sysinfo.Host()
62+
if err != nil {
63+
return nil, xerrors.Errorf("get host info: %w", err)
64+
}
65+
s := &Statter{
66+
hi: hi,
67+
sampleInterval: 100 * time.Millisecond,
68+
}
69+
for _, opt := range opts {
70+
opt(s)
71+
}
72+
return s, nil
73+
}
74+
75+
// HostCPU returns the CPU usage of the host. This is calculated by
76+
// taking two samples of CPU usage and calculating the difference.
77+
// Total will always be equal to the number of cores.
78+
// Used will be an estimate of the number of cores used during the sample interval.
79+
// This is calculated by taking the difference between the total and idle HostCPU time
80+
// and scaling it by the number of cores.
81+
// Units are in "cores".
82+
func (s *Statter) HostCPU() (*Result, error) {
83+
nproc := float64(runtime.NumCPU())
84+
r := &Result{
85+
Unit: "cores",
86+
}
87+
c1, err := s.hi.CPUTime()
88+
if err != nil {
89+
return nil, xerrors.Errorf("get first cpu sample: %w", err)
90+
}
91+
<-time.After(s.sampleInterval)
92+
c2, err := s.hi.CPUTime()
93+
if err != nil {
94+
return nil, xerrors.Errorf("get second cpu sample: %w", err)
95+
}
96+
r.Total = ptr.To(nproc)
97+
total := c2.Total() - c1.Total()
98+
idle := c2.Idle - c1.Idle
99+
used := total - idle
100+
scaleFactor := nproc / total.Seconds()
101+
r.Used = used.Seconds() * scaleFactor
102+
return r, nil
103+
}
104+
105+
// HostMemory returns the memory usage of the host, in gigabytes.
106+
func (s *Statter) HostMemory() (*Result, error) {
107+
r := &Result{
108+
Unit: "GB",
109+
}
110+
hm, err := s.hi.Memory()
111+
if err != nil {
112+
return nil, xerrors.Errorf("get memory info: %w", err)
113+
}
114+
r.Total = ptr.To(float64(hm.Total) / 1024 / 1024 / 1024)
115+
r.Used = float64(hm.Used) / 1024 / 1024 / 1024
116+
return r, nil
117+
}
118+
119+
// Uptime returns the uptime of the host, in seconds.
120+
// If the host is containerized, this will return the uptime of the container
121+
// by checking /proc/1/stat.
122+
func (s *Statter) Uptime() (*Result, error) {
123+
r := &Result{
124+
Unit: "seconds",
125+
Total: nil, // Is time a finite quantity? For this purpose, no.
126+
}
127+
128+
if ok := IsContainerized(); ok != nil && *ok {
129+
procStat, err := sysinfo.Process(1)
130+
if err != nil {
131+
return nil, xerrors.Errorf("get pid 1 info: %w", err)
132+
}
133+
procInfo, err := procStat.Info()
134+
if err != nil {
135+
return nil, xerrors.Errorf("get pid 1 stat: %w", err)
136+
}
137+
r.Used = time.Since(procInfo.StartTime).Seconds()
138+
return r, nil
139+
}
140+
r.Used = s.hi.Info().Uptime().Seconds()
141+
return r, nil
142+
}
143+
144+
// ContainerCPU returns the CPU usage of the container.
145+
func (s *Statter) ContainerCPU() (*Result, error) {
146+
return nil, xerrors.Errorf("not implemented")
147+
}
148+
149+
// ContainerMemory returns the memory usage of the container.
150+
func (s *Statter) ContainerMemory() (*Result, error) {
151+
return nil, xerrors.Errorf("not implemented")
152+
}
153+
154+
// IsContainerized returns whether the host is containerized.
155+
// This wraps the elastic/go-sysinfo library.
156+
func IsContainerized() *bool {
157+
hi, err := sysinfo.Host()
158+
if err != nil {
159+
return nil
160+
}
161+
return hi.Info().Containerized
162+
}

cli/clistat/stat_internal_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package clistat
2+
3+
import (
4+
"testing"
5+
6+
"tailscale.com/types/ptr"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestResultString(t *testing.T) {
12+
for _, tt := range []struct {
13+
Expected string
14+
Result Result
15+
}{
16+
{
17+
Expected: "1.2/5.7 quatloos",
18+
Result: Result{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"},
19+
},
20+
{
21+
Expected: "0.0/0.0 HP",
22+
Result: Result{Used: 0.0, Total: ptr.To(0.0), Unit: "HP"},
23+
},
24+
{
25+
Expected: "123.0 seconds",
26+
Result: Result{Used: 123.01, Total: nil, Unit: "seconds"},
27+
},
28+
{
29+
Expected: "12.3",
30+
Result: Result{Used: 12.34, Total: nil, Unit: ""},
31+
},
32+
} {
33+
assert.Equal(t, tt.Expected, tt.Result.String())
34+
}
35+
}

0 commit comments

Comments
 (0)