Skip to content

Commit 5db9006

Browse files
committed
add stat command
1 parent 2495386 commit 5db9006

File tree

4 files changed

+212
-0
lines changed

4 files changed

+212
-0
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
106106
r.stop(),
107107
r.update(),
108108
r.restart(),
109+
r.stat(),
109110

110111
// Hidden
111112
r.gitssh(),

cli/stat.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"runtime"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/elastic/go-sysinfo"
12+
13+
"github.com/coder/coder/cli/clibase"
14+
"github.com/coder/coder/cli/cliui"
15+
)
16+
17+
func (*RootCmd) stat() *clibase.Cmd {
18+
var (
19+
sampleInterval time.Duration
20+
formatter = cliui.NewOutputFormatter(
21+
cliui.TextFormat(),
22+
cliui.JSONFormat(),
23+
)
24+
)
25+
26+
cmd := &clibase.Cmd{
27+
Use: "stat",
28+
Short: "Show workspace resource usage.",
29+
Options: clibase.OptionSet{
30+
{
31+
Description: "Configure the sample interval.",
32+
Flag: "sample-interval",
33+
Value: clibase.DurationOf(&sampleInterval),
34+
Default: "100ms",
35+
},
36+
},
37+
Handler: func(inv *clibase.Invocation) error {
38+
stats, err := newStats(sampleInterval)
39+
if err != nil {
40+
return err
41+
}
42+
out, err := formatter.Format(inv.Context(), stats)
43+
if err != nil {
44+
return err
45+
}
46+
_, err = fmt.Fprintln(inv.Stdout, out)
47+
return err
48+
},
49+
}
50+
formatter.AttachOptions(&cmd.Options)
51+
return cmd
52+
}
53+
54+
type stats struct {
55+
HostCPU stat `json:"cpu_host"`
56+
HostMemory stat `json:"mem_host"`
57+
Disk stat `json:"disk"`
58+
InContainer bool `json:"in_container,omitempty"`
59+
ContainerCPU stat `json:"cpu_container,omitempty"`
60+
ContainerMemory stat `json:"mem_container,omitempty"`
61+
}
62+
63+
func (s *stats) String() string {
64+
var sb strings.Builder
65+
sb.WriteString(s.HostCPU.String())
66+
sb.WriteString("\n")
67+
sb.WriteString(s.HostMemory.String())
68+
sb.WriteString("\n")
69+
sb.WriteString(s.Disk.String())
70+
sb.WriteString("\n")
71+
if s.InContainer {
72+
sb.WriteString(s.ContainerCPU.String())
73+
sb.WriteString("\n")
74+
sb.WriteString(s.ContainerMemory.String())
75+
sb.WriteString("\n")
76+
}
77+
return sb.String()
78+
}
79+
80+
func newStats(dur time.Duration) (stats, error) {
81+
var s stats
82+
nproc := float64(runtime.NumCPU())
83+
// start := time.Now()
84+
// ticksPerDur := dur / tickInterval
85+
h1, err := sysinfo.Host()
86+
if err != nil {
87+
return s, err
88+
}
89+
<-time.After(dur)
90+
h2, err := sysinfo.Host()
91+
if err != nil {
92+
return s, err
93+
}
94+
// elapsed := time.Since(start)
95+
// numTicks := elapsed / tickInterval
96+
cts1, err := h1.CPUTime()
97+
if err != nil {
98+
return s, err
99+
}
100+
cts2, err := h2.CPUTime()
101+
if err != nil {
102+
return s, err
103+
}
104+
// Assuming the total measured should add up to $(nproc) "cores",
105+
// we determine a scaling factor such that scaleFactor * total = nproc.
106+
// We then calculate used as the total time spent idle, and multiply
107+
// that by scaleFactor to give a rough approximation of how busy the
108+
// CPU(s) were.
109+
s.HostCPU.Total = nproc
110+
total := (cts2.Total() - cts1.Total())
111+
idle := (cts2.Idle - cts1.Idle)
112+
used := total - idle
113+
scaleFactor := nproc / total.Seconds()
114+
s.HostCPU.Used = used.Seconds() * scaleFactor
115+
s.HostCPU.Unit = "cores"
116+
117+
return s, nil
118+
}
119+
120+
type stat struct {
121+
Used float64 `json:"used"`
122+
Total float64 `json:"total"`
123+
Unit string `json:"unit"`
124+
}
125+
126+
func (s *stat) String() string {
127+
var sb strings.Builder
128+
_, _ = sb.WriteString(strconv.FormatFloat(s.Used, 'f', 1, 64))
129+
_, _ = sb.WriteString("/")
130+
_, _ = sb.WriteString(strconv.FormatFloat(s.Total, 'f', 1, 64))
131+
_, _ = sb.WriteString(" ")
132+
if s.Unit != "" {
133+
_, _ = sb.WriteString(s.Unit)
134+
_, _ = sb.WriteString(" ")
135+
}
136+
_, _ = sb.WriteString("(")
137+
var pct float64
138+
if s.Total == 0 {
139+
pct = math.NaN()
140+
} else {
141+
pct = s.Used / s.Total * 100
142+
}
143+
_, _ = sb.WriteString(strconv.FormatFloat(pct, 'f', 1, 64))
144+
_, _ = sb.WriteString("%)")
145+
return sb.String()
146+
}

cli/stat_internal_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestStatString(t *testing.T) {
10+
for _, tt := range []struct {
11+
Expected string
12+
Stat stat
13+
}{
14+
{
15+
Expected: "1.2/3.4 quatloos (50%)",
16+
Stat: stat{Used: 1.2, Total: 3.4, Unit: "quatloos"},
17+
},
18+
{
19+
Expected: "0/0 HP (NaN%)",
20+
Stat: stat{Used: 0, Total: 0, Unit: "HP"},
21+
},
22+
} {
23+
assert.Equal(t, tt.Expected, tt.Stat.String())
24+
}
25+
}

cli/stat_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/cli/clitest"
11+
"github.com/coder/coder/testutil"
12+
)
13+
14+
// This just tests that the stat command is recognized and does not output
15+
// an empty string. Actually testing the output of the stat command is
16+
// fraught with all sorts of fun.
17+
func TestStatCmd(t *testing.T) {
18+
t.Run("JSON", func(t *testing.T) {
19+
t.Parallel()
20+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
21+
t.Cleanup(cancel)
22+
inv, _ := clitest.New(t, "stat", "--output=json")
23+
buf := new(bytes.Buffer)
24+
inv.Stdout = buf
25+
err := inv.WithContext(ctx).Run()
26+
require.NoError(t, err)
27+
require.NotEmpty(t, buf.String())
28+
})
29+
t.Run("Text", func(t *testing.T) {
30+
t.Parallel()
31+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
32+
t.Cleanup(cancel)
33+
inv, _ := clitest.New(t, "stat", "--output=text")
34+
buf := new(bytes.Buffer)
35+
inv.Stdout = buf
36+
err := inv.WithContext(ctx).Run()
37+
require.NoError(t, err)
38+
require.NotEmpty(t, buf.String())
39+
})
40+
}

0 commit comments

Comments
 (0)