@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,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
|
+
}
|