@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,286 @@
1
+ package vitals
2
+
3
+ import (
4
+ "fmt"
5
+ "syscall"
6
+ "time"
7
+ "unsafe"
8
+ )
9
+
10
+ var (
11
+ ntdll = syscall.NewLazyDLL("ntdll.dll")
12
+ procNtQuerySystemInformation = ntdll.NewProc("NtQuerySystemInformation")
13
+
14
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
15
+ procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
16
+ procK32GetPerformanceInfo = kernel32.NewProc("K32GetPerformanceInfo")
17
+ procGetTickCount64 = kernel32.NewProc("GetTickCount64")
18
+
19
+ iphlpapi = syscall.NewLazyDLL("iphlpapi.dll")
20
+ procGetIfTable2 = iphlpapi.NewProc("GetIfTable2")
21
+ procFreeMibTable = iphlpapi.NewProc("FreeMibTable")
22
+ )
23
+
24
+ const (
25
+ systemProcessorPerformanceInformation = 8
26
+ statusInfoLengthMismatch = 0xC0000004
27
+ ioctlDiskPerformance = 0x70020
28
+ ifTypeSoftwareLoopback = 24
29
+ devicesEvery = time.Minute
30
+ )
31
+
32
+ type sysState struct {
33
+ perf []processorPerformance
34
+ prevCPU, curCPU []cpuTimes
35
+ cpuAt time.Time
36
+
37
+ disks []syscall.Handle
38
+ disksAt time.Time
39
+ }
40
+
41
+ /* ── CPU ────────────────────────────────────────────────────────────────── */
42
+
43
+ // processorPerformance is SYSTEM_PROCESSOR_PERFORMANCE_INFORMATION. Kernel
44
+ // time includes idle time.
45
+ type processorPerformance struct {
46
+ IdleTime, KernelTime, UserTime, DpcTime, InterruptTime int64
47
+ InterruptCount uint32
48
+ _ uint32
49
+ }
50
+
51
+ type cpuTimes struct{ busy, total uint64 }
52
+
53
+ func (r *Reader) readCPU(dst []cpuTimes) []cpuTimes {
54
+ s := &r.sys
55
+ if s.perf == nil {
56
+ s.perf = make([]processorPerformance, 64)
57
+ }
58
+ for {
59
+ var n uint32
60
+ size := uint32(len(s.perf)) * uint32(unsafe.Sizeof(s.perf[0]))
61
+ st, _, _ := procNtQuerySystemInformation.Call(systemProcessorPerformanceInformation,
62
+ uintptr(unsafe.Pointer(&s.perf[0])), uintptr(size), uintptr(unsafe.Pointer(&n)))
63
+ if st == statusInfoLengthMismatch {
64
+ s.perf = make([]processorPerformance, 2*len(s.perf))
65
+ continue
66
+ }
67
+ if st != 0 {
68
+ return dst[:0]
69
+ }
70
+ dst = dst[:0]
71
+ for _, p := range s.perf[:n/uint32(unsafe.Sizeof(s.perf[0]))] {
72
+ total := uint64(p.KernelTime + p.UserTime)
73
+ dst = append(dst, cpuTimes{busy: total - uint64(p.IdleTime), total: total})
74
+ }
75
+ return dst
76
+ }
77
+ }
78
+
79
+ func (r *Reader) primeCPU() {
80
+ r.sys.prevCPU = r.readCPU(r.sys.prevCPU)
81
+ r.sys.cpuAt = time.Now()
82
+ }
83
+
84
+ // cpuCores mirrors the Linux version: a delta against the previous call, with
85
+ // a short blocking window when that call was too recent to divide by.
86
+ func (r *Reader) cpuCores() []float64 {
87
+ s := &r.sys
88
+ s.curCPU = r.readCPU(s.curCPU)
89
+ if len(s.curCPU) == 0 {
90
+ return nil
91
+ }
92
+ if len(s.prevCPU) == 0 || time.Since(s.cpuAt) < minDT {
93
+ s.prevCPU, s.curCPU = s.curCPU, s.prevCPU
94
+ s.cpuAt = time.Now()
95
+ time.Sleep(250 * time.Millisecond)
96
+ if s.curCPU = r.readCPU(s.curCPU); len(s.curCPU) == 0 {
97
+ return nil
98
+ }
99
+ }
100
+ prev, now := s.prevCPU, s.curCPU
101
+ out := make([]float64, len(now))
102
+ if len(prev) == len(now) {
103
+ for i := range now {
104
+ out[i] = busyPercent(float64(now[i].busy)-float64(prev[i].busy),
105
+ float64(now[i].total)-float64(prev[i].total))
106
+ }
107
+ }
108
+ s.prevCPU, s.curCPU = now, prev
109
+ s.cpuAt = time.Now()
110
+ return out
111
+ }
112
+
113
+ /* ── memory ─────────────────────────────────────────────────────────────── */
114
+
115
+ type memoryStatusEx struct {
116
+ Length, MemoryLoad uint32
117
+ TotalPhys, AvailPhys uint64
118
+ TotalPageFile, AvailPageFile uint64
119
+ TotalVirtual, AvailVirtual, AvailExtra uint64
120
+ }
121
+
122
+ type performanceInformation struct {
123
+ Size uint32
124
+ CommitTotal, CommitLimit, CommitPeak uintptr
125
+ PhysicalTotal, PhysicalAvailable, SystemCache uintptr
126
+ KernelTotal, KernelPaged, KernelNonpaged, PageSize uintptr
127
+ HandleCount, ProcessCount, ThreadCount uint32
128
+ }
129
+
130
+ // memory reports RAM. Windows has no swap partition in the Linux sense — its
131
+ // page file backs committed memory rather than overflowing RAM — so swap is
132
+ // reported as absent and the dashboard leaves that meter out.
133
+ func (r *Reader) memory() (Memory, Swap) {
134
+ ms := memoryStatusEx{Length: uint32(unsafe.Sizeof(memoryStatusEx{}))}
135
+ if ok, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))); ok == 0 {
136
+ return Memory{}, Swap{}
137
+ }
138
+ var cached uint64
139
+ pi := performanceInformation{Size: uint32(unsafe.Sizeof(performanceInformation{}))}
140
+ if ok, _, _ := procK32GetPerformanceInfo.Call(uintptr(unsafe.Pointer(&pi)), uintptr(pi.Size)); ok != 0 {
141
+ cached = uint64(pi.SystemCache) * uint64(pi.PageSize)
142
+ }
143
+ return memoryFigures(ms.TotalPhys, ms.AvailPhys, cached), Swap{}
144
+ }
145
+
146
+ /* ── network ────────────────────────────────────────────────────────────── */
147
+
148
+ // mibIfRow2 is MIB_IF_ROW2. Go's own alignment rules reproduce the C layout;
149
+ // a test pins the size and the offsets that are read.
150
+ type mibIfRow2 struct {
151
+ InterfaceLuid uint64
152
+ InterfaceIndex uint32
153
+ InterfaceGUID [16]byte
154
+ Alias [257]uint16
155
+ Description [257]uint16
156
+ PhysicalAddressLength uint32
157
+ PhysicalAddress [32]byte
158
+ PermanentPhysicalAddress [32]byte
159
+ Mtu uint32
160
+ Type uint32
161
+ TunnelType uint32
162
+ MediaType uint32
163
+ PhysicalMediumType uint32
164
+ AccessType uint32
165
+ DirectionType uint32
166
+ InterfaceAndOperStatusFlags uint8
167
+ OperStatus uint32
168
+ AdminStatus uint32
169
+ MediaConnectState uint32
170
+ NetworkGUID [16]byte
171
+ ConnectionType uint32
172
+ TransmitLinkSpeed uint64
173
+ ReceiveLinkSpeed uint64
174
+ InOctets uint64
175
+ InUcastPkts uint64
176
+ InNUcastPkts uint64
177
+ InDiscards uint64
178
+ InErrors uint64
179
+ InUnknownProtos uint64
180
+ InUcastOctets uint64
181
+ InMulticastOctets uint64
182
+ InBroadcastOctets uint64
183
+ OutOctets uint64
184
+ OutUcastPkts uint64
185
+ OutNUcastPkts uint64
186
+ OutDiscards uint64
187
+ OutErrors uint64
188
+ OutUcastOctets uint64
189
+ OutMulticastOctets uint64
190
+ OutBroadcastOctets uint64
191
+ OutQLen uint64
192
+ }
193
+
194
+ const (
195
+ ifHardwareInterface = 1 << 0
196
+ ifFilterInterface = 1 << 1
197
+ )
198
+
199
+ // netCounters sums the physical adapters only. Hyper-V switches, WSL, VPN
200
+ // tunnels and the filter drivers stacked on a NIC all report the same bytes
201
+ // again, the same double counting the Linux side avoids via sysfs.
202
+ func (r *Reader) netCounters() (rx, tx uint64, ok bool) {
203
+ // The table lives in memory Windows allocated, outside the Go heap, and
204
+ // is handed back with FreeMibTable.
205
+ var table unsafe.Pointer
206
+ if st, _, _ := procGetIfTable2.Call(uintptr(unsafe.Pointer(&table))); st != 0 || table == nil {
207
+ return 0, 0, false
208
+ }
209
+ defer procFreeMibTable.Call(uintptr(table))
210
+ n := *(*uint32)(table)
211
+ rows := unsafe.Slice((*mibIfRow2)(unsafe.Add(table, 8)), n)
212
+ for i := range rows {
213
+ row := &rows[i]
214
+ flags := row.InterfaceAndOperStatusFlags
215
+ if flags&ifHardwareInterface == 0 || flags&ifFilterInterface != 0 || row.Type == ifTypeSoftwareLoopback {
216
+ continue
217
+ }
218
+ rx += row.InOctets
219
+ tx += row.OutOctets
220
+ }
221
+ return rx, tx, true
222
+ }
223
+
224
+ /* ── disk ───────────────────────────────────────────────────────────────── */
225
+
226
+ type diskPerformance struct {
227
+ BytesRead, BytesWritten int64
228
+ ReadTime, WriteTime, IdleTime int64
229
+ ReadCount, WriteCount, QueueDepth, SplitCount uint32
230
+ QueryTime int64
231
+ StorageDeviceNumber uint32
232
+ StorageManagerName [8]uint16
233
+ }
234
+
235
+ // diskCounters sums IOCTL_DISK_PERFORMANCE over every physical drive. Opening
236
+ // a drive with no access rights is enough for this call, so it needs no
237
+ // administrator token.
238
+ func (r *Reader) diskCounters() (read, write uint64, ok bool) {
239
+ s := &r.sys
240
+ if s.disksAt.IsZero() || time.Since(s.disksAt) > devicesEvery {
241
+ s.reopenDisks()
242
+ }
243
+ for _, h := range s.disks {
244
+ var perf diskPerformance
245
+ var n uint32
246
+ if err := syscall.DeviceIoControl(h, ioctlDiskPerformance, nil, 0,
247
+ (*byte)(unsafe.Pointer(&perf)), uint32(unsafe.Sizeof(perf)), &n, nil); err != nil {
248
+ continue
249
+ }
250
+ read += uint64(perf.BytesRead)
251
+ write += uint64(perf.BytesWritten)
252
+ ok = true
253
+ }
254
+ return read, write, ok
255
+ }
256
+
257
+ func (s *sysState) reopenDisks() {
258
+ for _, h := range s.disks {
259
+ syscall.CloseHandle(h)
260
+ }
261
+ s.disks = s.disks[:0]
262
+ s.disksAt = time.Now()
263
+ // Drive numbers can have gaps after a disk is removed, so every slot is
264
+ // tried rather than stopping at the first miss.
265
+ for i := 0; i < 32; i++ {
266
+ p, _ := syscall.UTF16PtrFromString(fmt.Sprintf(`\\.\PhysicalDrive%d`, i))
267
+ h, err := syscall.CreateFile(p, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil,
268
+ syscall.OPEN_EXISTING, 0, 0)
269
+ if err == nil {
270
+ s.disks = append(s.disks, h)
271
+ }
272
+ }
273
+ }
274
+
275
+ /* ── load, temperature, uptime ──────────────────────────────────────────── */
276
+
277
+ // Windows keeps no load average, and its only CPU temperature source (the
278
+ // ACPI thermal zone over WMI) needs an administrator and is wrong on most
279
+ // desktop boards. Both are left null; the dashboard hides them.
280
+ func (r *Reader) loadAvg() *[3]float64 { return nil }
281
+ func (r *Reader) temperature() *float64 { return nil }
282
+
283
+ func (r *Reader) uptime() float64 {
284
+ ms, _, _ := procGetTickCount64.Call()
285
+ return float64(ms) / 1000
286
+ }
package/main.go ADDED
@@ -0,0 +1,129 @@
1
+ // Command kanshi serves an at-a-glance dashboard for a single machine.
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
+ "runtime"
14
+ "runtime/debug"
15
+ "strconv"
16
+ "syscall"
17
+ "time"
18
+
19
+ "github.com/rene-roid/kanshi/internal/access"
20
+ "github.com/rene-roid/kanshi/internal/config"
21
+ "github.com/rene-roid/kanshi/internal/server"
22
+ )
23
+
24
+ // version is stamped by the release build with -ldflags "-X main.version=…".
25
+ var version = "dev"
26
+
27
+ // The frontend is baked into the binary so the runtime image can be scratch —
28
+ // nothing to copy, nothing to keep in sync with the code that serves it.
29
+ //
30
+ //go:embed web
31
+ var embedded embed.FS
32
+
33
+ func main() {
34
+ var flags config.Flags
35
+ flag.StringVar(&flags.Access, "access", "", "who can reach the dashboard: local, lan, tailscale, all, or a mix like lan,tailscale (default local)")
36
+ flag.IntVar(&flags.Port, "port", 0, "port to listen on (default 8100)")
37
+ flag.StringVar(&flags.File, "config", "", "settings file to read (default: "+config.FileName+" next to the program, then in the user config directory)")
38
+ open := flag.Bool("open", ownConsole(), "open the dashboard in the browser once it is listening")
39
+ healthcheck := flag.Bool("healthcheck", false, "probe a running instance and exit; used by the container HEALTHCHECK")
40
+ showVersion := flag.Bool("version", false, "print the version and exit")
41
+ flag.Parse()
42
+
43
+ if *showVersion {
44
+ fmt.Println("kanshi", version)
45
+ return
46
+ }
47
+ cfg := config.Load(flags)
48
+ if *healthcheck {
49
+ os.Exit(probe(cfg))
50
+ }
51
+ resourceDefaults()
52
+
53
+ mode, err := access.Parse(cfg.Access)
54
+ if err != nil {
55
+ // A typo here should narrow access, never widen it.
56
+ logf("warning: %v; falling back to local only", err)
57
+ mode = access.Mode{}
58
+ }
59
+
60
+ web, err := webFS(cfg)
61
+ if err != nil {
62
+ fail(err)
63
+ }
64
+ srv, err := server.New(cfg, web, version, logf)
65
+ if err != nil {
66
+ fail(err)
67
+ }
68
+ if *open {
69
+ srv.OnReady = func() { openBrowser("http://localhost:" + strconv.Itoa(cfg.Port)) }
70
+ }
71
+
72
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
73
+ defer stop()
74
+ if err := srv.Run(ctx, mode); err != nil {
75
+ fail(err)
76
+ }
77
+ }
78
+
79
+ func logf(format string, args ...any) {
80
+ fmt.Fprintf(os.Stderr, "kanshi: "+format+"\n", args...)
81
+ }
82
+
83
+ func fail(err error) {
84
+ logf("%v", err)
85
+ if ownConsole() {
86
+ // Double-clicked on Windows: the console closes with the process, so
87
+ // hold it open long enough to read why.
88
+ fmt.Fprint(os.Stderr, "Press Enter to close this window.")
89
+ _, _ = fmt.Scanln()
90
+ }
91
+ os.Exit(1)
92
+ }
93
+
94
+ // resourceDefaults gives a bare binary the same conservative budget the
95
+ // Compose file sets: two scheduler threads are plenty for a 5-second poll and
96
+ // one walk, and a soft memory limit makes an unusually large walk collect
97
+ // harder rather than grow. Either can be overridden with GOMAXPROCS and
98
+ // GOMEMLIMIT.
99
+ func resourceDefaults() {
100
+ if os.Getenv("GOMAXPROCS") == "" {
101
+ runtime.GOMAXPROCS(min(2, runtime.NumCPU()))
102
+ }
103
+ if os.Getenv("GOMEMLIMIT") == "" {
104
+ debug.SetMemoryLimit(128 << 20)
105
+ }
106
+ }
107
+
108
+ func webFS(cfg config.Config) (fs.FS, error) {
109
+ if cfg.WebDir != "" {
110
+ return os.DirFS(cfg.WebDir), nil
111
+ }
112
+ return fs.Sub(embedded, "web")
113
+ }
114
+
115
+ // probe is the container health check. It runs in the same binary because the
116
+ // runtime image is scratch: there is no shell and no curl to call instead.
117
+ // Loopback is listened on in every access mode, so that is what it asks.
118
+ func probe(cfg config.Config) int {
119
+ client := &http.Client{Timeout: 4 * time.Second}
120
+ resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", cfg.Port))
121
+ if err != nil {
122
+ return 1
123
+ }
124
+ defer resp.Body.Close()
125
+ if resp.StatusCode != http.StatusOK {
126
+ return 1
127
+ }
128
+ return 0
129
+ }
package/main_other.go ADDED
@@ -0,0 +1,11 @@
1
+ //go:build !windows
2
+
3
+ package main
4
+
5
+ import "os/exec"
6
+
7
+ func ownConsole() bool { return false }
8
+
9
+ func openBrowser(url string) {
10
+ _ = exec.Command("xdg-open", url).Start()
11
+ }
@@ -0,0 +1,29 @@
1
+ package main
2
+
3
+ import (
4
+ "syscall"
5
+ "unsafe"
6
+ )
7
+
8
+ var (
9
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
10
+ procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
11
+ shell32 = syscall.NewLazyDLL("shell32.dll")
12
+ procShellExecuteW = shell32.NewProc("ShellExecuteW")
13
+ )
14
+
15
+ // ownConsole reports that kanshi.exe has a console window to itself, which is
16
+ // what happens when it is double-clicked in Explorer rather than started from
17
+ // a terminal (where the shell shares the console).
18
+ func ownConsole() bool {
19
+ var ids [2]uint32
20
+ n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&ids[0])), 2)
21
+ return n == 1
22
+ }
23
+
24
+ func openBrowser(url string) {
25
+ verb, _ := syscall.UTF16PtrFromString("open")
26
+ target, _ := syscall.UTF16PtrFromString(url)
27
+ const swShowNormal = 1
28
+ procShellExecuteW.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(target)), 0, 0, swShowNormal)
29
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@yuuki824/kanshi",
3
+ "version": "0.3.0",
4
+ "description": "Launch the Kanshi homeserver dashboard with npx.",
5
+ "license": "AGPL-3.0-only",
6
+ "bin": {
7
+ "kanshi": "bin/kanshi.js"
8
+ },
9
+ "files": [
10
+ "main.go",
11
+ "main_other.go",
12
+ "main_windows.go",
13
+ "go.mod",
14
+ "internal",
15
+ "!internal/**/*_test.go",
16
+ "web",
17
+ "bin",
18
+ "Dockerfile",
19
+ "docker-compose.yml",
20
+ "README.md",
21
+ "LICENSE",
22
+ ".env.example"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "pack:check": "npm pack --dry-run",
29
+ "test": "node --check bin/kanshi.js"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "keywords": [
35
+ "docker",
36
+ "go",
37
+ "dashboard",
38
+ "monitoring",
39
+ "tailscale",
40
+ "homeserver",
41
+ "windows",
42
+ "linux"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/rene-roid/kanshi.git"
47
+ },
48
+ "homepage": "https://github.com/rene-roid/kanshi#readme"
49
+ }