@yuuki824/kanshi 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -0
- package/Dockerfile +53 -0
- package/LICENSE +661 -0
- package/README.md +219 -0
- package/bin/kanshi.js +63 -0
- package/docker-compose.yml +84 -0
- package/go.mod +3 -0
- package/internal/access/access.go +275 -0
- package/internal/access/listeners.go +229 -0
- package/internal/config/config.go +256 -0
- package/internal/config/defaults_other.go +5 -0
- package/internal/config/defaults_windows.go +4 -0
- package/internal/config/envfile.go +147 -0
- package/internal/dockerstats/dial.go +32 -0
- package/internal/dockerstats/dial_other.go +13 -0
- package/internal/dockerstats/dial_windows.go +62 -0
- package/internal/dockerstats/dockerstats.go +626 -0
- package/internal/roots/roots.go +175 -0
- package/internal/roots/roots_linux.go +132 -0
- package/internal/roots/roots_windows.go +74 -0
- package/internal/server/access.go +155 -0
- package/internal/server/server.go +523 -0
- package/internal/server/static.go +165 -0
- package/internal/storage/cache.go +283 -0
- package/internal/storage/dir_linux.go +177 -0
- package/internal/storage/dir_windows.go +244 -0
- package/internal/storage/statat_fast.go +21 -0
- package/internal/storage/statat_other.go +11 -0
- package/internal/storage/storage.go +598 -0
- package/internal/storage/sysnum_amd64.go +7 -0
- package/internal/storage/sysnum_generic.go +7 -0
- package/internal/storage/walk.go +342 -0
- package/internal/vitals/vitals.go +246 -0
- package/internal/vitals/vitals_linux.go +548 -0
- package/internal/vitals/vitals_windows.go +286 -0
- package/main.go +129 -0
- package/main_other.go +11 -0
- package/main_windows.go +29 -0
- package/package.json +49 -0
- package/web/app.js +835 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon.svg +6 -0
- package/web/index.html +151 -0
- package/web/style.css +356 -0
- package/web/treemap.js +217 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
package storage
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"strings"
|
|
7
|
+
"sync/atomic"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/rene-roid/kanshi/internal/roots"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Largest individual files named in a listing. Everything past this is still
|
|
14
|
+
// counted, just not named.
|
|
15
|
+
const topFiles = 32
|
|
16
|
+
|
|
17
|
+
// dirNode is one directory within TreeDepth of where the walk started. Deeper
|
|
18
|
+
// directories have their bytes rolled into the nearest retained ancestor
|
|
19
|
+
// rather than getting a node of their own.
|
|
20
|
+
type dirNode struct {
|
|
21
|
+
name string
|
|
22
|
+
bytes int64 // files directly inside, plus everything below the retained depth
|
|
23
|
+
size int64 // bytes + children
|
|
24
|
+
unreadable int32 // directories at or below this one the walk could not enter
|
|
25
|
+
children []int32
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// tree is one walk. nodes is in breadth-first order, so every parent comes
|
|
29
|
+
// before all of its children and nodes[0] is where the walk started.
|
|
30
|
+
type tree struct {
|
|
31
|
+
nodes []*dirNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// dirEntry is one directory entry as the platform's reader reports it. name
|
|
35
|
+
// points into the reader's buffer and is only valid until its next read.
|
|
36
|
+
type dirEntry struct {
|
|
37
|
+
name []byte
|
|
38
|
+
dir bool
|
|
39
|
+
dev uint64 // directories: the filesystem, so the walk never crosses into another
|
|
40
|
+
size int64 // files: bytes allocated on disk
|
|
41
|
+
key uint64 // files that may have other hard links: counted once per key
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// walker carries what stays the same across every walk of one batch.
|
|
45
|
+
type walker struct {
|
|
46
|
+
maxDepth int
|
|
47
|
+
excluded map[string]bool
|
|
48
|
+
progress *atomic.Int64
|
|
49
|
+
pace *throttle
|
|
50
|
+
rd *dirReader
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// frontier is one BFS level: every directory path back to back in a single
|
|
54
|
+
// buffer, each NUL-terminated so it can go straight to the kernel. One growing
|
|
55
|
+
// buffer per level rather than a string per directory — on a tree with a wide
|
|
56
|
+
// node_modules or overlay2 level, the string headers and allocator rounding
|
|
57
|
+
// alone would cost more than the paths.
|
|
58
|
+
type frontier struct {
|
|
59
|
+
paths []byte
|
|
60
|
+
dirs []queued
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// queued is a directory waiting its turn. node is the retained node its files
|
|
64
|
+
// are counted into: its own when own is set, otherwise its nearest retained
|
|
65
|
+
// ancestor.
|
|
66
|
+
type queued struct {
|
|
67
|
+
start, end uint32 // its path is paths[start:end], with a NUL at end
|
|
68
|
+
node int32
|
|
69
|
+
depth uint16 // below where the walk started
|
|
70
|
+
own bool
|
|
71
|
+
dedupe bool // Windows: inside the system directory, where hard links live
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func (f *frontier) path(q queued) []byte { return f.paths[q.start:q.end] }
|
|
75
|
+
|
|
76
|
+
func (f *frontier) push(parent, name []byte, q queued) queued {
|
|
77
|
+
q.start = uint32(len(f.paths))
|
|
78
|
+
f.paths = append(f.paths, parent...)
|
|
79
|
+
if parent[len(parent)-1] != pathSep {
|
|
80
|
+
f.paths = append(f.paths, pathSep)
|
|
81
|
+
}
|
|
82
|
+
f.paths = append(f.paths, name...)
|
|
83
|
+
q.end = uint32(len(f.paths))
|
|
84
|
+
f.paths = append(f.paths, 0)
|
|
85
|
+
f.dirs = append(f.dirs, q)
|
|
86
|
+
return q
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// pop takes back the last push.
|
|
90
|
+
func (f *frontier) pop(q queued) {
|
|
91
|
+
f.paths = f.paths[:q.start]
|
|
92
|
+
f.dirs = f.dirs[:len(f.dirs)-1]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (f *frontier) reset() {
|
|
96
|
+
f.paths = f.paths[:0]
|
|
97
|
+
f.dirs = f.dirs[:0]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// walk sizes one folder with a level-by-level BFS. Only directories within
|
|
101
|
+
// maxDepth of the start get a node of their own; anything deeper is still
|
|
102
|
+
// fully traversed and counted, but its bytes roll up into the nearest retained
|
|
103
|
+
// ancestor. (Walking all of / on the host this was written for retains ~2.3k
|
|
104
|
+
// nodes instead of ~96k.)
|
|
105
|
+
//
|
|
106
|
+
// Going breadth-first means the frontier is never more than two levels of
|
|
107
|
+
// paths, and the retained levels are all finished before the deep, bulky part
|
|
108
|
+
// of the tree starts. Directories are opened by full path, so no descriptor is
|
|
109
|
+
// held open while its children wait in the queue.
|
|
110
|
+
func (w *walker) walk(ctx context.Context, rootPath string) (*tree, error) {
|
|
111
|
+
rootDev, err := rootDevice(rootPath)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return nil, err
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Windows de-duplicates hard links only inside its system directory. The
|
|
117
|
+
// walk is either inside it already, or may pass it at a known depth.
|
|
118
|
+
rootDedupe, sysLevel, sysKey := false, -1, ""
|
|
119
|
+
if sys := systemRoot(); sys != "" {
|
|
120
|
+
switch {
|
|
121
|
+
case roots.Within(sys, rootPath):
|
|
122
|
+
rootDedupe = true
|
|
123
|
+
case roots.Within(rootPath, sys):
|
|
124
|
+
sysKey = roots.Key(sys)
|
|
125
|
+
rel := strings.Trim(sysKey[len(roots.Key(rootPath)):], string(pathSep))
|
|
126
|
+
sysLevel = strings.Count(rel, string(pathSep)) + 1
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
t := &tree{nodes: make([]*dirNode, 1, 256)}
|
|
131
|
+
t.nodes[0] = &dirNode{name: baseName(rootPath)}
|
|
132
|
+
// Packed into one int rather than a (dev, ino) pair: overlay2 hardlinks
|
|
133
|
+
// everything, so this set reaches six figures and a struct key costs
|
|
134
|
+
// several times the bytes of a bare uint64.
|
|
135
|
+
seenInodes := make(map[uint64]struct{})
|
|
136
|
+
|
|
137
|
+
level, next := &frontier{}, &frontier{}
|
|
138
|
+
level.paths = append([]byte(rootPath), 0)
|
|
139
|
+
level.dirs = []queued{{end: uint32(len(rootPath)), node: 0, own: true, dedupe: rootDedupe}}
|
|
140
|
+
|
|
141
|
+
for depth := 0; len(level.dirs) > 0; depth++ {
|
|
142
|
+
atSys := depth+1 == sysLevel
|
|
143
|
+
checkPath := len(w.excluded) > 0 || atSys
|
|
144
|
+
for _, q := range level.dirs {
|
|
145
|
+
dirPath := level.path(q)
|
|
146
|
+
entries, seen, err := w.rd.read(dirPath, q.dedupe)
|
|
147
|
+
if err != nil {
|
|
148
|
+
// A directory we cannot read would otherwise silently vanish
|
|
149
|
+
// from the totals — count it so the UI can say the tree is
|
|
150
|
+
// incomplete rather than quietly under-reporting.
|
|
151
|
+
t.nodes[q.node].unreadable++
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
node := t.nodes[q.node]
|
|
155
|
+
var bytesDone int64
|
|
156
|
+
|
|
157
|
+
for i := range entries {
|
|
158
|
+
e := &entries[i]
|
|
159
|
+
if !e.dir {
|
|
160
|
+
if e.key != 0 {
|
|
161
|
+
if _, dup := seenInodes[e.key]; dup {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
seenInodes[e.key] = struct{}{}
|
|
165
|
+
}
|
|
166
|
+
bytesDone += e.size
|
|
167
|
+
node.bytes += e.size
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Don't cross into other filesystems — each root is sized
|
|
172
|
+
// separately, so we'd otherwise double-count.
|
|
173
|
+
if e.dev != rootDev {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
d := q.depth + 1
|
|
177
|
+
child := next.push(dirPath, e.name, queued{
|
|
178
|
+
node: q.node, depth: d, dedupe: q.dedupe,
|
|
179
|
+
own: q.own && int(d) <= w.maxDepth,
|
|
180
|
+
})
|
|
181
|
+
if checkPath {
|
|
182
|
+
key := normKey(next.path(child))
|
|
183
|
+
if w.excluded[key] {
|
|
184
|
+
next.pop(child)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if atSys && key == sysKey {
|
|
188
|
+
child.dedupe = true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if child.own {
|
|
192
|
+
child.node = int32(len(t.nodes))
|
|
193
|
+
t.nodes = append(t.nodes, &dirNode{name: string(e.name)})
|
|
194
|
+
node.children = append(node.children, child.node)
|
|
195
|
+
}
|
|
196
|
+
next.dirs[len(next.dirs)-1] = child
|
|
197
|
+
}
|
|
198
|
+
w.progress.Add(bytesDone)
|
|
199
|
+
|
|
200
|
+
if err := w.pace.wait(ctx, seen); err != nil {
|
|
201
|
+
return nil, err
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
level, next = next, level
|
|
205
|
+
next.reset()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Breadth-first order puts every child after its parent, so walking it
|
|
209
|
+
// backwards totals each child before the parent that sums it.
|
|
210
|
+
for i := len(t.nodes) - 1; i >= 0; i-- {
|
|
211
|
+
node := t.nodes[i]
|
|
212
|
+
node.size += node.bytes
|
|
213
|
+
for _, c := range node.children {
|
|
214
|
+
node.size += t.nodes[c].size
|
|
215
|
+
node.unreadable += t.nodes[c].unreadable
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return t, nil
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ── being a good neighbour ─────────────────────────────────────────────── */
|
|
222
|
+
|
|
223
|
+
// throttle holds the walk to a fraction of one core. Low priority only
|
|
224
|
+
// matters when something else wants the CPU; on an otherwise quiet box a
|
|
225
|
+
// warm-cache walk would happily pin a core for a minute, which shows up on the
|
|
226
|
+
// very dashboard it is feeding. So after every stretch of work the walk sleeps
|
|
227
|
+
// long enough that the CPU it actually used — measured per thread, so time
|
|
228
|
+
// blocked on the disk costs nothing — averages out to the configured share.
|
|
229
|
+
type throttle struct {
|
|
230
|
+
share float64 // of one core; >= 1 means unthrottled
|
|
231
|
+
ops int
|
|
232
|
+
mark time.Time
|
|
233
|
+
cpu time.Duration
|
|
234
|
+
timer *time.Timer
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Enough entries between checks that the CPU-time query is noise, few enough
|
|
238
|
+
// that each sleep is a few milliseconds rather than a visible stall.
|
|
239
|
+
const throttleEvery = 2048
|
|
240
|
+
|
|
241
|
+
func newThrottle(percent float64) *throttle {
|
|
242
|
+
t := &throttle{share: percent / 100}
|
|
243
|
+
if t.share <= 0 {
|
|
244
|
+
t.share = 1
|
|
245
|
+
}
|
|
246
|
+
t.timer = time.NewTimer(time.Hour)
|
|
247
|
+
t.timer.Stop()
|
|
248
|
+
t.mark, t.cpu = time.Now(), threadCPU()
|
|
249
|
+
return t
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func (t *throttle) wait(ctx context.Context, ops int) error {
|
|
253
|
+
if t.ops += ops; t.ops < throttleEvery {
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
t.ops = 0
|
|
257
|
+
if t.share < 1 {
|
|
258
|
+
cpu := threadCPU()
|
|
259
|
+
idle := time.Duration(float64(cpu-t.cpu)/t.share) - time.Since(t.mark)
|
|
260
|
+
if idle > time.Millisecond {
|
|
261
|
+
t.timer.Reset(min(idle, 2*time.Second))
|
|
262
|
+
select {
|
|
263
|
+
case <-ctx.Done():
|
|
264
|
+
return ctx.Err()
|
|
265
|
+
case <-t.timer.C:
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
t.mark, t.cpu = time.Now(), cpu
|
|
269
|
+
}
|
|
270
|
+
// Checked here rather than per entry: off the hot path, but still often
|
|
271
|
+
// enough to abort a huge walk promptly.
|
|
272
|
+
return ctx.Err()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ── bounded top-N of files ─────────────────────────────────────────────── */
|
|
276
|
+
|
|
277
|
+
type fileEntry struct {
|
|
278
|
+
size int64
|
|
279
|
+
name string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// fileHeap is a min-heap of the topFiles largest files seen, so a new file
|
|
283
|
+
// only has to beat the root to get in. It grows as needed rather than
|
|
284
|
+
// reserving topFiles slots, since most directories hold only a few files.
|
|
285
|
+
type fileHeap []fileEntry
|
|
286
|
+
|
|
287
|
+
// offer takes the name as bytes so the string is only allocated for a file
|
|
288
|
+
// that actually makes the cut.
|
|
289
|
+
func (h *fileHeap) offer(size int64, name []byte) {
|
|
290
|
+
if size <= 0 {
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
a := *h
|
|
294
|
+
if len(a) < topFiles {
|
|
295
|
+
a = append(a, fileEntry{size: size, name: string(name)})
|
|
296
|
+
for i := len(a) - 1; i > 0; {
|
|
297
|
+
p := (i - 1) / 2
|
|
298
|
+
if a[p].size <= a[i].size {
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
a[p], a[i] = a[i], a[p]
|
|
302
|
+
i = p
|
|
303
|
+
}
|
|
304
|
+
*h = a
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if size <= a[0].size {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
a[0] = fileEntry{size: size, name: string(name)}
|
|
311
|
+
for i := 0; ; {
|
|
312
|
+
l, small := 2*i+1, i
|
|
313
|
+
if l < len(a) && a[l].size < a[small].size {
|
|
314
|
+
small = l
|
|
315
|
+
}
|
|
316
|
+
if r := l + 1; r < len(a) && a[r].size < a[small].size {
|
|
317
|
+
small = r
|
|
318
|
+
}
|
|
319
|
+
if small == i {
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
a[i], a[small] = a[small], a[i]
|
|
323
|
+
i = small
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ── path helpers ───────────────────────────────────────────────────────── */
|
|
328
|
+
|
|
329
|
+
func baseName(path string) string {
|
|
330
|
+
for len(path) > 1 && os.IsPathSeparator(path[len(path)-1]) {
|
|
331
|
+
path = path[:len(path)-1]
|
|
332
|
+
}
|
|
333
|
+
for i := len(path) - 1; i >= 0; i-- {
|
|
334
|
+
if os.IsPathSeparator(path[i]) {
|
|
335
|
+
if i == len(path)-1 {
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
return path[i+1:]
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return path
|
|
342
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Package vitals reads host-wide CPU / memory / disk / network figures.
|
|
2
|
+
//
|
|
3
|
+
// On Linux they come straight out of /proc and /sys. Kanshi runs with
|
|
4
|
+
// `network_mode: host` and without lxcfs, so those report real host values
|
|
5
|
+
// with no special configuration. On Windows they come from the same kernel32,
|
|
6
|
+
// ntdll and iphlpapi calls Task Manager uses.
|
|
7
|
+
package vitals
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"math"
|
|
11
|
+
"sync"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/rene-roid/kanshi/internal/roots"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// Below this gap the counter deltas are too small to divide by: a 20ms window
|
|
18
|
+
// turns a routine 2MB read into a fake 100MB/s spike. Hold the previous rate.
|
|
19
|
+
const minDT = 500 * time.Millisecond
|
|
20
|
+
|
|
21
|
+
// Reader owns the counter baselines every rate is measured against. One is
|
|
22
|
+
// created per process; the mutex only ever guards against a REST handler
|
|
23
|
+
// sampling at the same moment as the poller.
|
|
24
|
+
type Reader struct {
|
|
25
|
+
roots *roots.Resolver
|
|
26
|
+
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
sys sysState // the platform's own baselines and cached handles
|
|
29
|
+
prevNet counterPair
|
|
30
|
+
prevDisk counterPair
|
|
31
|
+
netRate [2]float64
|
|
32
|
+
diskRate [2]float64
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type counterPair struct {
|
|
36
|
+
at time.Time
|
|
37
|
+
a, b uint64
|
|
38
|
+
ok bool
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func New(r *roots.Resolver) *Reader { return &Reader{roots: r} }
|
|
42
|
+
|
|
43
|
+
/* ── payload ────────────────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
// Sample is the JSON the browser consumes; field names are part of the wire
|
|
46
|
+
// contract with web/app.js.
|
|
47
|
+
type Sample struct {
|
|
48
|
+
TS float64 `json:"ts"`
|
|
49
|
+
CPU CPU `json:"cpu"`
|
|
50
|
+
Memory Memory `json:"memory"`
|
|
51
|
+
Swap Swap `json:"swap"`
|
|
52
|
+
Network RxTx `json:"network"`
|
|
53
|
+
DiskIO ReadWrite `json:"diskio"`
|
|
54
|
+
Filesystems []Filesystem `json:"filesystems"`
|
|
55
|
+
Uptime float64 `json:"uptime"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type CPU struct {
|
|
59
|
+
Percent float64 `json:"percent"`
|
|
60
|
+
Cores []float64 `json:"cores"`
|
|
61
|
+
// Load average and temperature are null where the platform has no such
|
|
62
|
+
// thing (Windows has no load average) or no readable sensor.
|
|
63
|
+
Load *[3]float64 `json:"load"`
|
|
64
|
+
Count int `json:"count"`
|
|
65
|
+
Temp *float64 `json:"temp"`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Memory struct {
|
|
69
|
+
Total uint64 `json:"total"`
|
|
70
|
+
Used uint64 `json:"used"`
|
|
71
|
+
Available uint64 `json:"available"`
|
|
72
|
+
Cached uint64 `json:"cached"`
|
|
73
|
+
Percent float64 `json:"percent"`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Swap struct {
|
|
77
|
+
Total uint64 `json:"total"`
|
|
78
|
+
Used uint64 `json:"used"`
|
|
79
|
+
Percent float64 `json:"percent"`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type RxTx struct {
|
|
83
|
+
RX float64 `json:"rx"`
|
|
84
|
+
TX float64 `json:"tx"`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type ReadWrite struct {
|
|
88
|
+
Read float64 `json:"read"`
|
|
89
|
+
Write float64 `json:"write"`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Filesystem struct {
|
|
93
|
+
Label string `json:"label"`
|
|
94
|
+
Path string `json:"path"`
|
|
95
|
+
Total uint64 `json:"total"`
|
|
96
|
+
Used uint64 `json:"used"`
|
|
97
|
+
Free uint64 `json:"free"`
|
|
98
|
+
Percent float64 `json:"percent"`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ── entry points ───────────────────────────────────────────────────────── */
|
|
102
|
+
|
|
103
|
+
// Prime seeds the counter baselines so the first published frame shows real
|
|
104
|
+
// numbers instead of a screen of zeros.
|
|
105
|
+
func (r *Reader) Prime() {
|
|
106
|
+
r.mu.Lock()
|
|
107
|
+
defer r.mu.Unlock()
|
|
108
|
+
r.primeCPU()
|
|
109
|
+
rx, tx, ok := r.netCounters()
|
|
110
|
+
rate(&r.prevNet, &r.netRate, rx, tx, ok)
|
|
111
|
+
read, write, ok := r.diskCounters()
|
|
112
|
+
rate(&r.prevDisk, &r.diskRate, read, write, ok)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Sample takes one full reading of the host.
|
|
116
|
+
func (r *Reader) Sample() Sample {
|
|
117
|
+
r.mu.Lock()
|
|
118
|
+
defer r.mu.Unlock()
|
|
119
|
+
|
|
120
|
+
cores := r.cpuCores()
|
|
121
|
+
mem, sw := r.memory()
|
|
122
|
+
|
|
123
|
+
rx, tx, netOK := r.netCounters()
|
|
124
|
+
net := rate(&r.prevNet, &r.netRate, rx, tx, netOK)
|
|
125
|
+
read, write, diskOK := r.diskCounters()
|
|
126
|
+
disk := rate(&r.prevDisk, &r.diskRate, read, write, diskOK)
|
|
127
|
+
|
|
128
|
+
var mean float64
|
|
129
|
+
if len(cores) > 0 {
|
|
130
|
+
var sum float64
|
|
131
|
+
for _, c := range cores {
|
|
132
|
+
sum += c
|
|
133
|
+
}
|
|
134
|
+
mean = round1(sum / float64(len(cores)))
|
|
135
|
+
}
|
|
136
|
+
if cores == nil {
|
|
137
|
+
cores = []float64{} // the browser iterates this; never ship null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Sample{
|
|
141
|
+
TS: float64(time.Now().UnixNano()) / 1e9,
|
|
142
|
+
CPU: CPU{
|
|
143
|
+
Percent: mean,
|
|
144
|
+
Cores: cores,
|
|
145
|
+
Load: r.loadAvg(),
|
|
146
|
+
Count: len(cores),
|
|
147
|
+
Temp: r.temperature(),
|
|
148
|
+
},
|
|
149
|
+
Memory: mem,
|
|
150
|
+
Swap: sw,
|
|
151
|
+
Network: RxTx{RX: net[0], TX: net[1]},
|
|
152
|
+
DiskIO: ReadWrite{Read: disk[0], Write: disk[1]},
|
|
153
|
+
Filesystems: r.Filesystems(),
|
|
154
|
+
Uptime: r.uptime(),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Filesystems reports usage for each storage root's volume. Roots that share a
|
|
159
|
+
// volume — C:\ and the profile folder on it — get one meter, labelled with the
|
|
160
|
+
// first of them.
|
|
161
|
+
func (r *Reader) Filesystems() []Filesystem {
|
|
162
|
+
list := r.roots.Roots()
|
|
163
|
+
out := make([]Filesystem, 0, len(list))
|
|
164
|
+
seen := make(map[string]bool, len(list))
|
|
165
|
+
for _, root := range list {
|
|
166
|
+
u, err := roots.DiskUsage(root.Path)
|
|
167
|
+
if err != nil || u.Total == 0 || seen[u.Volume] {
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
seen[u.Volume] = true
|
|
171
|
+
fs := Filesystem{Label: root.Label, Path: root.Path, Total: u.Total, Used: u.Used, Free: u.Free}
|
|
172
|
+
if u.Used+u.Free > 0 {
|
|
173
|
+
fs.Percent = round1(float64(u.Used) / float64(u.Used+u.Free) * 100)
|
|
174
|
+
}
|
|
175
|
+
out = append(out, fs)
|
|
176
|
+
}
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ── shared arithmetic ──────────────────────────────────────────────────── */
|
|
181
|
+
|
|
182
|
+
// busyPercent turns two counter deltas into a utilisation, clamped to 0–100.
|
|
183
|
+
func busyPercent(busyDelta, allDelta float64) float64 {
|
|
184
|
+
if allDelta <= 0 || busyDelta <= 0 {
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
return round1(math.Min(busyDelta/allDelta*100, 100))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func memoryFigures(total, avail, cached uint64) Memory {
|
|
191
|
+
if avail > total {
|
|
192
|
+
avail = total
|
|
193
|
+
}
|
|
194
|
+
used := total - avail
|
|
195
|
+
m := Memory{Total: total, Used: used, Available: avail, Cached: cached}
|
|
196
|
+
if total > 0 {
|
|
197
|
+
m.Percent = round1(float64(used) / float64(total) * 100)
|
|
198
|
+
}
|
|
199
|
+
return m
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func swapFigures(total, free uint64) Swap {
|
|
203
|
+
if free > total {
|
|
204
|
+
free = total
|
|
205
|
+
}
|
|
206
|
+
s := Swap{Total: total, Used: total - free}
|
|
207
|
+
if total > 0 {
|
|
208
|
+
s.Percent = round1(float64(s.Used) / float64(total) * 100)
|
|
209
|
+
}
|
|
210
|
+
return s
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// rate turns two counter readings into bytes/second, holding the previous
|
|
214
|
+
// answer when the window is too short to divide by.
|
|
215
|
+
func rate(prev *counterPair, held *[2]float64, a, b uint64, ok bool) [2]float64 {
|
|
216
|
+
if !ok {
|
|
217
|
+
return [2]float64{0, 0}
|
|
218
|
+
}
|
|
219
|
+
now := time.Now()
|
|
220
|
+
if prev.ok && now.Sub(prev.at) < minDT {
|
|
221
|
+
return *held
|
|
222
|
+
}
|
|
223
|
+
was := *prev
|
|
224
|
+
*prev = counterPair{at: now, a: a, b: b, ok: true}
|
|
225
|
+
if !was.ok {
|
|
226
|
+
return [2]float64{0, 0}
|
|
227
|
+
}
|
|
228
|
+
dt := now.Sub(was.at).Seconds()
|
|
229
|
+
if dt <= 0 {
|
|
230
|
+
return *held
|
|
231
|
+
}
|
|
232
|
+
// Counters are 64-bit but reset when an interface or disk disappears;
|
|
233
|
+
// clamp rather than reporting a negative or an absurd spike.
|
|
234
|
+
*held = [2]float64{diff(a, was.a) / dt, diff(b, was.b) / dt}
|
|
235
|
+
return *held
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func diff(now, prev uint64) float64 {
|
|
239
|
+
if now < prev {
|
|
240
|
+
return 0
|
|
241
|
+
}
|
|
242
|
+
return float64(now - prev)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func round1(v float64) float64 { return math.Round(v*10) / 10 }
|
|
246
|
+
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|