├── 2020-06-27-midna-webstat.jpg ├── go.mod ├── 2020-06-27-scan2drive-scan-gokrazy-stat.jpg ├── go.sum ├── internal ├── must │ └── must.go ├── thermal │ └── thermal.go ├── sys │ └── sys.go ├── statflag │ └── statflag.go ├── net │ └── net.go ├── disk │ └── disk.go ├── mem │ └── mem.go └── cpu │ └── cpu.go ├── .github └── workflows │ └── go.yml ├── statexp └── statexp.go ├── .goreleaser.yml ├── README.md ├── LICENSE ├── cmd ├── gokr-webstat │ ├── statustmpl.go │ └── webstat.go └── gokr-stat │ └── stat.go └── stat.go /2020-06-27-midna-webstat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gokrazy/stat/HEAD/2020-06-27-midna-webstat.jpg -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gokrazy/stat 2 | 3 | go 1.18 4 | 5 | require golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 6 | -------------------------------------------------------------------------------- /2020-06-27-scan2drive-scan-gokrazy-stat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gokrazy/stat/HEAD/2020-06-27-scan2drive-scan-gokrazy-stat.jpg -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= 2 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 3 | -------------------------------------------------------------------------------- /internal/must/must.go: -------------------------------------------------------------------------------- 1 | package must 2 | 3 | import "strconv" 4 | 5 | func Uint64(s string) uint64 { 6 | v, err := strconv.ParseUint(s, 10, 64) 7 | if err != nil { 8 | return 0 9 | } 10 | return v 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | testandinstall: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Ensure all files were formatted as per gofmt 17 | run: | 18 | [ "$(gofmt -l $(find . -name '*.go') 2>&1)" = "" ] 19 | 20 | - name: run tests 21 | run: go test ./... 22 | 23 | - name: install binaries 24 | run: go install github.com/gokrazy/stat/cmd/... 25 | -------------------------------------------------------------------------------- /statexp/statexp.go: -------------------------------------------------------------------------------- 1 | // Package statexp is an experimental API for the gokrazy/stat package. 2 | package statexp 3 | 4 | import ( 5 | "github.com/gokrazy/stat" 6 | "github.com/gokrazy/stat/internal/cpu" 7 | "github.com/gokrazy/stat/internal/disk" 8 | "github.com/gokrazy/stat/internal/mem" 9 | "github.com/gokrazy/stat/internal/net" 10 | "github.com/gokrazy/stat/internal/sys" 11 | ) 12 | 13 | type ProcessAndFormatter interface { 14 | ProcessAndFormat(map[string][]byte) []stat.Col 15 | } 16 | 17 | func DefaultModules() []ProcessAndFormatter { 18 | return []ProcessAndFormatter{ 19 | &cpu.Stats{}, 20 | &disk.Stats{}, 21 | &sys.Stats{}, 22 | &net.Stats{}, 23 | &mem.Stats{}, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/thermal/thermal.go: -------------------------------------------------------------------------------- 1 | package thermal 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gokrazy/stat" 7 | "github.com/gokrazy/stat/internal/must" 8 | ) 9 | 10 | type reading struct { 11 | temp uint64 12 | } 13 | 14 | type Stats struct { 15 | cur reading 16 | } 17 | 18 | func (s *Stats) Headers() []string { 19 | return []string{"cpu"} 20 | } 21 | 22 | func (s *Stats) FileContents() []string { 23 | return []string{"/sys/class/thermal/thermal_zone0/temp"} 24 | } 25 | 26 | func (s *Stats) process(contents map[string][]byte) { 27 | s.cur = reading{} 28 | 29 | line := string(contents["/sys/class/thermal/thermal_zone0/temp"]) 30 | if len(line) == 0 { 31 | return 32 | } 33 | 34 | therm := strings.TrimSpace(line) 35 | s.cur.temp = must.Uint64(therm) 36 | } 37 | 38 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 39 | s.process(contents) 40 | return []stat.Col{ 41 | {Type: stat.ColPercentage, ValFloat64: float64(s.cur.temp) / 1000, Width: 3, Scale: 34}, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod download 6 | - go generate ./... 7 | builds: 8 | - id: "stat" 9 | main: "./cmd/gokr-stat" 10 | binary: "gokr-stat" 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | goarch: 16 | - amd64 17 | - arm 18 | - arm64 19 | - 386 20 | - id: "webstat" 21 | main: "./cmd/gokr-webstat" 22 | binary: "gokr-webstat" 23 | env: 24 | - CGO_ENABLED=0 25 | goos: 26 | - linux 27 | goarch: 28 | - amd64 29 | - arm 30 | - arm64 31 | - 386 32 | archives: 33 | - name_template: "gokrazy_stat_{{ .Os }}_{{ .Arch }}" 34 | replacements: 35 | darwin: Darwin 36 | linux: Linux 37 | windows: Windows 38 | 386: i386 39 | amd64: x86_64 40 | checksum: 41 | name_template: 'checksums.txt' 42 | snapshot: 43 | name_template: "{{ .Tag }}-next" 44 | changelog: 45 | sort: asc 46 | filters: 47 | exclude: 48 | - '^docs:' 49 | - '^test:' 50 | release: 51 | github: 52 | owner: gokrazy 53 | name: stat 54 | -------------------------------------------------------------------------------- /internal/sys/sys.go: -------------------------------------------------------------------------------- 1 | package sys 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gokrazy/stat" 7 | "github.com/gokrazy/stat/internal/must" 8 | ) 9 | 10 | type reading struct { 11 | // TODO: verify in the kernel source the data type for these 12 | int uint64 13 | csw uint64 14 | } 15 | 16 | type Stats struct { 17 | old, cur reading 18 | } 19 | 20 | func (s *Stats) Headers() []string { 21 | return []string{" int ", " csw "} 22 | } 23 | 24 | func (s *Stats) FileContents() []string { 25 | return []string{"/proc/stat"} 26 | } 27 | 28 | func (s *Stats) process(contents map[string][]byte) { 29 | s.old = s.cur 30 | s.cur = reading{} 31 | 32 | lines := strings.Split(string(contents["/proc/stat"]), "\n") 33 | if len(lines) == 0 { 34 | return 35 | } 36 | 37 | for _, line := range lines { 38 | f := strings.Fields(line) 39 | if len(f) < 2 { 40 | continue 41 | } 42 | if f[0] == "intr" { 43 | s.cur.int = must.Uint64(f[1]) 44 | } 45 | if f[0] == "ctxt" { 46 | s.cur.csw = must.Uint64(f[1]) 47 | } 48 | } 49 | } 50 | 51 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 52 | s.process(contents) 53 | return []stat.Col{ 54 | stat.MetricCol(s.cur.int - s.old.int).WithWidth(5), 55 | stat.MetricCol(s.cur.csw - s.old.csw).WithWidth(5), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/statflag/statflag.go: -------------------------------------------------------------------------------- 1 | package statflag 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gokrazy/stat" 8 | "github.com/gokrazy/stat/internal/cpu" 9 | "github.com/gokrazy/stat/internal/disk" 10 | "github.com/gokrazy/stat/internal/mem" 11 | "github.com/gokrazy/stat/internal/net" 12 | "github.com/gokrazy/stat/internal/sys" 13 | "github.com/gokrazy/stat/internal/thermal" 14 | ) 15 | 16 | type Module interface { 17 | ProcessAndFormat(map[string][]byte) []stat.Col 18 | Headers() []string 19 | } 20 | 21 | func ModulesFromFlag(enabledModules string) ([]Module, error) { 22 | var modules []Module 23 | for _, name := range strings.Split(strings.TrimSpace(enabledModules), ",") { 24 | name = strings.TrimSpace(name) 25 | if name == "" { 26 | continue 27 | } 28 | switch name { 29 | case "cpu": 30 | modules = append(modules, &cpu.Stats{}) 31 | case "disk": 32 | modules = append(modules, &disk.Stats{}) 33 | case "sys": 34 | modules = append(modules, &sys.Stats{}) 35 | case "net": 36 | modules = append(modules, &net.Stats{}) 37 | case "mem": 38 | modules = append(modules, &mem.Stats{}) 39 | case "thermal": 40 | modules = append(modules, &thermal.Stats{}) 41 | default: 42 | return nil, fmt.Errorf("unknown module: %q", name) 43 | } 44 | } 45 | return modules, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/net/net.go: -------------------------------------------------------------------------------- 1 | package net 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gokrazy/stat" 7 | "github.com/gokrazy/stat/internal/must" 8 | ) 9 | 10 | type reading struct { 11 | recv uint64 12 | send uint64 13 | } 14 | 15 | type Stats struct { 16 | old, cur reading 17 | } 18 | 19 | func (s *Stats) Headers() []string { 20 | return []string{" recv", " send"} 21 | } 22 | 23 | func (s *Stats) FileContents() []string { 24 | return []string{"/proc/net/dev"} 25 | } 26 | 27 | func (s *Stats) process(contents map[string][]byte) { 28 | s.old = s.cur 29 | s.cur = reading{} 30 | 31 | lines := strings.Split(string(contents["/proc/net/dev"]), "\n") 32 | if len(lines) == 0 { 33 | return 34 | } 35 | var totalRecv uint64 36 | var totalSend uint64 37 | for _, line := range lines { 38 | line = strings.TrimSpace(strings.ReplaceAll(line, ":", "")) 39 | 40 | // As per proc(5): 41 | f := strings.Fields(line) 42 | if len(f) < 10 { 43 | continue 44 | } 45 | recv := must.Uint64(f[1]) 46 | send := must.Uint64(f[9]) 47 | totalRecv += recv 48 | totalSend += send 49 | } 50 | 51 | s.cur.recv = totalRecv 52 | s.cur.send = totalSend 53 | } 54 | 55 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 56 | s.process(contents) 57 | return []stat.Col{ 58 | stat.ByteCol(s.cur.recv - s.old.recv).WithWidth(5), 59 | stat.ByteCol(s.cur.send - s.old.send).WithWidth(5), 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gokrazy/stat 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/gokrazy/stat)](https://goreportcard.com/report/github.com/gokrazy/stat) 4 | 5 | `gokrazy/stat` is a program to visualize resource usage, producing output that 6 | looks very similar to [Dag Wieers’s 7 | `dstat`](https://github.com/dstat-real/dstat) default output. 8 | 9 | As the repository path implies, this program is meant to be used on the 10 | https://gokrazy.org/ Raspberry Pi platform, so it does not have any external 11 | runtime dependencies and is implemented natively in Go (not using any cgo). The 12 | resulting static binary might come in handy in other Linux environments, too! 13 | 14 | 15 | ## github.com/gokrazy/stat/cmd/gokr-webstat 16 | 17 | `gokr-webstat` displays system resource usage in your browser, with output 18 | matching `gokr-stat` or `dstat`! 19 | 20 | It uses the [EventSource 21 | API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) to stream new 22 | lines into your browser. 23 | 24 | ![webstat screenshot](2020-06-27-midna-webstat.jpg) 25 | 26 | ## github.com/gokrazy/stat/cmd/gokr-stat 27 | 28 | The `gokr-stat` program is a terminal program that displays system resource 29 | usage, like `dstat`. 30 | 31 | Here is the `gokr-stat` terminal output (via 32 | [`gokrazy/breakglass`](https://github.com/gokrazy/breakglass)) when scanning one 33 | double-sided piece of A4 paper with 34 | [scan2drive](https://github.com/stapelberg/scan2drive) on a Raspberry Pi 4: 35 | 36 | ![stat terminal output](2020-06-27-scan2drive-scan-gokrazy-stat.jpg) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 the gokrazy authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of gokrazy nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /cmd/gokr-webstat/statustmpl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const statusTmpl = ` 4 | 5 | 6 | {{ .Hostname }} - webstat 7 | 21 | 22 | 23 |

{{ .Hostname }} - webstat

24 | 25 | 26 | 27 | {{ range $idx, $val := .Headers }} 28 | 29 | {{ end }} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
{{ $val }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55 | 72 | 73 | ` 74 | -------------------------------------------------------------------------------- /internal/disk/disk.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/gokrazy/stat" 8 | "github.com/gokrazy/stat/internal/must" 9 | ) 10 | 11 | type reading struct { 12 | read uint64 13 | write uint64 14 | } 15 | 16 | type Stats struct { 17 | old, cur reading 18 | } 19 | 20 | func (s *Stats) Headers() []string { 21 | return []string{" read", " writ"} 22 | } 23 | 24 | func (s *Stats) FileContents() []string { 25 | return []string{"/proc/diskstats"} 26 | } 27 | 28 | var diskfilterRe = regexp.MustCompile(`^([hsv]d[a-z]+\d+|cciss/c\d+d\d+p\d+|dm-\d+|md\d+|loop\d+p\d+|nvme\d+n\d+p\d+|mmcblk\d+p\d+|VxVM\d+)$`) 29 | 30 | func (s *Stats) process(contents map[string][]byte) { 31 | s.old = s.cur 32 | s.cur = reading{} 33 | 34 | lines := strings.Split(string(contents["/proc/diskstats"]), "\n") 35 | if len(lines) == 0 { 36 | return 37 | } 38 | 39 | var totalSectorsRead uint64 40 | var totalSectorsWritten uint64 41 | for _, line := range lines { 42 | line = strings.TrimSpace(line) 43 | 44 | // As per https://www.kernel.org/doc/Documentation/iostats.txt: 45 | f := strings.Fields(line) 46 | if len(f) < 10 { 47 | continue 48 | } 49 | if diskfilterRe.MatchString(f[2]) { 50 | // filters out e.g. dm-* 51 | continue 52 | } 53 | 54 | sectorsRead := must.Uint64(f[5]) 55 | sectorsWritten := must.Uint64(f[9]) 56 | totalSectorsRead += sectorsRead 57 | totalSectorsWritten += sectorsWritten 58 | } 59 | 60 | // TODO: reference linux kernel source if this is indeed hard-coded 61 | const sectorSize = 512 62 | 63 | s.cur.read = totalSectorsRead * sectorSize 64 | s.cur.write = totalSectorsWritten * sectorSize 65 | 66 | return 67 | } 68 | 69 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 70 | s.process(contents) 71 | return []stat.Col{ 72 | stat.ByteCol(s.cur.read - s.old.read).WithWidth(5), 73 | stat.ByteCol(s.cur.write - s.old.write).WithWidth(5), 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/mem/mem.go: -------------------------------------------------------------------------------- 1 | package mem 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gokrazy/stat" 7 | "github.com/gokrazy/stat/internal/must" 8 | ) 9 | 10 | type reading struct { 11 | used uint64 12 | free uint64 13 | buff uint64 14 | cach uint64 15 | } 16 | 17 | type Stats struct { 18 | old, cur reading 19 | } 20 | 21 | func (s *Stats) Headers() []string { 22 | return []string{" used", " free", " buff", " cach"} 23 | } 24 | 25 | func (s *Stats) FileContents() []string { 26 | return []string{"/proc/meminfo"} 27 | } 28 | 29 | func (s *Stats) process(contents map[string][]byte) { 30 | s.old = s.cur 31 | s.cur = reading{} 32 | 33 | lines := strings.Split(string(contents["/proc/meminfo"]), "\n") 34 | if len(lines) == 0 { 35 | return 36 | } 37 | var ( 38 | memTotal uint64 39 | shmem uint64 40 | sreclaimable uint64 41 | ) 42 | for _, line := range lines { 43 | line = strings.TrimSpace(strings.ReplaceAll(line, ":", "")) 44 | 45 | // As per proc(5): 46 | f := strings.Fields(line) 47 | if len(f) < 2 { 48 | continue 49 | } 50 | switch f[0] { 51 | case "MemFree": 52 | s.cur.free = must.Uint64(f[1]) * 1024 53 | case "Buffers": 54 | s.cur.buff = must.Uint64(f[1]) * 1024 55 | case "Cached": 56 | s.cur.cach = must.Uint64(f[1]) * 1024 57 | case "MemTotal": 58 | memTotal = must.Uint64(f[1]) * 1024 59 | case "Shmem": 60 | shmem = must.Uint64(f[1]) * 1024 61 | case "SReclaimable": 62 | sreclaimable = must.Uint64(f[1]) * 1024 63 | } 64 | } 65 | 66 | s.cur.used = memTotal - s.cur.free - s.cur.buff - s.cur.cach - sreclaimable + shmem 67 | } 68 | 69 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 70 | s.process(contents) 71 | return []stat.Col{ 72 | {Type: stat.ColGauge, Unit: stat.UnitBytesFloat, ValFloat64: float64(s.cur.used), Width: 5}, 73 | {Type: stat.ColGauge, Unit: stat.UnitBytesFloat, ValFloat64: float64(s.cur.free), Width: 5}, 74 | {Type: stat.ColGauge, Unit: stat.UnitBytesFloat, ValFloat64: float64(s.cur.buff), Width: 5}, 75 | {Type: stat.ColGauge, Unit: stat.UnitBytesFloat, ValFloat64: float64(s.cur.cach), Width: 5}, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/cpu/cpu.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gokrazy/stat" 7 | "github.com/gokrazy/stat/internal/must" 8 | ) 9 | 10 | type reading struct { 11 | // TODO: verify in the kernel source the data type for these 12 | usr uint64 // user + nice + irq + softirq 13 | sys uint64 // system 14 | idl uint64 // idle 15 | wai uint64 // I/O wait 16 | stl uint64 // steal 17 | 18 | sum uint64 19 | } 20 | 21 | type Stats struct { 22 | old, cur reading 23 | } 24 | 25 | func (s *Stats) Headers() []string { 26 | return []string{"usr", "sys", "idl", "wai", "stl"} 27 | } 28 | 29 | func (s *Stats) FileContents() []string { 30 | return []string{"/proc/stat"} 31 | } 32 | 33 | func (s *Stats) process(contents map[string][]byte) { 34 | s.old = s.cur 35 | s.cur = reading{} 36 | 37 | lines := strings.Split(string(contents["/proc/stat"]), "\n") 38 | if len(lines) == 0 { 39 | return 40 | } 41 | 42 | // As per proc(5): 43 | f := strings.Fields(strings.TrimSpace(lines[0])) 44 | user := must.Uint64(f[1]) 45 | nice := must.Uint64(f[2]) 46 | system := must.Uint64(f[3]) 47 | idle := must.Uint64(f[4]) 48 | iowait := must.Uint64(f[5]) 49 | irq := must.Uint64(f[6]) 50 | softirq := must.Uint64(f[7]) 51 | steal := must.Uint64(f[8]) 52 | 53 | s.cur.usr = user + nice + irq + softirq 54 | s.cur.sys = system 55 | s.cur.idl = idle 56 | s.cur.wai = iowait 57 | s.cur.stl = steal 58 | s.cur.sum = s.cur.usr + s.cur.sys + s.cur.idl + s.cur.wai + s.cur.stl 59 | } 60 | 61 | func (s *Stats) ProcessAndFormat(contents map[string][]byte) []stat.Col { 62 | s.process(contents) 63 | total := float64(s.cur.sum - s.old.sum) 64 | return []stat.Col{ 65 | {Type: stat.ColPercentage, ValFloat64: 100 * float64(s.cur.usr-s.old.usr) / total, Width: 3, Scale: 34}, 66 | {Type: stat.ColPercentage, ValFloat64: 100 * float64(s.cur.sys-s.old.sys) / total, Width: 3, Scale: 34}, 67 | {Type: stat.ColPercentage, ValFloat64: 100 * float64(s.cur.idl-s.old.idl) / total, Width: 3, Scale: 34}, 68 | {Type: stat.ColPercentage, ValFloat64: 100 * float64(s.cur.wai-s.old.wai) / total, Width: 3, Scale: 34}, 69 | {Type: stat.ColPercentage, ValFloat64: 100 * float64(s.cur.stl-s.old.stl) / total, Width: 3, Scale: 34}, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/gokr-stat/stat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "sync" 13 | "syscall" 14 | "time" 15 | "unsafe" 16 | 17 | "github.com/gokrazy/stat" 18 | "github.com/gokrazy/stat/internal/statflag" 19 | ) 20 | 21 | func formatCols(cols []stat.Col) string { 22 | formatted := make([]string, len(cols)) 23 | for idx, col := range cols { 24 | formatted[idx] = col.String() 25 | } 26 | return strings.Join(formatted, " ") 27 | } 28 | 29 | const ( 30 | TIOCGWINSZ = 0x5413 31 | ) 32 | 33 | type window struct { 34 | Row uint16 35 | Col uint16 36 | Xpixel uint16 37 | Ypixel uint16 38 | } 39 | 40 | func terminalSize() (*window, error) { 41 | w := new(window) 42 | tio := syscall.TIOCGWINSZ 43 | res, _, err := syscall.Syscall(syscall.SYS_IOCTL, 44 | uintptr(syscall.Stdin), 45 | uintptr(tio), 46 | uintptr(unsafe.Pointer(w)), 47 | ) 48 | if int(res) == -1 { 49 | if err == syscall.ENOTTY { 50 | return &window{Row: 80}, nil 51 | } 52 | return nil, err 53 | } 54 | return w, nil 55 | } 56 | 57 | func printStats() error { 58 | var enabledModules = flag.String("modules", "cpu,disk,sys,net,mem", "comma-separated list of modules to show. known modules: cpu,disk,sys,net,mem,thermal") 59 | flag.Parse() 60 | 61 | ts, err := terminalSize() 62 | if err != nil { 63 | return err 64 | } 65 | var rowsMu sync.Mutex 66 | rows := int(ts.Row) - 1 67 | ch := make(chan os.Signal, 1) 68 | signal.Notify(ch, syscall.SIGWINCH) 69 | go func() { 70 | for range ch { 71 | ts, err := terminalSize() 72 | if err != nil { 73 | log.Print(err) 74 | continue 75 | } 76 | rowsMu.Lock() 77 | rows = int(ts.Row) - 1 78 | rowsMu.Unlock() 79 | } 80 | }() 81 | 82 | modules, err := statflag.ModulesFromFlag(*enabledModules) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | header := func() { 88 | const blue = "\033[1;34m" 89 | fmt.Printf("%s", blue) 90 | for idx, mod := range modules { 91 | headers := mod.Headers() 92 | suffix := " | " 93 | if idx == len(modules)-1 { 94 | suffix = "\n" // last module 95 | } 96 | fmt.Printf("%s%s", strings.Join(headers, " "), suffix) 97 | } 98 | } 99 | 100 | parts := make([]string, len(modules)) 101 | files := make(map[string]*os.File) 102 | for _, mod := range modules { 103 | // When a stats module implements the FileContents() interface, we 104 | // ensure all returned file contents are read and passed to 105 | // ProcessAndFormat. 106 | fc, ok := mod.(interface{ FileContents() []string }) 107 | if !ok { 108 | continue 109 | } 110 | for _, f := range fc.FileContents() { 111 | if _, ok := files[f]; ok { 112 | continue // already requested 113 | } 114 | fl, err := os.Open(f) 115 | if err != nil { 116 | return err 117 | } 118 | files[f] = fl 119 | } 120 | } 121 | for i := 0; ; i++ { 122 | rowsMu.Lock() 123 | showHeader := i%(int(rows)-1) == 0 124 | rowsMu.Unlock() 125 | if showHeader { 126 | header() 127 | } 128 | contents := make(map[string][]byte) 129 | for path, fl := range files { 130 | if _, err := fl.Seek(0, io.SeekStart); err != nil { 131 | return err 132 | } 133 | b, err := ioutil.ReadAll(fl) 134 | if err != nil { 135 | return err 136 | } 137 | contents[path] = b 138 | } 139 | 140 | for idx, mod := range modules { 141 | parts[idx] = formatCols(mod.ProcessAndFormat(contents)) 142 | } 143 | 144 | if i > 0 { 145 | const darkblue = "\033[0;34m" 146 | fmt.Println(strings.Join(parts, darkblue+" | ")) 147 | // TODO: clear colors at the end of line so that program can be interrupted 148 | } 149 | 150 | time.Sleep(1 * time.Second) 151 | } 152 | } 153 | 154 | func main() { 155 | if os.Getenv("GOKRAZY_FIRST_START") == "1" { 156 | // Do not supervise this process: it is meant for interactive usage on a 157 | // terminal. If you are looking for a daemon, use gokr-webstat instead. 158 | os.Exit(125) 159 | } 160 | 161 | if err := printStats(); err != nil { 162 | log.Fatal(err) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /cmd/gokr-webstat/webstat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "os" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/gokrazy/stat" 19 | "github.com/gokrazy/stat/internal/statflag" 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | func formatCols(cols []stat.Col) string { 24 | formatted := make([]string, len(cols)) 25 | for idx, col := range cols { 26 | formatted[idx] = fmt.Sprintf( 27 | `%s`, 28 | col.Width, 29 | col.HTML()) 30 | } 31 | return strings.Join(formatted, "\n") 32 | } 33 | 34 | func serveStats() error { 35 | var listen = flag.String("listen", ":6618", "[host]:port to serve HTML on") 36 | var enabledModules = flag.String("modules", "cpu,disk,sys,net,mem", "comma-separated list of modules to show. known modules: cpu,disk,sys,net,mem,thermal") 37 | flag.Parse() 38 | 39 | modules, err := statflag.ModulesFromFlag(*enabledModules) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var headers []string 45 | for _, mod := range modules { 46 | hdrs := mod.Headers() 47 | for idx, h := range hdrs { 48 | hdrs[idx] = strings.ReplaceAll(h, " ", "_") 49 | } 50 | headers = append(headers, hdrs...) 51 | } 52 | 53 | parts := make([]string, len(modules)) 54 | files := make(map[string]*os.File) 55 | for _, mod := range modules { 56 | // When a stats module implements the FileContents() interface, we 57 | // ensure all returned file contents are read and passed to 58 | // ProcessAndFormat. 59 | fc, ok := mod.(interface{ FileContents() []string }) 60 | if !ok { 61 | continue 62 | } 63 | for _, f := range fc.FileContents() { 64 | if _, ok := files[f]; ok { 65 | continue // already requested 66 | } 67 | fl, err := os.Open(f) 68 | if err != nil { 69 | return err 70 | } 71 | files[f] = fl 72 | } 73 | } 74 | 75 | statusTmpl, err := template.New("").Parse(statusTmpl) 76 | if err != nil { 77 | return err 78 | } 79 | hostname, _ := os.Hostname() 80 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 81 | if r.URL.Path != "/" { 82 | http.Error(w, "not found", http.StatusNotFound) 83 | return 84 | } 85 | var buf bytes.Buffer 86 | if err := statusTmpl.Execute(&buf, struct { 87 | Hostname string 88 | Headers []string 89 | }{ 90 | Hostname: hostname, 91 | Headers: headers, 92 | }); err != nil { 93 | log.Print(err) 94 | http.Error(w, err.Error(), http.StatusInternalServerError) 95 | return 96 | } 97 | io.Copy(w, &buf) 98 | }) 99 | var newEventMu sync.Mutex 100 | newEvent := sync.NewCond(&newEventMu) 101 | http.HandleFunc("/readings", func(w http.ResponseWriter, r *http.Request) { 102 | w.Header().Set("Content-Type", "text/event-stream") 103 | newEventMu.Lock() 104 | for { 105 | b, err := json.Marshal(parts) 106 | if err != nil { 107 | log.Print(err) 108 | http.Error(w, err.Error(), http.StatusInternalServerError) 109 | return 110 | } 111 | rdr := bytes.NewReader(append(append([]byte("data: "), b...), []byte("\n\n")...)) 112 | newEventMu.Unlock() 113 | 114 | if _, err := io.Copy(w, rdr); err != nil { 115 | log.Print(err) 116 | return 117 | } 118 | if f, ok := w.(http.Flusher); ok { 119 | f.Flush() 120 | } 121 | 122 | newEventMu.Lock() 123 | newEvent.Wait() 124 | } 125 | 126 | }) 127 | var eg errgroup.Group 128 | eg.Go(func() error { 129 | for i := 0; ; i++ { 130 | contents := make(map[string][]byte) 131 | for path, fl := range files { 132 | if _, err := fl.Seek(0, io.SeekStart); err != nil { 133 | return err 134 | } 135 | b, err := ioutil.ReadAll(fl) 136 | if err != nil { 137 | return err 138 | } 139 | contents[path] = b 140 | } 141 | 142 | newEventMu.Lock() 143 | for idx, mod := range modules { 144 | parts[idx] = formatCols(mod.ProcessAndFormat(contents)) 145 | } 146 | newEventMu.Unlock() 147 | 148 | if i > 0 { 149 | const darkblue = "\033[0;34m" 150 | //fmt.Println(strings.Join(parts, darkblue+" | ")) 151 | // TODO: clear colors at the end of line so that program can be interrupted 152 | newEvent.Broadcast() 153 | } 154 | 155 | time.Sleep(1 * time.Second) 156 | } 157 | }) 158 | eg.Go(func() error { 159 | log.Printf("listening on %s", *listen) 160 | return http.ListenAndServe(*listen, nil) 161 | }) 162 | return eg.Wait() 163 | } 164 | 165 | func main() { 166 | if err := serveStats(); err != nil { 167 | log.Fatal(err) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package stat 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | UnitNone = iota 11 | UnitBytes 12 | UnitBytesFloat 13 | UnitMetric 14 | ) 15 | 16 | var colorNameToANSI = map[string]string{ 17 | "darkgray": "\033[1;30m", 18 | "red": "\033[1;31m", 19 | "green": "\033[1;32m", 20 | "yellow": "\033[1;33m", 21 | "blue": "\033[1;34m", 22 | "magenta": "\033[1;35m", 23 | "cyan": "\033[1;36m", 24 | "white": "\033[1;37m", 25 | } 26 | 27 | var colorNameToHTML = map[string]string{ 28 | "darkgray": "#555753", 29 | "red": "#EF2929", 30 | "green": "#8AE234", 31 | "yellow": "#FCE94F", 32 | "blue": "#729FCF", 33 | "magenta": "#EE38DA", 34 | "cyan": "#34E2E2", 35 | "white": "#EEEEEC", 36 | } 37 | 38 | const ( 39 | black = "\033[0;30m" 40 | darkred = "\033[0;31m" 41 | darkgreen = "\033[0;32m" 42 | darkyellow = "\033[0;33m" 43 | darkblue = "\033[0;34m" 44 | darkmagenta = "\033[0;35m" 45 | darkcyan = "\033[0;36m" 46 | gray = "\033[0;37m" 47 | 48 | darkgray = "darkgray" 49 | red = "red" 50 | green = "green" 51 | yellow = "yellow" 52 | blue = "blue" 53 | magenta = "magenta" 54 | cyan = "cyan" 55 | white = "white" 56 | 57 | blackbg = "\033[40m" 58 | redbg = "\033[41m" 59 | greenbg = "\033[42m" 60 | yellowbg = "\033[43m" 61 | bluebg = "\033[44m" 62 | magentabg = "\033[45m" 63 | cyanbg = "\033[46m" 64 | whitebg = "\033[47m" 65 | ) 66 | 67 | var colors = []string{ 68 | red, 69 | yellow, 70 | green, 71 | blue, 72 | cyan, 73 | white, 74 | darkred, 75 | darkgreen, 76 | } 77 | 78 | type ColType int 79 | 80 | const ( 81 | ColGauge ColType = iota 82 | ColPercentage 83 | ) 84 | 85 | type Col struct { 86 | Type ColType 87 | ValU64 uint64 88 | ValInt int // used by ColPercentage 89 | ValFloat64 float64 // used by UnitBytes 90 | Unit int 91 | Width int 92 | Scale int 93 | } 94 | 95 | func (c Col) WithWidth(w int) Col { 96 | ret := c 97 | ret.Width = w 98 | return ret 99 | } 100 | 101 | var unitsuffix = []string{"B", "k", "M", "G", "T"} 102 | 103 | func (c Col) String() string { 104 | return c.colorize(func(col, text string) string { 105 | return colorNameToANSI[col] + text 106 | }) 107 | } 108 | 109 | func (c Col) HTML() string { 110 | return c.colorize(func(col, text string) string { 111 | return fmt.Sprintf(`%s`, colorNameToHTML[col], text) 112 | }) 113 | } 114 | 115 | func (c Col) RenderCustom(colorFunc func(col, text string) string) string { 116 | return c.colorize(func(col, text string) string { 117 | return colorFunc(col, text) 118 | }) 119 | } 120 | 121 | func (c Col) colorize(color func(col, text string) string) string { 122 | switch c.Type { 123 | case ColPercentage: 124 | v := fmt.Sprintf("%"+strconv.Itoa(c.Width)+"d", int(c.ValFloat64)) 125 | col := colors[int(c.ValFloat64/float64(c.Scale))%len(colors)] 126 | if strings.TrimSpace(v) == "0" { 127 | return color(darkgray, v) 128 | } 129 | if c.ValFloat64 >= 100 { 130 | return color(white, v) 131 | } 132 | return color(col, v) 133 | 134 | case ColGauge: 135 | if c.Unit == UnitBytes || c.Unit == UnitBytesFloat || 136 | c.Unit == UnitMetric { 137 | base := 1024 138 | if c.Unit == UnitMetric { 139 | base = 1000 140 | } 141 | width := c.Width 142 | if c.ValU64 == 0 && c.ValFloat64 == 0 { 143 | return color(darkgray, fmt.Sprintf("%"+strconv.Itoa(width)+"d", 0)) 144 | } 145 | width-- // for the unit suffix 146 | var f string 147 | var cl int 148 | if c.Unit == UnitBytesFloat { 149 | f, cl = fchg(c.ValFloat64, width, base) 150 | } else { 151 | f, cl = dchg(c.ValU64, width, base) 152 | } 153 | if len(f) < width { 154 | f = strings.Repeat(" ", width-len(f)) + f 155 | } 156 | if cl > 0 || c.Unit == UnitBytes || c.Unit == UnitBytesFloat { 157 | if cl < len(unitsuffix) { 158 | f += color(darkgray, unitsuffix[cl]) 159 | } else { 160 | f += "?" 161 | } 162 | } else { 163 | f += " " // empty suffix 164 | } 165 | col := colors[cl%len(colors)] 166 | return color(col, f) 167 | } 168 | return fmt.Sprintf("%4d", c.ValU64) 169 | 170 | default: 171 | return "?BUG?" 172 | } 173 | } 174 | 175 | func ByteCol(v uint64) Col { 176 | return Col{ 177 | Type: ColGauge, 178 | Unit: UnitBytes, 179 | ValU64: v, 180 | } 181 | } 182 | 183 | func MetricCol(v uint64) Col { 184 | return Col{ 185 | Type: ColGauge, 186 | Unit: UnitMetric, 187 | ValU64: v, 188 | } 189 | } 190 | 191 | // ----------------------------------------------------------------------------- 192 | // dchg and fchg were ported directly from dstat. 193 | // ----------------------------------------------------------------------------- 194 | 195 | func dchg(v uint64, width int, base int) (string, int) { 196 | c := 0 197 | for { 198 | ret := strconv.FormatUint(v, 10) 199 | if len(ret) <= width { 200 | return ret, c 201 | } 202 | v = v / uint64(base) 203 | c++ 204 | } 205 | } 206 | 207 | func fchg(v float64, width int, base int) (string, int) { 208 | if v == 0 { 209 | return "0", 0 210 | } 211 | c := 0 212 | var ret string 213 | for { 214 | ret = strconv.Itoa(int(v)) 215 | if len(ret) <= width { 216 | i := width - len(ret) - 1 217 | for ; i > 0; i-- { 218 | ret = strconv.FormatFloat(v, 'f', i, 64) 219 | if len(ret) <= width { 220 | break 221 | } 222 | } 223 | if i == 0 { 224 | ret = strconv.Itoa(int(v)) 225 | } 226 | break 227 | } 228 | v = v / float64(base) 229 | c++ 230 | } 231 | return ret, c 232 | } 233 | --------------------------------------------------------------------------------