@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,548 @@
1
+ package vitals
2
+
3
+ import (
4
+ "bytes"
5
+ "os"
6
+ "sort"
7
+ "strings"
8
+ "syscall"
9
+ "time"
10
+ )
11
+
12
+ // Linux reports block counts in 512-byte sectors regardless of device
13
+ // geometry, so this is a constant rather than something to look up.
14
+ const sectorSize = 512
15
+
16
+ // How often the sets of physical disks and network interfaces are re-read.
17
+ // Hot-plugging a NIC is rare; listing /sys every tick is not free.
18
+ const devicesEvery = time.Minute
19
+
20
+ // sysState is everything Linux sampling keeps between ticks.
21
+ //
22
+ // The /proc files stay open and are re-read with pread at offset zero, which
23
+ // makes the kernel regenerate them: no open/close per tick, and one reused
24
+ // buffer instead of a fresh one per file.
25
+ type sysState struct {
26
+ stat, meminfo, netdev, diskstats, loadavg, temp procFile
27
+
28
+ buf []byte
29
+ fields [][]byte
30
+
31
+ prevCPU, curCPU []cpuTimes
32
+ cpuAt time.Time
33
+ bootTime float64
34
+
35
+ netIfaces map[string]bool // physical interfaces; nil means "all but lo"
36
+ disks map[string]bool // whole physical disks
37
+ devicesAt time.Time
38
+
39
+ tempPath string
40
+ tempAt time.Time // last sensor search, so a sensorless box is not searched every tick
41
+ }
42
+
43
+ type procFile struct {
44
+ fd int
45
+ open bool
46
+ }
47
+
48
+ // read returns the whole file, regenerated. The slice aliases the shared
49
+ // buffer and is only valid until the next read.
50
+ func (s *sysState) read(f *procFile, path string) []byte {
51
+ if s.buf == nil {
52
+ s.buf = make([]byte, 16*1024)
53
+ }
54
+ for {
55
+ if !f.open {
56
+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC, 0)
57
+ if err != nil {
58
+ return nil
59
+ }
60
+ f.fd, f.open = fd, true
61
+ }
62
+ n, err := syscall.Pread(f.fd, s.buf, 0)
63
+ if err == syscall.EINTR {
64
+ continue
65
+ }
66
+ if err != nil {
67
+ syscall.Close(f.fd)
68
+ f.open = false
69
+ return nil
70
+ }
71
+ if n < len(s.buf) {
72
+ return s.buf[:n]
73
+ }
74
+ // /proc/stat's interrupt line grows with the core count; a full
75
+ // buffer means the file may have been cut short, so read it again.
76
+ s.buf = make([]byte, 2*len(s.buf))
77
+ }
78
+ }
79
+
80
+ /* ── CPU ────────────────────────────────────────────────────────────────── */
81
+
82
+ // cpuTimes mirrors one /proc/stat line. guest and guestNice are already
83
+ // counted inside user and nice, so they are left out of the total.
84
+ type cpuTimes struct {
85
+ user, nice, system, idle, iowait, irq, softirq, steal uint64
86
+ }
87
+
88
+ // total is everything the CPU could have been doing, minus iowait — during
89
+ // iowait the core is genuinely idle, and folding it into the denominator makes
90
+ // a busy disk look like a busy processor.
91
+ func (t cpuTimes) total() uint64 {
92
+ return t.user + t.nice + t.system + t.idle + t.irq + t.softirq + t.steal
93
+ }
94
+
95
+ func (t cpuTimes) busy() uint64 { return t.total() - t.idle }
96
+
97
+ // readCPU parses the per-core lines of /proc/stat into dst. The boot time is
98
+ // on a later line of the same file and never changes, so it is picked up once.
99
+ func (r *Reader) readCPU(dst []cpuTimes) []cpuTimes {
100
+ s := &r.sys
101
+ b := s.read(&s.stat, "/proc/stat")
102
+ dst = dst[:0]
103
+ for len(b) > 0 {
104
+ var line []byte
105
+ line, b = nextLine(b)
106
+ if !bytes.HasPrefix(line, []byte("cpu")) {
107
+ if s.bootTime > 0 {
108
+ break // the cpu lines always come first; stop before intr
109
+ }
110
+ if rest, ok := bytes.CutPrefix(line, []byte("btime ")); ok {
111
+ s.bootTime = float64(atou(bytes.TrimSpace(rest)))
112
+ break
113
+ }
114
+ continue
115
+ }
116
+ if len(line) > 3 && line[3] == ' ' {
117
+ continue // the aggregate line; we average the per-core ones instead
118
+ }
119
+ f := splitFields(line, s.fields)
120
+ s.fields = f
121
+ if len(f) < 5 {
122
+ continue
123
+ }
124
+ var n [8]uint64
125
+ for i := 1; i < len(f) && i <= 8; i++ {
126
+ n[i-1] = atou(f[i])
127
+ }
128
+ dst = append(dst, cpuTimes{
129
+ user: n[0], nice: n[1], system: n[2], idle: n[3], iowait: n[4],
130
+ irq: n[5], softirq: n[6], steal: n[7],
131
+ })
132
+ }
133
+ return dst
134
+ }
135
+
136
+ func (r *Reader) primeCPU() {
137
+ r.sys.prevCPU = r.readCPU(r.sys.prevCPU)
138
+ r.sys.cpuAt = time.Now()
139
+ }
140
+
141
+ // cpuCores returns per-core utilisation since the previous call.
142
+ //
143
+ // Every reading is a delta against the last one, so if that call was moments
144
+ // ago every core reads 0%. When the gap is too short to be meaningful, measure
145
+ // a real (short, blocking) window instead — Sample runs off the request path,
146
+ // so this never stalls anything the browser is waiting on.
147
+ func (r *Reader) cpuCores() []float64 {
148
+ s := &r.sys
149
+ s.curCPU = r.readCPU(s.curCPU)
150
+ if len(s.curCPU) == 0 {
151
+ return nil
152
+ }
153
+ if len(s.prevCPU) == 0 || time.Since(s.cpuAt) < minDT {
154
+ s.prevCPU, s.curCPU = s.curCPU, s.prevCPU
155
+ s.cpuAt = time.Now()
156
+ time.Sleep(250 * time.Millisecond)
157
+ if s.curCPU = r.readCPU(s.curCPU); len(s.curCPU) == 0 {
158
+ return nil
159
+ }
160
+ }
161
+
162
+ prev, now := s.prevCPU, s.curCPU
163
+ out := make([]float64, len(now))
164
+ if len(prev) == len(now) { // otherwise the core count changed; skip one frame
165
+ for i := range now {
166
+ out[i] = busyPercent(float64(now[i].busy())-float64(prev[i].busy()),
167
+ float64(now[i].total())-float64(prev[i].total()))
168
+ }
169
+ }
170
+ s.prevCPU, s.curCPU = now, prev
171
+ s.cpuAt = time.Now()
172
+ return out
173
+ }
174
+
175
+ /* ── memory ─────────────────────────────────────────────────────────────── */
176
+
177
+ func (r *Reader) memory() (Memory, Swap) {
178
+ s := &r.sys
179
+ var total, free, avail, buffers, cached, reclaimable, swapTotal, swapFree uint64
180
+ haveAvail := false
181
+ b := s.read(&s.meminfo, "/proc/meminfo")
182
+ for len(b) > 0 {
183
+ var line []byte
184
+ line, b = nextLine(b)
185
+ key, rest, ok := bytes.Cut(line, []byte(":"))
186
+ if !ok {
187
+ continue
188
+ }
189
+ // Every value used here is in kB.
190
+ v := atou(bytes.TrimLeft(rest, " ")) * 1024
191
+ switch string(key) { // no allocation: the compiler special-cases this
192
+ case "MemTotal":
193
+ total = v
194
+ case "MemFree":
195
+ free = v
196
+ case "MemAvailable":
197
+ avail, haveAvail = v, true
198
+ case "Buffers":
199
+ buffers = v
200
+ case "Cached":
201
+ cached = v
202
+ case "SReclaimable":
203
+ reclaimable = v
204
+ case "SwapTotal":
205
+ swapTotal = v
206
+ case "SwapFree":
207
+ swapFree = v
208
+ }
209
+ }
210
+ // MemAvailable is the kernel's own estimate and has been there since 3.14;
211
+ // the fallback is only for exotic kernels.
212
+ if !haveAvail {
213
+ avail = free + cached + buffers
214
+ }
215
+ // `free` counts reclaimable slab as cache, and so does htop.
216
+ return memoryFigures(total, avail, cached+reclaimable+buffers), swapFigures(swapTotal, swapFree)
217
+ }
218
+
219
+ /* ── network and disk rates ─────────────────────────────────────────────── */
220
+
221
+ // refreshDevices re-reads which interfaces and disks are physical.
222
+ //
223
+ // Summing every line of /proc/net/dev counts a container's traffic up to three
224
+ // times — on its veth, on the Docker bridge, and on the real NIC — plus all
225
+ // loopback chatter. Summing every line of /proc/diskstats counts an LVM or
226
+ // dm-crypt volume's I/O again on the disk underneath. Only hardware that sysfs
227
+ // ties to a device is what actually crosses the wire or hits the platter.
228
+ func (s *sysState) refreshDevices() {
229
+ if !s.devicesAt.IsZero() && time.Since(s.devicesAt) < devicesEvery {
230
+ return
231
+ }
232
+ s.devicesAt = time.Now()
233
+ s.netIfaces = withDevice("/sys/class/net")
234
+ s.disks = withDevice("/sys/block")
235
+ if len(s.disks) == 0 {
236
+ s.disks = partitionDevices() // no sysfs; fall back to /proc/partitions
237
+ }
238
+ }
239
+
240
+ // withDevice lists the entries of a sysfs class directory that are backed by
241
+ // real hardware (they have a "device" link).
242
+ func withDevice(dir string) map[string]bool {
243
+ entries, err := os.ReadDir(dir)
244
+ if err != nil {
245
+ return nil
246
+ }
247
+ out := make(map[string]bool)
248
+ for _, e := range entries {
249
+ if _, err := os.Stat(dir + "/" + e.Name() + "/device"); err == nil {
250
+ out[e.Name()] = true
251
+ }
252
+ }
253
+ if len(out) == 0 {
254
+ return nil
255
+ }
256
+ return out
257
+ }
258
+
259
+ func (r *Reader) netCounters() (rx, tx uint64, ok bool) {
260
+ s := &r.sys
261
+ s.refreshDevices()
262
+ b := s.read(&s.netdev, "/proc/net/dev")
263
+ if b == nil {
264
+ return 0, 0, false
265
+ }
266
+ for len(b) > 0 {
267
+ var line []byte
268
+ line, b = nextLine(b)
269
+ name, rest, found := bytes.Cut(line, []byte(":"))
270
+ if !found {
271
+ continue // the two header lines
272
+ }
273
+ name = bytes.TrimSpace(name)
274
+ if s.netIfaces != nil {
275
+ if !s.netIfaces[string(name)] {
276
+ continue
277
+ }
278
+ } else if string(name) == "lo" {
279
+ continue
280
+ }
281
+ f := splitFields(rest, s.fields)
282
+ s.fields = f
283
+ if len(f) < 9 {
284
+ continue
285
+ }
286
+ rx += atou(f[0])
287
+ tx += atou(f[8])
288
+ }
289
+ return rx, tx, true
290
+ }
291
+
292
+ func (r *Reader) diskCounters() (read, write uint64, ok bool) {
293
+ s := &r.sys
294
+ s.refreshDevices()
295
+ b := s.read(&s.diskstats, "/proc/diskstats")
296
+ if b == nil {
297
+ return 0, 0, false
298
+ }
299
+ for len(b) > 0 {
300
+ var line []byte
301
+ line, b = nextLine(b)
302
+ f := splitFields(line, s.fields)
303
+ s.fields = f
304
+ // The kernel has grown this line over the years (14, 18, 20 fields);
305
+ // the first ten have never moved.
306
+ if len(f) < 10 || !s.disks[string(f[2])] {
307
+ continue
308
+ }
309
+ read += atou(f[5]) * sectorSize
310
+ write += atou(f[9]) * sectorSize
311
+ }
312
+ return read, write, true
313
+ }
314
+
315
+ // partitionDevices picks block devices from /proc/partitions. Where a disk
316
+ // has partitions we count the partitions; where it has none we count the disk
317
+ // itself. Counting both would double every byte.
318
+ func partitionDevices() map[string]bool {
319
+ raw, err := os.ReadFile("/proc/partitions")
320
+ if err != nil {
321
+ return nil
322
+ }
323
+ var names []string
324
+ for _, line := range strings.Split(string(raw), "\n") {
325
+ fields := strings.Fields(line)
326
+ if len(fields) != 4 || fields[0] == "major" {
327
+ continue
328
+ }
329
+ names = append(names, fields[3])
330
+ }
331
+ // /proc/partitions lists a disk before its partitions, so walking it
332
+ // backwards means a partition is always seen before its parent disk.
333
+ out := make(map[string]bool, len(names))
334
+ var kept []string
335
+ for i := len(names) - 1; i >= 0; i-- {
336
+ name := names[i]
337
+ last := name[len(name)-1]
338
+ if last >= '0' && last <= '9' {
339
+ out[name] = true
340
+ kept = append(kept, name)
341
+ continue
342
+ }
343
+ if len(kept) == 0 || !strings.HasPrefix(kept[len(kept)-1], name) {
344
+ out[name] = true
345
+ kept = append(kept, name)
346
+ }
347
+ }
348
+ return out
349
+ }
350
+
351
+ /* ── temperature ────────────────────────────────────────────────────────── */
352
+
353
+ // preferredSensors are the chip names that actually mean "the processor",
354
+ // most-specific first. Anything else is a last resort.
355
+ var preferredSensors = []string{"coretemp", "k10temp", "cpu_thermal", "acpitz", "zenpower"}
356
+
357
+ // temperature reads the one sensor file picked by findSensor. Searching
358
+ // /sys/class/hwmon means dozens of directory reads, so it happens once rather
359
+ // than every tick, and again only if that file stops answering.
360
+ func (r *Reader) temperature() *float64 {
361
+ s := &r.sys
362
+ if s.tempPath == "" {
363
+ if !s.tempAt.IsZero() && time.Since(s.tempAt) < 5*time.Minute {
364
+ return nil
365
+ }
366
+ s.tempAt = time.Now()
367
+ if s.tempPath = findSensor(); s.tempPath == "" {
368
+ return nil
369
+ }
370
+ }
371
+ b := s.read(&s.temp, s.tempPath)
372
+ v := atou(bytes.TrimSpace(b))
373
+ if len(b) == 0 || v == 0 {
374
+ if s.temp.open {
375
+ syscall.Close(s.temp.fd)
376
+ s.temp.open = false
377
+ }
378
+ s.tempPath = ""
379
+ return nil
380
+ }
381
+ c := round1(float64(v) / 1000)
382
+ return &c
383
+ }
384
+
385
+ type sensor struct {
386
+ name, path string
387
+ }
388
+
389
+ func findSensor() string {
390
+ sensors := hwmonSensors()
391
+ for _, want := range preferredSensors {
392
+ for _, s := range sensors {
393
+ if s.name == want {
394
+ return s.path
395
+ }
396
+ }
397
+ }
398
+ if len(sensors) > 0 {
399
+ return sensors[0].path
400
+ }
401
+ return ""
402
+ }
403
+
404
+ func hwmonSensors() []sensor {
405
+ var out []sensor
406
+ for _, dir := range globDirs("/sys/class/hwmon") {
407
+ name := strings.TrimSpace(readFile(dir + "/name"))
408
+ if name == "" {
409
+ // Pre-3.15 kernels hang the name off the backing device.
410
+ name = strings.TrimSpace(readFile(dir + "/device/name"))
411
+ }
412
+ entries, err := os.ReadDir(dir)
413
+ if err != nil {
414
+ continue
415
+ }
416
+ var files []string
417
+ for _, e := range entries {
418
+ if strings.HasPrefix(e.Name(), "temp") && strings.HasSuffix(e.Name(), "_input") {
419
+ files = append(files, e.Name())
420
+ }
421
+ }
422
+ // temp1 is the package on every driver that exposes one, so read the
423
+ // lowest-numbered input rather than whichever the directory lists first.
424
+ sort.Strings(files)
425
+ for _, file := range files {
426
+ if readable(dir + "/" + file) {
427
+ out = append(out, sensor{name: name, path: dir + "/" + file})
428
+ break
429
+ }
430
+ }
431
+ }
432
+ // Boards with no hwmon driver still expose a thermal zone, and on ARM SBCs
433
+ // that is the only place a CPU temperature appears at all.
434
+ for _, dir := range globDirs("/sys/class/thermal") {
435
+ if strings.Contains(dir, "thermal_zone") && readable(dir+"/temp") {
436
+ out = append(out, sensor{name: strings.TrimSpace(readFile(dir + "/type")), path: dir + "/temp"})
437
+ }
438
+ }
439
+ return out
440
+ }
441
+
442
+ func readable(path string) bool {
443
+ raw := strings.TrimSpace(readFile(path))
444
+ return raw != "" && raw != "0"
445
+ }
446
+
447
+ func globDirs(parent string) []string {
448
+ entries, err := os.ReadDir(parent)
449
+ if err != nil {
450
+ return nil
451
+ }
452
+ out := make([]string, 0, len(entries))
453
+ for _, e := range entries {
454
+ out = append(out, parent+"/"+e.Name())
455
+ }
456
+ sort.Strings(out)
457
+ return out
458
+ }
459
+
460
+ func readFile(path string) string {
461
+ b, err := os.ReadFile(path)
462
+ if err != nil {
463
+ return ""
464
+ }
465
+ return string(b)
466
+ }
467
+
468
+ /* ── load and uptime ────────────────────────────────────────────────────── */
469
+
470
+ func (r *Reader) loadAvg() *[3]float64 {
471
+ s := &r.sys
472
+ f := splitFields(s.read(&s.loadavg, "/proc/loadavg"), s.fields)
473
+ s.fields = f
474
+ if len(f) < 3 {
475
+ return nil
476
+ }
477
+ var out [3]float64
478
+ for i := 0; i < 3; i++ {
479
+ out[i] = round2(atof(f[i]))
480
+ }
481
+ return &out
482
+ }
483
+
484
+ // uptime comes from /proc/stat's btime rather than /proc/uptime because the
485
+ // latter is namespaced on some runtimes and would report the container's age.
486
+ func (r *Reader) uptime() float64 {
487
+ if r.sys.bootTime == 0 {
488
+ r.readCPU(nil)
489
+ }
490
+ if r.sys.bootTime == 0 {
491
+ return 0
492
+ }
493
+ return float64(time.Now().UnixNano())/1e9 - r.sys.bootTime
494
+ }
495
+
496
+ /* ── allocation-free parsing ────────────────────────────────────────────── */
497
+
498
+ func nextLine(b []byte) (line, rest []byte) {
499
+ if i := bytes.IndexByte(b, '\n'); i >= 0 {
500
+ return b[:i], b[i+1:]
501
+ }
502
+ return b, nil
503
+ }
504
+
505
+ // splitFields is strings.Fields over bytes, reusing dst's backing array.
506
+ func splitFields(b []byte, dst [][]byte) [][]byte {
507
+ dst = dst[:0]
508
+ for i := 0; i < len(b); {
509
+ for i < len(b) && (b[i] == ' ' || b[i] == '\t') {
510
+ i++
511
+ }
512
+ start := i
513
+ for i < len(b) && b[i] != ' ' && b[i] != '\t' {
514
+ i++
515
+ }
516
+ if i > start {
517
+ dst = append(dst, b[start:i])
518
+ }
519
+ }
520
+ return dst
521
+ }
522
+
523
+ // atou parses the leading digits of b.
524
+ func atou(b []byte) uint64 {
525
+ var n uint64
526
+ for _, c := range b {
527
+ if c < '0' || c > '9' {
528
+ break
529
+ }
530
+ n = n*10 + uint64(c-'0')
531
+ }
532
+ return n
533
+ }
534
+
535
+ // atof parses the plain "12.34" decimals /proc/loadavg is written in.
536
+ func atof(b []byte) float64 {
537
+ whole, frac, _ := bytes.Cut(b, []byte("."))
538
+ v := float64(atou(whole))
539
+ scale := 0.1
540
+ for _, c := range frac {
541
+ if c < '0' || c > '9' {
542
+ break
543
+ }
544
+ v += float64(c-'0') * scale
545
+ scale /= 10
546
+ }
547
+ return v
548
+ }