@yuuki824/kanshi 0.1.0 → 0.1.2
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 +4 -4
- package/Dockerfile +24 -19
- package/README.md +139 -36
- package/docker-compose.yml +16 -7
- package/go.mod +3 -0
- package/internal/config/config.go +140 -0
- package/internal/dockerstats/dockerstats.go +501 -0
- package/internal/server/server.go +372 -0
- package/internal/storage/storage.go +586 -0
- package/internal/vitals/vitals.go +614 -0
- package/main.go +76 -0
- package/package.json +5 -3
- package/web/app.js +167 -8
- package/web/index.html +10 -0
- package/web/style.css +27 -0
- package/web/treemap.js +21 -63
- package/app/__init__.py +0 -0
- package/app/config.py +0 -72
- package/app/dockerstats.py +0 -207
- package/app/main.py +0 -174
- package/app/storage.py +0 -220
- package/app/vitals.py +0 -170
- package/requirements.txt +0 -4
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// Package vitals reads host-wide CPU / memory / disk / network figures
|
|
2
|
+
// straight out of /proc and /sys.
|
|
3
|
+
//
|
|
4
|
+
// Kanshi runs with `network_mode: host` and without lxcfs, so /proc/stat,
|
|
5
|
+
// /proc/meminfo, /proc/diskstats and /proc/net/dev all report real host values
|
|
6
|
+
// with no special configuration — the same reason the psutil version worked.
|
|
7
|
+
package vitals
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"bufio"
|
|
11
|
+
"math"
|
|
12
|
+
"os"
|
|
13
|
+
"sort"
|
|
14
|
+
"strconv"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
"syscall"
|
|
18
|
+
"time"
|
|
19
|
+
|
|
20
|
+
"github.com/yuuki824/kanshi/internal/config"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Below this gap the counter deltas are too small to divide by: a 20ms window
|
|
24
|
+
// turns a routine 2MB read into a fake 100MB/s spike. Hold the previous rate.
|
|
25
|
+
const minDT = 500 * time.Millisecond
|
|
26
|
+
|
|
27
|
+
// Linux reports block counts in 512-byte sectors regardless of device
|
|
28
|
+
// geometry, so this is a constant rather than something to look up.
|
|
29
|
+
const sectorSize = 512
|
|
30
|
+
|
|
31
|
+
// Reader owns the counter baselines every rate is measured against. One is
|
|
32
|
+
// created per process; the mutex only ever guards against a REST handler
|
|
33
|
+
// sampling at the same moment as the poller.
|
|
34
|
+
type Reader struct {
|
|
35
|
+
cfg config.Config
|
|
36
|
+
|
|
37
|
+
mu sync.Mutex
|
|
38
|
+
prevCPU []cpuTimes
|
|
39
|
+
cpuAt time.Time
|
|
40
|
+
prevNet counterPair
|
|
41
|
+
prevDisk counterPair
|
|
42
|
+
netRate [2]float64
|
|
43
|
+
diskRate [2]float64
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type counterPair struct {
|
|
47
|
+
at time.Time
|
|
48
|
+
a, b uint64
|
|
49
|
+
ok bool
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func New(cfg config.Config) *Reader { return &Reader{cfg: cfg} }
|
|
53
|
+
|
|
54
|
+
/* ── payload ────────────────────────────────────────────────────────────── */
|
|
55
|
+
|
|
56
|
+
// Sample is the JSON the browser consumes; field names are part of the wire
|
|
57
|
+
// contract with web/app.js.
|
|
58
|
+
type Sample struct {
|
|
59
|
+
TS float64 `json:"ts"`
|
|
60
|
+
CPU CPU `json:"cpu"`
|
|
61
|
+
Memory Memory `json:"memory"`
|
|
62
|
+
Swap Swap `json:"swap"`
|
|
63
|
+
Network RxTx `json:"network"`
|
|
64
|
+
DiskIO ReadWrite `json:"diskio"`
|
|
65
|
+
Filesystems []Filesystem `json:"filesystems"`
|
|
66
|
+
Uptime float64 `json:"uptime"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type CPU struct {
|
|
70
|
+
Percent float64 `json:"percent"`
|
|
71
|
+
Cores []float64 `json:"cores"`
|
|
72
|
+
Load [3]float64 `json:"load"`
|
|
73
|
+
Count int `json:"count"`
|
|
74
|
+
Temp *float64 `json:"temp"`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Memory struct {
|
|
78
|
+
Total uint64 `json:"total"`
|
|
79
|
+
Used uint64 `json:"used"`
|
|
80
|
+
Available uint64 `json:"available"`
|
|
81
|
+
Cached uint64 `json:"cached"`
|
|
82
|
+
Percent float64 `json:"percent"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Swap struct {
|
|
86
|
+
Total uint64 `json:"total"`
|
|
87
|
+
Used uint64 `json:"used"`
|
|
88
|
+
Percent float64 `json:"percent"`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type RxTx struct {
|
|
92
|
+
RX float64 `json:"rx"`
|
|
93
|
+
TX float64 `json:"tx"`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ReadWrite struct {
|
|
97
|
+
Read float64 `json:"read"`
|
|
98
|
+
Write float64 `json:"write"`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Filesystem struct {
|
|
102
|
+
Label string `json:"label"`
|
|
103
|
+
Path string `json:"path"`
|
|
104
|
+
Total uint64 `json:"total"`
|
|
105
|
+
Used uint64 `json:"used"`
|
|
106
|
+
Free uint64 `json:"free"`
|
|
107
|
+
Percent float64 `json:"percent"`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ── CPU ────────────────────────────────────────────────────────────────── */
|
|
111
|
+
|
|
112
|
+
// cpuTimes mirrors one /proc/stat line. guest and guestNice are already
|
|
113
|
+
// counted inside user and nice, so they are subtracted back out of the total.
|
|
114
|
+
type cpuTimes struct {
|
|
115
|
+
user, nice, system, idle, iowait, irq, softirq, steal, guest, guestNice uint64
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// total is everything the CPU could have been doing, minus iowait — during
|
|
119
|
+
// iowait the core is genuinely idle, and folding it into the denominator makes
|
|
120
|
+
// a busy disk look like a busy processor.
|
|
121
|
+
func (t cpuTimes) total() uint64 {
|
|
122
|
+
return t.user + t.nice + t.system + t.idle + t.irq + t.softirq + t.steal
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (t cpuTimes) busy() uint64 { return t.total() - t.idle }
|
|
126
|
+
|
|
127
|
+
func readCPUTimes() ([]cpuTimes, error) {
|
|
128
|
+
f, err := os.Open("/proc/stat")
|
|
129
|
+
if err != nil {
|
|
130
|
+
return nil, err
|
|
131
|
+
}
|
|
132
|
+
defer f.Close()
|
|
133
|
+
|
|
134
|
+
var out []cpuTimes
|
|
135
|
+
sc := bufio.NewScanner(f)
|
|
136
|
+
for sc.Scan() {
|
|
137
|
+
line := sc.Text()
|
|
138
|
+
if !strings.HasPrefix(line, "cpu") {
|
|
139
|
+
break // the cpu lines always come first; stop before intr/btime
|
|
140
|
+
}
|
|
141
|
+
if strings.HasPrefix(line, "cpu ") {
|
|
142
|
+
continue // the aggregate line; we average the per-core ones instead
|
|
143
|
+
}
|
|
144
|
+
fields := strings.Fields(line)
|
|
145
|
+
if len(fields) < 5 {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
var n [10]uint64
|
|
149
|
+
for i := 1; i < len(fields) && i <= 10; i++ {
|
|
150
|
+
n[i-1], _ = strconv.ParseUint(fields[i], 10, 64)
|
|
151
|
+
}
|
|
152
|
+
out = append(out, cpuTimes{
|
|
153
|
+
user: n[0], nice: n[1], system: n[2], idle: n[3], iowait: n[4],
|
|
154
|
+
irq: n[5], softirq: n[6], steal: n[7], guest: n[8], guestNice: n[9],
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return out, sc.Err()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// cpuCores returns per-core utilisation since the previous call.
|
|
161
|
+
//
|
|
162
|
+
// Every reading is a delta against the last one, so if that call was moments
|
|
163
|
+
// ago every core reads 0%. When the gap is too short to be meaningful, measure
|
|
164
|
+
// a real (short, blocking) window instead — Sample runs off the request path,
|
|
165
|
+
// so this never stalls anything the browser is waiting on.
|
|
166
|
+
func (r *Reader) cpuCores() []float64 {
|
|
167
|
+
now, err := readCPUTimes()
|
|
168
|
+
if err != nil {
|
|
169
|
+
return nil
|
|
170
|
+
}
|
|
171
|
+
if r.prevCPU == nil || time.Since(r.cpuAt) < minDT {
|
|
172
|
+
r.prevCPU, r.cpuAt = now, time.Now()
|
|
173
|
+
time.Sleep(250 * time.Millisecond)
|
|
174
|
+
if now, err = readCPUTimes(); err != nil {
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
prev := r.prevCPU
|
|
180
|
+
r.prevCPU, r.cpuAt = now, time.Now()
|
|
181
|
+
if len(prev) != len(now) {
|
|
182
|
+
return make([]float64, len(now)) // core count changed; skip one frame
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
out := make([]float64, len(now))
|
|
186
|
+
for i := range now {
|
|
187
|
+
allDelta := float64(now[i].total()) - float64(prev[i].total())
|
|
188
|
+
busyDelta := float64(now[i].busy()) - float64(prev[i].busy())
|
|
189
|
+
if allDelta <= 0 || busyDelta <= 0 {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
out[i] = round1(math.Min(busyDelta/allDelta*100, 100))
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── memory ─────────────────────────────────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
func readMeminfo() map[string]uint64 {
|
|
200
|
+
f, err := os.Open("/proc/meminfo")
|
|
201
|
+
if err != nil {
|
|
202
|
+
return nil
|
|
203
|
+
}
|
|
204
|
+
defer f.Close()
|
|
205
|
+
|
|
206
|
+
out := make(map[string]uint64, 64)
|
|
207
|
+
sc := bufio.NewScanner(f)
|
|
208
|
+
for sc.Scan() {
|
|
209
|
+
key, rest, ok := strings.Cut(sc.Text(), ":")
|
|
210
|
+
if !ok {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
fields := strings.Fields(rest)
|
|
214
|
+
if len(fields) == 0 {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
v, err := strconv.ParseUint(fields[0], 10, 64)
|
|
218
|
+
if err != nil {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
// Everything except HugePages counts is reported in kB.
|
|
222
|
+
if len(fields) > 1 && fields[1] == "kB" {
|
|
223
|
+
v *= 1024
|
|
224
|
+
}
|
|
225
|
+
out[key] = v
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func memory(mi map[string]uint64) Memory {
|
|
231
|
+
total := mi["MemTotal"]
|
|
232
|
+
// MemAvailable is the kernel's own estimate and has been there since 3.14;
|
|
233
|
+
// the fallback is only for exotic kernels.
|
|
234
|
+
avail, ok := mi["MemAvailable"]
|
|
235
|
+
if !ok {
|
|
236
|
+
avail = mi["MemFree"] + mi["Cached"] + mi["Buffers"]
|
|
237
|
+
}
|
|
238
|
+
if avail > total {
|
|
239
|
+
avail = total
|
|
240
|
+
}
|
|
241
|
+
used := total - avail
|
|
242
|
+
m := Memory{
|
|
243
|
+
Total: total,
|
|
244
|
+
Used: used,
|
|
245
|
+
Available: avail,
|
|
246
|
+
// `free` counts reclaimable slab as cache, and so does htop.
|
|
247
|
+
Cached: mi["Cached"] + mi["SReclaimable"] + mi["Buffers"],
|
|
248
|
+
}
|
|
249
|
+
if total > 0 {
|
|
250
|
+
m.Percent = round1(float64(used) / float64(total) * 100)
|
|
251
|
+
}
|
|
252
|
+
return m
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func swap(mi map[string]uint64) Swap {
|
|
256
|
+
total, free := mi["SwapTotal"], mi["SwapFree"]
|
|
257
|
+
if free > total {
|
|
258
|
+
free = total
|
|
259
|
+
}
|
|
260
|
+
s := Swap{Total: total, Used: total - free}
|
|
261
|
+
if total > 0 {
|
|
262
|
+
s.Percent = round1(float64(s.Used) / float64(total) * 100)
|
|
263
|
+
}
|
|
264
|
+
return s
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ── network and disk rates ─────────────────────────────────────────────── */
|
|
268
|
+
|
|
269
|
+
func readNetCounters() (rx, tx uint64, ok bool) {
|
|
270
|
+
f, err := os.Open("/proc/net/dev")
|
|
271
|
+
if err != nil {
|
|
272
|
+
return 0, 0, false
|
|
273
|
+
}
|
|
274
|
+
defer f.Close()
|
|
275
|
+
|
|
276
|
+
sc := bufio.NewScanner(f)
|
|
277
|
+
for sc.Scan() {
|
|
278
|
+
_, rest, found := strings.Cut(sc.Text(), ":")
|
|
279
|
+
if !found {
|
|
280
|
+
continue // the two header lines
|
|
281
|
+
}
|
|
282
|
+
fields := strings.Fields(rest)
|
|
283
|
+
if len(fields) < 9 {
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
r, _ := strconv.ParseUint(fields[0], 10, 64)
|
|
287
|
+
t, _ := strconv.ParseUint(fields[8], 10, 64)
|
|
288
|
+
rx += r
|
|
289
|
+
tx += t
|
|
290
|
+
}
|
|
291
|
+
return rx, tx, true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// diskDevices picks the block devices worth summing. Where a disk has
|
|
295
|
+
// partitions we count the partitions; where it has none (loop0, a bare nvme
|
|
296
|
+
// namespace) we count the disk itself. Counting both would double every byte.
|
|
297
|
+
func diskDevices() map[string]bool {
|
|
298
|
+
f, err := os.Open("/proc/partitions")
|
|
299
|
+
if err != nil {
|
|
300
|
+
return nil
|
|
301
|
+
}
|
|
302
|
+
defer f.Close()
|
|
303
|
+
|
|
304
|
+
var names []string
|
|
305
|
+
sc := bufio.NewScanner(f)
|
|
306
|
+
for sc.Scan() {
|
|
307
|
+
fields := strings.Fields(sc.Text())
|
|
308
|
+
if len(fields) != 4 || fields[0] == "major" {
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
names = append(names, fields[3])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// /proc/partitions lists a disk before its partitions, so walking it
|
|
315
|
+
// backwards means a partition is always seen before its parent disk.
|
|
316
|
+
out := make(map[string]bool, len(names))
|
|
317
|
+
var kept []string
|
|
318
|
+
for i := len(names) - 1; i >= 0; i-- {
|
|
319
|
+
name := names[i]
|
|
320
|
+
last := name[len(name)-1]
|
|
321
|
+
if last >= '0' && last <= '9' {
|
|
322
|
+
out[name] = true
|
|
323
|
+
kept = append(kept, name)
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
if len(kept) == 0 || !strings.HasPrefix(kept[len(kept)-1], name) {
|
|
327
|
+
out[name] = true
|
|
328
|
+
kept = append(kept, name)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return out
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func readDiskCounters(want map[string]bool) (read, write uint64, ok bool) {
|
|
335
|
+
f, err := os.Open("/proc/diskstats")
|
|
336
|
+
if err != nil {
|
|
337
|
+
return 0, 0, false
|
|
338
|
+
}
|
|
339
|
+
defer f.Close()
|
|
340
|
+
|
|
341
|
+
sc := bufio.NewScanner(f)
|
|
342
|
+
for sc.Scan() {
|
|
343
|
+
fields := strings.Fields(sc.Text())
|
|
344
|
+
// The kernel has grown this line over the years (14, 18, 20 fields);
|
|
345
|
+
// the first ten have never moved.
|
|
346
|
+
if len(fields) < 10 || !want[fields[2]] {
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
rs, _ := strconv.ParseUint(fields[5], 10, 64)
|
|
350
|
+
ws, _ := strconv.ParseUint(fields[9], 10, 64)
|
|
351
|
+
read += rs * sectorSize
|
|
352
|
+
write += ws * sectorSize
|
|
353
|
+
}
|
|
354
|
+
return read, write, true
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// rate turns two counter readings into bytes/second, holding the previous
|
|
358
|
+
// answer when the window is too short to divide by.
|
|
359
|
+
func rate(prev *counterPair, held *[2]float64, a, b uint64, ok bool) [2]float64 {
|
|
360
|
+
if !ok {
|
|
361
|
+
return [2]float64{0, 0}
|
|
362
|
+
}
|
|
363
|
+
now := time.Now()
|
|
364
|
+
if prev.ok && now.Sub(prev.at) < minDT {
|
|
365
|
+
return *held
|
|
366
|
+
}
|
|
367
|
+
was := *prev
|
|
368
|
+
*prev = counterPair{at: now, a: a, b: b, ok: true}
|
|
369
|
+
if !was.ok {
|
|
370
|
+
return [2]float64{0, 0}
|
|
371
|
+
}
|
|
372
|
+
dt := now.Sub(was.at).Seconds()
|
|
373
|
+
if dt <= 0 {
|
|
374
|
+
return *held
|
|
375
|
+
}
|
|
376
|
+
// Counters are 64-bit but reset when an interface or disk disappears;
|
|
377
|
+
// clamp rather than reporting a negative or an absurd spike.
|
|
378
|
+
*held = [2]float64{diff(a, was.a) / dt, diff(b, was.b) / dt}
|
|
379
|
+
return *held
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func diff(now, prev uint64) float64 {
|
|
383
|
+
if now < prev {
|
|
384
|
+
return 0
|
|
385
|
+
}
|
|
386
|
+
return float64(now - prev)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* ── temperature ────────────────────────────────────────────────────────── */
|
|
390
|
+
|
|
391
|
+
// preferredSensors are the chip names that actually mean "the processor",
|
|
392
|
+
// most-specific first. Anything else is a last resort.
|
|
393
|
+
var preferredSensors = []string{"coretemp", "k10temp", "cpu_thermal", "acpitz", "zenpower"}
|
|
394
|
+
|
|
395
|
+
func temperature() *float64 {
|
|
396
|
+
readings := hwmonReadings()
|
|
397
|
+
for _, want := range preferredSensors {
|
|
398
|
+
for _, r := range readings {
|
|
399
|
+
if r.name == want {
|
|
400
|
+
v := round1(r.celsius)
|
|
401
|
+
return &v
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if len(readings) > 0 {
|
|
406
|
+
v := round1(readings[0].celsius)
|
|
407
|
+
return &v
|
|
408
|
+
}
|
|
409
|
+
return nil
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
type reading struct {
|
|
413
|
+
name string
|
|
414
|
+
celsius float64
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func hwmonReadings() []reading {
|
|
418
|
+
var out []reading
|
|
419
|
+
for _, dir := range globDirs("/sys/class/hwmon") {
|
|
420
|
+
name := strings.TrimSpace(readFile(dir + "/name"))
|
|
421
|
+
if name == "" {
|
|
422
|
+
// Pre-3.15 kernels hang the name off the backing device.
|
|
423
|
+
name = strings.TrimSpace(readFile(dir + "/device/name"))
|
|
424
|
+
}
|
|
425
|
+
entries, err := os.ReadDir(dir)
|
|
426
|
+
if err != nil {
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
var files []string
|
|
430
|
+
for _, e := range entries {
|
|
431
|
+
if strings.HasPrefix(e.Name(), "temp") && strings.HasSuffix(e.Name(), "_input") {
|
|
432
|
+
files = append(files, e.Name())
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// temp1 is the package on every driver that exposes one, so read the
|
|
436
|
+
// lowest-numbered input rather than whichever the directory lists first.
|
|
437
|
+
sort.Strings(files)
|
|
438
|
+
for _, file := range files {
|
|
439
|
+
if v, ok := milliCelsius(dir + "/" + file); ok {
|
|
440
|
+
out = append(out, reading{name: name, celsius: v})
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Boards with no hwmon driver still expose a thermal zone, and on ARM SBCs
|
|
446
|
+
// that is the only place a CPU temperature appears at all.
|
|
447
|
+
for _, dir := range globDirs("/sys/class/thermal") {
|
|
448
|
+
if !strings.Contains(dir, "thermal_zone") {
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if v, ok := milliCelsius(dir + "/temp"); ok {
|
|
452
|
+
out = append(out, reading{name: strings.TrimSpace(readFile(dir + "/type")), celsius: v})
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return out
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func milliCelsius(path string) (float64, bool) {
|
|
459
|
+
raw := strings.TrimSpace(readFile(path))
|
|
460
|
+
if raw == "" {
|
|
461
|
+
return 0, false
|
|
462
|
+
}
|
|
463
|
+
v, err := strconv.ParseFloat(raw, 64)
|
|
464
|
+
if err != nil || v == 0 {
|
|
465
|
+
return 0, false
|
|
466
|
+
}
|
|
467
|
+
return v / 1000, true
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
func globDirs(parent string) []string {
|
|
471
|
+
entries, err := os.ReadDir(parent)
|
|
472
|
+
if err != nil {
|
|
473
|
+
return nil
|
|
474
|
+
}
|
|
475
|
+
out := make([]string, 0, len(entries))
|
|
476
|
+
for _, e := range entries {
|
|
477
|
+
out = append(out, parent+"/"+e.Name())
|
|
478
|
+
}
|
|
479
|
+
sort.Strings(out)
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func readFile(path string) string {
|
|
484
|
+
b, err := os.ReadFile(path)
|
|
485
|
+
if err != nil {
|
|
486
|
+
return ""
|
|
487
|
+
}
|
|
488
|
+
return string(b)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── filesystems and uptime ─────────────────────────────────────────────── */
|
|
492
|
+
|
|
493
|
+
// Filesystems reports usage for each configured storage root, read straight
|
|
494
|
+
// off statfs.
|
|
495
|
+
func (r *Reader) Filesystems() []Filesystem {
|
|
496
|
+
roots := r.cfg.Roots()
|
|
497
|
+
out := make([]Filesystem, 0, len(roots))
|
|
498
|
+
for _, root := range roots {
|
|
499
|
+
var st syscall.Statfs_t
|
|
500
|
+
if err := syscall.Statfs(root.Path, &st); err != nil {
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
bsize := uint64(st.Bsize)
|
|
504
|
+
total := st.Blocks * bsize
|
|
505
|
+
if total == 0 {
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
// Bavail excludes root-reserved blocks, so used+free won't equal total.
|
|
509
|
+
// Report "used" the way df does, against the non-reserved capacity.
|
|
510
|
+
free := st.Bavail * bsize
|
|
511
|
+
used := total - st.Bfree*bsize
|
|
512
|
+
fs := Filesystem{Label: root.Label, Path: root.Path, Total: total, Used: used, Free: free}
|
|
513
|
+
if used+free > 0 {
|
|
514
|
+
fs.Percent = round1(float64(used) / float64(used+free) * 100)
|
|
515
|
+
}
|
|
516
|
+
out = append(out, fs)
|
|
517
|
+
}
|
|
518
|
+
return out
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
func loadAvg() [3]float64 {
|
|
522
|
+
fields := strings.Fields(readFile("/proc/loadavg"))
|
|
523
|
+
var out [3]float64
|
|
524
|
+
for i := 0; i < 3 && i < len(fields); i++ {
|
|
525
|
+
v, _ := strconv.ParseFloat(fields[i], 64)
|
|
526
|
+
out[i] = round2(v)
|
|
527
|
+
}
|
|
528
|
+
return out
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// bootTime comes from /proc/stat's btime rather than /proc/uptime because the
|
|
532
|
+
// latter is namespaced on some runtimes and would report the container's age.
|
|
533
|
+
func bootTime() float64 {
|
|
534
|
+
f, err := os.Open("/proc/stat")
|
|
535
|
+
if err != nil {
|
|
536
|
+
return 0
|
|
537
|
+
}
|
|
538
|
+
defer f.Close()
|
|
539
|
+
sc := bufio.NewScanner(f)
|
|
540
|
+
for sc.Scan() {
|
|
541
|
+
if rest, ok := strings.CutPrefix(sc.Text(), "btime "); ok {
|
|
542
|
+
v, _ := strconv.ParseFloat(strings.TrimSpace(rest), 64)
|
|
543
|
+
return v
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return 0
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ── entry points ───────────────────────────────────────────────────────── */
|
|
550
|
+
|
|
551
|
+
// Prime seeds the counter baselines so the first published frame shows real
|
|
552
|
+
// numbers instead of a screen of zeros.
|
|
553
|
+
func (r *Reader) Prime() {
|
|
554
|
+
r.mu.Lock()
|
|
555
|
+
defer r.mu.Unlock()
|
|
556
|
+
if times, err := readCPUTimes(); err == nil {
|
|
557
|
+
r.prevCPU, r.cpuAt = times, time.Now()
|
|
558
|
+
}
|
|
559
|
+
rx, tx, ok := readNetCounters()
|
|
560
|
+
rate(&r.prevNet, &r.netRate, rx, tx, ok)
|
|
561
|
+
read, write, ok := readDiskCounters(diskDevices())
|
|
562
|
+
rate(&r.prevDisk, &r.diskRate, read, write, ok)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Sample takes one full reading of the host.
|
|
566
|
+
func (r *Reader) Sample() Sample {
|
|
567
|
+
r.mu.Lock()
|
|
568
|
+
defer r.mu.Unlock()
|
|
569
|
+
|
|
570
|
+
cores := r.cpuCores()
|
|
571
|
+
mi := readMeminfo()
|
|
572
|
+
|
|
573
|
+
rx, tx, netOK := readNetCounters()
|
|
574
|
+
net := rate(&r.prevNet, &r.netRate, rx, tx, netOK)
|
|
575
|
+
read, write, diskOK := readDiskCounters(diskDevices())
|
|
576
|
+
disk := rate(&r.prevDisk, &r.diskRate, read, write, diskOK)
|
|
577
|
+
|
|
578
|
+
var mean float64
|
|
579
|
+
if len(cores) > 0 {
|
|
580
|
+
var sum float64
|
|
581
|
+
for _, c := range cores {
|
|
582
|
+
sum += c
|
|
583
|
+
}
|
|
584
|
+
mean = round1(sum / float64(len(cores)))
|
|
585
|
+
}
|
|
586
|
+
if cores == nil {
|
|
587
|
+
cores = []float64{} // the browser iterates this; never ship null
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
var uptime float64
|
|
591
|
+
if bt := bootTime(); bt > 0 {
|
|
592
|
+
uptime = float64(time.Now().UnixNano())/1e9 - bt
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return Sample{
|
|
596
|
+
TS: float64(time.Now().UnixNano()) / 1e9,
|
|
597
|
+
CPU: CPU{
|
|
598
|
+
Percent: mean,
|
|
599
|
+
Cores: cores,
|
|
600
|
+
Load: loadAvg(),
|
|
601
|
+
Count: len(cores),
|
|
602
|
+
Temp: temperature(),
|
|
603
|
+
},
|
|
604
|
+
Memory: memory(mi),
|
|
605
|
+
Swap: swap(mi),
|
|
606
|
+
Network: RxTx{RX: net[0], TX: net[1]},
|
|
607
|
+
DiskIO: ReadWrite{Read: disk[0], Write: disk[1]},
|
|
608
|
+
Filesystems: r.Filesystems(),
|
|
609
|
+
Uptime: uptime,
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
func round1(v float64) float64 { return math.Round(v*10) / 10 }
|
|
614
|
+
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
package/main.go
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Command kanshi serves an at-a-glance dashboard for a single homeserver.
|
|
2
|
+
package main
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
"embed"
|
|
7
|
+
"flag"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io/fs"
|
|
10
|
+
"net/http"
|
|
11
|
+
"os"
|
|
12
|
+
"os/signal"
|
|
13
|
+
"syscall"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/yuuki824/kanshi/internal/config"
|
|
17
|
+
"github.com/yuuki824/kanshi/internal/server"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// The frontend is baked into the binary so the runtime image can be scratch —
|
|
21
|
+
// nothing to copy, nothing to keep in sync with the code that serves it.
|
|
22
|
+
//
|
|
23
|
+
//go:embed web
|
|
24
|
+
var embedded embed.FS
|
|
25
|
+
|
|
26
|
+
func main() {
|
|
27
|
+
healthcheck := flag.Bool("healthcheck", false, "probe a running instance and exit; used by HEALTHCHECK")
|
|
28
|
+
flag.Parse()
|
|
29
|
+
|
|
30
|
+
cfg := config.Load()
|
|
31
|
+
if *healthcheck {
|
|
32
|
+
os.Exit(probe(cfg))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
web, err := webFS(cfg)
|
|
36
|
+
if err != nil {
|
|
37
|
+
fmt.Fprintln(os.Stderr, "kanshi:", err)
|
|
38
|
+
os.Exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
42
|
+
defer stop()
|
|
43
|
+
|
|
44
|
+
if err := server.New(cfg, web).Run(ctx); err != nil && err != http.ErrServerClosed {
|
|
45
|
+
fmt.Fprintln(os.Stderr, "kanshi:", err)
|
|
46
|
+
os.Exit(1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func webFS(cfg config.Config) (fs.FS, error) {
|
|
51
|
+
if cfg.WebDir != "" {
|
|
52
|
+
return os.DirFS(cfg.WebDir), nil
|
|
53
|
+
}
|
|
54
|
+
return fs.Sub(embedded, "web")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// probe is the container health check. It runs in the same binary because the
|
|
58
|
+
// runtime image is scratch: there is no shell and no curl to call instead.
|
|
59
|
+
func probe(cfg config.Config) int {
|
|
60
|
+
host := cfg.Host
|
|
61
|
+
// With network_mode: host the bind address may be the Tailscale IP, but a
|
|
62
|
+
// wildcard bind is only reachable from inside via loopback.
|
|
63
|
+
if host == "0.0.0.0" || host == "::" || host == "" {
|
|
64
|
+
host = "127.0.0.1"
|
|
65
|
+
}
|
|
66
|
+
client := &http.Client{Timeout: 4 * time.Second}
|
|
67
|
+
resp, err := client.Get(fmt.Sprintf("http://%s:%d/healthz", host, cfg.Port))
|
|
68
|
+
if err != nil {
|
|
69
|
+
return 1
|
|
70
|
+
}
|
|
71
|
+
defer resp.Body.Close()
|
|
72
|
+
if resp.StatusCode != http.StatusOK {
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
75
|
+
return 0
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yuuki824/kanshi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Launch the Kanshi Docker host dashboard with npx.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|
|
7
7
|
"kanshi": "bin/kanshi.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
10
|
+
"main.go",
|
|
11
|
+
"go.mod",
|
|
12
|
+
"internal",
|
|
11
13
|
"web",
|
|
12
14
|
"bin",
|
|
13
15
|
"Dockerfile",
|
|
14
16
|
"docker-compose.yml",
|
|
15
|
-
"requirements.txt",
|
|
16
17
|
"README.md",
|
|
17
18
|
".env.example"
|
|
18
19
|
],
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
},
|
|
29
30
|
"keywords": [
|
|
30
31
|
"docker",
|
|
32
|
+
"go",
|
|
31
33
|
"dashboard",
|
|
32
34
|
"monitoring",
|
|
33
35
|
"tailscale"
|