@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.
Files changed (45) hide show
  1. package/.env.example +34 -0
  2. package/Dockerfile +53 -0
  3. package/LICENSE +661 -0
  4. package/README.md +219 -0
  5. package/bin/kanshi.js +63 -0
  6. package/docker-compose.yml +84 -0
  7. package/go.mod +3 -0
  8. package/internal/access/access.go +275 -0
  9. package/internal/access/listeners.go +229 -0
  10. package/internal/config/config.go +256 -0
  11. package/internal/config/defaults_other.go +5 -0
  12. package/internal/config/defaults_windows.go +4 -0
  13. package/internal/config/envfile.go +147 -0
  14. package/internal/dockerstats/dial.go +32 -0
  15. package/internal/dockerstats/dial_other.go +13 -0
  16. package/internal/dockerstats/dial_windows.go +62 -0
  17. package/internal/dockerstats/dockerstats.go +626 -0
  18. package/internal/roots/roots.go +175 -0
  19. package/internal/roots/roots_linux.go +132 -0
  20. package/internal/roots/roots_windows.go +74 -0
  21. package/internal/server/access.go +155 -0
  22. package/internal/server/server.go +523 -0
  23. package/internal/server/static.go +165 -0
  24. package/internal/storage/cache.go +283 -0
  25. package/internal/storage/dir_linux.go +177 -0
  26. package/internal/storage/dir_windows.go +244 -0
  27. package/internal/storage/statat_fast.go +21 -0
  28. package/internal/storage/statat_other.go +11 -0
  29. package/internal/storage/storage.go +598 -0
  30. package/internal/storage/sysnum_amd64.go +7 -0
  31. package/internal/storage/sysnum_generic.go +7 -0
  32. package/internal/storage/walk.go +342 -0
  33. package/internal/vitals/vitals.go +246 -0
  34. package/internal/vitals/vitals_linux.go +548 -0
  35. package/internal/vitals/vitals_windows.go +286 -0
  36. package/main.go +129 -0
  37. package/main_other.go +11 -0
  38. package/main_windows.go +29 -0
  39. package/package.json +49 -0
  40. package/web/app.js +835 -0
  41. package/web/apple-touch-icon.png +0 -0
  42. package/web/icon.svg +6 -0
  43. package/web/index.html +151 -0
  44. package/web/style.css +356 -0
  45. 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 }