@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.
@@ -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.0",
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
- "app",
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"