@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,501 @@
1
+ // Package dockerstats talks to the Docker Engine API over its unix socket.
2
+ //
3
+ // It uses `GET /containers/{id}/stats?stream=false&one-shot=true`. The one-shot
4
+ // form returns immediately; without it the daemon blocks each request for a
5
+ // full collection cycle to produce `precpu_stats`, which measured 8.3s per tick
6
+ // across 31 containers versus 0.07s here.
7
+ //
8
+ // The tradeoff is that one-shot zeroes `precpu_stats`, so CPU% is computed
9
+ // against the previous tick's counters instead — the same thing the daemon
10
+ // would have done, just over the poll interval rather than a 1s window. That is
11
+ // also a steadier number to read at a glance.
12
+ package dockerstats
13
+
14
+ import (
15
+ "context"
16
+ "encoding/json"
17
+ "fmt"
18
+ "math"
19
+ "net"
20
+ "net/http"
21
+ "sort"
22
+ "sync"
23
+ "time"
24
+
25
+ "github.com/yuuki824/kanshi/internal/config"
26
+ )
27
+
28
+ // Pinned rather than negotiated: every field Kanshi reads has been stable
29
+ // since 1.43, and asking for a version the daemon predates is a hard error.
30
+ const apiVersion = "v1.43"
31
+
32
+ // Client holds the socket transport and the previous tick's counters. Every
33
+ // rate here is a delta, so the client has to outlive a single sample.
34
+ type Client struct {
35
+ cfg config.Config
36
+ http *http.Client
37
+
38
+ mu sync.Mutex
39
+ prevCPU map[string]cpuCounters
40
+ prevNet map[string]netCounters
41
+ }
42
+
43
+ type cpuCounters struct{ total, system uint64 }
44
+ type netCounters struct {
45
+ at time.Time
46
+ rx, tx uint64
47
+ }
48
+
49
+ func New(cfg config.Config) *Client {
50
+ socket := cfg.DockerSocket
51
+ dialer := &net.Dialer{Timeout: 5 * time.Second}
52
+ return &Client{
53
+ cfg: cfg,
54
+ http: &http.Client{
55
+ Timeout: 20 * time.Second,
56
+ Transport: &http.Transport{
57
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
58
+ return dialer.DialContext(ctx, "unix", socket)
59
+ },
60
+ // One idle connection per in-flight request, so a tick reuses
61
+ // the sockets the previous tick opened instead of paying a
62
+ // connect per container.
63
+ MaxIdleConns: cfg.DockerConcurrency,
64
+ MaxIdleConnsPerHost: cfg.DockerConcurrency,
65
+ IdleConnTimeout: 90 * time.Second,
66
+ },
67
+ },
68
+ prevCPU: make(map[string]cpuCounters),
69
+ prevNet: make(map[string]netCounters),
70
+ }
71
+ }
72
+
73
+ // Close releases the idle sockets so a shutdown does not leave the daemon
74
+ // holding connections.
75
+ func (c *Client) Close() { c.http.CloseIdleConnections() }
76
+
77
+ func (c *Client) get(ctx context.Context, path string, out any) error {
78
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/"+apiVersion+path, nil)
79
+ if err != nil {
80
+ return err
81
+ }
82
+ resp, err := c.http.Do(req)
83
+ if err != nil {
84
+ return err
85
+ }
86
+ defer resp.Body.Close()
87
+ if resp.StatusCode >= 400 {
88
+ return fmt.Errorf("HTTPStatusError: %s", resp.Status)
89
+ }
90
+ return json.NewDecoder(resp.Body).Decode(out)
91
+ }
92
+
93
+ /* ── engine payloads ────────────────────────────────────────────────────── */
94
+
95
+ type containerMeta struct {
96
+ ID string `json:"Id"`
97
+ Names []string `json:"Names"`
98
+ Image string `json:"Image"`
99
+ State string `json:"State"`
100
+ Status string `json:"Status"`
101
+ Created int64 `json:"Created"`
102
+ Labels map[string]string `json:"Labels"`
103
+ }
104
+
105
+ // Pointers on total_usage and system_cpu_usage so a missing field is
106
+ // distinguishable from a genuine zero — a container that has used no CPU at
107
+ // all still reports 0, and that is a real reading.
108
+ type statsJSON struct {
109
+ CPUStats struct {
110
+ CPUUsage struct {
111
+ TotalUsage *uint64 `json:"total_usage"`
112
+ PercpuUsage []uint64 `json:"percpu_usage"`
113
+ } `json:"cpu_usage"`
114
+ SystemUsage *uint64 `json:"system_cpu_usage"`
115
+ OnlineCPUs int `json:"online_cpus"`
116
+ } `json:"cpu_stats"`
117
+ MemoryStats struct {
118
+ Usage *uint64 `json:"usage"`
119
+ Limit uint64 `json:"limit"`
120
+ Stats map[string]uint64 `json:"stats"`
121
+ } `json:"memory_stats"`
122
+ Networks map[string]struct {
123
+ RxBytes uint64 `json:"rx_bytes"`
124
+ TxBytes uint64 `json:"tx_bytes"`
125
+ } `json:"networks"`
126
+ PidsStats struct {
127
+ Current int `json:"current"`
128
+ } `json:"pids_stats"`
129
+ BlkioStats struct {
130
+ IoServiceBytesRecursive []struct {
131
+ Op string `json:"op"`
132
+ Value uint64 `json:"value"`
133
+ } `json:"io_service_bytes_recursive"`
134
+ } `json:"blkio_stats"`
135
+ }
136
+
137
+ /* ── wire payload ───────────────────────────────────────────────────────── */
138
+
139
+ // Result is the JSON the browser consumes; field names are part of the wire
140
+ // contract with web/app.js.
141
+ type Result struct {
142
+ Containers []Container `json:"containers"`
143
+ Running int `json:"running"`
144
+ Total int `json:"total"`
145
+ Error string `json:"error,omitempty"`
146
+ }
147
+
148
+ type Container struct {
149
+ ID string `json:"id"`
150
+ Name string `json:"name"`
151
+ FullName string `json:"full_name"`
152
+ Project string `json:"project"`
153
+ Image string `json:"image"`
154
+ State string `json:"state"`
155
+ Status string `json:"status"`
156
+ Health *string `json:"health"`
157
+ Created int64 `json:"created"`
158
+ CPU float64 `json:"cpu"`
159
+ MemUsed uint64 `json:"mem_used"`
160
+ MemLimit uint64 `json:"mem_limit"`
161
+ MemPercent float64 `json:"mem_percent"`
162
+ PIDs int `json:"pids"`
163
+ Net *Net `json:"net"`
164
+ Blkio *Blkio `json:"blkio"`
165
+ }
166
+
167
+ type Net struct {
168
+ RX uint64 `json:"rx"`
169
+ TX uint64 `json:"tx"`
170
+ RXRate float64 `json:"rx_rate"`
171
+ TXRate float64 `json:"tx_rate"`
172
+ }
173
+
174
+ type Blkio struct {
175
+ Read uint64 `json:"read"`
176
+ Write uint64 `json:"write"`
177
+ }
178
+
179
+ /* ── derived figures ────────────────────────────────────────────────────── */
180
+
181
+ // cpuPercent measures against the previous tick. 100% = one full core, the
182
+ // same scale `docker stats` prints.
183
+ func (c *Client) cpuPercent(id string, s *statsJSON) float64 {
184
+ usage, system := s.CPUStats.CPUUsage.TotalUsage, s.CPUStats.SystemUsage
185
+ if usage == nil || system == nil {
186
+ return 0
187
+ }
188
+
189
+ c.mu.Lock()
190
+ prev, seen := c.prevCPU[id]
191
+ c.prevCPU[id] = cpuCounters{total: *usage, system: *system}
192
+ c.mu.Unlock()
193
+ if !seen {
194
+ return 0 // first sighting; the next tick has a real delta
195
+ }
196
+
197
+ // A restarted container resets its counters — report 0 rather than a
198
+ // nonsensical negative or a huge spike.
199
+ if *usage < prev.total || *system <= prev.system {
200
+ return 0
201
+ }
202
+ cpuDelta := float64(*usage - prev.total)
203
+ sysDelta := float64(*system - prev.system)
204
+
205
+ // online_cpus is absent on older daemons; fall back to the per-cpu array.
206
+ ncpu := s.CPUStats.OnlineCPUs
207
+ if ncpu == 0 {
208
+ ncpu = len(s.CPUStats.CPUUsage.PercpuUsage)
209
+ }
210
+ if ncpu == 0 {
211
+ ncpu = 1
212
+ }
213
+ pct := math.Min(cpuDelta/sysDelta*float64(ncpu)*100, float64(ncpu)*100)
214
+ return math.Round(pct*100) / 100
215
+ }
216
+
217
+ func memory(s *statsJSON) (used, limit uint64) {
218
+ if s.MemoryStats.Usage == nil {
219
+ return 0, 0
220
+ }
221
+ used = *s.MemoryStats.Usage
222
+ // Match `docker stats`: subtract page cache so the number reflects the
223
+ // working set. cgroup v2 exposes inactive_file, v1 exposes cache.
224
+ if v, ok := s.MemoryStats.Stats["inactive_file"]; ok {
225
+ used -= min(v, used)
226
+ } else if v, ok := s.MemoryStats.Stats["cache"]; ok {
227
+ used -= min(v, used)
228
+ }
229
+ return used, s.MemoryStats.Limit
230
+ }
231
+
232
+ func (c *Client) network(id string, s *statsJSON, now time.Time) *Net {
233
+ if len(s.Networks) == 0 {
234
+ // Containers on `network_mode: service:...` (e.g. behind gluetun)
235
+ // report no interfaces of their own — their traffic shows up on the
236
+ // provider instead.
237
+ c.mu.Lock()
238
+ delete(c.prevNet, id)
239
+ c.mu.Unlock()
240
+ return nil
241
+ }
242
+ var rx, tx uint64
243
+ for _, n := range s.Networks {
244
+ rx += n.RxBytes
245
+ tx += n.TxBytes
246
+ }
247
+
248
+ c.mu.Lock()
249
+ prev, seen := c.prevNet[id]
250
+ c.prevNet[id] = netCounters{at: now, rx: rx, tx: tx}
251
+ c.mu.Unlock()
252
+
253
+ out := &Net{RX: rx, TX: tx}
254
+ if seen && now.After(prev.at) {
255
+ dt := now.Sub(prev.at).Seconds()
256
+ // A restarted container resets its counters; clamp instead of going
257
+ // negative.
258
+ if rx > prev.rx {
259
+ out.RXRate = float64(rx-prev.rx) / dt
260
+ }
261
+ if tx > prev.tx {
262
+ out.TXRate = float64(tx-prev.tx) / dt
263
+ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ func blockIO(s *statsJSON) *Blkio {
269
+ entries := s.BlkioStats.IoServiceBytesRecursive
270
+ if len(entries) == 0 {
271
+ return nil // commonly empty under cgroup v2
272
+ }
273
+ var out Blkio
274
+ for _, e := range entries {
275
+ switch {
276
+ case equalFold(e.Op, "read"):
277
+ out.Read += e.Value
278
+ case equalFold(e.Op, "write"):
279
+ out.Write += e.Value
280
+ }
281
+ }
282
+ return &out
283
+ }
284
+
285
+ // identify returns (full name, display name, project).
286
+ //
287
+ // Runtipi names containers `<project>-<service>-1`, so at phone width three
288
+ // Immich containers all truncate to the same "immich_migra…". The compose
289
+ // labels carry the service name on its own, which is what actually
290
+ // distinguishes them.
291
+ func identify(meta containerMeta) (full, display, project string) {
292
+ full = "?"
293
+ if len(meta.Names) > 0 {
294
+ full = trimLeadingSlash(meta.Names[0])
295
+ }
296
+ service := meta.Labels["com.docker.compose.service"]
297
+ project = meta.Labels["com.docker.compose.project"]
298
+ display = service
299
+ if display == "" {
300
+ display = full
301
+ }
302
+ return full, display, project
303
+ }
304
+
305
+ // knownHealth is the set the UI styles. Anything else the daemon writes in
306
+ // parentheses (a port mapping, a custom status) is not a health state and must
307
+ // not be shown as one.
308
+ var knownHealth = map[string]bool{
309
+ "healthy": true, "unhealthy": true, "health: starting": true, "starting": true,
310
+ }
311
+
312
+ // health digs the state out of the human-readable Status string, which is the
313
+ // only place /containers/json exposes it.
314
+ func health(status string) *string {
315
+ open := lastIndexByte(status, '(')
316
+ if open < 0 {
317
+ return nil
318
+ }
319
+ inner := trimTrailingParen(status[open+1:])
320
+ if !knownHealth[inner] {
321
+ return nil
322
+ }
323
+ return &inner
324
+ }
325
+
326
+ /* ── sampling ───────────────────────────────────────────────────────────── */
327
+
328
+ func (c *Client) one(ctx context.Context, meta containerMeta) *Container {
329
+ var s statsJSON
330
+ if err := c.get(ctx, "/containers/"+meta.ID+"/stats?stream=false&one-shot=true", &s); err != nil {
331
+ return nil
332
+ }
333
+ used, limit := memory(&s)
334
+ full, name, project := identify(meta)
335
+
336
+ out := &Container{
337
+ ID: shortID(meta.ID),
338
+ Name: name,
339
+ FullName: full,
340
+ Project: project,
341
+ Image: meta.Image,
342
+ State: meta.State,
343
+ Status: meta.Status,
344
+ Health: health(meta.Status),
345
+ Created: meta.Created,
346
+ CPU: c.cpuPercent(meta.ID, &s),
347
+ MemUsed: used,
348
+ MemLimit: limit,
349
+ PIDs: s.PidsStats.Current,
350
+ Net: c.network(meta.ID, &s, time.Now()),
351
+ Blkio: blockIO(&s),
352
+ }
353
+ if limit > 0 {
354
+ out.MemPercent = math.Round(float64(used)/float64(limit)*100*100) / 100
355
+ }
356
+ return out
357
+ }
358
+
359
+ // Sample runs one full pass: list containers, then fetch stats for the running
360
+ // ones concurrently.
361
+ func (c *Client) Sample(ctx context.Context) Result {
362
+ var listing []containerMeta
363
+ if err := c.get(ctx, "/containers/json?all=true", &listing); err != nil {
364
+ return Result{Error: errString(err), Containers: []Container{}}
365
+ }
366
+
367
+ var running []containerMeta
368
+ for _, meta := range listing {
369
+ if meta.State == "running" {
370
+ running = append(running, meta)
371
+ }
372
+ }
373
+
374
+ results := make([]*Container, len(running))
375
+ sem := make(chan struct{}, max(1, c.cfg.DockerConcurrency))
376
+ var wg sync.WaitGroup
377
+ for i, meta := range running {
378
+ wg.Add(1)
379
+ go func(i int, meta containerMeta) {
380
+ defer wg.Done()
381
+ sem <- struct{}{}
382
+ defer func() { <-sem }()
383
+ results[i] = c.one(ctx, meta)
384
+ }(i, meta)
385
+ }
386
+ wg.Wait()
387
+
388
+ containers := make([]Container, 0, len(listing))
389
+ for _, r := range results {
390
+ if r != nil {
391
+ containers = append(containers, *r)
392
+ }
393
+ }
394
+
395
+ // Keep stopped containers visible but without stats, so a crashed service
396
+ // is obvious at a glance rather than silently missing from the list.
397
+ for _, meta := range listing {
398
+ if meta.State == "running" {
399
+ continue
400
+ }
401
+ full, name, project := identify(meta)
402
+ containers = append(containers, Container{
403
+ ID: shortID(meta.ID), Name: name, FullName: full, Project: project,
404
+ Image: meta.Image, State: meta.State, Status: meta.Status,
405
+ Created: meta.Created,
406
+ })
407
+ }
408
+
409
+ c.forgetStopped(running)
410
+
411
+ sort.SliceStable(containers, func(i, j int) bool {
412
+ a, b := containers[i], containers[j]
413
+ if (a.State == "running") != (b.State == "running") {
414
+ return a.State == "running"
415
+ }
416
+ if a.CPU != b.CPU {
417
+ return a.CPU > b.CPU
418
+ }
419
+ return a.Name < b.Name
420
+ })
421
+
422
+ return Result{Containers: containers, Running: len(running), Total: len(listing)}
423
+ }
424
+
425
+ // forgetStopped drops baselines for containers that are gone, so the maps do
426
+ // not grow without bound on a host that churns through short-lived jobs.
427
+ func (c *Client) forgetStopped(running []containerMeta) {
428
+ live := make(map[string]bool, len(running))
429
+ for _, meta := range running {
430
+ live[meta.ID] = true
431
+ }
432
+ c.mu.Lock()
433
+ defer c.mu.Unlock()
434
+ for id := range c.prevCPU {
435
+ if !live[id] {
436
+ delete(c.prevCPU, id)
437
+ }
438
+ }
439
+ for id := range c.prevNet {
440
+ if !live[id] {
441
+ delete(c.prevNet, id)
442
+ }
443
+ }
444
+ }
445
+
446
+ /* ── small helpers ──────────────────────────────────────────────────────── */
447
+
448
+ func shortID(id string) string {
449
+ if len(id) > 12 {
450
+ return id[:12]
451
+ }
452
+ return id
453
+ }
454
+
455
+ func trimLeadingSlash(s string) string {
456
+ for len(s) > 0 && s[0] == '/' {
457
+ s = s[1:]
458
+ }
459
+ return s
460
+ }
461
+
462
+ func trimTrailingParen(s string) string {
463
+ for len(s) > 0 && s[len(s)-1] == ')' {
464
+ s = s[:len(s)-1]
465
+ }
466
+ return s
467
+ }
468
+
469
+ func lastIndexByte(s string, b byte) int {
470
+ for i := len(s) - 1; i >= 0; i-- {
471
+ if s[i] == b {
472
+ return i
473
+ }
474
+ }
475
+ return -1
476
+ }
477
+
478
+ func equalFold(a, b string) bool {
479
+ if len(a) != len(b) {
480
+ return false
481
+ }
482
+ for i := 0; i < len(a); i++ {
483
+ ca, cb := a[i], b[i]
484
+ if 'A' <= ca && ca <= 'Z' {
485
+ ca += 'a' - 'A'
486
+ }
487
+ if 'A' <= cb && cb <= 'Z' {
488
+ cb += 'a' - 'A'
489
+ }
490
+ if ca != cb {
491
+ return false
492
+ }
493
+ }
494
+ return true
495
+ }
496
+
497
+ // errString keeps the "Type: message" shape the dashboard already renders for
498
+ // Docker failures.
499
+ func errString(err error) string {
500
+ return fmt.Sprintf("%T: %v", err, err)
501
+ }